From 488487f3cbfccae34ed39d546ea79c48d31d92b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Mon, 17 Jul 2023 15:47:23 +0100 Subject: [PATCH 1/6] feat: various updates --- src/main.test.ts | 44 +++++++++++++++++++++++++++++++++++++------- src/main.ts | 32 ++++++++++++++++++++++---------- 2 files changed, 59 insertions(+), 17 deletions(-) diff --git a/src/main.test.ts b/src/main.test.ts index 52b9c2d..9cac2e9 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,14 +19,14 @@ beforeAll(async () => { } }) +const siteID = '12345' +const key = '54321' +const value = 'some value' +const apiToken = 'some token' +const signedURL = 'https://signed.url/123456789' + 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 fetcher = async (...args: Parameters) => { const [url, options] = args const headers = options?.headers as Record @@ -58,4 +57,35 @@ describe('With API credentials', () => { expect(val).toBe(value) }) + + 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 + + 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') + }) }) diff --git a/src/main.ts b/src/main.ts index 5cc473d..840b104 100644 --- a/src/main.ts +++ b/src/main.ts @@ -69,7 +69,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 +100,19 @@ 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 !== 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) + delete(key: string) { + return this.makeStoreRequest(key, HTTPMethod.Delete) } async get(key: string): Promise @@ -119,20 +127,24 @@ export class Blobs { const { type } = options ?? {} const res = await this.makeStoreRequest(key, HTTPMethod.Get) + if (res.status === 404) { + return null + } + 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 +154,8 @@ 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) + set(key: string, data: BlobInput) { + return this.makeStoreRequest(key, HTTPMethod.Put, {}, data) } async setJSON(key: string, data: unknown) { From a6355aa1fb7e5c7b507be73e2d12140c66eb4205 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Mon, 17 Jul 2023 15:50:58 +0100 Subject: [PATCH 2/6] feat: update signature --- src/main.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main.ts b/src/main.ts index 840b104..8f4dc2a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -111,8 +111,8 @@ export class Blobs { return res } - delete(key: string) { - return this.makeStoreRequest(key, HTTPMethod.Delete) + async delete(key: string) { + await this.makeStoreRequest(key, HTTPMethod.Delete) } async get(key: string): Promise @@ -154,8 +154,8 @@ export class Blobs { throw new Error(`Invalid 'type' property: ${type}. Expected: arrayBuffer, blob, json, stream, or text.`) } - set(key: string, data: BlobInput) { - return this.makeStoreRequest(key, HTTPMethod.Put, {}, data) + async set(key: string, data: BlobInput) { + await this.makeStoreRequest(key, HTTPMethod.Put, {}, data) } async setJSON(key: string, data: unknown) { From 37182c582c8acb986f0adc97437a887a156072fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Mon, 17 Jul 2023 15:59:31 +0100 Subject: [PATCH 3/6] feat: return null on GET 404 --- src/main.test.ts | 35 +++++++++++++++++++++++++++++++++-- src/main.ts | 8 ++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/main.test.ts b/src/main.test.ts index 9cac2e9..c20752a 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -25,8 +25,8 @@ const value = 'some value' const apiToken = 'some token' const signedURL = 'https://signed.url/123456789' -describe('With API credentials', () => { - test('Reads a key from the blob store', async () => { +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 @@ -58,6 +58,37 @@ 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 + + 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 diff --git a/src/main.ts b/src/main.ts index 8f4dc2a..c73e134 100644 --- a/src/main.ts +++ b/src/main.ts @@ -102,6 +102,10 @@ export class Blobs { 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() @@ -127,8 +131,8 @@ export class Blobs { const { type } = options ?? {} const res = await this.makeStoreRequest(key, HTTPMethod.Get) - if (res.status === 404) { - return null + if (res === null) { + return res } if (type === undefined || type === ResponseType.Text) { From 3c9c9b2a6d2f678fbc97b74221c82ba259ffd69a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Mon, 17 Jul 2023 17:22:17 +0100 Subject: [PATCH 4/6] feat: add support for TTL --- src/main.test.ts | 123 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.ts | 25 +++++++++- 2 files changed, 146 insertions(+), 2 deletions(-) diff --git a/src/main.test.ts b/src/main.test.ts index c20752a..d6d1c9d 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -31,6 +31,8 @@ describe('get', () => { 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 }) @@ -63,6 +65,8 @@ describe('get', () => { 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 }) @@ -94,6 +98,8 @@ describe('get', () => { 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 }) @@ -119,4 +125,121 @@ describe('get', () => { 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.getMilliseconds().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 }) + }) }) diff --git a/src/main.ts b/src/main.ts index c73e134..ee15ef2 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 @@ -130,6 +132,15 @@ 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 @@ -158,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.getMilliseconds().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) { From 2a9801492396c68f997d459899b81f840ac114b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Mon, 17 Jul 2023 18:02:20 +0100 Subject: [PATCH 5/6] chore: add test --- src/main.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/main.test.ts b/src/main.test.ts index d6d1c9d..d14e6c1 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -243,3 +243,42 @@ describe('set', () => { 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) + }) +}) From 1bc9f427f859b8fea4a74a57108c7361cfb92060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Mon, 17 Jul 2023 18:02:50 +0100 Subject: [PATCH 6/6] fix: use getTime() --- src/main.test.ts | 2 +- src/main.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.test.ts b/src/main.test.ts index d14e6c1..92032ee 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -224,7 +224,7 @@ describe('set', () => { 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.getMilliseconds().toString()) + expect(headers['x-nf-expires-at']).toBe(ttl.getTime().toString()) return new Response(value) } diff --git a/src/main.ts b/src/main.ts index ee15ef2..29e6e47 100644 --- a/src/main.ts +++ b/src/main.ts @@ -175,7 +175,7 @@ export class Blobs { if (typeof ttl === 'number') { headers[EXPIRY_HEADER] = (Date.now() + ttl).toString() } else if (ttl instanceof Date) { - headers[EXPIRY_HEADER] = ttl.getMilliseconds().toString() + 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.`) }