Skip to content

Commit f6098c0

Browse files
authoredOct 12, 2023
feat: add purgeCache helper (#433)
**Which problem is this pull request solving?** Adds a `purgeCache` helper for https://www.notion.so/netlify/Cache-Purge-API-12b8eb7359c549a4aad56d528f19feb0, using the environment variable added in netlify/serverless-functions-api#161. I still need to add some tests, but wanted to get the PR up sooner rather than later to get feedback on the approach.
1 parent 82f6c12 commit f6098c0

File tree

8 files changed

+257
-2
lines changed

8 files changed

+257
-2
lines changed
 

‎.eslintrc.js

+5-1
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,19 @@ const { overrides } = require('@netlify/eslint-config-node')
44

55
module.exports = {
66
extends: '@netlify/eslint-config-node',
7-
rules: {},
7+
rules: {
8+
'max-statements': 'off',
9+
},
810
overrides: [
911
...overrides,
1012
{
1113
files: 'test/**/*.+(t|j)s',
1214
rules: {
1315
'no-magic-numbers': 'off',
16+
'no-undef': 'off',
1417
'promise/prefer-await-to-callbacks': 'off',
1518
'unicorn/filename-case': 'off',
19+
'unicorn/consistent-function-scoping': 'off',
1620
},
1721
},
1822
],

‎package-lock.json

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json

+1
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"husky": "^7.0.4",
6565
"npm-run-all": "^4.1.5",
6666
"nyc": "^15.0.0",
67+
"semver": "^7.5.4",
6768
"tsd": "^0.29.0",
6869
"typescript": "^4.4.4"
6970
},

‎src/lib/purge_cache.ts

+82
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { env } from 'process'
2+
3+
interface BasePurgeCacheOptions {
4+
apiURL?: string
5+
deployAlias?: string
6+
tags?: string[]
7+
token?: string
8+
}
9+
10+
interface PurgeCacheOptionsWithSiteID extends BasePurgeCacheOptions {
11+
siteID?: string
12+
}
13+
14+
interface PurgeCacheOptionsWithSiteSlug extends BasePurgeCacheOptions {
15+
siteSlug: string
16+
}
17+
18+
interface PurgeCacheOptionsWithDomain extends BasePurgeCacheOptions {
19+
domain: string
20+
}
21+
22+
type PurgeCacheOptions = PurgeCacheOptionsWithSiteID | PurgeCacheOptionsWithSiteSlug | PurgeCacheOptionsWithDomain
23+
24+
interface PurgeAPIPayload {
25+
cache_tags?: string[]
26+
deploy_alias?: string
27+
domain?: string
28+
site_id?: string
29+
site_slug?: string
30+
}
31+
32+
export const purgeCache = async (options: PurgeCacheOptions = {}) => {
33+
if (globalThis.fetch === undefined) {
34+
throw new Error(
35+
"`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.",
36+
)
37+
}
38+
39+
const payload: PurgeAPIPayload = {
40+
cache_tags: options.tags,
41+
deploy_alias: options.deployAlias,
42+
}
43+
const token = env.NETLIFY_PURGE_API_TOKEN || options.token
44+
45+
if ('siteSlug' in options) {
46+
payload.site_slug = options.siteSlug
47+
} else if ('domain' in options) {
48+
payload.domain = options.domain
49+
} else {
50+
// The `siteID` from `options` takes precedence over the one from the
51+
// environment.
52+
const siteID = options.siteID || env.SITE_ID
53+
54+
if (!siteID) {
55+
throw new Error(
56+
'The Netlify site ID was not found in the execution environment. Please supply it manually using the `siteID` property.',
57+
)
58+
}
59+
60+
payload.site_id = siteID
61+
}
62+
63+
if (!token) {
64+
throw new Error(
65+
'The cache purge API token was not found in the execution environment. Please supply it manually using the `token` property.',
66+
)
67+
}
68+
69+
const apiURL = options.apiURL || 'https://api.netlify.com'
70+
const response = await fetch(`${apiURL}/api/v1/purge`, {
71+
method: 'POST',
72+
headers: {
73+
'Content-Type': 'application/json; charset=utf8',
74+
Authorization: `Bearer ${token}`,
75+
},
76+
body: JSON.stringify(payload),
77+
})
78+
79+
if (!response.ok) {
80+
throw new Error(`Cache purge API call returned an unexpected status code: ${response.status}`)
81+
}
82+
}

‎src/main.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { getNetlifyGlobal } from '@netlify/serverless-functions-api'
22

33
export { builder } from './lib/builder.js'
4+
export { purgeCache } from './lib/purge_cache.js'
45
export { schedule } from './lib/schedule.js'
56
export { stream } from './lib/stream.js'
67
export * from './function/index.js'

‎test/helpers/mock_fetch.js

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
const assert = require('assert')
2+
3+
module.exports = class MockFetch {
4+
constructor() {
5+
this.requests = []
6+
}
7+
8+
addExpectedRequest({ body, headers = {}, method, response, url }) {
9+
this.requests.push({ body, fulfilled: false, headers, method, response, url })
10+
11+
return this
12+
}
13+
14+
delete(options) {
15+
return this.addExpectedRequest({ ...options, method: 'delete' })
16+
}
17+
18+
get(options) {
19+
return this.addExpectedRequest({ ...options, method: 'get' })
20+
}
21+
22+
post(options) {
23+
return this.addExpectedRequest({ ...options, method: 'post' })
24+
}
25+
26+
put(options) {
27+
return this.addExpectedRequest({ ...options, method: 'put' })
28+
}
29+
30+
get fetcher() {
31+
// eslint-disable-next-line require-await
32+
return async (...args) => {
33+
const [url, options] = args
34+
const headers = options?.headers
35+
const urlString = url.toString()
36+
const match = this.requests.find(
37+
(request) =>
38+
request.method.toLowerCase() === options?.method.toLowerCase() &&
39+
request.url === urlString &&
40+
!request.fulfilled,
41+
)
42+
43+
if (!match) {
44+
throw new Error(`Unexpected fetch call: ${url}`)
45+
}
46+
47+
for (const key in match.headers) {
48+
assert.equal(headers[key], match.headers[key])
49+
}
50+
51+
if (typeof match.body === 'string') {
52+
assert.equal(options?.body, match.body)
53+
} else if (typeof match.body === 'function') {
54+
const bodyFn = match.body
55+
56+
bodyFn(options?.body)
57+
} else {
58+
assert.equal(options?.body, undefined)
59+
}
60+
61+
match.fulfilled = true
62+
63+
if (match.response instanceof Error) {
64+
throw match.response
65+
}
66+
67+
return match.response
68+
}
69+
}
70+
71+
get fulfilled() {
72+
return this.requests.every((request) => request.fulfilled)
73+
}
74+
}

‎test/types/Handler.test-d.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Handler } from '../../src/main.js'
44

55
// Ensure void is NOT a valid return type in async handlers
66
expectError(() => {
7-
// eslint-disable-next-line @typescript-eslint/no-unused-vars, unicorn/consistent-function-scoping
7+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
88
const handler: Handler = async () => {
99
// void
1010
}

‎test/unit/purge_cache.js

+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
const process = require('process')
2+
3+
const test = require('ava')
4+
const semver = require('semver')
5+
6+
const { purgeCache } = require('../../dist/lib/purge_cache')
7+
const { invokeLambda } = require('../helpers/main')
8+
const MockFetch = require('../helpers/mock_fetch')
9+
10+
const globalFetch = globalThis.fetch
11+
const hasFetchAPI = semver.gte(process.version, '18.0.0')
12+
13+
test.beforeEach(() => {
14+
delete process.env.NETLIFY_PURGE_API_TOKEN
15+
delete process.env.SITE_ID
16+
})
17+
18+
test.afterEach(() => {
19+
globalThis.fetch = globalFetch
20+
})
21+
22+
test.serial('Calls the purge API endpoint and returns `undefined` if the operation was successful', async (t) => {
23+
if (!hasFetchAPI) {
24+
console.warn('Skipping test requires the fetch API')
25+
26+
return t.pass()
27+
}
28+
29+
const mockSiteID = '123456789'
30+
const mockToken = '1q2w3e4r5t6y7u8i9o0p'
31+
32+
process.env.NETLIFY_PURGE_API_TOKEN = mockToken
33+
process.env.SITE_ID = mockSiteID
34+
35+
const mockAPI = new MockFetch().post({
36+
body: (payload) => {
37+
const data = JSON.parse(payload)
38+
39+
t.is(data.site_id, mockSiteID)
40+
},
41+
headers: { Authorization: `Bearer ${mockToken}` },
42+
method: 'post',
43+
response: new Response(null, { status: 202 }),
44+
url: `https://api.netlify.com/api/v1/purge`,
45+
})
46+
const myFunction = async () => {
47+
await purgeCache()
48+
}
49+
50+
globalThis.fetch = mockAPI.fetcher
51+
52+
const response = await invokeLambda(myFunction)
53+
54+
t.is(response, undefined)
55+
t.true(mockAPI.fulfilled)
56+
})
57+
58+
test.serial('Throws if the API response does not have a successful status code', async (t) => {
59+
if (!hasFetchAPI) {
60+
console.warn('Skipping test requires the fetch API')
61+
62+
return t.pass()
63+
}
64+
65+
const mockSiteID = '123456789'
66+
const mockToken = '1q2w3e4r5t6y7u8i9o0p'
67+
68+
process.env.NETLIFY_PURGE_API_TOKEN = mockToken
69+
process.env.SITE_ID = mockSiteID
70+
71+
const mockAPI = new MockFetch().post({
72+
body: (payload) => {
73+
const data = JSON.parse(payload)
74+
75+
t.is(data.site_id, mockSiteID)
76+
},
77+
headers: { Authorization: `Bearer ${mockToken}` },
78+
method: 'post',
79+
response: new Response(null, { status: 500 }),
80+
url: `https://api.netlify.com/api/v1/purge`,
81+
})
82+
const myFunction = async () => {
83+
await purgeCache()
84+
}
85+
86+
globalThis.fetch = mockAPI.fetcher
87+
88+
await t.throwsAsync(
89+
async () => await invokeLambda(myFunction),
90+
'Cache purge API call returned an unexpected status code: 500',
91+
)
92+
})

0 commit comments

Comments
 (0)
Please sign in to comment.