diff --git a/src/main.test.ts b/src/main.test.ts index 52b9c2d..92032ee 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -1,7 +1,6 @@ import { version as nodeVersion } from 'process' import semver from 'semver' - import { describe, test, expect, beforeAll } from 'vitest' import { Blobs } from './main.js' @@ -20,18 +19,20 @@ beforeAll(async () => { } }) -describe('With API credentials', () => { - test('Reads a key from the blob store', async () => { - const siteID = '12345' - const key = '54321' - const value = 'some value' - const apiToken = 'some token' - const signedURL = 'https://signed.url/123456789' +const siteID = '12345' +const key = '54321' +const value = 'some value' +const apiToken = 'some token' +const signedURL = 'https://signed.url/123456789' +describe('get', () => { + test('Reads from the blob store using API credentials', async () => { const fetcher = async (...args: Parameters) => { const [url, options] = args const headers = options?.headers as Record + expect(options?.method).toBe('get') + if (url === `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`) { const data = JSON.stringify({ url: signedURL }) @@ -58,4 +59,226 @@ describe('With API credentials', () => { expect(val).toBe(value) }) + + test('Returns `null` when the pre-signed URL returns a 404', async () => { + const fetcher = async (...args: Parameters) => { + const [url, options] = args + const headers = options?.headers as Record + + expect(options?.method).toBe('get') + + 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) { + return new Response('Something went wrong', { status: 404 }) + } + + throw new Error(`Unexpected fetch call: ${url}`) + } + + const blobs = new Blobs({ + authentication: { + token: apiToken, + }, + fetcher, + siteID, + }) + + expect(await blobs.get(key)).toBeNull() + }) + + test('Throws when a pre-signed URL returns a non-200 status code', async () => { + const fetcher = async (...args: Parameters) => { + const [url, options] = args + const headers = options?.headers as Record + + expect(options?.method).toBe('get') + + 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) { + return new Response('Something went wrong', { status: 401 }) + } + + throw new Error(`Unexpected fetch call: ${url}`) + } + + const blobs = new Blobs({ + authentication: { + token: apiToken, + }, + fetcher, + siteID, + }) + + expect(async () => await blobs.get(key)).rejects.toThrowError('get operation has failed: Something went wrong') + }) + + test('Returns `null` when the blob entry contains an expiry date in the past', async () => { + const fetcher = async (...args: Parameters) => { + const [url, options] = args + const headers = options?.headers as Record + + expect(options?.method).toBe('get') + + 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) { + return new Response(value, { + headers: { + 'x-nf-expires-at': (Date.now() - 1000).toString(), + }, + }) + } + + throw new Error(`Unexpected fetch call: ${url}`) + } + + const blobs = new Blobs({ + authentication: { + token: apiToken, + }, + fetcher, + siteID, + }) + + expect(await blobs.get(key)).toBeNull() + }) +}) + +describe('set', () => { + 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(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.set(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(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.set(key, value, { ttl }) + }) +}) + +describe('delete', () => { + test('Deletes from the blob store using API credentials', async () => { + expect.assertions(4) + + const fetcher = async (...args: Parameters) => { + const [url, options] = args + const headers = options?.headers as Record + + expect(options?.method).toBe('delete') + + 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).toBeUndefined() + + return new Response(value) + } + + throw new Error(`Unexpected fetch call: ${url}`) + } + + const blobs = new Blobs({ + authentication: { + token: apiToken, + }, + fetcher, + siteID, + }) + + await blobs.delete(key) + }) }) diff --git a/src/main.ts b/src/main.ts index 5cc473d..29e6e47 100644 --- a/src/main.ts +++ b/src/main.ts @@ -31,6 +31,8 @@ enum ResponseType { type BlobInput = ReadableStream | string | ArrayBuffer | Blob +const EXPIRY_HEADER = 'x-nf-expires-at' + export class Blobs { private authentication: APICredentials | ContextCredentials private context: string @@ -69,7 +71,7 @@ export class Blobs { authorization: `Bearer ${this.authentication.token}`, }, method: finalMethod, - url: `${this.authentication.contextURL}/${this.siteID}:${this.context}:${key}`, + url: `${this.authentication.contextURL}/${this.siteID}/${this.context}/${key}`, } } @@ -100,11 +102,23 @@ export class Blobs { headers['cache-control'] = 'max-age=0, stale-while-revalidate=60' } - return await this.fetcher(url, { body, headers, method: finalMethod }) + const res = await this.fetcher(url, { body, headers, method: finalMethod }) + + if (res.status === 404 && finalMethod === HTTPMethod.Get) { + return null + } + + if (res.status !== 200) { + const details = await res.text() + + throw new Error(`${method} operation has failed: ${details}`) + } + + return res } async delete(key: string) { - return await this.makeStoreRequest(key, HTTPMethod.Delete) + await this.makeStoreRequest(key, HTTPMethod.Delete) } async get(key: string): Promise @@ -118,21 +132,34 @@ export class Blobs { ): Promise { const { type } = options ?? {} const res = await this.makeStoreRequest(key, HTTPMethod.Get) + const expiry = res?.headers.get(EXPIRY_HEADER) + + if (typeof expiry === 'string') { + const expiryTS = Number.parseInt(expiry) + + if (!Number.isNaN(expiryTS) && expiryTS <= Date.now()) { + return null + } + } + + if (res === null) { + return res + } if (type === undefined || type === ResponseType.Text) { - return await res.text() + return res.text() } if (type === ResponseType.ArrayBuffer) { - return await res.arrayBuffer() + return res.arrayBuffer() } if (type === ResponseType.Blob) { - return await res.blob() + return res.blob() } if (type === ResponseType.JSON) { - return await res.json() + return res.json() } if (type === ResponseType.Stream) { @@ -142,8 +169,18 @@ export class Blobs { throw new Error(`Invalid 'type' property: ${type}. Expected: arrayBuffer, blob, json, stream, or text.`) } - async set(key: string, data: BlobInput) { - await this.makeStoreRequest(key, HTTPMethod.Put, {}, data) + 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.`) + } + + await this.makeStoreRequest(key, HTTPMethod.Put, headers, data) } async setJSON(key: string, data: unknown) {