From 03187bb2b5b0a8d6ce1e823f811f168b05635886 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Wed, 3 Jul 2024 14:49:39 +0100 Subject: [PATCH] feat!: add region parameter (#183) --- src/client.ts | 5 ++ src/consistency.test.ts | 8 +- src/main.test.ts | 189 ++++++++++++++++++++++++++++++++++------ src/region.ts | 20 +++++ src/server.test.ts | 6 +- src/store_factory.ts | 41 ++++----- 6 files changed, 213 insertions(+), 56 deletions(-) create mode 100644 src/region.ts diff --git a/src/client.ts b/src/client.ts index 46956d8..96c85d3 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,6 +1,7 @@ import { BlobsConsistencyError, ConsistencyMode } from './consistency.ts' import { EnvironmentContext, getEnvironmentContext, MissingBlobsEnvironmentError } from './environment.ts' import { encodeMetadata, Metadata, METADATA_HEADER_EXTERNAL, METADATA_HEADER_INTERNAL } from './metadata.ts' +import { InvalidBlobsRegionError, isValidRegion } from './region.ts' import { fetchAndRetry } from './retry.ts' import { BlobInput, Fetcher, HTTPMethod } from './types.ts' import { BlobsInternalError } from './util.ts' @@ -231,6 +232,10 @@ export const getClientOptions = ( throw new MissingBlobsEnvironmentError(['siteID', 'token']) } + if (options.region !== undefined && !isValidRegion(options.region)) { + throw new InvalidBlobsRegionError(options.region) + } + const clientOptions: InternalClientOptions = { apiURL: context.apiURL ?? options.apiURL, consistency: options.consistency, diff --git a/src/consistency.test.ts b/src/consistency.test.ts index 2ccc51f..2aed891 100644 --- a/src/consistency.test.ts +++ b/src/consistency.test.ts @@ -152,6 +152,7 @@ describe('Consistency configuration', () => { cool: true, functions: ['edge', 'serverless'], } + const mockRegion = 'us-east-1' const headers = { etag: '123456789', 'x-amz-meta-user': `b64;${base64Encode(mockMetadata)}`, @@ -160,17 +161,17 @@ describe('Consistency configuration', () => { .get({ headers: { authorization: `Bearer ${edgeToken}` }, response: new Response(value), - url: `${uncachedEdgeURL}/${siteID}/deploy:${deployID}/${key}`, + url: `${uncachedEdgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}/${key}`, }) .head({ headers: { authorization: `Bearer ${edgeToken}` }, response: new Response(null, { headers }), - url: `${uncachedEdgeURL}/${siteID}/deploy:${deployID}/${key}`, + url: `${uncachedEdgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}/${key}`, }) .get({ headers: { authorization: `Bearer ${edgeToken}` }, response: new Response(value, { headers }), - url: `${uncachedEdgeURL}/${siteID}/deploy:${deployID}/${key}`, + url: `${uncachedEdgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}/${key}`, }) globalThis.fetch = mockStore.fetch @@ -179,6 +180,7 @@ describe('Consistency configuration', () => { consistency: 'strong', edgeURL, deployID, + region: mockRegion, token: edgeToken, siteID, uncachedEdgeURL, diff --git a/src/main.test.ts b/src/main.test.ts index 83c03ac..64df070 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -1316,16 +1316,17 @@ describe('Deploy scope', () => { test('Returns a deploy-scoped store if the `getDeployStore` method is called and the environment context is present', async () => { const mockToken = 'some-token' + const mockRegion = 'us-east-2' const mockStore = new MockFetch() .get({ headers: { authorization: `Bearer ${mockToken}` }, response: new Response(value), - url: `${edgeURL}/${siteID}/deploy:${deployID}/${key}`, + url: `${edgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}/${key}`, }) .get({ headers: { authorization: `Bearer ${mockToken}` }, response: new Response(value), - url: `${edgeURL}/${siteID}/deploy:${deployID}/${key}`, + url: `${edgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}/${key}`, }) globalThis.fetch = mockStore.fetch @@ -1333,6 +1334,7 @@ describe('Deploy scope', () => { const context = { deployID, edgeURL, + primaryRegion: mockRegion, siteID, token: mockToken, } @@ -1355,7 +1357,7 @@ describe('Deploy scope', () => { .get({ headers: { authorization: `Bearer ${apiToken}` }, response: new Response(JSON.stringify({ url: signedURL })), - url: `https://api.netlify.com/api/v1/blobs/${siteID}/deploy:${deployID}/${key}`, + url: `https://api.netlify.com/api/v1/blobs/${siteID}/deploy:${deployID}/${key}?region=auto`, }) .get({ response: new Response(value), @@ -1364,7 +1366,7 @@ describe('Deploy scope', () => { .get({ headers: { authorization: `Bearer ${apiToken}` }, response: new Response(JSON.stringify({ url: signedURL })), - url: `https://api.netlify.com/api/v1/blobs/${siteID}/deploy:${deployID}/${key}`, + url: `https://api.netlify.com/api/v1/blobs/${siteID}/deploy:${deployID}/${key}?region=auto`, }) .get({ response: new Response(value), @@ -1385,18 +1387,19 @@ describe('Deploy scope', () => { }) test('Returns a named deploy-scoped store if `getDeployStore` receives a string parameter', async () => { + const mockRegion = 'us-east-1' const mockToken = 'some-token' const mockStoreName = 'my-store' const mockStore = new MockFetch() .get({ headers: { authorization: `Bearer ${mockToken}` }, response: new Response(value), - url: `${edgeURL}/${siteID}/deploy:${deployID}:${mockStoreName}/${key}`, + url: `${edgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}:${mockStoreName}/${key}`, }) .get({ headers: { authorization: `Bearer ${mockToken}` }, response: new Response(value), - url: `${edgeURL}/${siteID}/deploy:${deployID}:${mockStoreName}/${key}`, + url: `${edgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}:${mockStoreName}/${key}`, }) globalThis.fetch = mockStore.fetch @@ -1404,6 +1407,7 @@ describe('Deploy scope', () => { const context = { deployID, edgeURL, + primaryRegion: mockRegion, siteID, token: mockToken, } @@ -1422,18 +1426,19 @@ describe('Deploy scope', () => { }) test('Returns a named deploy-scoped store if `getDeployStore` receives an object with a `name` property', async () => { + const mockRegion = 'us-east-1' const mockToken = 'some-token' const mockStoreName = 'my-store' const mockStore = new MockFetch() .get({ headers: { authorization: `Bearer ${mockToken}` }, response: new Response(value), - url: `${edgeURL}/${siteID}/deploy:${deployID}:${mockStoreName}/${key}`, + url: `${edgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}:${mockStoreName}/${key}`, }) .get({ headers: { authorization: `Bearer ${mockToken}` }, response: new Response(value), - url: `${edgeURL}/${siteID}/deploy:${deployID}:${mockStoreName}/${key}`, + url: `${edgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}:${mockStoreName}/${key}`, }) globalThis.fetch = mockStore.fetch @@ -1441,6 +1446,7 @@ describe('Deploy scope', () => { const context = { deployID, edgeURL, + primaryRegion: mockRegion, siteID, token: mockToken, } @@ -1459,13 +1465,14 @@ describe('Deploy scope', () => { }) test('Throws if the deploy ID fails validation', async () => { + const mockRegion = 'us-east-2' const mockToken = 'some-token' const mockStore = new MockFetch() const longDeployID = 'd'.repeat(80) globalThis.fetch = mockStore.fetch - expect(() => getDeployStore({ deployID: 'deploy/ID', siteID, token: apiToken })).toThrowError( + expect(() => getDeployStore({ deployID: 'deploy/ID', siteID, region: mockRegion, token: apiToken })).toThrowError( `'deploy/ID' is not a valid Netlify deploy ID`, ) expect(() => getStore({ deployID: 'deploy/ID', siteID, token: apiToken })).toThrowError( @@ -1478,6 +1485,7 @@ describe('Deploy scope', () => { const context = { deployID: 'uhoh!', edgeURL, + primaryRegion: mockRegion, siteID, token: mockToken, } @@ -1601,8 +1609,8 @@ describe(`getStore`, () => { }) }) -describe('Region configuration', () => { - describe('With `experimentalRegion: "auto"`', () => { +describe('Region configuration in deploy-scoped stores', () => { + describe('Without a `region` option', () => { test('The client sends a `region=auto` parameter to API calls', async () => { const mockStore = new MockFetch() .get({ @@ -1626,7 +1634,7 @@ describe('Region configuration', () => { globalThis.fetch = mockStore.fetch - const deployStore = getDeployStore({ deployID, siteID, token: apiToken, experimentalRegion: 'auto' }) + const deployStore = getDeployStore({ deployID, siteID, token: apiToken }) const string = await deployStore.get(key) expect(string).toBe(value) @@ -1637,7 +1645,7 @@ describe('Region configuration', () => { expect(mockStore.fulfilled).toBeTruthy() }) - test('Throws when used with `edgeURL`', async () => { + test('The client sends the region configured in the context to edge calls', async () => { const mockRegion = 'us-east-2' const mockToken = 'some-token' const mockStore = new MockFetch() @@ -1652,22 +1660,59 @@ describe('Region configuration', () => { url: `${edgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}/${key}`, }) + const context = { + edgeURL, + deployID, + siteID, + primaryRegion: mockRegion, + token: mockToken, + } + + env.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(context)).toString('base64') + globalThis.fetch = mockStore.fetch - expect(() => - getDeployStore({ deployID, edgeURL, siteID, token: mockToken, experimentalRegion: 'auto' }), - ).toThrowError() + 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() + }) + + test('Throws an error if using the edge URL and no region is configured in the context', async () => { + const mockRegion = 'us-east-2' + const mockToken = 'some-token' + const mockStore = new MockFetch() + .get({ + headers: { authorization: `Bearer ${mockToken}` }, + response: new Response(value), + url: `${edgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}/${key}`, + }) + .get({ + headers: { authorization: `Bearer ${mockToken}` }, + response: new Response(value), + url: `${edgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}/${key}`, + }) + + globalThis.fetch = mockStore.fetch + + expect(() => getDeployStore({ deployID, edgeURL, siteID, token: mockToken })).toThrowError() expect(mockStore.fulfilled).toBeFalsy() }) }) - describe('With `experimentalRegion: "context"`', () => { - test('Adds a `region` parameter to API calls with the value set in the context', async () => { + describe('With a `region` option', () => { + test('The client sends that region to API calls', async () => { + const mockRegion = 'us-east-1' const mockStore = new MockFetch() .get({ headers: { authorization: `Bearer ${apiToken}` }, response: new Response(JSON.stringify({ url: signedURL })), - url: `https://api.netlify.com/api/v1/blobs/${siteID}/deploy:${deployID}/${key}?region=us-east-1`, + url: `https://api.netlify.com/api/v1/blobs/${siteID}/deploy:${deployID}/${key}?region=${mockRegion}`, }) .get({ response: new Response(value), @@ -1676,7 +1721,7 @@ describe('Region configuration', () => { .get({ headers: { authorization: `Bearer ${apiToken}` }, response: new Response(JSON.stringify({ url: signedURL })), - url: `https://api.netlify.com/api/v1/blobs/${siteID}/deploy:${deployID}/${key}?region=us-east-1`, + url: `https://api.netlify.com/api/v1/blobs/${siteID}/deploy:${deployID}/${key}?region=${mockRegion}`, }) .get({ response: new Response(value), @@ -1686,7 +1731,6 @@ describe('Region configuration', () => { const context = { deployID, siteID, - primaryRegion: 'us-east-1', token: apiToken, } @@ -1694,7 +1738,7 @@ describe('Region configuration', () => { globalThis.fetch = mockStore.fetch - const deployStore = getDeployStore({ experimentalRegion: 'context' }) + const deployStore = getDeployStore({ region: mockRegion }) const string = await deployStore.get(key) expect(string).toBe(value) @@ -1705,7 +1749,89 @@ describe('Region configuration', () => { expect(mockStore.fulfilled).toBeTruthy() }) - test('Adds a `region:` segment to the edge URL path with the value set in the context', async () => { + test('The client sends that region to API calls, even if a different region is present in the context', async () => { + const mockRegion = 'us-east-1' + const mockStore = new MockFetch() + .get({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/blobs/${siteID}/deploy:${deployID}/${key}?region=${mockRegion}`, + }) + .get({ + response: new Response(value), + url: signedURL, + }) + .get({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/blobs/${siteID}/deploy:${deployID}/${key}?region=${mockRegion}`, + }) + .get({ + response: new Response(value), + url: signedURL, + }) + + const context = { + deployID, + siteID, + primaryRegion: 'us-east-2', + token: apiToken, + } + + env.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(context)).toString('base64') + + globalThis.fetch = mockStore.fetch + + const deployStore = getDeployStore({ region: mockRegion }) + + 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() + }) + + test('The client throws an error if the region supplied is not supported', async () => { + const mockRegion = 'us-east-1' + const mockStore = new MockFetch() + .get({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/blobs/${siteID}/deploy:${deployID}/${key}?region=${mockRegion}`, + }) + .get({ + response: new Response(value), + url: signedURL, + }) + .get({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/blobs/${siteID}/deploy:${deployID}/${key}?region=${mockRegion}`, + }) + .get({ + response: new Response(value), + url: signedURL, + }) + + const context = { + deployID, + siteID, + primaryRegion: 'us-east-2', + token: apiToken, + } + + env.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(context)).toString('base64') + + globalThis.fetch = mockStore.fetch + + // @ts-expect-error Knowingly supplying an invalid value to `region`. + expect(() => getDeployStore({ deployID, edgeURL, siteID, region: 'eu-west-1' })).toThrowError() + expect(mockStore.fulfilled).toBeFalsy() + }) + + test('The client sends that region to edge calls', async () => { const mockRegion = 'us-east-2' const mockToken = 'some-token' const mockStore = new MockFetch() @@ -1725,7 +1851,6 @@ describe('Region configuration', () => { const context = { deployID, edgeURL, - primaryRegion: mockRegion, siteID, token: mockToken, } @@ -1734,7 +1859,7 @@ describe('Region configuration', () => { globalThis.fetch = mockStore.fetch - const deployStore = getDeployStore({ experimentalRegion: 'context' }) + const deployStore = getDeployStore({ region: mockRegion }) const string = await deployStore.get(key) expect(string).toBe(value) @@ -1745,7 +1870,7 @@ describe('Region configuration', () => { expect(mockStore.fulfilled).toBeTruthy() }) - test('Throws an error when there is no region set in the context', async () => { + test('The client sends that region to edge calls, even if a different region is set in the context', async () => { const mockRegion = 'us-east-2' const mockToken = 'some-token' const mockStore = new MockFetch() @@ -1765,6 +1890,7 @@ describe('Region configuration', () => { const context = { deployID, edgeURL, + primaryRegion: 'us-east-1', siteID, token: mockToken, } @@ -1773,8 +1899,15 @@ describe('Region configuration', () => { globalThis.fetch = mockStore.fetch - expect(() => getDeployStore({ experimentalRegion: 'context' })).toThrowError() - expect(mockStore.fulfilled).toBeFalsy() + const deployStore = getDeployStore({ region: mockRegion }) + + 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() }) }) }) diff --git a/src/region.ts b/src/region.ts new file mode 100644 index 0000000..08f714e --- /dev/null +++ b/src/region.ts @@ -0,0 +1,20 @@ +export const REGION_AUTO = 'auto' + +const regions = { + 'us-east-1': true, + 'us-east-2': true, +} + +export type Region = keyof typeof regions + +export const isValidRegion = (input: string): input is Region => Object.keys(regions).includes(input) + +export class InvalidBlobsRegionError extends Error { + constructor(region: string) { + super( + `${region} is not a supported Netlify Blobs region. Supported values are: ${Object.keys(regions).join(', ')}.`, + ) + + this.name = 'InvalidBlobsRegionError' + } +} diff --git a/src/server.test.ts b/src/server.test.ts index 512c559..5391de3 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -306,6 +306,7 @@ test('Works with a deploy-scoped store', async () => { const store = getDeployStore({ deployID, edgeURL: `http://localhost:${port}`, + region: 'us-east-1', token, siteID, }) @@ -356,6 +357,7 @@ test('Lists site stores', async () => { const store3 = getDeployStore({ deployID: '655f77a1b48f470008e5879a', edgeURL: `http://localhost:${port}`, + region: 'us-east-1', token, siteID, }) @@ -431,7 +433,7 @@ test('Returns a signed URL or the blob directly based on the request parameters' await fs.rm(directory.path, { force: true, recursive: true }) }) -test('Accepts stores with `experimentalRegion`', async () => { +test('Accepts deploy-scoped stores with the region defined in the context', async () => { const deployID = '655f77a1b48f470008e5879a' const directory = await tmp.dir() const server = new BlobsServer({ @@ -450,7 +452,7 @@ test('Accepts stores with `experimentalRegion`', async () => { env.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(context)).toString('base64') - const store = getDeployStore({ experimentalRegion: 'context' }) + const store = getDeployStore() const key = 'my-key' const value = 'hello from a deploy store' diff --git a/src/store_factory.ts b/src/store_factory.ts index 7d35a96..94a680f 100644 --- a/src/store_factory.ts +++ b/src/store_factory.ts @@ -1,18 +1,12 @@ import { Client, ClientOptions, getClientOptions } from './client.ts' import { getEnvironmentContext, MissingBlobsEnvironmentError } from './environment.ts' +import { Region, REGION_AUTO } from './region.ts' import { Store } from './store.ts' -type ExperimentalRegion = - // Sets "region=auto", which is supported by our API in deploy stores. - | 'auto' - - // Loads the region from the environment context and throws if not found. - | 'context' - interface GetDeployStoreOptions extends Partial { deployID?: string name?: string - experimentalRegion?: ExperimentalRegion + region?: Region } /** @@ -29,22 +23,23 @@ export const getDeployStore = (input: GetDeployStoreOptions | string = {}): Stor const clientOptions = getClientOptions(options, context) - if (options.experimentalRegion === 'context') { - if (!context.primaryRegion) { - throw new Error( - 'The Netlify Blobs client was initialized with `experimentalRegion: "context"` but there is no region configured in the environment', - ) + if (!clientOptions.region) { + // If a region hasn't been supplied and we're dealing with an edge request, + // use the region from the context if one is defined, otherwise throw. + if (clientOptions.edgeURL || clientOptions.uncachedEdgeURL) { + // eslint-disable-next-line max-depth + if (!context.primaryRegion) { + throw new Error( + 'When accessing a deploy store, the Netlify Blobs client needs to be configured with a region, and one was not found in the environment. To manually set the region, set the `region` property in the `getDeployStore` options.', + ) + } + + clientOptions.region = context.primaryRegion + } else { + // For API requests, we can use `auto` and let the API choose the right + // region. + clientOptions.region = REGION_AUTO } - - clientOptions.region = context.primaryRegion - } else if (options.experimentalRegion === 'auto') { - if (clientOptions.edgeURL) { - throw new Error( - 'The Netlify Blobs client was initialized with `experimentalRegion: "auto"` which is not compatible with the `edgeURL` property; consider using `apiURL` instead', - ) - } - - clientOptions.region = options.experimentalRegion } const client = new Client(clientOptions)