Skip to content

Instantly share code, notes, and snippets.

@nwalters512
Created June 23, 2020 20:12
Show Gist options
  • Save nwalters512/472b5fb7d4cc7d32c4cecaa69b21baf5 to your computer and use it in GitHub Desktop.
Save nwalters512/472b5fb7d4cc7d32c4cecaa69b21baf5 to your computer and use it in GitHub Desktop.
apollo-datasource-rest cache status
/* 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,
});
}
}
/* 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