diff --git a/src/main.test.ts b/src/main.test.ts index bab80ea..9f65759 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -152,7 +152,9 @@ describe('get', () => { siteID, }) - expect(async () => await blobs.get(key)).rejects.toThrowError('get operation has failed: Something went wrong') + expect(async () => await blobs.get(key)).rejects.toThrowError( + 'get operation has failed: store returned a 401 response', + ) }) test('Returns `null` when the blob entry contains an expiry date in the past', async () => { @@ -364,6 +366,86 @@ describe('set', () => { }) }) +describe('setJSON', () => { + test('Writes to the blob store using API credentials', async () => { + expect.assertions(5) + + const fetcher = async (...args: Parameters) => { + const [url, options] = args + const headers = options?.headers as Record + + expect(options?.method).toBe('put') + + if (url === `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`) { + const data = JSON.stringify({ url: signedURL }) + + expect(headers.authorization).toBe(`Bearer ${apiToken}`) + + return new Response(data) + } + + if (url === signedURL) { + expect(options?.body).toBe(JSON.stringify({ value })) + expect(headers['cache-control']).toBe('max-age=0, stale-while-revalidate=60') + + return new Response(value) + } + + throw new Error(`Unexpected fetch call: ${url}`) + } + + const blobs = new Blobs({ + authentication: { + token: apiToken, + }, + fetcher, + siteID, + }) + + await blobs.setJSON(key, { value }) + }) + + test('Accepts a TTL parameter', async () => { + expect.assertions(6) + + const ttl = new Date(Date.now() + 15_000) + const fetcher = async (...args: Parameters) => { + const [url, options] = args + const headers = options?.headers as Record + + expect(options?.method).toBe('put') + + if (url === `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`) { + const data = JSON.stringify({ url: signedURL }) + + expect(headers.authorization).toBe(`Bearer ${apiToken}`) + + return new Response(data) + } + + if (url === signedURL) { + expect(options?.body).toBe(JSON.stringify({ value })) + expect(headers['cache-control']).toBe('max-age=0, stale-while-revalidate=60') + expect(headers['x-nf-expires-at']).toBe(ttl.getTime().toString()) + + return new Response(value) + } + + throw new Error(`Unexpected fetch call: ${url}`) + } + + const blobs = new Blobs({ + authentication: { + token: apiToken, + }, + fetcher, + siteID, + }) + + await blobs.setJSON(key, { value }, { ttl }) + }) +}) + describe('delete', () => { test('Deletes from the blob store using API credentials', async () => { expect.assertions(4) diff --git a/src/main.ts b/src/main.ts index a5b723d..751a629 100644 --- a/src/main.ts +++ b/src/main.ts @@ -29,6 +29,10 @@ enum ResponseType { Text = 'text', } +interface SetOptions { + ttl?: Date | number +} + type BlobInput = ReadableStream | string | ArrayBuffer | Blob const EXPIRY_HEADER = 'x-nf-expires-at' @@ -63,14 +67,11 @@ export class Blobs { } private async getFinalRequest(key: string, method: string) { - const finalMethod = method - if ('contextURL' in this.authentication) { return { headers: { authorization: `Bearer ${this.authentication.token}`, }, - method: finalMethod, url: `${this.authentication.contextURL}/${this.siteID}/${this.context}/${key}`, } } @@ -86,11 +87,30 @@ export class Blobs { const { url } = await res.json() return { - method: finalMethod, url, } } + private static getTTLHeaders(ttl: Date | number | undefined): Record { + if (typeof ttl === 'number') { + return { + [EXPIRY_HEADER]: (Date.now() + ttl).toString(), + } + } + + if (ttl instanceof Date) { + return { + [EXPIRY_HEADER]: ttl.getTime().toString(), + } + } + + if (ttl === undefined) { + return {} + } + + throw new TypeError(`'ttl' value must be a number or a Date, ${typeof ttl} found.`) + } + private isConfigured() { return Boolean(this.authentication?.token) && Boolean(this.siteID) } @@ -105,7 +125,7 @@ export class Blobs { throw new Error("The blob store is unavailable because it's missing required configuration properties") } - const { headers: baseHeaders = {}, method: finalMethod, url } = await this.getFinalRequest(key, method) + const { headers: baseHeaders = {}, url } = await this.getFinalRequest(key, method) const headers: Record = { ...baseHeaders, ...extraHeaders, @@ -115,16 +135,14 @@ export class Blobs { headers['cache-control'] = 'max-age=0, stale-while-revalidate=60' } - const res = await this.fetcher(url, { body, headers, method: finalMethod }) + const res = await this.fetcher(url, { body, headers, method }) - if (res.status === 404 && finalMethod === HTTPMethod.Get) { + if (res.status === 404 && method === HTTPMethod.Get) { return null } if (res.status !== 200) { - const details = await res.text() - - throw new Error(`${method} operation has failed: ${details}`) + throw new Error(`${method} operation has failed: store returned a ${res.status} response`) } return res @@ -182,23 +200,16 @@ export class Blobs { throw new Error(`Invalid 'type' property: ${type}. Expected: arrayBuffer, blob, json, stream, or text.`) } - async set(key: string, data: BlobInput, { ttl }: { ttl?: Date | number } = {}) { - const headers: Record = {} - - if (typeof ttl === 'number') { - headers[EXPIRY_HEADER] = (Date.now() + ttl).toString() - } else if (ttl instanceof Date) { - headers[EXPIRY_HEADER] = ttl.getTime().toString() - } else if (ttl !== undefined) { - throw new TypeError(`'ttl' value must be a number or a Date, ${typeof ttl} found.`) - } + async set(key: string, data: BlobInput, { ttl }: SetOptions = {}) { + const headers = Blobs.getTTLHeaders(ttl) await this.makeStoreRequest(key, HTTPMethod.Put, headers, data) } - async setJSON(key: string, data: unknown) { + async setJSON(key: string, data: unknown, { ttl }: SetOptions = {}) { const payload = JSON.stringify(data) const headers = { + ...Blobs.getTTLHeaders(ttl), 'content-type': 'application/json', }