From 30b5785d8b02886a91b42d10b7a2b681d8203910 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Sun, 30 Jul 2023 11:57:57 +0100 Subject: [PATCH 1/8] chore: add MockFetch class --- src/main.test.ts | 574 ++++++++++++++++++--------------------------- test/mock_fetch.ts | 97 ++++++++ 2 files changed, 324 insertions(+), 347 deletions(-) create mode 100644 test/mock_fetch.ts diff --git a/src/main.test.ts b/src/main.test.ts index 00efcfd..2170426 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -5,6 +5,7 @@ import semver from 'semver' import tmp from 'tmp-promise' import { describe, test, expect, beforeAll } from 'vitest' +import { MockFetch } from '../test/mock_fetch.js' import { streamToString } from '../test/util.js' import { Blobs } from './main.js' @@ -31,32 +32,31 @@ 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 }) - - expect(headers.authorization).toBe(`Bearer ${apiToken}`) - - return new Response(data) - } - - if (url === signedURL) { - return new Response(value) - } - - throw new Error(`Unexpected fetch call: ${url}`) - } + const store = new MockFetch() + .get({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + }) + .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/sites/${siteID}/blobs/${key}?context=production`, + }) + .get({ + response: new Response(value), + url: signedURL, + }) const blobs = new Blobs({ authentication: { token: apiToken, }, - fetcher, + fetcher: store.fetcher, siteID, }) @@ -65,148 +65,112 @@ describe('get', () => { const stream = await blobs.get(key, { type: 'stream' }) expect(await streamToString(stream as unknown as NodeJS.ReadableStream)).toBe(value) + + expect(store.fulfilled).toBeTruthy() }) 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 store = new MockFetch() + .get({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + }) + .get({ + response: new Response('Something went wrong', { status: 404 }), + url: signedURL, + }) const blobs = new Blobs({ authentication: { token: apiToken, }, - fetcher, + fetcher: store.fetcher, siteID, }) expect(await blobs.get(key)).toBeNull() + expect(store.fulfilled).toBeTruthy() }) test('Throws when the API 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`) { - expect(headers.authorization).toBe(`Bearer ${apiToken}`) - - return new Response(null, { status: 401, statusText: 'Unauthorized' }) - } - - throw new Error(`Unexpected fetch call: ${url}`) - } + const store = new MockFetch().get({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(null, { status: 401 }), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + }) const blobs = new Blobs({ authentication: { token: apiToken, }, - fetcher, + fetcher: store.fetcher, siteID, }) expect(async () => await blobs.get(key)).rejects.toThrowError( 'get operation has failed: API returned a 401 response', ) + expect(store.fulfilled).toBeTruthy() }) 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 store = new MockFetch() + .get({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + }) + .get({ + response: new Response('Something went wrong', { status: 401 }), + url: signedURL, + }) const blobs = new Blobs({ authentication: { token: apiToken, }, - fetcher, + fetcher: store.fetcher, siteID, }) - expect(async () => await blobs.get(key)).rejects.toThrowError( + await expect(async () => await blobs.get(key)).rejects.toThrowError( 'get operation has failed: store returned a 401 response', ) + + expect(store.fulfilled).toBeTruthy() }) 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, { + const store = new MockFetch() + .get({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + }) + .get({ + response: new Response(value, { headers: { 'x-nf-expires-at': (Date.now() - 1000).toString(), }, - }) - } - - throw new Error(`Unexpected fetch call: ${url}`) - } + }), + url: signedURL, + }) const blobs = new Blobs({ authentication: { token: apiToken, }, - fetcher, + fetcher: store.fetcher, siteID, }) expect(await blobs.get(key)).toBeNull() + expect(store.fulfilled).toBeTruthy() }) test('Throws when the instance is missing required configuration properties', async () => { - const fetcher = (...args: Parameters) => { - const [url] = args - - throw new Error(`Unexpected fetch call: ${url}`) - } + const { fetcher } = new MockFetch() const blobs1 = new Blobs({ authentication: { @@ -235,112 +199,83 @@ describe('get', () => { 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 store = new MockFetch() + .put({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + }) + .put({ + body: value, + headers: { 'cache-control': 'max-age=0, stale-while-revalidate=60' }, + response: new Response(null), + url: signedURL, + }) const blobs = new Blobs({ authentication: { token: apiToken, }, - fetcher, + fetcher: store.fetcher, siteID, }) await blobs.set(key, value) + + expect(store.fulfilled).toBeTruthy() }) 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 store = new MockFetch() + .put({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + }) + .put({ + body: value, + headers: { + 'cache-control': 'max-age=0, stale-while-revalidate=60', + 'x-nf-expires-at': ttl.getTime().toString(), + }, + response: new Response(null), + url: signedURL, + }) const blobs = new Blobs({ authentication: { token: apiToken, }, - fetcher, + fetcher: store.fetcher, siteID, }) await blobs.set(key, value, { ttl }) + + expect(store.fulfilled).toBeTruthy() }) // We need `Readable.toWeb` to be available, which needs Node 16+. if (semver.gte(nodeVersion, '16.0.0')) { test('Accepts a file', async () => { - expect.assertions(5) - const fileContents = 'Hello from a file' - 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(await streamToString(options?.body as unknown as NodeJS.ReadableStream)).toBe(fileContents) - expect(headers['cache-control']).toBe('max-age=0, stale-while-revalidate=60') - - return new Response(value) - } - - throw new Error(`Unexpected fetch call: ${url}`) - } + const store = new MockFetch() + .put({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + }) + .put({ + body: async (body) => { + expect(await streamToString(body as unknown as NodeJS.ReadableStream)).toBe(fileContents) + }, + headers: { + 'cache-control': 'max-age=0, stale-while-revalidate=60', + }, + response: new Response(null), + url: signedURL, + }) const { cleanup, path } = await tmp.file() @@ -350,50 +285,41 @@ describe('set', () => { authentication: { token: apiToken, }, - fetcher, + fetcher: store.fetcher, siteID, }) await blobs.setFile(key, path) + + expect(store.fulfilled).toBeTruthy() + await cleanup() }) } test('Throws when the API 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('put') - - if (url === `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`) { - expect(headers.authorization).toBe(`Bearer ${apiToken}`) - - return new Response(null, { status: 401 }) - } - - throw new Error(`Unexpected fetch call: ${url}`) - } + const store = new MockFetch().put({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(null, { status: 401 }), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + }) const blobs = new Blobs({ authentication: { token: apiToken, }, - fetcher, + fetcher: store.fetcher, siteID, }) expect(async () => await blobs.set(key, 'value')).rejects.toThrowError( 'put operation has failed: API returned a 401 response', ) + expect(store.fulfilled).toBeTruthy() }) test('Throws when the instance is missing required configuration properties', async () => { - const fetcher = (...args: Parameters) => { - const [url] = args - - throw new Error(`Unexpected fetch call: ${url}`) - } + const { fetcher } = new MockFetch() const blobs1 = new Blobs({ authentication: { @@ -420,216 +346,170 @@ describe('set', () => { }) test('Retries failed operations', async () => { - let attempts = 0 - - 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) { - attempts += 1 - - expect(options?.body).toBe(value) - - if (attempts === 1) { - return new Response(null, { status: 500 }) - } - - if (attempts === 2) { - throw new Error('Some network problem') - } - - if (attempts === 3) { - return new Response(null, { headers: { 'X-RateLimit-Reset': '10' }, status: 429 }) - } - - return new Response(value) - } - - throw new Error(`Unexpected fetch call: ${url}`) - } + const store = new MockFetch() + .put({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + }) + .put({ + body: value, + headers: { + 'cache-control': 'max-age=0, stale-while-revalidate=60', + }, + response: new Response(null, { status: 500 }), + url: signedURL, + }) + .put({ + body: value, + headers: { + 'cache-control': 'max-age=0, stale-while-revalidate=60', + }, + response: new Error('Some network problem'), + url: signedURL, + }) + .put({ + body: value, + headers: { + 'cache-control': 'max-age=0, stale-while-revalidate=60', + }, + response: new Response(null, { headers: { 'X-RateLimit-Reset': '10' }, status: 429 }), + url: signedURL, + }) + .put({ + body: value, + headers: { + 'cache-control': 'max-age=0, stale-while-revalidate=60', + }, + response: new Response(null), + url: signedURL, + }) const blobs = new Blobs({ authentication: { token: apiToken, }, - fetcher, + fetcher: store.fetcher, siteID, }) await blobs.set(key, value) - expect(attempts).toBe(4) + expect(store.fulfilled).toBeTruthy() }) }) 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 store = new MockFetch() + .put({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + }) + .put({ + body: JSON.stringify({ value }), + headers: { + 'cache-control': 'max-age=0, stale-while-revalidate=60', + }, + response: new Response(null), + url: signedURL, + }) const blobs = new Blobs({ authentication: { token: apiToken, }, - fetcher, + fetcher: store.fetcher, siteID, }) await blobs.setJSON(key, { value }) + + expect(store.fulfilled).toBeTruthy() }) 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 store = new MockFetch() + .put({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + }) + .put({ + body: JSON.stringify({ value }), + headers: { + 'cache-control': 'max-age=0, stale-while-revalidate=60', + 'x-nf-expires-at': ttl.getTime().toString(), + }, + response: new Response(null), + url: signedURL, + }) const blobs = new Blobs({ authentication: { token: apiToken, }, - fetcher, + fetcher: store.fetcher, siteID, }) await blobs.setJSON(key, { value }, { ttl }) + + expect(store.fulfilled).toBeTruthy() }) }) 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 store = new MockFetch() + .delete({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + }) + .delete({ + response: new Response(null), + url: signedURL, + }) const blobs = new Blobs({ authentication: { token: apiToken, }, - fetcher, + fetcher: store.fetcher, siteID, }) await blobs.delete(key) + + expect(store.fulfilled).toBeTruthy() }) test('Throws when the API 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('delete') - - if (url === `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`) { - expect(headers.authorization).toBe(`Bearer ${apiToken}`) - - return new Response(null, { status: 401 }) - } - - if (url === signedURL) { - return new Response('Something went wrong', { status: 401 }) - } - - throw new Error(`Unexpected fetch call: ${url}`) - } + const store = new MockFetch().delete({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(null, { status: 401 }), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + }) const blobs = new Blobs({ authentication: { token: apiToken, }, - fetcher, + fetcher: store.fetcher, siteID, }) expect(async () => await blobs.delete(key)).rejects.toThrowError( 'delete operation has failed: API returned a 401 response', ) + expect(store.fulfilled).toBeTruthy() }) test('Throws when the instance is missing required configuration properties', async () => { - const fetcher = (...args: Parameters) => { - const [url] = args - - throw new Error(`Unexpected fetch call: ${url}`) - } + const { fetcher } = new MockFetch() const blobs1 = new Blobs({ authentication: { diff --git a/test/mock_fetch.ts b/test/mock_fetch.ts new file mode 100644 index 0000000..6da7cb9 --- /dev/null +++ b/test/mock_fetch.ts @@ -0,0 +1,97 @@ +import { expect } from 'vitest' + +type BodyFunction = (req: BodyInit | null | undefined) => void + +interface ExpectedRequest { + body?: string | BodyFunction + fulfilled: boolean + headers: Record + method: string + response: Response | Error + url: string +} + +interface ExpectedRequestOptions { + body?: string | BodyFunction + headers?: Record + response: Response | Error + url: string +} + +export class MockFetch { + requests: ExpectedRequest[] + + constructor() { + this.requests = [] + } + + private addExpectedRequest({ + body, + headers = {}, + method, + response, + url, + }: ExpectedRequestOptions & { method: string }) { + this.requests.push({ body, fulfilled: false, headers, method, response, url }) + + return this + } + + delete(options: ExpectedRequestOptions) { + return this.addExpectedRequest({ ...options, method: 'delete' }) + } + + get(options: ExpectedRequestOptions) { + return this.addExpectedRequest({ ...options, method: 'get' }) + } + + post(options: ExpectedRequestOptions) { + return this.addExpectedRequest({ ...options, method: 'post' }) + } + + put(options: ExpectedRequestOptions) { + return this.addExpectedRequest({ ...options, method: 'put' }) + } + + get fetcher() { + // eslint-disable-next-line require-await + return async (...args: Parameters) => { + const [url, options] = args + const headers = options?.headers as Record + const urlString = url.toString() + const match = this.requests.find( + (request) => request.method === options?.method && request.url === urlString && !request.fulfilled, + ) + + if (!match) { + throw new Error(`Unexpected fetch call: ${url}`) + } + + for (const key in match.headers) { + expect(headers[key]).toBe(match.headers[key]) + } + + if (typeof match.body === 'string') { + expect(options?.body).toBe(match.body) + } else if (typeof match.body === 'function') { + const bodyFn = match.body + + expect(() => bodyFn(options?.body)).not.toThrow() + } else { + expect(options?.body).toBeUndefined() + } + + match.fulfilled = true + + if (match.response instanceof Error) { + throw match.response + } + + return match.response + } + } + + get fulfilled() { + return this.requests.every((request) => request.fulfilled) + } +} From 2fa2a31651a62f404899bd7c2343206895a6d98a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Sun, 30 Jul 2023 11:59:49 +0100 Subject: [PATCH 2/8] feat: add setFiles method --- package-lock.json | 19 ++++++++++- package.json | 3 +- src/main.test.ts | 80 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.ts | 15 +++++++++ 4 files changed, 115 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 76a5334..71968d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "1.5.0", "license": "MIT", "dependencies": { - "esbuild": "0.18.16" + "esbuild": "0.18.16", + "p-map": "^6.0.0" }, "devDependencies": { "@commitlint/cli": "^17.0.0", @@ -6288,6 +6289,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-6.0.0.tgz", + "integrity": "sha512-T8BatKGY+k5rU+Q/GTYgrEf2r4xRMevAN5mtXc2aPc4rS1j3s+vWTaO2Wag94neXuCAUAs8cxBL9EeB5EA6diw==", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -12968,6 +12980,11 @@ "p-limit": "^3.0.2" } }, + "p-map": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-6.0.0.tgz", + "integrity": "sha512-T8BatKGY+k5rU+Q/GTYgrEf2r4xRMevAN5mtXc2aPc4rS1j3s+vWTaO2Wag94neXuCAUAs8cxBL9EeB5EA6diw==" + }, "parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", diff --git a/package.json b/package.json index adbce55..3bdba75 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "node": "^14.16.0 || >=16.0.0" }, "dependencies": { - "esbuild": "0.18.16" + "esbuild": "0.18.16", + "p-map": "^6.0.0" } } diff --git a/src/main.test.ts b/src/main.test.ts index 2170426..05be0f4 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -295,6 +295,86 @@ describe('set', () => { await cleanup() }) + + test('Accepts multiple files concurrently', async () => { + const contents = ['Hello from key-0', 'Hello from key-1', 'Hello from key-2'] + const signedURLs = ['https://signed-url.aws/0', 'https://signed-url.aws/1', 'https://signed-url.aws/2'] + + const store = new MockFetch() + .put({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURLs[0] })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/key-0?context=production`, + }) + .put({ + body: async (body) => { + expect(await streamToString(body as unknown as NodeJS.ReadableStream)).toBe(contents[0]) + }, + headers: { + 'cache-control': 'max-age=0, stale-while-revalidate=60', + }, + response: new Response(null), + url: signedURLs[0], + }) + .put({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURLs[1] })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/key-1?context=production`, + }) + .put({ + body: async (body) => { + expect(await streamToString(body as unknown as NodeJS.ReadableStream)).toBe(contents[1]) + }, + headers: { + 'cache-control': 'max-age=0, stale-while-revalidate=60', + }, + response: new Response(null), + url: signedURLs[1], + }) + .put({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURLs[2] })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/key-2?context=production`, + }) + .put({ + body: async (body) => { + expect(await streamToString(body as unknown as NodeJS.ReadableStream)).toBe(contents[2]) + }, + headers: { + 'cache-control': 'max-age=0, stale-while-revalidate=60', + }, + response: new Response(null), + url: signedURLs[2], + }) + + const writes = await Promise.all( + contents.map(async (content) => { + const { cleanup, path } = await tmp.file() + + await writeFile(path, content) + + return { cleanup, path } + }), + ) + const files = writes.map(({ path }, idx) => ({ + key: `key-${idx}`, + path, + })) + + const blobs = new Blobs({ + authentication: { + token: apiToken, + }, + fetcher: store.fetcher, + siteID, + }) + + await blobs.setFiles(files) + + expect(store.fulfilled).toBeTruthy() + + await Promise.all(writes.map(({ cleanup }) => cleanup())) + }) } test('Throws when the API returns a non-200 status code', async () => { diff --git a/src/main.ts b/src/main.ts index a587ba9..d96e810 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,8 @@ import { createReadStream } from 'node:fs' import { stat } from 'node:fs/promises' import { Readable } from 'node:stream' +import pMap from 'p-map' + import { fetchAndRetry } from './retry.ts' interface APICredentials { @@ -31,6 +33,15 @@ interface SetOptions { ttl?: Date | number } +interface SetFilesItem extends SetOptions { + key: string + path: string +} + +interface SetFilesOptions { + concurrency?: number +} + type BlobInput = ReadableStream | string | ArrayBuffer | Blob const EXPIRY_HEADER = 'x-nf-expires-at' @@ -229,6 +240,10 @@ export class Blobs { await this.makeStoreRequest(key, HTTPMethod.Put, headers, file as ReadableStream) } + setFiles(files: SetFilesItem[], { concurrency = 5 }: SetFilesOptions = {}) { + return pMap(files, ({ key, path, ...options }) => this.setFile(key, path, options), { concurrency }) + } + async setJSON(key: string, data: unknown, { ttl }: SetOptions = {}) { const payload = JSON.stringify(data) const headers = { From eb635fd1abcd741a24ed6ab2d0c9188ad2649430 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Sun, 30 Jul 2023 12:44:02 +0100 Subject: [PATCH 3/8] chore: add tests for context credentials --- src/main.test.ts | 231 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 230 insertions(+), 1 deletion(-) diff --git a/src/main.test.ts b/src/main.test.ts index 05be0f4..2870d1c 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -29,6 +29,8 @@ const key = '54321' const value = 'some value' const apiToken = 'some token' const signedURL = 'https://signed.url/123456789' +const edgeToken = 'some other token' +const edgeURL = 'https://cloudfront.url' describe('get', () => { test('Reads from the blob store using API credentials', async () => { @@ -69,6 +71,37 @@ describe('get', () => { expect(store.fulfilled).toBeTruthy() }) + test('Reads from the blob store using context credentials', async () => { + const store = new MockFetch() + .get({ + headers: { authorization: `Bearer ${edgeToken}` }, + response: new Response(value), + url: `${edgeURL}/${siteID}/production/${key}`, + }) + .get({ + headers: { authorization: `Bearer ${edgeToken}` }, + response: new Response(value), + url: `${edgeURL}/${siteID}/production/${key}`, + }) + + const blobs = new Blobs({ + authentication: { + contextURL: edgeURL, + token: edgeToken, + }, + fetcher: store.fetcher, + siteID, + }) + + const string = await blobs.get(key) + expect(string).toBe(value) + + const stream = await blobs.get(key, { type: 'stream' }) + expect(await streamToString(stream as unknown as NodeJS.ReadableStream)).toBe(value) + + expect(store.fulfilled).toBeTruthy() + }) + test('Returns `null` when the pre-signed URL returns a 404', async () => { const store = new MockFetch() .get({ @@ -93,6 +126,26 @@ describe('get', () => { expect(store.fulfilled).toBeTruthy() }) + test('Returns `null` when the edge URL returns a 404', async () => { + const store = new MockFetch().get({ + headers: { authorization: `Bearer ${edgeToken}` }, + response: new Response(null, { status: 404 }), + url: `${edgeURL}/${siteID}/production/${key}`, + }) + + const blobs = new Blobs({ + authentication: { + contextURL: edgeURL, + token: edgeToken, + }, + fetcher: store.fetcher, + siteID, + }) + + expect(await blobs.get(key)).toBeNull() + expect(store.fulfilled).toBeTruthy() + }) + test('Throws when the API returns a non-200 status code', async () => { const store = new MockFetch().get({ headers: { authorization: `Bearer ${apiToken}` }, @@ -141,6 +194,29 @@ describe('get', () => { expect(store.fulfilled).toBeTruthy() }) + test('Throws when an edge URL returns a non-200 status code', async () => { + const store = new MockFetch().get({ + headers: { authorization: `Bearer ${edgeToken}` }, + response: new Response(null, { status: 401 }), + url: `${edgeURL}/${siteID}/production/${key}`, + }) + + const blobs = new Blobs({ + authentication: { + contextURL: edgeURL, + token: edgeToken, + }, + fetcher: store.fetcher, + siteID, + }) + + await expect(async () => await blobs.get(key)).rejects.toThrowError( + 'get operation has failed: store returned a 401 response', + ) + + expect(store.fulfilled).toBeTruthy() + }) + test('Returns `null` when the blob entry contains an expiry date in the past', async () => { const store = new MockFetch() .get({ @@ -225,6 +301,28 @@ describe('set', () => { expect(store.fulfilled).toBeTruthy() }) + test('Writes to the blob store using context credentials', async () => { + const store = new MockFetch().put({ + body: value, + headers: { authorization: `Bearer ${edgeToken}`, 'cache-control': 'max-age=0, stale-while-revalidate=60' }, + response: new Response(null), + url: `${edgeURL}/${siteID}/production/${key}`, + }) + + const blobs = new Blobs({ + authentication: { + contextURL: edgeURL, + token: edgeToken, + }, + fetcher: store.fetcher, + siteID, + }) + + await blobs.set(key, value) + + expect(store.fulfilled).toBeTruthy() + }) + test('Accepts a TTL parameter', async () => { const ttl = new Date(Date.now() + 15_000) const store = new MockFetch() @@ -398,6 +496,30 @@ describe('set', () => { expect(store.fulfilled).toBeTruthy() }) + test('Throws when the edge URL returns a non-200 status code', async () => { + const store = new MockFetch().put({ + body: value, + headers: { authorization: `Bearer ${edgeToken}`, 'cache-control': 'max-age=0, stale-while-revalidate=60' }, + response: new Response(null, { status: 401 }), + url: `${edgeURL}/${siteID}/production/${key}`, + }) + + const blobs = new Blobs({ + authentication: { + contextURL: edgeURL, + token: edgeToken, + }, + fetcher: store.fetcher, + siteID, + }) + + await expect(async () => await blobs.set(key, value)).rejects.toThrowError( + 'put operation has failed: store returned a 401 response', + ) + + expect(store.fulfilled).toBeTruthy() + }) + test('Throws when the instance is missing required configuration properties', async () => { const { fetcher } = new MockFetch() @@ -425,7 +547,7 @@ describe('set', () => { ) }) - test('Retries failed operations', async () => { + test('Retries failed operations when using API credentials', async () => { const store = new MockFetch() .put({ headers: { authorization: `Bearer ${apiToken}` }, @@ -477,6 +599,47 @@ describe('set', () => { expect(store.fulfilled).toBeTruthy() }) + + test('Retries failed operations when using context credentials', async () => { + const store = new MockFetch() + .put({ + body: value, + headers: { authorization: `Bearer ${edgeToken}`, 'cache-control': 'max-age=0, stale-while-revalidate=60' }, + response: new Response(null, { status: 500 }), + url: `${edgeURL}/${siteID}/production/${key}`, + }) + .put({ + body: value, + headers: { authorization: `Bearer ${edgeToken}`, 'cache-control': 'max-age=0, stale-while-revalidate=60' }, + response: new Error('Some network problem'), + url: `${edgeURL}/${siteID}/production/${key}`, + }) + .put({ + body: value, + headers: { authorization: `Bearer ${edgeToken}`, 'cache-control': 'max-age=0, stale-while-revalidate=60' }, + response: new Response(null, { headers: { 'X-RateLimit-Reset': '10' }, status: 429 }), + url: `${edgeURL}/${siteID}/production/${key}`, + }) + .put({ + body: value, + headers: { authorization: `Bearer ${edgeToken}`, 'cache-control': 'max-age=0, stale-while-revalidate=60' }, + response: new Response(null), + url: `${edgeURL}/${siteID}/production/${key}`, + }) + + const blobs = new Blobs({ + authentication: { + contextURL: edgeURL, + token: edgeToken, + }, + fetcher: store.fetcher, + siteID, + }) + + await blobs.set(key, value) + + expect(store.fulfilled).toBeTruthy() + }) }) describe('setJSON', () => { @@ -509,6 +672,28 @@ describe('setJSON', () => { expect(store.fulfilled).toBeTruthy() }) + test('Writes to the blob store using context credentials', async () => { + const store = new MockFetch().put({ + body: JSON.stringify({ value }), + headers: { authorization: `Bearer ${edgeToken}`, 'cache-control': 'max-age=0, stale-while-revalidate=60' }, + response: new Response(null), + url: `${edgeURL}/${siteID}/production/${key}`, + }) + + const blobs = new Blobs({ + authentication: { + contextURL: edgeURL, + token: edgeToken, + }, + fetcher: store.fetcher, + siteID, + }) + + await blobs.setJSON(key, { value }) + + expect(store.fulfilled).toBeTruthy() + }) + test('Accepts a TTL parameter', async () => { const ttl = new Date(Date.now() + 15_000) const store = new MockFetch() @@ -567,6 +752,27 @@ describe('delete', () => { expect(store.fulfilled).toBeTruthy() }) + test('Deletes from the blob store using context credentials', async () => { + const store = new MockFetch().delete({ + headers: { authorization: `Bearer ${edgeToken}` }, + response: new Response(null), + url: `${edgeURL}/${siteID}/production/${key}`, + }) + + const blobs = new Blobs({ + authentication: { + contextURL: edgeURL, + token: edgeToken, + }, + fetcher: store.fetcher, + siteID, + }) + + await blobs.delete(key) + + expect(store.fulfilled).toBeTruthy() + }) + test('Throws when the API returns a non-200 status code', async () => { const store = new MockFetch().delete({ headers: { authorization: `Bearer ${apiToken}` }, @@ -588,6 +794,29 @@ describe('delete', () => { expect(store.fulfilled).toBeTruthy() }) + test('Throws when the edge URL returns a non-200 status code', async () => { + const store = new MockFetch().delete({ + headers: { authorization: `Bearer ${edgeToken}` }, + response: new Response(null, { status: 401 }), + url: `${edgeURL}/${siteID}/production/${key}`, + }) + + const blobs = new Blobs({ + authentication: { + contextURL: edgeURL, + token: edgeToken, + }, + fetcher: store.fetcher, + siteID, + }) + + await expect(async () => await blobs.delete(key)).rejects.toThrowError( + 'delete operation has failed: store returned a 401 response', + ) + + expect(store.fulfilled).toBeTruthy() + }) + test('Throws when the instance is missing required configuration properties', async () => { const { fetcher } = new MockFetch() From b8da6a83be7e6772ce2c97f61e94561661a6c2f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Sun, 30 Jul 2023 17:07:39 +0100 Subject: [PATCH 4/8] feat: rename `ttl` parameter to `expiration` --- src/main.test.ts | 16 ++++++++-------- src/main.ts | 24 ++++++++++++------------ 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/main.test.ts b/src/main.test.ts index 2870d1c..becdd53 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -323,8 +323,8 @@ describe('set', () => { expect(store.fulfilled).toBeTruthy() }) - test('Accepts a TTL parameter', async () => { - const ttl = new Date(Date.now() + 15_000) + test('Accepts an `expiration` parameter', async () => { + const expiration = new Date(Date.now() + 15_000) const store = new MockFetch() .put({ headers: { authorization: `Bearer ${apiToken}` }, @@ -335,7 +335,7 @@ describe('set', () => { body: value, headers: { 'cache-control': 'max-age=0, stale-while-revalidate=60', - 'x-nf-expires-at': ttl.getTime().toString(), + 'x-nf-expires-at': expiration.getTime().toString(), }, response: new Response(null), url: signedURL, @@ -349,7 +349,7 @@ describe('set', () => { siteID, }) - await blobs.set(key, value, { ttl }) + await blobs.set(key, value, { expiration }) expect(store.fulfilled).toBeTruthy() }) @@ -694,8 +694,8 @@ describe('setJSON', () => { expect(store.fulfilled).toBeTruthy() }) - test('Accepts a TTL parameter', async () => { - const ttl = new Date(Date.now() + 15_000) + test('Accepts an `expiration` parameter', async () => { + const expiration = new Date(Date.now() + 15_000) const store = new MockFetch() .put({ headers: { authorization: `Bearer ${apiToken}` }, @@ -706,7 +706,7 @@ describe('setJSON', () => { body: JSON.stringify({ value }), headers: { 'cache-control': 'max-age=0, stale-while-revalidate=60', - 'x-nf-expires-at': ttl.getTime().toString(), + 'x-nf-expires-at': expiration.getTime().toString(), }, response: new Response(null), url: signedURL, @@ -720,7 +720,7 @@ describe('setJSON', () => { siteID, }) - await blobs.setJSON(key, { value }, { ttl }) + await blobs.setJSON(key, { value }, { expiration }) expect(store.fulfilled).toBeTruthy() }) diff --git a/src/main.ts b/src/main.ts index d96e810..8c46089 100644 --- a/src/main.ts +++ b/src/main.ts @@ -30,7 +30,7 @@ enum HTTPMethod { } interface SetOptions { - ttl?: Date | number + expiration?: Date | number } interface SetFilesItem extends SetOptions { @@ -100,7 +100,7 @@ export class Blobs { } } - private static getTTLHeaders(ttl: Date | number | undefined): Record { + private static getExpirationeaders(ttl: Date | number | undefined): Record { if (typeof ttl === 'number') { return { [EXPIRY_HEADER]: (Date.now() + ttl).toString(), @@ -186,12 +186,12 @@ export class Blobs { ): Promise { const { type } = options ?? {} const res = await this.makeStoreRequest(key, HTTPMethod.Get) - const expiry = res?.headers.get(EXPIRY_HEADER) + const expiration = res?.headers.get(EXPIRY_HEADER) - if (typeof expiry === 'string') { - const expiryTS = Number.parseInt(expiry) + if (typeof expiration === 'string') { + const expirationTS = Number.parseInt(expiration) - if (!Number.isNaN(expiryTS) && expiryTS <= Date.now()) { + if (!Number.isNaN(expirationTS) && expirationTS <= Date.now()) { return null } } @@ -223,17 +223,17 @@ export class Blobs { throw new Error(`Invalid 'type' property: ${type}. Expected: arrayBuffer, blob, json, stream, or text.`) } - async set(key: string, data: BlobInput, { ttl }: SetOptions = {}) { - const headers = Blobs.getTTLHeaders(ttl) + async set(key: string, data: BlobInput, { expiration }: SetOptions = {}) { + const headers = Blobs.getExpirationeaders(expiration) await this.makeStoreRequest(key, HTTPMethod.Put, headers, data) } - async setFile(key: string, path: string, { ttl }: SetOptions = {}) { + async setFile(key: string, path: string, { expiration }: SetOptions = {}) { const { size } = await stat(path) const file = Readable.toWeb(createReadStream(path)) const headers = { - ...Blobs.getTTLHeaders(ttl), + ...Blobs.getExpirationeaders(expiration), 'content-length': size.toString(), } @@ -244,10 +244,10 @@ export class Blobs { return pMap(files, ({ key, path, ...options }) => this.setFile(key, path, options), { concurrency }) } - async setJSON(key: string, data: unknown, { ttl }: SetOptions = {}) { + async setJSON(key: string, data: unknown, { expiration }: SetOptions = {}) { const payload = JSON.stringify(data) const headers = { - ...Blobs.getTTLHeaders(ttl), + ...Blobs.getExpirationeaders(expiration), 'content-type': 'application/json', } From 97be03eccb1e702367cc4e545e34881da1d60a6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Sun, 30 Jul 2023 22:47:04 +0100 Subject: [PATCH 5/8] chore: structure tests --- src/main.test.ts | 1182 +++++++++++++++++++++++----------------------- src/main.ts | 14 +- 2 files changed, 606 insertions(+), 590 deletions(-) diff --git a/src/main.test.ts b/src/main.test.ts index becdd53..350f858 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -33,216 +33,220 @@ const edgeToken = 'some other token' const edgeURL = 'https://cloudfront.url' describe('get', () => { - test('Reads from the blob store using API credentials', async () => { - const store = new MockFetch() - .get({ - headers: { authorization: `Bearer ${apiToken}` }, - response: new Response(JSON.stringify({ url: signedURL })), - url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, - }) - .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/sites/${siteID}/blobs/${key}?context=production`, - }) - .get({ - response: new Response(value), - url: signedURL, + describe('With API credentials', () => { + test('Reads from the blob store', async () => { + const store = new MockFetch() + .get({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + }) + .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/sites/${siteID}/blobs/${key}?context=production`, + }) + .get({ + response: new Response(value), + url: signedURL, + }) + + const blobs = new Blobs({ + authentication: { + token: apiToken, + }, + fetcher: store.fetcher, + siteID, }) - const blobs = new Blobs({ - authentication: { - token: apiToken, - }, - fetcher: store.fetcher, - siteID, - }) + const string = await blobs.get(key) + expect(string).toBe(value) - const string = await blobs.get(key) - expect(string).toBe(value) + const stream = await blobs.get(key, { type: 'stream' }) + expect(await streamToString(stream as unknown as NodeJS.ReadableStream)).toBe(value) - const stream = await blobs.get(key, { type: 'stream' }) - expect(await streamToString(stream as unknown as NodeJS.ReadableStream)).toBe(value) + expect(store.fulfilled).toBeTruthy() + }) - expect(store.fulfilled).toBeTruthy() - }) + test('Returns `null` when the pre-signed URL returns a 404', async () => { + const store = new MockFetch() + .get({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + }) + .get({ + response: new Response('Something went wrong', { status: 404 }), + url: signedURL, + }) - test('Reads from the blob store using context credentials', async () => { - const store = new MockFetch() - .get({ - headers: { authorization: `Bearer ${edgeToken}` }, - response: new Response(value), - url: `${edgeURL}/${siteID}/production/${key}`, - }) - .get({ - headers: { authorization: `Bearer ${edgeToken}` }, - response: new Response(value), - url: `${edgeURL}/${siteID}/production/${key}`, + const blobs = new Blobs({ + authentication: { + token: apiToken, + }, + fetcher: store.fetcher, + siteID, }) - const blobs = new Blobs({ - authentication: { - contextURL: edgeURL, - token: edgeToken, - }, - fetcher: store.fetcher, - siteID, + expect(await blobs.get(key)).toBeNull() + expect(store.fulfilled).toBeTruthy() }) - const string = await blobs.get(key) - expect(string).toBe(value) - - const stream = await blobs.get(key, { type: 'stream' }) - expect(await streamToString(stream as unknown as NodeJS.ReadableStream)).toBe(value) - - expect(store.fulfilled).toBeTruthy() - }) - - test('Returns `null` when the pre-signed URL returns a 404', async () => { - const store = new MockFetch() - .get({ + test('Throws when the API returns a non-200 status code', async () => { + const store = new MockFetch().get({ headers: { authorization: `Bearer ${apiToken}` }, - response: new Response(JSON.stringify({ url: signedURL })), + response: new Response(null, { status: 401 }), url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, }) - .get({ - response: new Response('Something went wrong', { status: 404 }), - url: signedURL, + + const blobs = new Blobs({ + authentication: { + token: apiToken, + }, + fetcher: store.fetcher, + siteID, }) - const blobs = new Blobs({ - authentication: { - token: apiToken, - }, - fetcher: store.fetcher, - siteID, + expect(async () => await blobs.get(key)).rejects.toThrowError( + 'get operation has failed: API returned a 401 response', + ) + expect(store.fulfilled).toBeTruthy() }) - expect(await blobs.get(key)).toBeNull() - expect(store.fulfilled).toBeTruthy() - }) + test('Throws when a pre-signed URL returns a non-200 status code', async () => { + const store = new MockFetch() + .get({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + }) + .get({ + response: new Response('Something went wrong', { status: 401 }), + url: signedURL, + }) - test('Returns `null` when the edge URL returns a 404', async () => { - const store = new MockFetch().get({ - headers: { authorization: `Bearer ${edgeToken}` }, - response: new Response(null, { status: 404 }), - url: `${edgeURL}/${siteID}/production/${key}`, - }) + const blobs = new Blobs({ + authentication: { + token: apiToken, + }, + fetcher: store.fetcher, + siteID, + }) - const blobs = new Blobs({ - authentication: { - contextURL: edgeURL, - token: edgeToken, - }, - fetcher: store.fetcher, - siteID, + await expect(async () => await blobs.get(key)).rejects.toThrowError( + 'get operation has failed: store returned a 401 response', + ) + + expect(store.fulfilled).toBeTruthy() }) - expect(await blobs.get(key)).toBeNull() - expect(store.fulfilled).toBeTruthy() - }) + test('Returns `null` when the blob entry contains an expiry date in the past', async () => { + const store = new MockFetch() + .get({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + }) + .get({ + response: new Response(value, { + headers: { + 'x-nf-expires-at': (Date.now() - 1000).toString(), + }, + }), + url: signedURL, + }) - test('Throws when the API returns a non-200 status code', async () => { - const store = new MockFetch().get({ - headers: { authorization: `Bearer ${apiToken}` }, - response: new Response(null, { status: 401 }), - url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, - }) + const blobs = new Blobs({ + authentication: { + token: apiToken, + }, + fetcher: store.fetcher, + siteID, + }) - const blobs = new Blobs({ - authentication: { - token: apiToken, - }, - fetcher: store.fetcher, - siteID, + expect(await blobs.get(key)).toBeNull() + expect(store.fulfilled).toBeTruthy() }) - - expect(async () => await blobs.get(key)).rejects.toThrowError( - 'get operation has failed: API returned a 401 response', - ) - expect(store.fulfilled).toBeTruthy() }) - test('Throws when a pre-signed URL returns a non-200 status code', async () => { - const store = new MockFetch() - .get({ - headers: { authorization: `Bearer ${apiToken}` }, - response: new Response(JSON.stringify({ url: signedURL })), - url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, - }) - .get({ - response: new Response('Something went wrong', { status: 401 }), - url: signedURL, - }) + describe('With context credentials', () => { + test('Reads from the blob store', async () => { + const store = new MockFetch() + .get({ + headers: { authorization: `Bearer ${edgeToken}` }, + response: new Response(value), + url: `${edgeURL}/${siteID}/production/${key}`, + }) + .get({ + headers: { authorization: `Bearer ${edgeToken}` }, + response: new Response(value), + url: `${edgeURL}/${siteID}/production/${key}`, + }) - const blobs = new Blobs({ - authentication: { - token: apiToken, - }, - fetcher: store.fetcher, - siteID, - }) + const blobs = new Blobs({ + authentication: { + contextURL: edgeURL, + token: edgeToken, + }, + fetcher: store.fetcher, + siteID, + }) - await expect(async () => await blobs.get(key)).rejects.toThrowError( - 'get operation has failed: store returned a 401 response', - ) + const string = await blobs.get(key) + expect(string).toBe(value) - expect(store.fulfilled).toBeTruthy() - }) + const stream = await blobs.get(key, { type: 'stream' }) + expect(await streamToString(stream as unknown as NodeJS.ReadableStream)).toBe(value) - test('Throws when an edge URL returns a non-200 status code', async () => { - const store = new MockFetch().get({ - headers: { authorization: `Bearer ${edgeToken}` }, - response: new Response(null, { status: 401 }), - url: `${edgeURL}/${siteID}/production/${key}`, + expect(store.fulfilled).toBeTruthy() }) - const blobs = new Blobs({ - authentication: { - contextURL: edgeURL, - token: edgeToken, - }, - fetcher: store.fetcher, - siteID, - }) + test('Returns `null` when the edge URL returns a 404', async () => { + const store = new MockFetch().get({ + headers: { authorization: `Bearer ${edgeToken}` }, + response: new Response(null, { status: 404 }), + url: `${edgeURL}/${siteID}/production/${key}`, + }) - await expect(async () => await blobs.get(key)).rejects.toThrowError( - 'get operation has failed: store returned a 401 response', - ) + const blobs = new Blobs({ + authentication: { + contextURL: edgeURL, + token: edgeToken, + }, + fetcher: store.fetcher, + siteID, + }) - expect(store.fulfilled).toBeTruthy() - }) + expect(await blobs.get(key)).toBeNull() + expect(store.fulfilled).toBeTruthy() + }) - test('Returns `null` when the blob entry contains an expiry date in the past', async () => { - const store = new MockFetch() - .get({ - headers: { authorization: `Bearer ${apiToken}` }, - response: new Response(JSON.stringify({ url: signedURL })), - url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + test('Throws when an edge URL returns a non-200 status code', async () => { + const store = new MockFetch().get({ + headers: { authorization: `Bearer ${edgeToken}` }, + response: new Response(null, { status: 401 }), + url: `${edgeURL}/${siteID}/production/${key}`, }) - .get({ - response: new Response(value, { - headers: { - 'x-nf-expires-at': (Date.now() - 1000).toString(), - }, - }), - url: signedURL, + + const blobs = new Blobs({ + authentication: { + contextURL: edgeURL, + token: edgeToken, + }, + fetcher: store.fetcher, + siteID, }) - const blobs = new Blobs({ - authentication: { - token: apiToken, - }, - fetcher: store.fetcher, - siteID, - }) + await expect(async () => await blobs.get(key)).rejects.toThrowError( + 'get operation has failed: store returned a 401 response', + ) - expect(await blobs.get(key)).toBeNull() - expect(store.fulfilled).toBeTruthy() + expect(store.fulfilled).toBeTruthy() + }) }) test('Throws when the instance is missing required configuration properties', async () => { @@ -274,90 +278,8 @@ describe('get', () => { }) describe('set', () => { - test('Writes to the blob store using API credentials', async () => { - const store = new MockFetch() - .put({ - headers: { authorization: `Bearer ${apiToken}` }, - response: new Response(JSON.stringify({ url: signedURL })), - url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, - }) - .put({ - body: value, - headers: { 'cache-control': 'max-age=0, stale-while-revalidate=60' }, - response: new Response(null), - url: signedURL, - }) - - const blobs = new Blobs({ - authentication: { - token: apiToken, - }, - fetcher: store.fetcher, - siteID, - }) - - await blobs.set(key, value) - - expect(store.fulfilled).toBeTruthy() - }) - - test('Writes to the blob store using context credentials', async () => { - const store = new MockFetch().put({ - body: value, - headers: { authorization: `Bearer ${edgeToken}`, 'cache-control': 'max-age=0, stale-while-revalidate=60' }, - response: new Response(null), - url: `${edgeURL}/${siteID}/production/${key}`, - }) - - const blobs = new Blobs({ - authentication: { - contextURL: edgeURL, - token: edgeToken, - }, - fetcher: store.fetcher, - siteID, - }) - - await blobs.set(key, value) - - expect(store.fulfilled).toBeTruthy() - }) - - test('Accepts an `expiration` parameter', async () => { - const expiration = new Date(Date.now() + 15_000) - const store = new MockFetch() - .put({ - headers: { authorization: `Bearer ${apiToken}` }, - response: new Response(JSON.stringify({ url: signedURL })), - url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, - }) - .put({ - body: value, - headers: { - 'cache-control': 'max-age=0, stale-while-revalidate=60', - 'x-nf-expires-at': expiration.getTime().toString(), - }, - response: new Response(null), - url: signedURL, - }) - - const blobs = new Blobs({ - authentication: { - token: apiToken, - }, - fetcher: store.fetcher, - siteID, - }) - - await blobs.set(key, value, { expiration }) - - expect(store.fulfilled).toBeTruthy() - }) - - // We need `Readable.toWeb` to be available, which needs Node 16+. - if (semver.gte(nodeVersion, '16.0.0')) { - test('Accepts a file', async () => { - const fileContents = 'Hello from a file' + describe('With API credentials', () => { + test('Writes to the blob store', async () => { const store = new MockFetch() .put({ headers: { authorization: `Bearer ${apiToken}` }, @@ -365,20 +287,12 @@ describe('set', () => { url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, }) .put({ - body: async (body) => { - expect(await streamToString(body as unknown as NodeJS.ReadableStream)).toBe(fileContents) - }, - headers: { - 'cache-control': 'max-age=0, stale-while-revalidate=60', - }, + body: value, + headers: { 'cache-control': 'max-age=0, stale-while-revalidate=60' }, response: new Response(null), url: signedURL, }) - const { cleanup, path } = await tmp.file() - - await writeFile(path, fileContents) - const blobs = new Blobs({ authentication: { token: apiToken, @@ -387,78 +301,29 @@ describe('set', () => { siteID, }) - await blobs.setFile(key, path) + await blobs.set(key, value) expect(store.fulfilled).toBeTruthy() - - await cleanup() }) - test('Accepts multiple files concurrently', async () => { - const contents = ['Hello from key-0', 'Hello from key-1', 'Hello from key-2'] - const signedURLs = ['https://signed-url.aws/0', 'https://signed-url.aws/1', 'https://signed-url.aws/2'] - + test('Accepts an `expiration` parameter', async () => { + const expiration = new Date(Date.now() + 15_000) const store = new MockFetch() .put({ headers: { authorization: `Bearer ${apiToken}` }, - response: new Response(JSON.stringify({ url: signedURLs[0] })), - url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/key-0?context=production`, - }) - .put({ - body: async (body) => { - expect(await streamToString(body as unknown as NodeJS.ReadableStream)).toBe(contents[0]) - }, - headers: { - 'cache-control': 'max-age=0, stale-while-revalidate=60', - }, - response: new Response(null), - url: signedURLs[0], - }) - .put({ - headers: { authorization: `Bearer ${apiToken}` }, - response: new Response(JSON.stringify({ url: signedURLs[1] })), - url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/key-1?context=production`, - }) - .put({ - body: async (body) => { - expect(await streamToString(body as unknown as NodeJS.ReadableStream)).toBe(contents[1]) - }, - headers: { - 'cache-control': 'max-age=0, stale-while-revalidate=60', - }, - response: new Response(null), - url: signedURLs[1], - }) - .put({ - headers: { authorization: `Bearer ${apiToken}` }, - response: new Response(JSON.stringify({ url: signedURLs[2] })), - url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/key-2?context=production`, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, }) .put({ - body: async (body) => { - expect(await streamToString(body as unknown as NodeJS.ReadableStream)).toBe(contents[2]) - }, + body: value, headers: { 'cache-control': 'max-age=0, stale-while-revalidate=60', + 'x-nf-expires-at': expiration.getTime().toString(), }, response: new Response(null), - url: signedURLs[2], + url: signedURL, }) - const writes = await Promise.all( - contents.map(async (content) => { - const { cleanup, path } = await tmp.file() - - await writeFile(path, content) - - return { cleanup, path } - }), - ) - const files = writes.map(({ path }, idx) => ({ - key: `key-${idx}`, - path, - })) - const blobs = new Blobs({ authentication: { token: apiToken, @@ -467,57 +332,294 @@ describe('set', () => { siteID, }) - await blobs.setFiles(files) + await blobs.set(key, value, { expiration }) expect(store.fulfilled).toBeTruthy() - - await Promise.all(writes.map(({ cleanup }) => cleanup())) }) - } - test('Throws when the API returns a non-200 status code', async () => { - const store = new MockFetch().put({ - headers: { authorization: `Bearer ${apiToken}` }, - response: new Response(null, { status: 401 }), - url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, - }) + // We need `Readable.toWeb` to be available, which needs Node 16+. + if (semver.gte(nodeVersion, '16.0.0')) { + test('Accepts a file', async () => { + const fileContents = 'Hello from a file' + const store = new MockFetch() + .put({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + }) + .put({ + body: async (body) => { + expect(await streamToString(body as unknown as NodeJS.ReadableStream)).toBe(fileContents) + }, + headers: { + 'cache-control': 'max-age=0, stale-while-revalidate=60', + }, + response: new Response(null), + url: signedURL, + }) + + const { cleanup, path } = await tmp.file() + + await writeFile(path, fileContents) + + const blobs = new Blobs({ + authentication: { + token: apiToken, + }, + fetcher: store.fetcher, + siteID, + }) - const blobs = new Blobs({ - authentication: { - token: apiToken, - }, - fetcher: store.fetcher, - siteID, + await blobs.setFile(key, path) + + expect(store.fulfilled).toBeTruthy() + + await cleanup() + }) + + test('Accepts multiple files concurrently', async () => { + const contents = ['Hello from key-0', 'Hello from key-1', 'Hello from key-2'] + const signedURLs = ['https://signed-url.aws/0', 'https://signed-url.aws/1', 'https://signed-url.aws/2'] + + const store = new MockFetch() + .put({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURLs[0] })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/key-0?context=production`, + }) + .put({ + body: async (body) => { + expect(await streamToString(body as unknown as NodeJS.ReadableStream)).toBe(contents[0]) + }, + headers: { + 'cache-control': 'max-age=0, stale-while-revalidate=60', + }, + response: new Response(null), + url: signedURLs[0], + }) + .put({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURLs[1] })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/key-1?context=production`, + }) + .put({ + body: async (body) => { + expect(await streamToString(body as unknown as NodeJS.ReadableStream)).toBe(contents[1]) + }, + headers: { + 'cache-control': 'max-age=0, stale-while-revalidate=60', + }, + response: new Response(null), + url: signedURLs[1], + }) + .put({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURLs[2] })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/key-2?context=production`, + }) + .put({ + body: async (body) => { + expect(await streamToString(body as unknown as NodeJS.ReadableStream)).toBe(contents[2]) + }, + headers: { + 'cache-control': 'max-age=0, stale-while-revalidate=60', + }, + response: new Response(null), + url: signedURLs[2], + }) + + const writes = await Promise.all( + contents.map(async (content) => { + const { cleanup, path } = await tmp.file() + + await writeFile(path, content) + + return { cleanup, path } + }), + ) + const files = writes.map(({ path }, idx) => ({ + key: `key-${idx}`, + path, + })) + + const blobs = new Blobs({ + authentication: { + token: apiToken, + }, + fetcher: store.fetcher, + siteID, + }) + + await blobs.setFiles(files) + + expect(store.fulfilled).toBeTruthy() + + await Promise.all(writes.map(({ cleanup }) => cleanup())) + }) + } + + test('Throws when the API returns a non-200 status code', async () => { + const store = new MockFetch().put({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(null, { status: 401 }), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + }) + + const blobs = new Blobs({ + authentication: { + token: apiToken, + }, + fetcher: store.fetcher, + siteID, + }) + + expect(async () => await blobs.set(key, 'value')).rejects.toThrowError( + 'put operation has failed: API returned a 401 response', + ) + expect(store.fulfilled).toBeTruthy() }) - expect(async () => await blobs.set(key, 'value')).rejects.toThrowError( - 'put operation has failed: API returned a 401 response', - ) - expect(store.fulfilled).toBeTruthy() + test('Retries failed operations', async () => { + const store = new MockFetch() + .put({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + }) + .put({ + body: value, + headers: { + 'cache-control': 'max-age=0, stale-while-revalidate=60', + }, + response: new Response(null, { status: 500 }), + url: signedURL, + }) + .put({ + body: value, + headers: { + 'cache-control': 'max-age=0, stale-while-revalidate=60', + }, + response: new Error('Some network problem'), + url: signedURL, + }) + .put({ + body: value, + headers: { + 'cache-control': 'max-age=0, stale-while-revalidate=60', + }, + response: new Response(null, { headers: { 'X-RateLimit-Reset': '10' }, status: 429 }), + url: signedURL, + }) + .put({ + body: value, + headers: { + 'cache-control': 'max-age=0, stale-while-revalidate=60', + }, + response: new Response(null), + url: signedURL, + }) + + const blobs = new Blobs({ + authentication: { + token: apiToken, + }, + fetcher: store.fetcher, + siteID, + }) + + await blobs.set(key, value) + + expect(store.fulfilled).toBeTruthy() + }) }) - test('Throws when the edge URL returns a non-200 status code', async () => { - const store = new MockFetch().put({ - body: value, - headers: { authorization: `Bearer ${edgeToken}`, 'cache-control': 'max-age=0, stale-while-revalidate=60' }, - response: new Response(null, { status: 401 }), - url: `${edgeURL}/${siteID}/production/${key}`, + describe('With context credentials', () => { + test('Writes to the blob store', async () => { + const store = new MockFetch().put({ + body: value, + headers: { authorization: `Bearer ${edgeToken}`, 'cache-control': 'max-age=0, stale-while-revalidate=60' }, + response: new Response(null), + url: `${edgeURL}/${siteID}/production/${key}`, + }) + + const blobs = new Blobs({ + authentication: { + contextURL: edgeURL, + token: edgeToken, + }, + fetcher: store.fetcher, + siteID, + }) + + await blobs.set(key, value) + + expect(store.fulfilled).toBeTruthy() }) - const blobs = new Blobs({ - authentication: { - contextURL: edgeURL, - token: edgeToken, - }, - fetcher: store.fetcher, - siteID, + test('Throws when the edge URL returns a non-200 status code', async () => { + const store = new MockFetch().put({ + body: value, + headers: { authorization: `Bearer ${edgeToken}`, 'cache-control': 'max-age=0, stale-while-revalidate=60' }, + response: new Response(null, { status: 401 }), + url: `${edgeURL}/${siteID}/production/${key}`, + }) + + const blobs = new Blobs({ + authentication: { + contextURL: edgeURL, + token: edgeToken, + }, + fetcher: store.fetcher, + siteID, + }) + + await expect(async () => await blobs.set(key, value)).rejects.toThrowError( + 'put operation has failed: store returned a 401 response', + ) + + expect(store.fulfilled).toBeTruthy() }) - await expect(async () => await blobs.set(key, value)).rejects.toThrowError( - 'put operation has failed: store returned a 401 response', - ) + test('Retries failed operations', async () => { + const store = new MockFetch() + .put({ + body: value, + headers: { authorization: `Bearer ${edgeToken}`, 'cache-control': 'max-age=0, stale-while-revalidate=60' }, + response: new Response(null, { status: 500 }), + url: `${edgeURL}/${siteID}/production/${key}`, + }) + .put({ + body: value, + headers: { authorization: `Bearer ${edgeToken}`, 'cache-control': 'max-age=0, stale-while-revalidate=60' }, + response: new Error('Some network problem'), + url: `${edgeURL}/${siteID}/production/${key}`, + }) + .put({ + body: value, + headers: { authorization: `Bearer ${edgeToken}`, 'cache-control': 'max-age=0, stale-while-revalidate=60' }, + response: new Response(null, { headers: { 'X-RateLimit-Reset': '10' }, status: 429 }), + url: `${edgeURL}/${siteID}/production/${key}`, + }) + .put({ + body: value, + headers: { authorization: `Bearer ${edgeToken}`, 'cache-control': 'max-age=0, stale-while-revalidate=60' }, + response: new Response(null), + url: `${edgeURL}/${siteID}/production/${key}`, + }) + + const blobs = new Blobs({ + authentication: { + contextURL: edgeURL, + token: edgeToken, + }, + fetcher: store.fetcher, + siteID, + }) + + await blobs.set(key, value) - expect(store.fulfilled).toBeTruthy() + expect(store.fulfilled).toBeTruthy() + }) }) test('Throws when the instance is missing required configuration properties', async () => { @@ -546,275 +648,189 @@ describe('set', () => { `The blob store is unavailable because it's missing required configuration properties`, ) }) +}) - test('Retries failed operations when using API credentials', async () => { - const store = new MockFetch() - .put({ - headers: { authorization: `Bearer ${apiToken}` }, - response: new Response(JSON.stringify({ url: signedURL })), - url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, - }) - .put({ - body: value, - headers: { - 'cache-control': 'max-age=0, stale-while-revalidate=60', - }, - response: new Response(null, { status: 500 }), - url: signedURL, - }) - .put({ - body: value, - headers: { - 'cache-control': 'max-age=0, stale-while-revalidate=60', - }, - response: new Error('Some network problem'), - url: signedURL, - }) - .put({ - body: value, - headers: { - 'cache-control': 'max-age=0, stale-while-revalidate=60', - }, - response: new Response(null, { headers: { 'X-RateLimit-Reset': '10' }, status: 429 }), - url: signedURL, - }) - .put({ - body: value, - headers: { - 'cache-control': 'max-age=0, stale-while-revalidate=60', +describe('setJSON', () => { + describe('With API credentials', () => { + test('Writes to the blob store', async () => { + const store = new MockFetch() + .put({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + }) + .put({ + body: JSON.stringify({ value }), + headers: { + 'cache-control': 'max-age=0, stale-while-revalidate=60', + }, + response: new Response(null), + url: signedURL, + }) + + const blobs = new Blobs({ + authentication: { + token: apiToken, }, - response: new Response(null), - url: signedURL, + fetcher: store.fetcher, + siteID, }) - const blobs = new Blobs({ - authentication: { - token: apiToken, - }, - fetcher: store.fetcher, - siteID, - }) + await blobs.setJSON(key, { value }) - await blobs.set(key, value) - - expect(store.fulfilled).toBeTruthy() + expect(store.fulfilled).toBeTruthy() + }) }) - test('Retries failed operations when using context credentials', async () => { - const store = new MockFetch() - .put({ - body: value, - headers: { authorization: `Bearer ${edgeToken}`, 'cache-control': 'max-age=0, stale-while-revalidate=60' }, - response: new Response(null, { status: 500 }), - url: `${edgeURL}/${siteID}/production/${key}`, - }) - .put({ - body: value, - headers: { authorization: `Bearer ${edgeToken}`, 'cache-control': 'max-age=0, stale-while-revalidate=60' }, - response: new Error('Some network problem'), - url: `${edgeURL}/${siteID}/production/${key}`, - }) - .put({ - body: value, - headers: { authorization: `Bearer ${edgeToken}`, 'cache-control': 'max-age=0, stale-while-revalidate=60' }, - response: new Response(null, { headers: { 'X-RateLimit-Reset': '10' }, status: 429 }), - url: `${edgeURL}/${siteID}/production/${key}`, - }) - .put({ - body: value, + describe('With context credentials', () => { + test('Writes to the blob store', async () => { + const store = new MockFetch().put({ + body: JSON.stringify({ value }), headers: { authorization: `Bearer ${edgeToken}`, 'cache-control': 'max-age=0, stale-while-revalidate=60' }, response: new Response(null), url: `${edgeURL}/${siteID}/production/${key}`, }) - const blobs = new Blobs({ - authentication: { - contextURL: edgeURL, - token: edgeToken, - }, - fetcher: store.fetcher, - siteID, - }) - - await blobs.set(key, value) - - expect(store.fulfilled).toBeTruthy() - }) -}) - -describe('setJSON', () => { - test('Writes to the blob store using API credentials', async () => { - const store = new MockFetch() - .put({ - headers: { authorization: `Bearer ${apiToken}` }, - response: new Response(JSON.stringify({ url: signedURL })), - url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, - }) - .put({ - body: JSON.stringify({ value }), - headers: { - 'cache-control': 'max-age=0, stale-while-revalidate=60', + const blobs = new Blobs({ + authentication: { + contextURL: edgeURL, + token: edgeToken, }, - response: new Response(null), - url: signedURL, + fetcher: store.fetcher, + siteID, }) - const blobs = new Blobs({ - authentication: { - token: apiToken, - }, - fetcher: store.fetcher, - siteID, + await blobs.setJSON(key, { value }) + + expect(store.fulfilled).toBeTruthy() }) - await blobs.setJSON(key, { value }) + test('Accepts an `expiration` parameter', async () => { + const expiration = new Date(Date.now() + 15_000) + const store = new MockFetch() + .put({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + }) + .put({ + body: JSON.stringify({ value }), + headers: { + 'cache-control': 'max-age=0, stale-while-revalidate=60', + 'x-nf-expires-at': expiration.getTime().toString(), + }, + response: new Response(null), + url: signedURL, + }) - expect(store.fulfilled).toBeTruthy() - }) + const blobs = new Blobs({ + authentication: { + token: apiToken, + }, + fetcher: store.fetcher, + siteID, + }) - test('Writes to the blob store using context credentials', async () => { - const store = new MockFetch().put({ - body: JSON.stringify({ value }), - headers: { authorization: `Bearer ${edgeToken}`, 'cache-control': 'max-age=0, stale-while-revalidate=60' }, - response: new Response(null), - url: `${edgeURL}/${siteID}/production/${key}`, - }) + await blobs.setJSON(key, { value }, { expiration }) - const blobs = new Blobs({ - authentication: { - contextURL: edgeURL, - token: edgeToken, - }, - fetcher: store.fetcher, - siteID, + expect(store.fulfilled).toBeTruthy() }) - - await blobs.setJSON(key, { value }) - - expect(store.fulfilled).toBeTruthy() }) +}) - test('Accepts an `expiration` parameter', async () => { - const expiration = new Date(Date.now() + 15_000) - const store = new MockFetch() - .put({ - headers: { authorization: `Bearer ${apiToken}` }, - response: new Response(JSON.stringify({ url: signedURL })), - url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, - }) - .put({ - body: JSON.stringify({ value }), - headers: { - 'cache-control': 'max-age=0, stale-while-revalidate=60', - 'x-nf-expires-at': expiration.getTime().toString(), +describe('delete', () => { + describe('With API credentials', () => { + test('Deletes from the blob store', async () => { + const store = new MockFetch() + .delete({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + }) + .delete({ + response: new Response(null), + url: signedURL, + }) + + const blobs = new Blobs({ + authentication: { + token: apiToken, }, - response: new Response(null), - url: signedURL, + fetcher: store.fetcher, + siteID, }) - const blobs = new Blobs({ - authentication: { - token: apiToken, - }, - fetcher: store.fetcher, - siteID, - }) - - await blobs.setJSON(key, { value }, { expiration }) + await blobs.delete(key) - expect(store.fulfilled).toBeTruthy() - }) -}) + expect(store.fulfilled).toBeTruthy() + }) -describe('delete', () => { - test('Deletes from the blob store using API credentials', async () => { - const store = new MockFetch() - .delete({ + test('Throws when the API returns a non-200 status code', async () => { + const store = new MockFetch().delete({ headers: { authorization: `Bearer ${apiToken}` }, - response: new Response(JSON.stringify({ url: signedURL })), + response: new Response(null, { status: 401 }), url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, }) - .delete({ - response: new Response(null), - url: signedURL, + + const blobs = new Blobs({ + authentication: { + token: apiToken, + }, + fetcher: store.fetcher, + siteID, }) - const blobs = new Blobs({ - authentication: { - token: apiToken, - }, - fetcher: store.fetcher, - siteID, + expect(async () => await blobs.delete(key)).rejects.toThrowError( + 'delete operation has failed: API returned a 401 response', + ) + expect(store.fulfilled).toBeTruthy() }) - - await blobs.delete(key) - - expect(store.fulfilled).toBeTruthy() }) - test('Deletes from the blob store using context credentials', async () => { - const store = new MockFetch().delete({ - headers: { authorization: `Bearer ${edgeToken}` }, - response: new Response(null), - url: `${edgeURL}/${siteID}/production/${key}`, - }) - - const blobs = new Blobs({ - authentication: { - contextURL: edgeURL, - token: edgeToken, - }, - fetcher: store.fetcher, - siteID, - }) + describe('With context credentials', () => { + test('Deletes from the blob store', async () => { + const store = new MockFetch().delete({ + headers: { authorization: `Bearer ${edgeToken}` }, + response: new Response(null), + url: `${edgeURL}/${siteID}/production/${key}`, + }) - await blobs.delete(key) + const blobs = new Blobs({ + authentication: { + contextURL: edgeURL, + token: edgeToken, + }, + fetcher: store.fetcher, + siteID, + }) - expect(store.fulfilled).toBeTruthy() - }) + await blobs.delete(key) - test('Throws when the API returns a non-200 status code', async () => { - const store = new MockFetch().delete({ - headers: { authorization: `Bearer ${apiToken}` }, - response: new Response(null, { status: 401 }), - url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`, + expect(store.fulfilled).toBeTruthy() }) - const blobs = new Blobs({ - authentication: { - token: apiToken, - }, - fetcher: store.fetcher, - siteID, - }) + test('Throws when the edge URL returns a non-200 status code', async () => { + const store = new MockFetch().delete({ + headers: { authorization: `Bearer ${edgeToken}` }, + response: new Response(null, { status: 401 }), + url: `${edgeURL}/${siteID}/production/${key}`, + }) - expect(async () => await blobs.delete(key)).rejects.toThrowError( - 'delete operation has failed: API returned a 401 response', - ) - expect(store.fulfilled).toBeTruthy() - }) + const blobs = new Blobs({ + authentication: { + contextURL: edgeURL, + token: edgeToken, + }, + fetcher: store.fetcher, + siteID, + }) - test('Throws when the edge URL returns a non-200 status code', async () => { - const store = new MockFetch().delete({ - headers: { authorization: `Bearer ${edgeToken}` }, - response: new Response(null, { status: 401 }), - url: `${edgeURL}/${siteID}/production/${key}`, - }) + await expect(async () => await blobs.delete(key)).rejects.toThrowError( + 'delete operation has failed: store returned a 401 response', + ) - const blobs = new Blobs({ - authentication: { - contextURL: edgeURL, - token: edgeToken, - }, - fetcher: store.fetcher, - siteID, + expect(store.fulfilled).toBeTruthy() }) - - await expect(async () => await blobs.delete(key)).rejects.toThrowError( - 'delete operation has failed: store returned a 401 response', - ) - - expect(store.fulfilled).toBeTruthy() }) test('Throws when the instance is missing required configuration properties', async () => { diff --git a/src/main.ts b/src/main.ts index 8c46089..9d59e12 100644 --- a/src/main.ts +++ b/src/main.ts @@ -100,24 +100,24 @@ export class Blobs { } } - private static getExpirationeaders(ttl: Date | number | undefined): Record { - if (typeof ttl === 'number') { + private static getExpirationeaders(expiration: Date | number | undefined): Record { + if (typeof expiration === 'number') { return { - [EXPIRY_HEADER]: (Date.now() + ttl).toString(), + [EXPIRY_HEADER]: (Date.now() + expiration).toString(), } } - if (ttl instanceof Date) { + if (expiration instanceof Date) { return { - [EXPIRY_HEADER]: ttl.getTime().toString(), + [EXPIRY_HEADER]: expiration.getTime().toString(), } } - if (ttl === undefined) { + if (expiration === undefined) { return {} } - throw new TypeError(`'ttl' value must be a number or a Date, ${typeof ttl} found.`) + throw new TypeError(`'expiration' value must be a number or a Date, ${typeof expiration} found.`) } private isConfigured() { From 55257d74694e05e1cb4b64d8bef7949ed1b431fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Mon, 31 Jul 2023 12:05:06 +0100 Subject: [PATCH 6/8] chore: install p-map --- package-lock.json | 19 ++++++++++++++++++- package.json | 3 ++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index a69e9d8..c6ae2af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "1.5.0", "license": "MIT", "dependencies": { - "esbuild": "0.18.17" + "esbuild": "0.18.17", + "p-map": "^6.0.0" }, "devDependencies": { "@commitlint/cli": "^17.0.0", @@ -6288,6 +6289,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-6.0.0.tgz", + "integrity": "sha512-T8BatKGY+k5rU+Q/GTYgrEf2r4xRMevAN5mtXc2aPc4rS1j3s+vWTaO2Wag94neXuCAUAs8cxBL9EeB5EA6diw==", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -12968,6 +12980,11 @@ "p-limit": "^3.0.2" } }, + "p-map": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-6.0.0.tgz", + "integrity": "sha512-T8BatKGY+k5rU+Q/GTYgrEf2r4xRMevAN5mtXc2aPc4rS1j3s+vWTaO2Wag94neXuCAUAs8cxBL9EeB5EA6diw==" + }, "parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", diff --git a/package.json b/package.json index 629c949..0bb38a9 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "node": "^14.16.0 || >=16.0.0" }, "dependencies": { - "esbuild": "0.18.17" + "esbuild": "0.18.17", + "p-map": "^6.0.0" } } From 43b40b606af4ad0bc03d288a376d77c3ce465027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Mon, 31 Jul 2023 15:22:43 +0100 Subject: [PATCH 7/8] fix: move esbuild to devDependencies --- package-lock.json | 48 ++++++++++++++++++++++++++++++++++++++++++++++- package.json | 2 +- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index c6ae2af..779f064 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "1.5.0", "license": "MIT", "dependencies": { - "esbuild": "0.18.17", "p-map": "^6.0.0" }, "devDependencies": { @@ -17,6 +16,7 @@ "@commitlint/config-conventional": "^17.0.0", "@netlify/eslint-config-node": "^7.0.1", "c8": "^7.11.0", + "esbuild": "^0.18.17", "husky": "^8.0.0", "node-fetch": "^3.3.1", "semver": "^7.5.3", @@ -807,6 +807,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "android" @@ -822,6 +823,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" @@ -837,6 +839,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "android" @@ -852,6 +855,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -867,6 +871,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -882,6 +887,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -897,6 +903,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -912,6 +919,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -927,6 +935,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -942,6 +951,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "linux" @@ -957,6 +967,7 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -972,6 +983,7 @@ "cpu": [ "mips64el" ], + "dev": true, "optional": true, "os": [ "linux" @@ -987,6 +999,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1002,6 +1015,7 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1017,6 +1031,7 @@ "cpu": [ "s390x" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1032,6 +1047,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1047,6 +1063,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "netbsd" @@ -1062,6 +1079,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -1077,6 +1095,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "sunos" @@ -1092,6 +1111,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -1107,6 +1127,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "win32" @@ -1122,6 +1143,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -3197,6 +3219,7 @@ "version": "0.18.17", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.17.tgz", "integrity": "sha512-1GJtYnUxsJreHYA0Y+iQz2UEykonY66HNWOb0yXYZi9/kNrORUEHVg87eQsCtqh59PEJ5YVZJO98JHznMJSWjg==", + "dev": true, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" @@ -9163,132 +9186,154 @@ "version": "0.18.17", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.17.tgz", "integrity": "sha512-wHsmJG/dnL3OkpAcwbgoBTTMHVi4Uyou3F5mf58ZtmUyIKfcdA7TROav/6tCzET4A3QW2Q2FC+eFneMU+iyOxg==", + "dev": true, "optional": true }, "@esbuild/android-arm64": { "version": "0.18.17", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.17.tgz", "integrity": "sha512-9np+YYdNDed5+Jgr1TdWBsozZ85U1Oa3xW0c7TWqH0y2aGghXtZsuT8nYRbzOMcl0bXZXjOGbksoTtVOlWrRZg==", + "dev": true, "optional": true }, "@esbuild/android-x64": { "version": "0.18.17", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.17.tgz", "integrity": "sha512-O+FeWB/+xya0aLg23hHEM2E3hbfwZzjqumKMSIqcHbNvDa+dza2D0yLuymRBQQnC34CWrsJUXyH2MG5VnLd6uw==", + "dev": true, "optional": true }, "@esbuild/darwin-arm64": { "version": "0.18.17", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.17.tgz", "integrity": "sha512-M9uJ9VSB1oli2BE/dJs3zVr9kcCBBsE883prage1NWz6pBS++1oNn/7soPNS3+1DGj0FrkSvnED4Bmlu1VAE9g==", + "dev": true, "optional": true }, "@esbuild/darwin-x64": { "version": "0.18.17", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.17.tgz", "integrity": "sha512-XDre+J5YeIJDMfp3n0279DFNrGCXlxOuGsWIkRb1NThMZ0BsrWXoTg23Jer7fEXQ9Ye5QjrvXpxnhzl3bHtk0g==", + "dev": true, "optional": true }, "@esbuild/freebsd-arm64": { "version": "0.18.17", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.17.tgz", "integrity": "sha512-cjTzGa3QlNfERa0+ptykyxs5A6FEUQQF0MuilYXYBGdBxD3vxJcKnzDlhDCa1VAJCmAxed6mYhA2KaJIbtiNuQ==", + "dev": true, "optional": true }, "@esbuild/freebsd-x64": { "version": "0.18.17", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.17.tgz", "integrity": "sha512-sOxEvR8d7V7Kw8QqzxWc7bFfnWnGdaFBut1dRUYtu+EIRXefBc/eIsiUiShnW0hM3FmQ5Zf27suDuHsKgZ5QrA==", + "dev": true, "optional": true }, "@esbuild/linux-arm": { "version": "0.18.17", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.17.tgz", "integrity": "sha512-2d3Lw6wkwgSLC2fIvXKoMNGVaeY8qdN0IC3rfuVxJp89CRfA3e3VqWifGDfuakPmp90+ZirmTfye1n4ncjv2lg==", + "dev": true, "optional": true }, "@esbuild/linux-arm64": { "version": "0.18.17", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.17.tgz", "integrity": "sha512-c9w3tE7qA3CYWjT+M3BMbwMt+0JYOp3vCMKgVBrCl1nwjAlOMYzEo+gG7QaZ9AtqZFj5MbUc885wuBBmu6aADQ==", + "dev": true, "optional": true }, "@esbuild/linux-ia32": { "version": "0.18.17", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.17.tgz", "integrity": "sha512-1DS9F966pn5pPnqXYz16dQqWIB0dmDfAQZd6jSSpiT9eX1NzKh07J6VKR3AoXXXEk6CqZMojiVDSZi1SlmKVdg==", + "dev": true, "optional": true }, "@esbuild/linux-loong64": { "version": "0.18.17", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.17.tgz", "integrity": "sha512-EvLsxCk6ZF0fpCB6w6eOI2Fc8KW5N6sHlIovNe8uOFObL2O+Mr0bflPHyHwLT6rwMg9r77WOAWb2FqCQrVnwFg==", + "dev": true, "optional": true }, "@esbuild/linux-mips64el": { "version": "0.18.17", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.17.tgz", "integrity": "sha512-e0bIdHA5p6l+lwqTE36NAW5hHtw2tNRmHlGBygZC14QObsA3bD4C6sXLJjvnDIjSKhW1/0S3eDy+QmX/uZWEYQ==", + "dev": true, "optional": true }, "@esbuild/linux-ppc64": { "version": "0.18.17", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.17.tgz", "integrity": "sha512-BAAilJ0M5O2uMxHYGjFKn4nJKF6fNCdP1E0o5t5fvMYYzeIqy2JdAP88Az5LHt9qBoUa4tDaRpfWt21ep5/WqQ==", + "dev": true, "optional": true }, "@esbuild/linux-riscv64": { "version": "0.18.17", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.17.tgz", "integrity": "sha512-Wh/HW2MPnC3b8BqRSIme/9Zhab36PPH+3zam5pqGRH4pE+4xTrVLx2+XdGp6fVS3L2x+DrsIcsbMleex8fbE6g==", + "dev": true, "optional": true }, "@esbuild/linux-s390x": { "version": "0.18.17", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.17.tgz", "integrity": "sha512-j/34jAl3ul3PNcK3pfI0NSlBANduT2UO5kZ7FCaK33XFv3chDhICLY8wJJWIhiQ+YNdQ9dxqQctRg2bvrMlYgg==", + "dev": true, "optional": true }, "@esbuild/linux-x64": { "version": "0.18.17", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.17.tgz", "integrity": "sha512-QM50vJ/y+8I60qEmFxMoxIx4de03pGo2HwxdBeFd4nMh364X6TIBZ6VQ5UQmPbQWUVWHWws5MmJXlHAXvJEmpQ==", + "dev": true, "optional": true }, "@esbuild/netbsd-x64": { "version": "0.18.17", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.17.tgz", "integrity": "sha512-/jGlhWR7Sj9JPZHzXyyMZ1RFMkNPjC6QIAan0sDOtIo2TYk3tZn5UDrkE0XgsTQCxWTTOcMPf9p6Rh2hXtl5TQ==", + "dev": true, "optional": true }, "@esbuild/openbsd-x64": { "version": "0.18.17", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.17.tgz", "integrity": "sha512-rSEeYaGgyGGf4qZM2NonMhMOP/5EHp4u9ehFiBrg7stH6BYEEjlkVREuDEcQ0LfIl53OXLxNbfuIj7mr5m29TA==", + "dev": true, "optional": true }, "@esbuild/sunos-x64": { "version": "0.18.17", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.17.tgz", "integrity": "sha512-Y7ZBbkLqlSgn4+zot4KUNYst0bFoO68tRgI6mY2FIM+b7ZbyNVtNbDP5y8qlu4/knZZ73fgJDlXID+ohY5zt5g==", + "dev": true, "optional": true }, "@esbuild/win32-arm64": { "version": "0.18.17", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.17.tgz", "integrity": "sha512-bwPmTJsEQcbZk26oYpc4c/8PvTY3J5/QK8jM19DVlEsAB41M39aWovWoHtNm78sd6ip6prilxeHosPADXtEJFw==", + "dev": true, "optional": true }, "@esbuild/win32-ia32": { "version": "0.18.17", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.17.tgz", "integrity": "sha512-H/XaPtPKli2MhW+3CQueo6Ni3Avggi6hP/YvgkEe1aSaxw+AeO8MFjq8DlgfTd9Iz4Yih3QCZI6YLMoyccnPRg==", + "dev": true, "optional": true }, "@esbuild/win32-x64": { "version": "0.18.17", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.17.tgz", "integrity": "sha512-fGEb8f2BSA3CW7riJVurug65ACLuQAzKq0SSqkY2b2yHHH0MzDfbLyKIGzHwOI/gkHcxM/leuSW6D5w/LMNitA==", + "dev": true, "optional": true }, "@eslint-community/eslint-utils": { @@ -10760,6 +10805,7 @@ "version": "0.18.17", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.17.tgz", "integrity": "sha512-1GJtYnUxsJreHYA0Y+iQz2UEykonY66HNWOb0yXYZi9/kNrORUEHVg87eQsCtqh59PEJ5YVZJO98JHznMJSWjg==", + "dev": true, "requires": { "@esbuild/android-arm": "0.18.17", "@esbuild/android-arm64": "0.18.17", diff --git a/package.json b/package.json index 0bb38a9..eceb1e1 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@commitlint/config-conventional": "^17.0.0", "@netlify/eslint-config-node": "^7.0.1", "c8": "^7.11.0", + "esbuild": "^0.18.17", "husky": "^8.0.0", "node-fetch": "^3.3.1", "semver": "^7.5.3", @@ -63,7 +64,6 @@ "node": "^14.16.0 || >=16.0.0" }, "dependencies": { - "esbuild": "0.18.17", "p-map": "^6.0.0" } } From 06e362f051264e875de7bf4a625ee6c1365c4127 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Mon, 31 Jul 2023 15:23:11 +0100 Subject: [PATCH 8/8] fix: fix typo --- src/main.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main.ts b/src/main.ts index 9d59e12..cbcb5c7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -100,7 +100,7 @@ export class Blobs { } } - private static getExpirationeaders(expiration: Date | number | undefined): Record { + private static getExpirationHeaders(expiration: Date | number | undefined): Record { if (typeof expiration === 'number') { return { [EXPIRY_HEADER]: (Date.now() + expiration).toString(), @@ -224,7 +224,7 @@ export class Blobs { } async set(key: string, data: BlobInput, { expiration }: SetOptions = {}) { - const headers = Blobs.getExpirationeaders(expiration) + const headers = Blobs.getExpirationHeaders(expiration) await this.makeStoreRequest(key, HTTPMethod.Put, headers, data) } @@ -233,7 +233,7 @@ export class Blobs { const { size } = await stat(path) const file = Readable.toWeb(createReadStream(path)) const headers = { - ...Blobs.getExpirationeaders(expiration), + ...Blobs.getExpirationHeaders(expiration), 'content-length': size.toString(), } @@ -247,7 +247,7 @@ export class Blobs { async setJSON(key: string, data: unknown, { expiration }: SetOptions = {}) { const payload = JSON.stringify(data) const headers = { - ...Blobs.getExpirationeaders(expiration), + ...Blobs.getExpirationHeaders(expiration), 'content-type': 'application/json', }