-
Notifications
You must be signed in to change notification settings - Fork 2k
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
Apollo Server 2.0 - Caching #1163
Changes from all commits
8f74518
1c23535
f8658b9
4f25000
395da0f
dbd8288
eda8ce9
80d074f
e6aae1d
29d5998
7b2d694
b39db20
93d21f8
6548196
1703e27
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
--- | ||
title: Data Sources | ||
description: Caching Partial Query Results | ||
--- | ||
|
||
Data sources are components that encapsulate loading data from a particular backend, like a REST API, with built in best practices for caching, deduplication, batching, and error handling. You write the code that is specific to your backend, and Apollo Server takes care of the rest. | ||
|
||
## REST Data Source | ||
|
||
A `RESTDataSource` is responsible for fetching data from a given REST API. It contains data source specific configuration and relies on convenience methods to perform HTTP requests. | ||
|
||
You define a data source by extending the `RESTDataSource` class. This code snippet shows a data source for the Star Wars API. Note that these requests will be automatically cached based on the caching headers returned from the API. | ||
|
||
```js | ||
export class StarWarsAPI extends RESTDataSource { | ||
baseURL = 'https://swapi.co/api/'; | ||
|
||
async getPerson(id: string) { | ||
return this.get(`people/${id}`); | ||
} | ||
|
||
async searchPerson(search: string) { | ||
return this.get(`people/`, { | ||
search, | ||
}); | ||
} | ||
} | ||
``` | ||
|
||
To create a data source, we provide them to the `ApolloServer` constructor | ||
|
||
```js | ||
const server = new ApolloServer({ | ||
typeDefs, | ||
resolvers, | ||
dataSources: () => ({ | ||
starWars: new StarWarsAPI(), | ||
}), | ||
}); | ||
``` | ||
|
||
Apollo Server will put the data sources on the context, so you can access them from your resolvers. It will also give data sources access to the context, which is why they need to be configured separately. | ||
|
||
Then in our resolvers, we can access the data source and return the result: | ||
|
||
```js | ||
const typeDefs = gql` | ||
type Query { | ||
person: Person | ||
} | ||
|
||
type Person { | ||
name: String | ||
} | ||
`; | ||
|
||
const resolvers = { | ||
Query: { | ||
person: (_, id, { dataSources }) => { | ||
return dataSources.starWars.getPerson(id); | ||
}, | ||
}, | ||
}; | ||
``` | ||
|
||
The raw response from the Star Wars REST API appears as follows: | ||
|
||
```js | ||
{ | ||
"name": "Luke Skywalker", | ||
} | ||
``` |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -13,7 +13,5 @@ | |
} | ||
}, | ||
"hoist": true, | ||
"packages": [ | ||
"packages/*" | ||
] | ||
"packages": ["packages/*"] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
* | ||
!src/**/* | ||
!dist/**/* | ||
dist/**/*.test.* | ||
!package.json | ||
!README.md |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
{ | ||
"name": "apollo-datasource-rest", | ||
"version": "2.0.0-beta.7", | ||
"author": "[email protected]", | ||
"license": "MIT", | ||
"repository": { | ||
"type": "git", | ||
"url": "https://github.com/apollographql/apollo-server/tree/master/packages/apollo-datasource-rest" | ||
}, | ||
"homepage": "https://github.com/apollographql/apollo-server#readme", | ||
"bugs": { | ||
"url": "https://github.com/apollographql/apollo-server/issues" | ||
}, | ||
"scripts": { | ||
"clean": "rm -rf lib", | ||
"compile": "tsc", | ||
"prepublish": "npm run clean && npm run compile", | ||
"test": "jest --verbose" | ||
}, | ||
"main": "dist/index.js", | ||
"types": "dist/index.d.ts", | ||
"engines": { | ||
"node": ">=6" | ||
}, | ||
"dependencies": { | ||
"apollo-server-env": "2.0.0-beta.7", | ||
"http-cache-semantics": "^4.0.0", | ||
"lru-cache": "^4.1.3" | ||
}, | ||
"devDependencies": { | ||
"@types/jest": "^23.0.0", | ||
"@types/lru-cache": "^4.1.1", | ||
"jest": "^23.1.0", | ||
"ts-jest": "^22.4.6" | ||
}, | ||
"jest": { | ||
"testEnvironment": "node", | ||
"transform": { | ||
"^.+\\.(ts|js)$": "ts-jest" | ||
}, | ||
"moduleFileExtensions": [ | ||
"ts", | ||
"js", | ||
"json" | ||
], | ||
"testRegex": "/__tests__/.*$", | ||
"globals": { | ||
"ts-jest": { | ||
"skipBabel": true | ||
} | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,118 @@ | ||
import CachePolicy from 'http-cache-semantics'; | ||
|
||
import { KeyValueCache } from './KeyValueCache'; | ||
import { InMemoryKeyValueCache } from './InMemoryKeyValueCache'; | ||
|
||
export class HTTPCache { | ||
constructor( | ||
private keyValueCache: KeyValueCache = new InMemoryKeyValueCache(), | ||
) {} | ||
|
||
async fetch(input: RequestInfo, init?: RequestInit): Promise<Response> { | ||
const request = new Request(input, init); | ||
|
||
const cacheKey = cacheKeyFor(request); | ||
|
||
const entry = await this.keyValueCache.get(cacheKey); | ||
if (!entry) { | ||
const response = await fetch(request); | ||
|
||
const policy = new CachePolicy( | ||
policyRequestFrom(request), | ||
policyResponseFrom(response), | ||
); | ||
|
||
return this.storeResponseAndReturnClone(request, response, policy); | ||
} | ||
|
||
const { policy: policyRaw, body } = JSON.parse(entry); | ||
|
||
const policy = CachePolicy.fromObject(policyRaw); | ||
|
||
if (policy.satisfiesWithoutRevalidation(policyRequestFrom(request))) { | ||
const headers = policy.responseHeaders(); | ||
return new Response(body, { status: policy._status, headers }); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of depending on this internal detail, just save |
||
} else { | ||
const revalidationHeaders = policy.revalidationHeaders( | ||
policyRequestFrom(request), | ||
); | ||
const revalidationRequest = new Request(request, { | ||
headers: revalidationHeaders, | ||
}); | ||
const revalidationResponse = await fetch(revalidationRequest); | ||
|
||
const { policy: revalidatedPolicy, modified } = policy.revalidatedPolicy( | ||
policyRequestFrom(revalidationRequest), | ||
policyResponseFrom(revalidationResponse), | ||
); | ||
|
||
return this.storeResponseAndReturnClone( | ||
revalidationRequest, | ||
modified | ||
? revalidationResponse | ||
: new Response(body, { | ||
status: revalidatedPolicy._status, | ||
headers: revalidatedPolicy.responseHeaders(), | ||
}), | ||
revalidatedPolicy, | ||
); | ||
} | ||
} | ||
|
||
private async storeResponseAndReturnClone( | ||
request: Request, | ||
response: Response, | ||
policy: CachePolicy, | ||
): Promise<Response> { | ||
if (!response.headers.has('Cache-Control') || !policy.storable()) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This first clause deserves a comment. Specifically, this means we are not caching the "cacheable by default" response types like 200 based on a heuristic based on its last-modified. Seems reasonable but worth documenting. It also means we don't support the somewhat older "expires" header, which maybe was an oversight? |
||
return response; | ||
|
||
const cacheKey = cacheKeyFor(request); | ||
|
||
const body = await response.text(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These lines assume that you are always getting text/UTF8 and not say some binary protocol. quick-lru or keyv (I forget which one) uses https://www.npmjs.com/package/json-buffer which is nice I think. |
||
const entry = JSON.stringify({ policy: policy.toObject(), body }); | ||
|
||
await this.keyValueCache.set(cacheKey, entry); | ||
|
||
// 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, { | ||
status: response.status, | ||
statusText: response.statusText, | ||
headers: policy.responseHeaders(), | ||
}); | ||
} | ||
} | ||
|
||
function cacheKeyFor(request: Request): string { | ||
// FIXME: Find a way to take Vary header fields into account when computing a cache key | ||
// 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. | ||
// (I think we have similar heuristics in the Engine proxy) | ||
return `httpcache:${request.url}`; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should include method too? Assuming we even cache non-GETs. We probably cache HEADs... Btw I think instead of having to add prefixes to cache keys manually, we should be able to assume that a KeyValueCache doesn't overlap with any other data — eg, it's a single in-memory cache, or it's a client to something like memcached that got a prefix when you constructed it. Or if you really want to be able to share a cache across features, you could have a PrefixedKeyValueCache which wraps another KVC with a prefix. |
||
} | ||
|
||
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), | ||
}; | ||
} | ||
|
||
function headersToObject(headers: Headers) { | ||
const object = Object.create(null); | ||
for (const [name, value] of headers as Headers) { | ||
object[name] = value; | ||
} | ||
return object; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
import LRU from 'lru-cache'; | ||
import { KeyValueCache } from './KeyValueCache'; | ||
|
||
export class InMemoryKeyValueCache implements KeyValueCache { | ||
private store: LRU.Cache<string, string>; | ||
|
||
// FIXME: Define reasonable default max size of the cache | ||
constructor(maxSize: number = Infinity) { | ||
this.store = LRU({ | ||
max: maxSize, | ||
length: item => item.length, | ||
}); | ||
} | ||
|
||
async get(key: string) { | ||
return this.store.get(key); | ||
} | ||
async set(key: string, value: string) { | ||
this.store.set(key, value); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
export interface KeyValueCache { | ||
get(key: string): Promise<string | undefined>; | ||
set(key: string, value: string): Promise<void>; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We're using quick-lru for PQs — unless there's a compelling reason we should try to use the same one for both.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
lru-cache
supports max size based on the size of the items in it (not just the number of items), so that seemed important for a response cache.