Skip to content

Commit

Permalink
feat: add purgeCache helper (#433)
Browse files Browse the repository at this point in the history
**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.
  • Loading branch information
eduardoboucas authored Oct 12, 2023
1 parent 82f6c12 commit f6098c0
Show file tree
Hide file tree
Showing 8 changed files with 257 additions and 2 deletions.
6 changes: 5 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@ const { overrides } = require('@netlify/eslint-config-node')

module.exports = {
extends: '@netlify/eslint-config-node',
rules: {},
rules: {
'max-statements': 'off',
},
overrides: [
...overrides,
{
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',
},
},
],
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
82 changes: 82 additions & 0 deletions src/lib/purge_cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { env } from 'process'

interface BasePurgeCacheOptions {
apiURL?: string
deployAlias?: string
tags?: string[]
token?: string
}

interface PurgeCacheOptionsWithSiteID extends BasePurgeCacheOptions {
siteID?: string
}

interface PurgeCacheOptionsWithSiteSlug extends BasePurgeCacheOptions {
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 = 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.",
)
}

const payload: PurgeAPIPayload = {
cache_tags: options.tags,
deploy_alias: options.deployAlias,
}
const token = env.NETLIFY_PURGE_API_TOKEN || options.token

if ('siteSlug' in options) {
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.
const siteID = options.siteID || env.SITE_ID

if (!siteID) {
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) {
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 = options.apiURL || 'https://api.netlify.com'
const response = await fetch(`${apiURL}/api/v1/purge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf8',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
})

if (!response.ok) {
throw new Error(`Cache purge API call returned an unexpected status code: ${response.status}`)
}
}
1 change: 1 addition & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
74 changes: 74 additions & 0 deletions test/helpers/mock_fetch.js
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion test/types/Handler.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
92 changes: 92 additions & 0 deletions test/unit/purge_cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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
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) => {
if (!hasFetchAPI) {
console.warn('Skipping test requires the fetch API')

return t.pass()
}

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) => {
if (!hasFetchAPI) {
console.warn('Skipping test requires the fetch API')

return t.pass()
}

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',
)
})

0 comments on commit f6098c0

Please sign in to comment.