Skip to content

feat: add API cache #6195

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

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
9 changes: 7 additions & 2 deletions packages/config/src/api/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@ import { NetlifyAPI } from 'netlify'
import { removeUndefined } from '../utils/remove_falsy.js'

// Retrieve Netlify API client, if an access token was passed
export const getApiClient = function ({ token, offline, testOpts = {}, host, scheme, pathPrefix }) {
export const getApiClient = function ({ cache, token, offline, testOpts = {}, host, scheme, pathPrefix }) {
if (!token || offline) {
return
}

// TODO: find less intrusive way to mock HTTP requests
const parameters = removeUndefined({ scheme: testOpts.scheme || scheme, host: testOpts.host || host, pathPrefix })
const parameters = removeUndefined({
cache,
scheme: testOpts.scheme || scheme,
host: testOpts.host || host,
pathPrefix,
})
const api = new NetlifyAPI(token, parameters)
return api
}
31 changes: 15 additions & 16 deletions packages/config/src/api/site_info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const getSiteInfo = async function ({
const integrations =
mode === 'buildbot' && !offline
? await getIntegrations({
api,
siteId,
testOpts,
offline,
Expand All @@ -74,10 +75,9 @@ export const getSiteInfo = async function ({
return { siteInfo, accounts: [], addons: [], integrations }
}

const [siteInfo, accounts, addons, integrations] = await Promise.all([
const [siteInfo, accounts, integrations] = await Promise.all([
getSite(api, siteId, siteFeatureFlagPrefix),
getAccounts(api),
getAddons(api, siteId),
getIntegrations({ siteId, testOpts, offline, accountId, token, featureFlags, extensionApiBaseUrl, mode }),
])

Expand All @@ -87,7 +87,7 @@ export const getSiteInfo = async function ({
siteInfo.build_settings.env = envelope
}

return { siteInfo, accounts, addons, integrations }
return { siteInfo, accounts, addons: [], integrations }
}

const getSite = async function (api: NetlifyAPI, siteId: string, siteFeatureFlagPrefix: string) {
Expand Down Expand Up @@ -128,20 +128,8 @@ const getAccounts = async function (api: NetlifyAPI): Promise<MinimalAccount[]>
}
}

const getAddons = async function (api: NetlifyAPI, siteId: string) {
if (siteId === undefined) {
return []
}

try {
const addons = await (api as any).listServiceInstancesForSite({ siteId })
return Array.isArray(addons) ? addons : []
} catch (error) {
throwUserError(`Failed retrieving addons for site ${siteId}: ${error.message}. ${ERROR_CALL_TO_ACTION}`)
}
}

type GetIntegrationsOpts = {
api?: NetlifyAPI
siteId?: string
accountId?: string
testOpts: TestOptions
Expand All @@ -153,6 +141,7 @@ type GetIntegrationsOpts = {
}

const getIntegrations = async function ({
api,
siteId,
accountId,
testOpts,
Expand Down Expand Up @@ -196,6 +185,16 @@ const getIntegrations = async function ({
: `${baseUrl}site/${siteId}/integrations/safe`

try {
// Even though integrations don't come through the Netlify API, we can
// still leverage the API cache if one is being used.
if (api?.cache) {
const response = await api.cache.get(url, 'get', {})

if (response !== null && Array.isArray(response.body)) {
return response.body
}
}

const requestOptions = {} as RequestInit

// This is used to identify where the request is coming from
Expand Down
3 changes: 2 additions & 1 deletion packages/config/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export type Config = {
*/
export const resolveConfig = async function (opts): Promise<Config> {
const {
apiCache,
cachedConfig,
cachedConfigPath,
host,
Expand All @@ -63,7 +64,7 @@ export const resolveConfig = async function (opts): Promise<Config> {
...optsA
} = addDefaultOpts(opts) as $TSFixMe
// `api` is not JSON-serializable, so we cannot cache it inside `cachedConfig`
const api = getApiClient({ token, offline, host, scheme, pathPrefix, testOpts })
const api = getApiClient({ token, offline, host, scheme, pathPrefix, testOpts, cache: apiCache })

const parsedCachedConfig = await getCachedConfig({ cachedConfig, cachedConfigPath, token, api })
// If there is a cached config, use it. The exception is when a default config,
Expand Down
33 changes: 33 additions & 0 deletions packages/js-client/src/buffered_response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { getHeadersObject, HeadersObject } from './headers.js'
import { getResponseType } from './methods/response_type.js'

type JSONResponse = { type: 'json'; body: any }
type TextResponse = { type: 'text'; body: string }

/**
* An HTTP response that has been fully read. The body has been buffered and so
* it can be read multiple times and serialized to disk.
*/
export type BufferedResponse = { headers: HeadersObject; timestamp: number } & (JSONResponse | TextResponse)

/**
* Consumes an HTTP response and returns a `BufferedResponse` object.
*/
export const getBufferedResponse = async (res: Response): Promise<BufferedResponse> => {
const headers = getHeadersObject(res.headers)
const data = {
headers,
timestamp: Date.now(),
}
const type = getResponseType(res)

if (type === 'json') {
return {
...data,
type: 'json',
body: await res.json(),
}
}

return { ...data, type: 'text', body: await res.text() }
}
198 changes: 198 additions & 0 deletions packages/js-client/src/cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { promises as fs } from 'fs'
import path from 'path'

import test, { ExecutionContext } from 'ava'

import { APICache } from '../lib/index.js'

const dateNow = Date.now
const globalFetch = globalThis.fetch

test.afterEach(() => {
Date.now = dateNow
globalThis.fetch = globalFetch
})

const getMockFetch = (t: ExecutionContext, mocks: Record<string, () => Response>) => {
const calls: Record<string, number> = {}

const mockFetch = async (input: URL | RequestInfo) => {
for (const url in mocks) {
if (input.toString() === url) {
calls[url] = calls[url] ?? 0
calls[url]++

return mocks[url]()
}
}

t.fail(`Unexpected fetch call: ${input}`)

return new Response(null, { status: 400 })
}

return { calls, mockFetch }
}

const sleep = (ms: number) =>
new Promise((resolve) => {
setTimeout(resolve, ms)
})

test.serial('Returns response from cache if within TTL', async (t) => {
const mockEndpoint = 'https://api.netlify/endpoint'
const mockResponse = { messages: ['Hello', 'Goodbye'] }
const { calls, mockFetch } = getMockFetch(t, {
[mockEndpoint]: () => Response.json(mockResponse),
})

globalThis.fetch = mockFetch

const cache = new APICache({
ttl: 30,
swr: 30,
})

const res1 = await cache.get(mockEndpoint, 'get', {})
t.deepEqual(res1?.body, mockResponse)
t.deepEqual(res1?.headers, { 'content-type': 'application/json' })

const now = Date.now()

const future = now + 10
Date.now = () => future

const res2 = await cache.get(mockEndpoint, 'get', {})
t.deepEqual(res2?.body, mockResponse)
t.deepEqual(res2?.headers, { 'content-type': 'application/json' })

t.is(calls[mockEndpoint], 1)
})

test.serial('Returns response from cache if outside of TTL but within SWR', async (t) => {
const mockEndpoint = 'https://api.netlify/endpoint'
const mockResponse = { messages: ['Hello', 'Goodbye'] }
const { calls, mockFetch } = getMockFetch(t, {
[mockEndpoint]: () => Response.json(mockResponse),
})

globalThis.fetch = mockFetch

const cache = new APICache({
ttl: 30,
swr: 60,
})

const res1 = await cache.get(mockEndpoint, 'get', {})
t.deepEqual(res1?.body, mockResponse)
t.deepEqual(res1?.headers, { 'content-type': 'application/json' })

const now = Date.now()

const future = now + 45
Date.now = () => future

const res2 = await cache.get(mockEndpoint, 'get', {})
t.deepEqual(res2?.body, mockResponse)
t.deepEqual(res2?.headers, { 'content-type': 'application/json' })

t.is(calls[mockEndpoint], 1)

await sleep(10)

t.is(calls[mockEndpoint], 2)

const cacheKey = cache.getCacheKey(mockEndpoint, 'get', {})
t.is(cache.entries[cacheKey].timestamp, future)
})

test.serial('Returns fresh response if outside of TTL and SWR', async (t) => {
const mockEndpoint = 'https://api.netlify/endpoint'
const mockResponse = { messages: ['Hello', 'Goodbye'] }
const { calls, mockFetch } = getMockFetch(t, {
[mockEndpoint]: () => Response.json(mockResponse),
})

globalThis.fetch = mockFetch

const cache = new APICache({
ttl: 30,
swr: 60,
})

const res1 = await cache.get(mockEndpoint, 'get', {})
t.deepEqual(res1?.body, mockResponse)
t.deepEqual(res1?.headers, { 'content-type': 'application/json' })

const now = Date.now()

const future = now + 90
Date.now = () => future

const res2 = await cache.get(mockEndpoint, 'get', {})
t.deepEqual(res2?.body, mockResponse)
t.deepEqual(res2?.headers, { 'content-type': 'application/json' })

t.is(calls[mockEndpoint], 2)
})

test.serial('Uses disk fallback', async (t) => {
const mockEndpoint = 'https://api.netlify/endpoint'
const mockResponse = { messages: ['Hello', 'Goodbye'] }
const { calls, mockFetch } = getMockFetch(t, {
[mockEndpoint]: () => Response.json(mockResponse),
})

globalThis.fetch = mockFetch

const fsPath = await fs.mkdtemp('netlify-js-client-test')
const cache = new APICache({
fsPath,
ttl: 30,
swr: 60,
})

t.teardown(async () => {
await fs.rm(fsPath, { recursive: true })
})

const now = Date.now()
const cacheKey = cache.getCacheKey(mockEndpoint, 'get', {})
const filePath = path.join(fsPath, cacheKey)
const file = {
body: mockResponse,
headers: {
'content-type': 'application/json',
},
timestamp: now - 20,
type: 'json',
}

await fs.writeFile(filePath, JSON.stringify(file))

const res1 = await cache.get(mockEndpoint, 'get', {})
t.deepEqual(res1?.body, mockResponse)
t.deepEqual(res1?.headers, { 'content-type': 'application/json' })

t.falsy(calls[mockEndpoint])

const future = now + 20
Date.now = () => future

const res2 = await cache.get(mockEndpoint, 'get', {})
t.deepEqual(res2?.body, mockResponse)
t.deepEqual(res2?.headers, { 'content-type': 'application/json' })

t.falsy(calls[mockEndpoint])

await sleep(10)

t.is(calls[mockEndpoint], 1)

const newFile = await fs.readFile(filePath, 'utf8')
const data = JSON.parse(newFile)

t.deepEqual(data.body, mockResponse)
t.deepEqual(data.headers, { 'content-type': 'application/json' })
t.is(data.timestamp, future)
})
Loading
Loading