From 656cfb5bba643836a804af0df579f8978d520b7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Wed, 11 Oct 2023 15:39:55 +0100 Subject: [PATCH 1/8] feat: add `purgeCache` helper --- src/lib/purge_cache.ts | 46 ++++++++++++++++++++++++++++++++++++++++++ src/main.ts | 1 + 2 files changed, 47 insertions(+) create mode 100644 src/lib/purge_cache.ts diff --git a/src/lib/purge_cache.ts b/src/lib/purge_cache.ts new file mode 100644 index 00000000..1af1be1a --- /dev/null +++ b/src/lib/purge_cache.ts @@ -0,0 +1,46 @@ +import { env } from 'process' + +interface PurgeCacheOptions { + apiURL?: string + siteID?: string + tags: string[] + token?: string +} + +export const purgeCache = ({ tags, ...overrides }: PurgeCacheOptions) => { + if (globalThis.fetch === undefined) { + throw new Error( + "`fetch` is not available. Please ensure you're using Node.js version 18.0.0 or above. Refer to https://ntl.fyi/functions-runtime for more information.", + ) + } + + const siteID = env.SITE_ID || overrides.siteID + + if (!siteID) { + throw new Error( + 'The Netlify site ID was not found in the execution environment. Please supply it manually using the `siteID` property.', + ) + } + + const token = env.NETLIFY_PURGE_TOKEN_1 || overrides.token + + if (!siteID) { + throw new Error( + 'The cache purge API token was not found in the execution environment. Please supply it manually using the `token` property.', + ) + } + + const apiURL = overrides.apiURL || 'https://api.netlify.com' + + return fetch(`${apiURL}/api/v1/purge`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf8', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + site_id: siteID, + cache_tags: [tags], + }), + }) +} diff --git a/src/main.ts b/src/main.ts index 803d20e2..b28ed7b2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,7 @@ import type { getNetlifyGlobal } from '@netlify/serverless-functions-api' export { builder } from './lib/builder.js' +export { purgeCache } from './lib/purge_cache.js' export { schedule } from './lib/schedule.js' export { stream } from './lib/stream.js' export * from './function/index.js' From 9eff5ebd8aa325e6cf192889c3f8dde8a82ad446 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Wed, 11 Oct 2023 15:48:05 +0100 Subject: [PATCH 2/8] fix: oops --- src/lib/purge_cache.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/purge_cache.ts b/src/lib/purge_cache.ts index 1af1be1a..3d8f175a 100644 --- a/src/lib/purge_cache.ts +++ b/src/lib/purge_cache.ts @@ -24,7 +24,7 @@ export const purgeCache = ({ tags, ...overrides }: PurgeCacheOptions) => { const token = env.NETLIFY_PURGE_TOKEN_1 || overrides.token - if (!siteID) { + if (!token) { throw new Error( 'The cache purge API token was not found in the execution environment. Please supply it manually using the `token` property.', ) From f6368ca8ecb70512e14df01d40e601f7caef3140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Wed, 11 Oct 2023 15:48:30 +0100 Subject: [PATCH 3/8] fix: use correct environment variable --- src/lib/purge_cache.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/purge_cache.ts b/src/lib/purge_cache.ts index 3d8f175a..93f1bf54 100644 --- a/src/lib/purge_cache.ts +++ b/src/lib/purge_cache.ts @@ -22,7 +22,7 @@ export const purgeCache = ({ tags, ...overrides }: PurgeCacheOptions) => { ) } - const token = env.NETLIFY_PURGE_TOKEN_1 || overrides.token + const token = env.NETLIFY_PURGE_API_TOKEN || overrides.token if (!token) { throw new Error( From 51998f3ce86305574e2604b5bad1c39b57728d65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Wed, 11 Oct 2023 16:11:36 +0100 Subject: [PATCH 4/8] refactor: accept additional parameters --- .eslintrc.js | 4 ++- src/lib/purge_cache.ts | 63 +++++++++++++++++++++++++++++++++--------- 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index c83d5b14..eb47444a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,7 +4,9 @@ const { overrides } = require('@netlify/eslint-config-node') module.exports = { extends: '@netlify/eslint-config-node', - rules: {}, + rules: { + 'max-statements': 'off', + }, overrides: [ ...overrides, { diff --git a/src/lib/purge_cache.ts b/src/lib/purge_cache.ts index 93f1bf54..1a1446b0 100644 --- a/src/lib/purge_cache.ts +++ b/src/lib/purge_cache.ts @@ -1,36 +1,76 @@ import { env } from 'process' -interface PurgeCacheOptions { +interface BasePurgeCacheOptions { apiURL?: string - siteID?: string - tags: string[] + tags?: string[] token?: string } -export const purgeCache = ({ tags, ...overrides }: PurgeCacheOptions) => { +interface PurgeCacheOptionsWithSiteID extends BasePurgeCacheOptions { + deployAlias?: string + siteID?: string +} + +interface PurgeCacheOptionsWithSiteSlug extends BasePurgeCacheOptions { + deployAlias?: string + siteSlug: string +} + +interface PurgeCacheOptionsWithDomain extends BasePurgeCacheOptions { + domain: string +} + +type PurgeCacheOptions = PurgeCacheOptionsWithSiteID | PurgeCacheOptionsWithSiteSlug | PurgeCacheOptionsWithDomain + +interface PurgeAPIPayload { + cache_tags?: string[] + deploy_alias?: string + domain?: string + site_id?: string + site_slug?: string +} + +export const purgeCache = (options: PurgeCacheOptions = {}) => { if (globalThis.fetch === undefined) { throw new Error( "`fetch` is not available. Please ensure you're using Node.js version 18.0.0 or above. Refer to https://ntl.fyi/functions-runtime for more information.", ) } - const siteID = env.SITE_ID || overrides.siteID + const payload: PurgeAPIPayload = { + cache_tags: options.tags, + site_id: env.SITE_ID, + } + const token = env.NETLIFY_PURGE_API_TOKEN || options.token + + if ('siteSlug' in options) { + payload.deploy_alias = options.deployAlias + payload.site_slug = options.siteSlug + } else if ('domain' in options) { + payload.domain = options.domain + } else { + // The `siteID` from `options` takes precedence over the one from the + // environment. + if (options.siteID) { + payload.site_id = options.siteID + } + + payload.deploy_alias = options.deployAlias + } - if (!siteID) { + if (!payload.site_id) { throw new Error( 'The Netlify site ID was not found in the execution environment. Please supply it manually using the `siteID` property.', ) } - const token = env.NETLIFY_PURGE_API_TOKEN || overrides.token - if (!token) { throw new Error( 'The cache purge API token was not found in the execution environment. Please supply it manually using the `token` property.', ) } - const apiURL = overrides.apiURL || 'https://api.netlify.com' + const apiURL = options.apiURL || 'https://api.netlify.com' return fetch(`${apiURL}/api/v1/purge`, { method: 'POST', @@ -38,9 +78,6 @@ export const purgeCache = ({ tags, ...overrides }: PurgeCacheOptions) => { 'Content-Type': 'application/json; charset=utf8', Authorization: `Bearer ${token}`, }, - body: JSON.stringify({ - site_id: siteID, - cache_tags: [tags], - }), + body: JSON.stringify(payload), }) } From 7782a3bddaa6a4fc69a86260e02e08b9d452fe70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Wed, 11 Oct 2023 16:22:14 +0100 Subject: [PATCH 5/8] refactor: check response status --- src/lib/purge_cache.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/lib/purge_cache.ts b/src/lib/purge_cache.ts index 1a1446b0..f1ff608d 100644 --- a/src/lib/purge_cache.ts +++ b/src/lib/purge_cache.ts @@ -30,7 +30,7 @@ interface PurgeAPIPayload { site_slug?: string } -export const purgeCache = (options: PurgeCacheOptions = {}) => { +export const purgeCache = async (options: PurgeCacheOptions = {}) => { if (globalThis.fetch === undefined) { throw new Error( "`fetch` is not available. Please ensure you're using Node.js version 18.0.0 or above. Refer to https://ntl.fyi/functions-runtime for more information.", @@ -71,8 +71,7 @@ export const purgeCache = (options: PurgeCacheOptions = {}) => { } const apiURL = options.apiURL || 'https://api.netlify.com' - - return fetch(`${apiURL}/api/v1/purge`, { + const response = await fetch(`${apiURL}/api/v1/purge`, { method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf8', @@ -80,4 +79,8 @@ export const purgeCache = (options: PurgeCacheOptions = {}) => { }, body: JSON.stringify(payload), }) + + if (!response.ok) { + throw new Error(`Cache purge API call returned an unexpected status code: ${response.status}`) + } } From e70b66fff769eb26e1c3a130efb9c7bc5831aeef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Wed, 11 Oct 2023 16:44:56 +0100 Subject: [PATCH 6/8] refactor: adjust parameters --- src/lib/purge_cache.ts | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/lib/purge_cache.ts b/src/lib/purge_cache.ts index f1ff608d..bb9067f5 100644 --- a/src/lib/purge_cache.ts +++ b/src/lib/purge_cache.ts @@ -2,17 +2,16 @@ import { env } from 'process' interface BasePurgeCacheOptions { apiURL?: string + deployAlias?: string tags?: string[] token?: string } interface PurgeCacheOptionsWithSiteID extends BasePurgeCacheOptions { - deployAlias?: string siteID?: string } interface PurgeCacheOptionsWithSiteSlug extends BasePurgeCacheOptions { - deployAlias?: string siteSlug: string } @@ -39,29 +38,26 @@ export const purgeCache = async (options: PurgeCacheOptions = {}) => { const payload: PurgeAPIPayload = { cache_tags: options.tags, - site_id: env.SITE_ID, + deploy_alias: options.deployAlias, } const token = env.NETLIFY_PURGE_API_TOKEN || options.token if ('siteSlug' in options) { - payload.deploy_alias = options.deployAlias payload.site_slug = options.siteSlug } else if ('domain' in options) { payload.domain = options.domain } else { // The `siteID` from `options` takes precedence over the one from the // environment. - if (options.siteID) { - payload.site_id = options.siteID - } + const siteID = options.siteID || env.SITE_ID - payload.deploy_alias = options.deployAlias - } + if (!siteID) { + throw new Error( + 'The Netlify site ID was not found in the execution environment. Please supply it manually using the `siteID` property.', + ) + } - if (!payload.site_id) { - throw new Error( - 'The Netlify site ID was not found in the execution environment. Please supply it manually using the `siteID` property.', - ) + payload.site_id = siteID } if (!token) { From 542c5201e2536a97d56650ad60f2f930e0ed5684 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Wed, 11 Oct 2023 17:18:42 +0100 Subject: [PATCH 7/8] chore: add tests --- .eslintrc.js | 2 + test/helpers/mock_fetch.js | 74 ++++++++++++++++++++++++++++++++++ test/types/Handler.test-d.ts | 2 +- test/unit/purge_cache.js | 78 ++++++++++++++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 test/helpers/mock_fetch.js create mode 100644 test/unit/purge_cache.js diff --git a/.eslintrc.js b/.eslintrc.js index eb47444a..a7cad672 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -13,8 +13,10 @@ module.exports = { files: 'test/**/*.+(t|j)s', rules: { 'no-magic-numbers': 'off', + 'no-undef': 'off', 'promise/prefer-await-to-callbacks': 'off', 'unicorn/filename-case': 'off', + 'unicorn/consistent-function-scoping': 'off', }, }, ], diff --git a/test/helpers/mock_fetch.js b/test/helpers/mock_fetch.js new file mode 100644 index 00000000..ff7124fc --- /dev/null +++ b/test/helpers/mock_fetch.js @@ -0,0 +1,74 @@ +const assert = require('assert') + +module.exports = class MockFetch { + constructor() { + this.requests = [] + } + + addExpectedRequest({ body, headers = {}, method, response, url }) { + this.requests.push({ body, fulfilled: false, headers, method, response, url }) + + return this + } + + delete(options) { + return this.addExpectedRequest({ ...options, method: 'delete' }) + } + + get(options) { + return this.addExpectedRequest({ ...options, method: 'get' }) + } + + post(options) { + return this.addExpectedRequest({ ...options, method: 'post' }) + } + + put(options) { + return this.addExpectedRequest({ ...options, method: 'put' }) + } + + get fetcher() { + // eslint-disable-next-line require-await + return async (...args) => { + const [url, options] = args + const headers = options?.headers + const urlString = url.toString() + const match = this.requests.find( + (request) => + request.method.toLowerCase() === options?.method.toLowerCase() && + request.url === urlString && + !request.fulfilled, + ) + + if (!match) { + throw new Error(`Unexpected fetch call: ${url}`) + } + + for (const key in match.headers) { + assert.equal(headers[key], match.headers[key]) + } + + if (typeof match.body === 'string') { + assert.equal(options?.body, match.body) + } else if (typeof match.body === 'function') { + const bodyFn = match.body + + bodyFn(options?.body) + } else { + assert.equal(options?.body, undefined) + } + + match.fulfilled = true + + if (match.response instanceof Error) { + throw match.response + } + + return match.response + } + } + + get fulfilled() { + return this.requests.every((request) => request.fulfilled) + } +} diff --git a/test/types/Handler.test-d.ts b/test/types/Handler.test-d.ts index d3c4fe8d..6e23a91e 100644 --- a/test/types/Handler.test-d.ts +++ b/test/types/Handler.test-d.ts @@ -4,7 +4,7 @@ import { Handler } from '../../src/main.js' // Ensure void is NOT a valid return type in async handlers expectError(() => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars, unicorn/consistent-function-scoping + // eslint-disable-next-line @typescript-eslint/no-unused-vars const handler: Handler = async () => { // void } diff --git a/test/unit/purge_cache.js b/test/unit/purge_cache.js new file mode 100644 index 00000000..05bc4234 --- /dev/null +++ b/test/unit/purge_cache.js @@ -0,0 +1,78 @@ +const process = require('process') + +const test = require('ava') + +const { purgeCache } = require('../../dist/lib/purge_cache') +const { invokeLambda } = require('../helpers/main') +const MockFetch = require('../helpers/mock_fetch') + +const globalFetch = globalThis.fetch + +test.beforeEach(() => { + delete process.env.NETLIFY_PURGE_API_TOKEN + delete process.env.SITE_ID +}) + +test.afterEach(() => { + globalThis.fetch = globalFetch +}) + +test.serial('Calls the purge API endpoint and returns `undefined` if the operation was successful', async (t) => { + const mockSiteID = '123456789' + const mockToken = '1q2w3e4r5t6y7u8i9o0p' + + process.env.NETLIFY_PURGE_API_TOKEN = mockToken + process.env.SITE_ID = mockSiteID + + const mockAPI = new MockFetch().post({ + body: (payload) => { + const data = JSON.parse(payload) + + t.is(data.site_id, mockSiteID) + }, + headers: { Authorization: `Bearer ${mockToken}` }, + method: 'post', + response: new Response(null, { status: 202 }), + url: `https://api.netlify.com/api/v1/purge`, + }) + const myFunction = async () => { + await purgeCache() + } + + globalThis.fetch = mockAPI.fetcher + + const response = await invokeLambda(myFunction) + + t.is(response, undefined) + t.true(mockAPI.fulfilled) +}) + +test.serial('Throws if the API response does not have a successful status code', async (t) => { + const mockSiteID = '123456789' + const mockToken = '1q2w3e4r5t6y7u8i9o0p' + + process.env.NETLIFY_PURGE_API_TOKEN = mockToken + process.env.SITE_ID = mockSiteID + + const mockAPI = new MockFetch().post({ + body: (payload) => { + const data = JSON.parse(payload) + + t.is(data.site_id, mockSiteID) + }, + headers: { Authorization: `Bearer ${mockToken}` }, + method: 'post', + response: new Response(null, { status: 500 }), + url: `https://api.netlify.com/api/v1/purge`, + }) + const myFunction = async () => { + await purgeCache() + } + + globalThis.fetch = mockAPI.fetcher + + await t.throwsAsync( + async () => await invokeLambda(myFunction), + 'Cache purge API call returned an unexpected status code: 500', + ) +}) From 514d90dfd6d68e959ef075d0a70dac61ae43b1d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Thu, 12 Oct 2023 09:21:53 +0100 Subject: [PATCH 8/8] chore: skip tests on Node <18 --- package-lock.json | 1 + package.json | 1 + test/unit/purge_cache.js | 14 ++++++++++++++ 3 files changed, 16 insertions(+) diff --git a/package-lock.json b/package-lock.json index 3a6143d0..37abc787 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "husky": "^7.0.4", "npm-run-all": "^4.1.5", "nyc": "^15.0.0", + "semver": "^7.5.4", "tsd": "^0.29.0", "typescript": "^4.4.4" }, diff --git a/package.json b/package.json index 81a8a90d..fd4ba280 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "husky": "^7.0.4", "npm-run-all": "^4.1.5", "nyc": "^15.0.0", + "semver": "^7.5.4", "tsd": "^0.29.0", "typescript": "^4.4.4" }, diff --git a/test/unit/purge_cache.js b/test/unit/purge_cache.js index 05bc4234..d3b97dfc 100644 --- a/test/unit/purge_cache.js +++ b/test/unit/purge_cache.js @@ -1,12 +1,14 @@ const process = require('process') const test = require('ava') +const semver = require('semver') const { purgeCache } = require('../../dist/lib/purge_cache') const { invokeLambda } = require('../helpers/main') const MockFetch = require('../helpers/mock_fetch') const globalFetch = globalThis.fetch +const hasFetchAPI = semver.gte(process.version, '18.0.0') test.beforeEach(() => { delete process.env.NETLIFY_PURGE_API_TOKEN @@ -18,6 +20,12 @@ test.afterEach(() => { }) test.serial('Calls the purge API endpoint and returns `undefined` if the operation was successful', async (t) => { + if (!hasFetchAPI) { + console.warn('Skipping test requires the fetch API') + + return t.pass() + } + const mockSiteID = '123456789' const mockToken = '1q2w3e4r5t6y7u8i9o0p' @@ -48,6 +56,12 @@ test.serial('Calls the purge API endpoint and returns `undefined` if the operati }) test.serial('Throws if the API response does not have a successful status code', async (t) => { + if (!hasFetchAPI) { + console.warn('Skipping test requires the fetch API') + + return t.pass() + } + const mockSiteID = '123456789' const mockToken = '1q2w3e4r5t6y7u8i9o0p'