diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e99503..c60cc1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [3.1.0](https://github.com/netlify/blobs/compare/v3.0.0...v3.1.0) (2023-10-18) + + +### Features + +* add `getDeployStore` method ([#68](https://github.com/netlify/blobs/issues/68)) ([5135f3d](https://github.com/netlify/blobs/commit/5135f3d3dfaf55c48c51e8b115ab64c8728e73aa)) + ## [3.0.0](https://github.com/netlify/blobs/compare/v2.2.0...v3.0.0) (2023-10-17) diff --git a/README.md b/README.md index a1f64d6..c981b72 100644 --- a/README.md +++ b/README.md @@ -126,15 +126,15 @@ know whether an item is still relevant or safe to delete. But sometimes it's useful to have data pegged to a specific deploy, and shift to the platform the responsibility of managing that data — keep it as long as the deploy is around, and wipe it if the deploy is deleted. -You can opt-in to this behavior by supplying a `deployID` instead of a `name` to the `getStore` method. +You can opt-in to this behavior by creating the store using the `getDeployStore` method. ```ts import { assert } from 'node:assert' -import { getStore } from '@netlify/blobs' +import { getDeployStore } from '@netlify/blobs' // Using API access -const store1 = getStore({ +const store1 = getDeployStore({ deployID: 'MY_DEPLOY_ID', token: 'MY_API_TOKEN', }) @@ -142,9 +142,7 @@ const store1 = getStore({ await store1.set('my-key', 'my value') // Using environment-based configuration -const store2 = getStore({ - deployID: 'MY_DEPLOY_ID', -}) +const store2 = getDeployStore() assert.equal(await store2.get('my-key'), 'my value') ``` diff --git a/package-lock.json b/package-lock.json index 9d2fbce..7932123 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@netlify/blobs", - "version": "3.0.0", + "version": "3.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@netlify/blobs", - "version": "3.0.0", + "version": "3.1.0", "license": "MIT", "devDependencies": { "@commitlint/cli": "^17.0.0", diff --git a/package.json b/package.json index 27c0706..3fce2ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@netlify/blobs", - "version": "3.0.0", + "version": "3.1.0", "description": "A JavaScript client for the Netlify Blob Store", "type": "module", "engines": { diff --git a/src/client.ts b/src/client.ts index c267215..ad08a78 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,30 +1,7 @@ -import { Buffer } from 'node:buffer' -import { env } from 'node:process' - +import { EnvironmentContext, getEnvironmentContext, MissingBlobsEnvironmentError } from './environment.ts' import { fetchAndRetry } from './retry.ts' import { BlobInput, Fetcher, HTTPMethod } from './types.ts' -/** - * The name of the environment variable that holds the context in a Base64, - * JSON-encoded object. If we ever need to change the encoding or the shape - * of this object, we should bump the version and create a new variable, so - * that the client knows how to consume the data and can advise the user to - * update the client if needed. - */ -export const NETLIFY_CONTEXT_VARIABLE = 'NETLIFY_BLOBS_CONTEXT' - -export interface Context { - apiURL?: string - edgeURL?: string - siteID?: string - token?: string -} - -interface PopulatedContext extends Context { - siteID: string - token: string -} - interface MakeStoreRequestOptions { body?: BlobInput | null headers?: Record @@ -33,59 +10,45 @@ interface MakeStoreRequestOptions { storeName: string } +export interface ClientOptions { + apiURL?: string + edgeURL?: string + fetch?: Fetcher + siteID: string + token: string +} + export class Client { - private context?: Context + private apiURL?: string + private edgeURL?: string private fetch?: Fetcher + private siteID: string + private token: string - constructor(context?: Context, fetch?: Fetcher) { - this.context = context ?? {} + constructor({ apiURL, edgeURL, fetch, siteID, token }: ClientOptions) { + this.apiURL = apiURL + this.edgeURL = edgeURL this.fetch = fetch - } - - private static getEnvironmentContext() { - if (!env[NETLIFY_CONTEXT_VARIABLE]) { - return - } - - const data = Buffer.from(env[NETLIFY_CONTEXT_VARIABLE], 'base64').toString() - - try { - return JSON.parse(data) as Context - } catch { - // no-op - } - } - - private getContext() { - const context = { - ...Client.getEnvironmentContext(), - ...this.context, - } - - if (!context.siteID || !context.token) { - throw new Error(`The blob store is unavailable because it's missing required configuration properties`) - } - - return context as PopulatedContext + this.siteID = siteID + this.token = token } private async getFinalRequest(storeName: string, key: string, method: string) { - const context = this.getContext() const encodedKey = encodeURIComponent(key) - if ('edgeURL' in context) { + if (this.edgeURL) { return { headers: { - authorization: `Bearer ${context.token}`, + authorization: `Bearer ${this.token}`, }, - url: `${context.edgeURL}/${context.siteID}/${storeName}/${encodedKey}`, + url: `${this.edgeURL}/${this.siteID}/${storeName}/${encodedKey}`, } } - const apiURL = `${context.apiURL ?? 'https://api.netlify.com'}/api/v1/sites/${ - context.siteID + const apiURL = `${this.apiURL ?? 'https://api.netlify.com'}/api/v1/sites/${ + this.siteID }/blobs/${encodedKey}?context=${storeName}` - const headers = { authorization: `Bearer ${context.token}` } + const headers = { authorization: `Bearer ${this.token}` } const fetch = this.fetch ?? globalThis.fetch const res = await fetch(apiURL, { headers, method }) @@ -137,3 +100,33 @@ export class Client { return res } } + +/** + * Merges a set of options supplied by the user when getting a reference to a + * store with a context object found in the environment. + * + * @param options User-supplied options + * @param contextOverride Context to be used instead of the environment object + */ +export const getClientOptions = ( + options: Partial, + contextOverride?: EnvironmentContext, +): ClientOptions => { + const context = contextOverride ?? getEnvironmentContext() + const siteID = context.siteID ?? options.siteID + const token = context.token ?? options.token + + if (!siteID || !token) { + throw new MissingBlobsEnvironmentError(['siteID', 'token']) + } + + const clientOptions = { + apiURL: context.apiURL ?? options.apiURL, + edgeURL: context.edgeURL ?? options.edgeURL, + fetch: options.fetch, + siteID, + token, + } + + return clientOptions +} diff --git a/src/environment.ts b/src/environment.ts new file mode 100644 index 0000000..5d4e1e6 --- /dev/null +++ b/src/environment.ts @@ -0,0 +1,50 @@ +import { Buffer } from 'node:buffer' +import { env } from 'node:process' + +/** + * The name of the environment variable that holds the context in a Base64, + * JSON-encoded object. If we ever need to change the encoding or the shape + * of this object, we should bump the version and create a new variable, so + * that the client knows how to consume the data and can advise the user to + * update the client if needed. + */ +const NETLIFY_CONTEXT_VARIABLE = 'NETLIFY_BLOBS_CONTEXT' + +/** + * The context object that we expect in the environment. + */ +export interface EnvironmentContext { + apiURL?: string + deployID?: string + edgeURL?: string + siteID?: string + token?: string +} + +export const getEnvironmentContext = (): EnvironmentContext => { + if (!env[NETLIFY_CONTEXT_VARIABLE]) { + return {} + } + + const data = Buffer.from(env[NETLIFY_CONTEXT_VARIABLE], 'base64').toString() + + try { + return JSON.parse(data) as EnvironmentContext + } catch { + // no-op + } + + return {} +} + +export class MissingBlobsEnvironmentError extends Error { + constructor(requiredProperties: string[]) { + super( + `The environment has not been configured to use Netlify Blobs. To use it manually, supply the following properties when creating a store: ${requiredProperties.join( + ', ', + )}`, + ) + + this.name = 'MissingBlobsEnvironmentError' + } +} diff --git a/src/main.test.ts b/src/main.test.ts index c4df92c..160a49f 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -7,7 +7,8 @@ import { describe, test, expect, beforeAll, afterEach } from 'vitest' import { MockFetch } from '../test/mock_fetch.js' import { streamToString } from '../test/util.js' -import { getStore } from './main.js' +import { MissingBlobsEnvironmentError } from './environment.js' +import { getDeployStore, getStore } from './main.js' beforeAll(async () => { if (semver.lt(nodeVersion, '18.0.0')) { @@ -315,28 +316,6 @@ describe('get', () => { expect(mockStore.fulfilled).toBeTruthy() }) }) - - test('Throws when the instance is missing required configuration properties', async () => { - const { fetch } = new MockFetch() - - globalThis.fetch = fetch - - const blobs1 = getStore('production') - - expect(async () => await blobs1.get(key)).rejects.toThrowError( - `The blob store is unavailable because it's missing required configuration properties`, - ) - - const blobs2 = getStore({ - name: 'production', - token: apiToken, - siteID: '', - }) - - expect(async () => await blobs2.get(key)).rejects.toThrowError( - `The blob store is unavailable because it's missing required configuration properties`, - ) - }) }) describe('set', () => { @@ -584,28 +563,6 @@ describe('set', () => { expect(mockStore.fulfilled).toBeTruthy() }) }) - - test('Throws when the instance is missing required configuration properties', async () => { - const { fetch } = new MockFetch() - - globalThis.fetch = fetch - - const blobs1 = getStore('production') - - expect(async () => await blobs1.set(key, value)).rejects.toThrowError( - `The blob store is unavailable because it's missing required configuration properties`, - ) - - const blobs2 = getStore({ - name: 'production', - token: apiToken, - siteID: '', - }) - - expect(async () => await blobs2.set(key, value)).rejects.toThrowError( - `The blob store is unavailable because it's missing required configuration properties`, - ) - }) }) describe('setJSON', () => { @@ -802,28 +759,6 @@ describe('delete', () => { expect(mockStore.fulfilled).toBeTruthy() }) }) - - test('Throws when the instance is missing required configuration properties', async () => { - const { fetch } = new MockFetch() - - globalThis.fetch = fetch - - const blobs1 = getStore('production') - - expect(async () => await blobs1.delete(key)).rejects.toThrowError( - `The blob store is unavailable because it's missing required configuration properties`, - ) - - const blobs2 = getStore({ - name: 'production', - token: apiToken, - siteID: '', - }) - - expect(async () => await blobs2.delete(key)).rejects.toThrowError( - `The blob store is unavailable because it's missing required configuration properties`, - ) - }) }) describe('Deploy scope', () => { @@ -843,12 +778,12 @@ describe('Deploy scope', () => { .get({ headers: { authorization: `Bearer ${mockToken}` }, response: new Response(value), - url: `${edgeURL}/${siteID}/${deployID}/${key}`, + url: `${edgeURL}/${siteID}/deploy:${deployID}/${key}`, }) .get({ headers: { authorization: `Bearer ${mockToken}` }, response: new Response(value), - url: `${edgeURL}/${siteID}/${deployID}/${key}`, + url: `${edgeURL}/${siteID}/deploy:${deployID}/${key}`, }) globalThis.fetch = mockStore.fetch @@ -879,6 +814,42 @@ describe('Deploy scope', () => { expect(mockStore.fulfilled).toBeTruthy() }) + + test('Returns a deploy-scoped store if the `getDeployStore` method is called', async () => { + const mockToken = 'some-token' + const mockStore = new MockFetch() + .get({ + headers: { authorization: `Bearer ${mockToken}` }, + response: new Response(value), + url: `${edgeURL}/${siteID}/deploy:${deployID}/${key}`, + }) + .get({ + headers: { authorization: `Bearer ${mockToken}` }, + response: new Response(value), + url: `${edgeURL}/${siteID}/deploy:${deployID}/${key}`, + }) + + globalThis.fetch = mockStore.fetch + + const context = { + deployID, + edgeURL, + siteID, + token: mockToken, + } + + env.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(context)).toString('base64') + + const deployStore = getDeployStore() + + const string = await deployStore.get(key) + expect(string).toBe(value) + + const stream = await deployStore.get(key, { type: 'stream' }) + expect(await streamToString(stream as unknown as NodeJS.ReadableStream)).toBe(value) + + expect(mockStore.fulfilled).toBeTruthy() + }) }) describe('Custom `fetch`', () => { @@ -909,3 +880,20 @@ describe('Custom `fetch`', () => { expect(mockStore.fulfilled).toBeTruthy() }) }) + +describe(`getStore`, () => { + test('Throws when the instance is missing required configuration properties', async () => { + const { fetch } = new MockFetch() + + globalThis.fetch = fetch + + expect(() => getStore('production')).toThrowError(MissingBlobsEnvironmentError) + expect(() => + getStore({ + name: 'production', + token: apiToken, + siteID: '', + }), + ).toThrowError(MissingBlobsEnvironmentError) + }) +}) diff --git a/src/main.ts b/src/main.ts index 9cbe45a..c0c6e89 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1 +1 @@ -export { getStore } from './store.ts' +export { getDeployStore, getStore } from './store.ts' diff --git a/src/store.ts b/src/store.ts index 6724c28..9057f29 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,5 +1,6 @@ -import { Client, Context } from './client.ts' -import { BlobInput, Fetcher, HTTPMethod } from './types.ts' +import { Client, ClientOptions, getClientOptions } from './client.ts' +import { getEnvironmentContext, MissingBlobsEnvironmentError } from './environment.ts' +import { BlobInput, HTTPMethod } from './types.ts' interface BaseStoreOptions { client: Client @@ -137,34 +138,68 @@ class Store { } } -interface GetStoreOptions extends Context { +/** + * Gets a reference to a deploy-scoped store. + */ +export const getDeployStore = (options: Partial = {}): Store => { + const context = getEnvironmentContext() + const { deployID } = context + + if (!deployID) { + throw new MissingBlobsEnvironmentError(['deployID']) + } + + const clientOptions = getClientOptions(options, context) + const client = new Client(clientOptions) + + return new Store({ client, deployID }) +} + +interface GetStoreOptions extends Partial { deployID?: string - fetch?: Fetcher name?: string } +/** + * Gets a reference to a store. + * + * @param input Either a string containing the store name or an options object + */ export const getStore: { (name: string): Store (options: GetStoreOptions): Store } = (input) => { if (typeof input === 'string') { - const client = new Client() + const clientOptions = getClientOptions({}) + const client = new Client(clientOptions) return new Store({ client, name: input }) } if (typeof input.name === 'string') { - const { fetch, name, ...context } = input - const client = new Client(context, fetch) + const { name } = input + const clientOptions = getClientOptions(input) + + if (!name) { + throw new MissingBlobsEnvironmentError(['name']) + } + + const client = new Client(clientOptions) return new Store({ client, name }) } if (typeof input.deployID === 'string') { - const { fetch, deployID, ...context } = input - const client = new Client(context, fetch) + const clientOptions = getClientOptions(input) + const { deployID } = input + + if (!deployID) { + throw new MissingBlobsEnvironmentError(['deployID']) + } + + const client = new Client(clientOptions) - return new Store({ client, name: deployID }) + return new Store({ client, deployID }) } throw new Error('`getStore()` requires a `name` or `siteID` properties.') diff --git a/test/mock_fetch.ts b/test/mock_fetch.ts index 7248a0a..78e1aab 100644 --- a/test/mock_fetch.ts +++ b/test/mock_fetch.ts @@ -57,6 +57,7 @@ export class MockFetch { // eslint-disable-next-line require-await return async (...args: Parameters) => { const [url, options] = args + const method = options?.method ?? 'get' const headers = options?.headers as Record const urlString = url.toString() const match = this.requests.find( @@ -64,7 +65,7 @@ export class MockFetch { ) if (!match) { - throw new Error(`Unexpected fetch call: ${url}`) + throw new Error(`Unexpected fetch call: ${method} ${url}`) } for (const key in match.headers) {