diff --git a/README.md b/README.md index 78b7f8f..fbd837f 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ const entry = await blobs.get('some-key', { type: 'json' }) console.log(entry) ``` -### `getWithMetadata(key: string, { type?: string }): Promise<{ data: any, etag: string, metadata: object }>` +### `getWithMetadata(key: string, { etag?: string, type?: string }): Promise<{ data: any, etag: string, metadata: object }>` Retrieves an object with the given key, the [ETag value](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag) for the entry, and any metadata that has been stored with the entry. @@ -214,6 +214,25 @@ const blob = await blobs.getWithMetadata('some-key', { type: 'json' }) console.log(blob.data, blob.etag, blob.metadata) ``` +The `etag` input parameter lets you implement conditional requests, where the blob is only returned if it differs from a +version you have previously obtained. + +```javascript +// Mock implementation of a system for locally persisting blobs and their etags +const cachedETag = getFromMockCache('my-key') + +// Get entry from the blob store only if its ETag is different from the one you +// have locally, which means the entry has changed since you last obtained it +const { data, etag, fresh } = await blobs.getWithMetadata('some-key', { etag: cachedETag }) + +if (fresh) { + // `data` is `null` because the local blob is fresh +} else { + // `data` contains the new blob, store it locally alongside the new ETag + writeInMockCache('my-key', data, etag) +} +``` + ### `set(key: string, value: ArrayBuffer | Blob | ReadableStream | string, { metadata?: object }): Promise` Creates an object with the given key and value. diff --git a/src/client.ts b/src/client.ts index e7dc6ba..360c8c5 100644 --- a/src/client.ts +++ b/src/client.ts @@ -113,7 +113,7 @@ export class Client { return null } - if (res.status !== 200) { + if (res.status !== 200 && res.status !== 304) { throw new Error(`${method} operation has failed: store returned a ${res.status} response`) } diff --git a/src/main.test.ts b/src/main.test.ts index 31bb1b9..db71871 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -397,6 +397,61 @@ describe('getWithMetadata', () => { expect(mockStore.fulfilled).toBeTruthy() }) + + test('Supports conditional requests', async () => { + const mockMetadata = { + name: 'Netlify', + cool: true, + functions: ['edge', 'serverless'], + } + const etags = ['"thewrongetag"', '"therightetag"'] + const metadataHeaders = { + 'x-amz-meta-user': `b64;${base64Encode(mockMetadata)}`, + } + const mockStore = new MockFetch() + .get({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: `${signedURL}b` })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + }) + .get({ + headers: { 'if-none-match': etags[0] }, + response: new Response(value, { headers: { ...metadataHeaders, etag: etags[0] }, status: 200 }), + url: `${signedURL}b`, + }) + .get({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: `${signedURL}a` })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + }) + .get({ + headers: { 'if-none-match': etags[1] }, + response: new Response(null, { headers: { ...metadataHeaders, etag: etags[0] }, status: 304 }), + url: `${signedURL}a`, + }) + + globalThis.fetch = mockStore.fetch + + const blobs = getStore({ + name: 'production', + token: apiToken, + siteID, + }) + + const staleEntry = await blobs.getWithMetadata(key, { etag: etags[0] }) + expect(staleEntry.data).toBe(value) + expect(staleEntry.etag).toBe(etags[0]) + expect(staleEntry.fresh).toBe(false) + expect(staleEntry.metadata).toEqual(mockMetadata) + + const freshEntry = await blobs.getWithMetadata(key, { etag: etags[1], type: 'text' }) + expect(freshEntry.data).toBe(null) + expect(freshEntry.etag).toBe(etags[0]) + expect(freshEntry.fresh).toBe(true) + expect(freshEntry.metadata).toEqual(mockMetadata) + + expect(mockStore.fulfilled).toBeTruthy() + }) }) describe('With edge credentials', () => { diff --git a/src/store.ts b/src/store.ts index bd94d9d..eae1346 100644 --- a/src/store.ts +++ b/src/store.ts @@ -16,6 +16,16 @@ interface NamedStoreOptions extends BaseStoreOptions { type StoreOptions = DeployStoreOptions | NamedStoreOptions +interface GetWithMetadataOptions { + etag?: string +} + +interface GetWithMetadataResult { + etag?: string + fresh: boolean + metadata: Metadata +} + interface SetOptions { /** * Arbitrary metadata object to associate with an entry. Must be seralizable @@ -24,7 +34,6 @@ interface SetOptions { metadata?: Metadata } -type BlobWithMetadata = { etag?: string } & { metadata: Metadata } type BlobResponseType = 'arrayBuffer' | 'blob' | 'json' | 'stream' | 'text' export class Store { @@ -88,34 +97,53 @@ export class Store { throw new Error(`Invalid 'type' property: ${type}. Expected: arrayBuffer, blob, json, stream, or text.`) } - async getWithMetadata(key: string): Promise<{ data: string } & BlobWithMetadata> + async getWithMetadata( + key: string, + options?: GetWithMetadataOptions, + ): Promise<{ data: string } & GetWithMetadataResult> async getWithMetadata( key: string, - { type }: { type: 'arrayBuffer' }, - ): Promise<{ data: ArrayBuffer } & BlobWithMetadata> + options: { type: 'arrayBuffer' } & GetWithMetadataOptions, + ): Promise<{ data: ArrayBuffer } & GetWithMetadataResult> - async getWithMetadata(key: string, { type }: { type: 'blob' }): Promise<{ data: Blob } & BlobWithMetadata> + async getWithMetadata( + key: string, + options: { type: 'blob' } & GetWithMetadataOptions, + ): Promise<{ data: Blob } & GetWithMetadataResult> - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async getWithMetadata(key: string, { type }: { type: 'json' }): Promise<{ data: any } & BlobWithMetadata> + /* eslint-disable @typescript-eslint/no-explicit-any */ - async getWithMetadata(key: string, { type }: { type: 'stream' }): Promise<{ data: ReadableStream } & BlobWithMetadata> + async getWithMetadata( + key: string, + options: { type: 'json' } & GetWithMetadataOptions, + ): Promise<{ data: any } & GetWithMetadataResult> - async getWithMetadata(key: string, { type }: { type: 'text' }): Promise<{ data: string } & BlobWithMetadata> + /* eslint-enable @typescript-eslint/no-explicit-any */ async getWithMetadata( key: string, - options?: { type: BlobResponseType }, + options: { type: 'stream' } & GetWithMetadataOptions, + ): Promise<{ data: ReadableStream } & GetWithMetadataResult> + + async getWithMetadata( + key: string, + options: { type: 'text' } & GetWithMetadataOptions, + ): Promise<{ data: string } & GetWithMetadataResult> + + async getWithMetadata( + key: string, + options?: { type: BlobResponseType } & GetWithMetadataOptions, ): Promise< | ({ data: ArrayBuffer | Blob | ReadableStream | string | null - } & BlobWithMetadata) + } & GetWithMetadataResult) | null > { - const { type } = options ?? {} - const res = await this.client.makeRequest({ key, method: HTTPMethod.GET, storeName: this.name }) - const etag = res?.headers.get('etag') ?? undefined + const { etag: requestETag, type } = options ?? {} + const headers = requestETag ? { 'if-none-match': requestETag } : undefined + const res = await this.client.makeRequest({ headers, key, method: HTTPMethod.GET, storeName: this.name }) + const responseETag = res?.headers.get('etag') ?? undefined let metadata: Metadata = {} @@ -131,24 +159,34 @@ export class Store { return null } + const result: GetWithMetadataResult = { + etag: responseETag, + fresh: false, + metadata, + } + + if (res.status === 304 && requestETag) { + return { data: null, ...result, fresh: true } + } + if (type === undefined || type === 'text') { - return { data: await res.text(), etag, metadata } + return { data: await res.text(), ...result } } if (type === 'arrayBuffer') { - return { data: await res.arrayBuffer(), etag, metadata } + return { data: await res.arrayBuffer(), ...result } } if (type === 'blob') { - return { data: await res.blob(), etag, metadata } + return { data: await res.blob(), ...result } } if (type === 'json') { - return { data: await res.json(), etag, metadata } + return { data: await res.json(), ...result } } if (type === 'stream') { - return { data: res.body, etag, metadata } + return { data: res.body, ...result } } throw new Error(`Invalid 'type' property: ${type}. Expected: arrayBuffer, blob, json, stream, or text.`)