Created
June 23, 2020 20:12
-
-
Save nwalters512/472b5fb7d4cc7d32c4cecaa69b21baf5 to your computer and use it in GitHub Desktop.
apollo-datasource-rest cache status
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* eslint-disable no-underscore-dangle, no-param-reassign */ | |
/** | |
* This file is largely a direct copy of `HTTPCache.ts` from the `apollo-datasource-rest` package | |
* (https://github.com/apollographql/apollo-server/blob/136a25e89b5f7e55c465b5d657fe735e33a0017f/packages/apollo-datasource-rest/src/HTTPCache.ts). | |
* The primary modification is to the `fetch` function, which now provides metadata about | |
* whether the request was a cache hit or miss. Those changes can be seen in this commit: | |
* https://github.com/NerdWallet/query0/commit/064e7b2b5c1a7235618042e5dbabd94447cdb67b. | |
*/ | |
import { fetch, Request, Response, Headers } from 'apollo-server-env'; | |
import CachePolicy from 'http-cache-semantics'; | |
import { | |
KeyValueCache, | |
InMemoryLRUCache, | |
PrefixingKeyValueCache, | |
} from 'apollo-server-caching'; | |
import { CacheOptions } from './RESTDataSource'; | |
interface CacheResult { | |
context: { | |
cacheHit: boolean; | |
}; | |
response: Response; | |
} | |
function canBeRevalidated(response: Response): boolean { | |
return response.headers.has('ETag'); | |
} | |
function headersToObject(headers: Headers) { | |
const object = Object.create(null); | |
// We need to use Iterators here since headers isn't a pure object; | |
// we can't just `Object.entries` it. | |
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore | |
// @ts-ignore | |
// eslint-disable-next-line no-restricted-syntax | |
for (const [name, value] of headers) { | |
object[name] = value; | |
} | |
return object; | |
} | |
function policyRequestFrom(request: Request) { | |
return { | |
url: request.url, | |
method: request.method, | |
headers: headersToObject(request.headers), | |
}; | |
} | |
function policyResponseFrom(response: Response) { | |
return { | |
status: response.status, | |
headers: headersToObject(response.headers), | |
}; | |
} | |
// eslint-disable-next-line import/prefer-default-export | |
export class HTTPCache { | |
private keyValueCache: KeyValueCache; | |
private httpFetch: typeof fetch; | |
constructor( | |
keyValueCache: KeyValueCache = new InMemoryLRUCache(), | |
httpFetch: typeof fetch = fetch | |
) { | |
this.keyValueCache = new PrefixingKeyValueCache( | |
keyValueCache, | |
'httpcache:' | |
); | |
this.httpFetch = httpFetch; | |
} | |
async fetch( | |
request: Request, | |
options: { | |
cacheKey?: string; | |
cacheOptions?: | |
| CacheOptions | |
| ((response: Response, request: Request) => CacheOptions | undefined); | |
} = {} | |
): Promise<CacheResult> { | |
const cacheKey = options.cacheKey ? options.cacheKey : request.url; | |
const entry = await this.keyValueCache.get(cacheKey); | |
if (!entry) { | |
const response = await this.httpFetch(request); | |
const policy = new CachePolicy( | |
policyRequestFrom(request), | |
policyResponseFrom(response) | |
); | |
const clonedResponse = await this.storeResponseAndReturnClone( | |
response, | |
request, | |
policy, | |
cacheKey, | |
options.cacheOptions | |
); | |
return { | |
context: { | |
cacheHit: false, | |
}, | |
response: clonedResponse, | |
}; | |
} | |
const { policy: policyRaw, ttlOverride, body } = JSON.parse(entry); | |
const policy = CachePolicy.fromObject(policyRaw); | |
// Remove url from the policy, because otherwise it would never match a request with a custom cache key | |
policy._url = undefined; | |
if ( | |
(ttlOverride && policy.age() < ttlOverride) || | |
(!ttlOverride && | |
policy.satisfiesWithoutRevalidation(policyRequestFrom(request))) | |
) { | |
const headers = policy.responseHeaders(); | |
const response = new Response(body, { | |
url: policy._url, | |
status: policy._status, | |
headers, | |
}); | |
return { | |
context: { | |
cacheHit: true, | |
}, | |
response, | |
}; | |
} | |
const revalidationHeaders = policy.revalidationHeaders( | |
policyRequestFrom(request) | |
); | |
const revalidationRequest = new Request(request, { | |
headers: revalidationHeaders, | |
}); | |
const revalidationResponse = await this.httpFetch(revalidationRequest); | |
const { policy: revalidatedPolicy, modified } = policy.revalidatedPolicy( | |
policyRequestFrom(revalidationRequest), | |
policyResponseFrom(revalidationResponse) | |
); | |
const response = await this.storeResponseAndReturnClone( | |
new Response(modified ? await revalidationResponse.text() : body, { | |
url: revalidatedPolicy._url, | |
status: revalidatedPolicy._status, | |
headers: revalidatedPolicy.responseHeaders(), | |
}), | |
request, | |
revalidatedPolicy, | |
cacheKey, | |
options.cacheOptions | |
); | |
return { | |
context: { | |
// Technically, a request was made either way, but if the response | |
// indicates the data didn't change, we use the cached body, so we'll | |
// mark this as a cache hit. | |
cacheHit: !modified, | |
}, | |
response, | |
}; | |
} | |
private async storeResponseAndReturnClone( | |
response: Response, | |
request: Request, | |
policy: CachePolicy, | |
cacheKey: string, | |
cacheOptions?: | |
| CacheOptions | |
| ((response: Response, request: Request) => CacheOptions | undefined) | |
): Promise<Response> { | |
if (cacheOptions && typeof cacheOptions === 'function') { | |
cacheOptions = cacheOptions(response, request); | |
} | |
const ttlOverride = cacheOptions && cacheOptions.ttl; | |
if ( | |
// With a TTL override, only cache succesful responses but otherwise ignore method and response headers | |
!(ttlOverride && policy._status >= 200 && policy._status <= 299) && | |
// Without an override, we only cache GET requests and respect standard HTTP cache semantics | |
!(request.method === 'GET' && policy.storable()) | |
) { | |
return response; | |
} | |
let ttl = | |
ttlOverride === undefined | |
? Math.round(policy.timeToLive() / 1000) | |
: ttlOverride; | |
if (ttl <= 0) return response; | |
// If a response can be revalidated, we don't want to remove it from the cache right after it expires. | |
// We may be able to use better heuristics here, but for now we'll take the max-age times 2. | |
if (canBeRevalidated(response)) { | |
ttl *= 2; | |
} | |
const body = await response.text(); | |
const entry = JSON.stringify({ | |
policy: policy.toObject(), | |
ttlOverride, | |
body, | |
}); | |
await this.keyValueCache.set(cacheKey, entry, { | |
ttl, | |
}); | |
// We have to clone the response before returning it because the | |
// body can only be used once. | |
// To avoid https://github.com/bitinn/node-fetch/issues/151, we don't use | |
// response.clone() but create a new response from the consumed body | |
return new Response(body, { | |
url: response.url, | |
status: response.status, | |
statusText: response.statusText, | |
headers: response.headers, | |
}); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* eslint-disable class-methods-use-this */ | |
/** | |
* This file is largely a direct copy of `RESTDataSource.ts` from the `apollo-datasource-rest` package | |
* (https://github.com/apollographql/apollo-server/blob/136a25e89b5f7e55c465b5d657fe735e33a0017f/packages/apollo-datasource-rest/src/RESTDataSource.ts). | |
* The primary modification is to the `fetch` function (and `get`/`post`/etc. that wrap it), | |
* which now provides metadata about how the cache handled the request (whether it was a cache | |
* hit or miss, and whether the call was memoized). Those changes can be seen in this commit: | |
* https://github.com/NerdWallet/query0/commit/e0208bd469a76ddedcef0f5418fd1a211c0116d1. | |
*/ | |
import { | |
Request, | |
RequestInit, | |
Response, | |
BodyInit, | |
Headers, | |
URL, | |
URLSearchParams, | |
URLSearchParamsInit, | |
fetch, | |
} from 'apollo-server-env'; | |
import { ValueOrPromise } from 'apollo-server-types'; | |
import { DataSource, DataSourceConfig } from 'apollo-datasource'; | |
import { | |
ApolloError, | |
AuthenticationError, | |
ForbiddenError, | |
} from 'apollo-server-errors'; | |
import { HTTPCache } from './HTTPCache'; | |
export type RequestOptions = RequestInit & { | |
path: string; | |
params: URLSearchParams; | |
headers: Headers; | |
body?: Body; | |
}; | |
export interface CacheOptions { | |
ttl?: number; | |
} | |
export type Body = BodyInit | object; | |
export { Request }; | |
export interface FetchResult<TResult> { | |
context: { | |
cacheHit: boolean; | |
memoized: boolean; | |
}; | |
response: Promise<TResult>; | |
} | |
export abstract class RESTDataSource<TContext = any> extends DataSource { | |
httpCache!: HTTPCache; | |
context!: TContext; | |
memoizedResults = new Map<string, Promise<any>>(); | |
constructor(private httpFetch?: typeof fetch) { | |
super(); | |
} | |
initialize(config: DataSourceConfig<TContext>): void { | |
this.context = config.context; | |
this.httpCache = new HTTPCache(config.cache, this.httpFetch); | |
} | |
baseURL?: string; | |
// By default, we use the full request URL as the cache key. | |
// You can override this to remove query parameters or compute a cache key in any way that makes sense. | |
// For example, you could use this to take Vary header fields into account. | |
// Although we do validate header fields and don't serve responses from cache when they don't match, | |
// new reponses overwrite old ones with different vary header fields. | |
protected cacheKeyFor(request: Request): string { | |
return request.url; | |
} | |
protected willSendRequest?(request: RequestOptions): ValueOrPromise<void>; | |
protected resolveURL(request: RequestOptions): ValueOrPromise<URL> { | |
let { path } = request; | |
if (path.startsWith('/')) { | |
path = path.slice(1); | |
} | |
const { baseURL } = this; | |
if (baseURL) { | |
const normalizedBaseURL = baseURL.endsWith('/') | |
? baseURL | |
: baseURL.concat('/'); | |
return new URL(path, normalizedBaseURL); | |
} | |
return new URL(path); | |
} | |
protected cacheOptionsFor?( | |
response: Response, | |
request: Request | |
): CacheOptions | undefined; | |
protected async didReceiveResponse<TResult = any>( | |
response: Response | |
): Promise<TResult> { | |
if (!response.ok) { | |
throw await this.errorFromResponse(response); | |
} | |
return (this.parseBody(response) as any) as Promise<TResult>; | |
} | |
protected didEncounterError(error: Error) { | |
throw error; | |
} | |
protected parseBody(response: Response): Promise<object | string> { | |
const contentType = response.headers.get('Content-Type'); | |
const contentLength = response.headers.get('Content-Length'); | |
if ( | |
// As one might expect, a "204 No Content" is empty! This means there | |
// isn't enough to `JSON.parse`, and trying will result in an error. | |
response.status !== 204 && | |
contentLength !== '0' && | |
contentType && | |
(contentType.startsWith('application/json') || | |
contentType.startsWith('application/hal+json')) | |
) { | |
return response.json(); | |
} | |
return response.text(); | |
} | |
protected async errorFromResponse(response: Response) { | |
const message = `${response.status}: ${response.statusText}`; | |
let error: ApolloError; | |
if (response.status === 401) { | |
error = new AuthenticationError(message); | |
} else if (response.status === 403) { | |
error = new ForbiddenError(message); | |
} else { | |
error = new ApolloError(message); | |
} | |
const body = await this.parseBody(response); | |
Object.assign(error.extensions, { | |
response: { | |
url: response.url, | |
status: response.status, | |
statusText: response.statusText, | |
body, | |
}, | |
}); | |
return error; | |
} | |
protected async get<TResult = any>( | |
path: string, | |
params?: URLSearchParamsInit, | |
init?: RequestInit | |
): Promise<FetchResult<TResult>> { | |
return this.fetch<TResult>( | |
Object.assign({ method: 'GET', path, params }, init) | |
); | |
} | |
protected async post<TResult = any>( | |
path: string, | |
body?: Body, | |
init?: RequestInit | |
): Promise<FetchResult<TResult>> { | |
return this.fetch<TResult>( | |
Object.assign({ method: 'POST', path, body }, init) | |
); | |
} | |
protected async patch<TResult = any>( | |
path: string, | |
body?: Body, | |
init?: RequestInit | |
): Promise<FetchResult<TResult>> { | |
return this.fetch<TResult>( | |
Object.assign({ method: 'PATCH', path, body }, init) | |
); | |
} | |
protected async put<TResult = any>( | |
path: string, | |
body?: Body, | |
init?: RequestInit | |
): Promise<FetchResult<TResult>> { | |
return this.fetch<TResult>( | |
Object.assign({ method: 'PUT', path, body }, init) | |
); | |
} | |
protected async delete<TResult = any>( | |
path: string, | |
params?: URLSearchParamsInit, | |
init?: RequestInit | |
): Promise<FetchResult<TResult>> { | |
return this.fetch<TResult>( | |
Object.assign({ method: 'DELETE', path, params }, init) | |
); | |
} | |
private async fetch<TResult>( | |
init: RequestInit & { | |
path: string; | |
params?: URLSearchParamsInit; | |
} | |
): Promise<FetchResult<TResult>> { | |
const options: RequestOptions = { ...init } as RequestOptions; | |
if (!(options.params instanceof URLSearchParams)) { | |
options.params = new URLSearchParams(options.params); | |
} | |
if (!(options.headers && options.headers instanceof Headers)) { | |
options.headers = new Headers(options.headers || Object.create(null)); | |
} | |
if (this.willSendRequest) { | |
await this.willSendRequest(options); | |
} | |
const url = await this.resolveURL(options); | |
// Append params to existing params in the path | |
options.params.forEach((value, name) => { | |
url.searchParams.append(name, value); | |
}); | |
// We accept arbitrary objects and arrays as body and serialize them as JSON | |
if ( | |
options.body !== undefined && | |
options.body !== null && | |
(options.body.constructor === Object || | |
Array.isArray(options.body) || | |
((options.body as any).toJSON && | |
typeof (options.body as any).toJSON === 'function')) | |
) { | |
options.body = JSON.stringify(options.body); | |
// If Content-Type header has not been previously set, set to application/json | |
if (!options.headers.get('Content-Type')) { | |
options.headers.set('Content-Type', 'application/json'); | |
} | |
} | |
const request = new Request(String(url), options); | |
const cacheKey = this.cacheKeyFor(request); | |
const performRequest = async (): Promise<FetchResult<TResult>> => { | |
return this.trace(`${options.method || 'GET'} ${url}`, async () => { | |
const cacheOptions = options.cacheOptions | |
? options.cacheOptions | |
: this.cacheOptionsFor && this.cacheOptionsFor.bind(this); | |
const cacheResult = await this.httpCache.fetch(request, { | |
cacheKey, | |
cacheOptions, | |
}); | |
const result = this.didReceiveResponse(cacheResult.response); | |
return { | |
context: { | |
cacheHit: cacheResult.context.cacheHit, | |
memoized: false, | |
}, | |
response: result, | |
}; | |
}); | |
}; | |
if (request.method === 'GET') { | |
let promise = this.memoizedResults.get(cacheKey); | |
if (promise) { | |
const result = (await promise) as FetchResult<TResult>; | |
return { | |
context: { | |
cacheHit: result.context.cacheHit, | |
memoized: true, | |
}, | |
response: result.response, | |
}; | |
} | |
promise = performRequest(); | |
this.memoizedResults.set(cacheKey, promise); | |
return promise; | |
} | |
this.memoizedResults.delete(cacheKey); | |
return performRequest(); | |
} | |
private async trace<TResult>( | |
label: string, | |
fn: () => Promise<TResult> | |
): Promise<TResult> { | |
if (process && process.env && process.env.NODE_ENV === 'development') { | |
// We're not using console.time because that isn't supported on Cloudflare | |
const startTime = Date.now(); | |
try { | |
return await fn(); | |
} finally { | |
const duration = Date.now() - startTime; | |
console.log(`${label} (${duration}ms)`); | |
} | |
} else { | |
return fn(); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment