Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add purgeCache helper #433

Merged
merged 8 commits into from
Oct 12, 2023
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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
eduardoboucas marked this conversation as resolved.
Show resolved Hide resolved
} 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
78 changes: 78 additions & 0 deletions test/unit/purge_cache.js
Original file line number Diff line number Diff line change
@@ -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',
)
})
Loading