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

Apollo Server 2.0 - Caching #1163

Merged
merged 15 commits into from
Jun 15, 2018
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"editor.wordWrapColumn": 110,
"editor.formatOnSave": true,
"prettier.singleQuote": true,
"prettier.printWidth": 110,
"files.exclude": {
Expand Down
1 change: 1 addition & 0 deletions docs/_config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ sidebar_categories:
- features/mocking
- features/errors
- features/apq
- features/data-sources
- features/logging
- features/scalars-enums
- features/unions-interfaces
Expand Down
72 changes: 72 additions & 0 deletions docs/source/features/data-sources.md
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",
}
```
4 changes: 1 addition & 3 deletions lerna.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,5 @@
}
},
"hoist": true,
"packages": [
"packages/*"
]
"packages": ["packages/*"]
}
6 changes: 6 additions & 0 deletions packages/apollo-datasource-rest/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
*
!src/**/*
!dist/**/*
dist/**/*.test.*
!package.json
!README.md
53 changes: 53 additions & 0 deletions packages/apollo-datasource-rest/package.json
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"
Copy link
Member

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.

Copy link
Contributor Author

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.

},
"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
}
}
}
}
118 changes: 118 additions & 0 deletions packages/apollo-datasource-rest/src/HTTPCache.ts
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 });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of depending on this internal detail, just save status separately in our cache alongside policy and body. (There's another case of it being used below; I'm pretty sure that revalidatedPolicy._status is always equal to policy._status so you can just use the same saved value.)

} 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())
Copy link
Member

Choose a reason for hiding this comment

The 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();
Copy link
Member

Choose a reason for hiding this comment

The 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}`;
Copy link
Member

Choose a reason for hiding this comment

The 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;
}
21 changes: 21 additions & 0 deletions packages/apollo-datasource-rest/src/InMemoryKeyValueCache.ts
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);
}
}
4 changes: 4 additions & 0 deletions packages/apollo-datasource-rest/src/KeyValueCache.ts
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>;
}
Loading