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:Automatic Persisted Queries + KeyValueCache Interface #1149

Merged
merged 23 commits into from
Jun 11, 2018
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
719fa93
core: add cache interface
evans Jun 7, 2018
bba1902
core: initial implementation of APQ
evans Jun 7, 2018
fc788d0
core: initial tests for persisted queries
evans Jun 7, 2018
53e25b6
core: add explicit reference to ApolloCache for tsc
evans Jun 7, 2018
2268c7b
core: format persisted query Error and correct sha import
evans Jun 7, 2018
681a99a
core: tests for persisted queries and remove old persistedQuery unsup…
evans Jun 7, 2018
6e463c7
core: change ApolloCache to KeyValueCache
evans Jun 7, 2018
45ca6d7
docs: add documentation for automatic persisted queries
evans Jun 8, 2018
2a85be9
core: add test for GET request with persisted query hashes
evans Jun 8, 2018
81431d2
core: add APQ to changelog
evans Jun 8, 2018
c961b43
integration-testsuite: add apq tests
evans Jun 8, 2018
085b71d
core: KeyValueCache change invalidate to invalidateTags
evans Jun 8, 2018
11a005e
docs: update curls to use -g and add note about using GET
evans Jun 9, 2018
d480191
core: move APQ tests out of subscription describe block
evans Jun 9, 2018
1481c7e
core: separate persisted query cache and have a default impl
evans Jun 9, 2018
87cbe51
core: middleware throw NotSupported if no cache impl passed in
evans Jun 9, 2018
317cf0d
core: make default cache an lru implementation
evans Jun 9, 2018
b130457
core: prettify all JSON.stringify with helper method
evans Jun 9, 2018
1ae8e32
core: remove commented out cache from context args
evans Jun 11, 2018
8cba571
core: make persisted query write completely asynchronous
evans Jun 11, 2018
0f46177
Merge branch 'version-2' into server-2.0/apq
evans Jun 11, 2018
fd27bc8
core: fix sha import and fix persisted query cache test
evans Jun 11, 2018
dfde27c
A few tweaks from code review pairing
glasser Jun 11, 2018
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 docs/_config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ sidebar_categories:
Features:
- features/mocking
- features/errors
- features/apq
- features/logging
- features/scalars-enums
- features/unions-interfaces
Expand Down
Binary file added docs/sketch/APQs.sketch
Binary file not shown.
86 changes: 86 additions & 0 deletions docs/source/features/apq.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
---
title: Automatic Persisted Queries
description: Reduce the size of GraphQL requests over the wire
---

The size of individual GraphQL query strings can be a major pain point. Apollo Server allows implements Automatic Persisted Queries (APQ), a technique that greatly improves network performance for GraphQL with zero build-time configuration. A persisted query is a ID or hash that can be sent to the server instead of the entire GraphQL query string. This smaller signature reduces bandwidth utilization and speeds up client loading times. Persisted queries are especially nice paired with GET requests, enabling the browser cache and [integration with a CDN](#get).

To accommodate the new query representation, Apollo Server contains a cache that stores the mapping between hash and query string. Previously, the generation of mapping would require a complex build step. Apollo Server paired with Apollo Client provides the ability to generate the mapping automatically.
Copy link
Member

Choose a reason for hiding this comment

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

"previously" isn't really a thing that actually happened though

With Apollo Persisted Queries, the ID is a deterministic hash of the input query, so you don't need a complex build step to share the ID between clients and servers. If a server doesn't know about a given hash, the client can expand the query for it; Apollo Server caches that mapping.

perhaps?


<h2 id="setup">Setup</h2>

To get started with APQ, add the [Automatic Persisted Queries Link](https://github.com/apollographql/apollo-link-persisted-queries) to the client codebase with `npm install apollo-link-persisted-queries`. Next incorporate the APQ link with Apollo Client's link chain before the HTTP link:

```js
import { createPersistedQueryLink } from "apollo-link-persisted-queries";
import { createHttpLink } from "apollo-link-http";
import { InMemoryCache } from "apollo-cache-inmemory";
import ApolloClient from "apollo-client";

const link = createPersistedQueryLink().concat(createHttpLink({ uri: "/graphql" }));

const client = new ApolloClient({
cache: new InMemoryCache(),
link: link,
});
```

> Note: using `apollo-link-persisted-query` requires migrating from [apollo-boost](https://www.apollographql.com/docs/react/advanced/boost-migration.html):

Inside Apollo Server, the query registry is stored in a user-configurable cache. By default, Apollo Server uses a in-memory cache (shared with other caching features). Read more here about [how to configure caching](caching.html).
Copy link
Member

Choose a reason for hiding this comment

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

There doesn't actually seem to be a default cache.

I would lean towards the default cache being LRU instead of unbounded FWIW.

Copy link
Member

Choose a reason for hiding this comment

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

Also this link doesn't work.

Copy link
Member

Choose a reason for hiding this comment

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

This link still is dead?


<h2 id="verify">Verify</h2>

Apollo Server's persisted queries configuration can be tested from the command-line. The following examples assume Apollo Server is running at `localhost:4000/`.
This example persists a dummy query of `{__typename}`, using its sha256 hash: `ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38`.


1. Request a persisted query:

```bash
curl -g 'http://localhost:4000/?extensions={"persistedQuery":{"version":1,"sha256Hash":"ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38"}}'
```

Expect a response of: `{"errors": [{"message": "PersistedQueryNotFound", "extensions": {...}}]}`.

2. Store the query to the cache:

```bash
curl -g 'http://localhost:4000/?query={__typename}&extensions={"persistedQuery":{"version":1,"sha256Hash":"ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38"}}'
```

Expect a response of `{"data": {"__typename": "Query"}}"`.

3. Request the persisted query again:

```bash
curl -g 'http://localhost:4000/?extensions={"persistedQuery":{"version":1,"sha256Hash":"ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38"}}'
```

Expect a response of `{"data": {"__typename": "Query"}}"`, as the query string is loaded from the cache.

<h2 id="get">Using GET requests with APQ to enable CDNs</h2>

A great application for APQ is running Apollo Server behind a CDN. Many CDNs only cache GET requests, but many GraphQL queries are too long to fit comfortably in a cacheable GET request. When the APQ link is created with `createPersistedQueryLink({useGETForHashedQueries: true})`, Apollo Client automatically sends the short hashed queries as GET requests allowing a CDN to serve those request. For full-length queries and for all mutations, Apollo Client will continue to use POST requests. For more about this pattern, read about how [Apollo Server provides cache information to CDNs]().

<h2 id="how-it-works">How it works</h2>

The mechanism is based on a lightweight protocol extension between Apollo Client and Apollo Server. It works as follows:

- When the client makes a query, it will optimistically send a short (64-byte) cryptographic hash instead of the full query text.
- *Optimized Path:* If a request containing a persisted query hash is detected, Apollo Server will look it up to find a corresponding query in its registry. Upon finding a match, Apollo Server will expand the request with the full text of the query and execute it.
- *New Query Path:* In the unlikely event that the query is not already in the Apollo Server registry (this only happens the very first time that Apollo Server sees a query), it will ask the client to resend the request using the full text of the query. At that point Apollo Server will store the query / hash mapping in the registry for all subsequent requests to benefit from.

<div align="center">
<h3 id="Optimized-Path">**Optimized Path**</h3>
</div>

![Optimized path](../img/persistedQueries.optPath.png)

<div align="center">
<h3 id="New-Query-Path">**New Query Path**</h3>
</div>

![New query path](../img/persistedQueries.newPath.png)


Binary file added docs/source/img/persistedQueries.newPath.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/source/img/persistedQueries.optPath.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions packages/apollo-server-core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Changelog

### vNEXT

* `apollo-server-core`: Add persisted queries [PR#1149](https://github.com/apollographql/apollo-server/pull/1149)
* `apollo-server-core`: added `BadUserInputError`
* `apollo-server-core`: **breaking** gql is exported from gql-tag and ApolloServer requires a DocumentNode [PR#1146](https://github.com/apollographql/apollo-server/pull/1146)
* `apollo-server-core`: accept `Request` object in `runQuery` [PR#1108](https://github.com/apollographql/apollo-server/pull/1108)
Expand Down
9 changes: 9 additions & 0 deletions packages/apollo-server-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,18 @@
"devDependencies": {
"@types/fibers": "0.0.30",
"@types/graphql": "^0.13.1",
"@types/keyv": "^3.0.1",
"@types/node-fetch": "^2.1.1",
"@types/quick-lru": "^1.1.0",
"@types/ws": "^5.1.2",
"apollo-engine": "^1.1.2",
"apollo-fetch": "^0.7.0",
"apollo-link": "^1.2.2",
"apollo-link-http": "^1.5.4",
"apollo-link-persisted-queries": "^0.2.0",
"fibers": "2.0.2",
"graphql-tag": "^2.9.2",
"js-sha256": "^0.9.0",
"meteor-promise": "0.8.6",
"mock-req": "^0.2.0"
},
Expand All @@ -53,7 +59,10 @@
"graphql-extensions": "0.1.0-beta.13",
"graphql-subscriptions": "^0.5.8",
"graphql-tools": "^3.0.2",
"hash.js": "^1.1.3",
"keyv": "^3.0.0",
"node-fetch": "^2.1.2",
"quick-lru": "^1.1.0",
"subscriptions-transport-ws": "^0.9.9",
"ws": "^5.2.0"
}
Expand Down
153 changes: 151 additions & 2 deletions packages/apollo-server-core/src/ApolloServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { expect } from 'chai';
import { stub } from 'sinon';
import http from 'http';
import net from 'net';
import url from 'url';
import 'mocha';
import { sha256 } from 'js-sha256';

import {
GraphQLSchema,
Expand All @@ -18,6 +20,13 @@ import { PubSub } from 'graphql-subscriptions';
import { SubscriptionClient } from 'subscriptions-transport-ws';
import WebSocket from 'ws';

import { execute } from 'apollo-link';
import { createHttpLink } from 'apollo-link-http';
import {
createPersistedQueryLink as createPersistedQuery,
VERSION,
} from 'apollo-link-persisted-queries';

import { createApolloFetch } from 'apollo-fetch';
import { ApolloServerBase } from './ApolloServer';
import { AuthenticationError } from './errors';
Expand Down Expand Up @@ -68,11 +77,13 @@ function createHttpServer(server) {
body = Buffer.concat(body).toString();
// At this point, we have the headers, method, url and body, and can now
// do whatever we need to in order to respond to this request.

runHttpQuery([req, res], {
method: req.method,
options: server.graphQLServerOptionsForRequest(req as any),
query: JSON.parse(body),
query:
req.method.toUpperCase() === 'GET'
? url.parse(req.url, true)
: JSON.parse(body),
request: convertNodeHttpToRequest(req),
})
.then(gqlResponse => {
Expand All @@ -85,6 +96,13 @@ function createHttpServer(server) {
res.end();
})
.catch(error => {
if (error.headers) {
Object.keys(error.headers).forEach(header => {
res.setHeader(header, error.headers[header]);
});
}

res.statusCode = error.statusCode;
res.write(error.message);
res.end();
});
Expand Down Expand Up @@ -866,4 +884,135 @@ describe('ApolloServerBase', () => {
.catch(done);
});
});
describe('Persisted Queries', () => {
let server;
const query = gql`
${TEST_STRING_QUERY}
`;
const hash = sha256
.create()
.update(TEST_STRING_QUERY)
.hex();
const extensions = {
persistedQuery: {
version: VERSION,
sha256Hash: hash,
},
};
let uri: string;

beforeEach(async () => {
server = new ApolloServerBase({
schema,
introspection: false,
persistedQueries: {
cache: new Map<string, string>() as any,
},
});

const httpServer = createHttpServer(server);

server.use({
getHttp: () => httpServer,
path: '/graphql',
});
uri = (await server.listen()).url;
});

afterEach(async () => {
await server.stop();
});

it('returns PersistedQueryNotFound on the first try', async () => {
const apolloFetch = createApolloFetch({ uri });

const result = await apolloFetch({
extensions,
} as any);

expect(result.data).not.to.exist;
expect(result.errors.length).to.equal(1);
expect(result.errors[0].message).to.equal('PersistedQueryNotFound');
expect(result.errors[0].extensions.code).to.equal(
'PERSISTED_QUERY_NOT_FOUND',
);
});
it('returns result on the second try', async () => {
const apolloFetch = createApolloFetch({ uri });

await apolloFetch({
extensions,
} as any);
const result = await apolloFetch({
extensions,
query: TEST_STRING_QUERY,
} as any);

expect(result.data).to.deep.equal({ testString: 'test string' });
expect(result.errors).not.to.exist;
});

it('returns result on the persisted query', async () => {
const apolloFetch = createApolloFetch({ uri });

await apolloFetch({
extensions,
} as any);
await apolloFetch({
extensions,
query: TEST_STRING_QUERY,
} as any);
const result = await apolloFetch({
extensions,
} as any);

expect(result.data).to.deep.equal({ testString: 'test string' });
expect(result.errors).not.to.exist;
});

it('returns error when hash does not match', async () => {
const apolloFetch = createApolloFetch({ uri });

try {
await apolloFetch({
extensions: {
persistedQuery: {
version: VERSION,
sha:
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
},
},
query: TEST_STRING_QUERY,
} as any);
expect.fail();
} catch (e) {
expect(e.response.status).to.equal(400);
expect(e.response.raw).to.match(/does not match query/);
}
});

it('returns correct result for persisted query link', done => {
const variables = { id: 1 };
const link = createPersistedQuery().concat(
createHttpLink({ uri, fetch } as any),
);

execute(link, { query, variables } as any).subscribe(result => {
expect(result.data).to.deep.equal({ testString: 'test string' });
done();
}, done);
});

it('returns correct result for persisted query link using get request', done => {
const variables = { id: 1 };
const link = createPersistedQuery({
useGETForHashedQueries: true,
}).concat(createHttpLink({ uri, fetch } as any));

execute(link, { query, variables } as any).subscribe(result => {
expect(result.data).to.deep.equal({ testString: 'test string' });
done();
}, done);
});
});
});
32 changes: 29 additions & 3 deletions packages/apollo-server-core/src/ApolloServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,15 @@ import {
ExecutionParams,
} from 'subscriptions-transport-ws';

//use as default persisted query store
import Keyv = require('keyv');
import QuickLru = require('quick-lru');

import { formatApolloErrors } from './errors';
import { GraphQLServerOptions as GraphQLOptions } from './graphqlOptions';
import {
GraphQLServerOptions as GraphQLOptions,
PersistedQueryOptions,
} from './graphqlOptions';
import { LogFunction } from './logging';

import {
Expand Down Expand Up @@ -108,7 +115,22 @@ export class ApolloServerBase<Request = RequestInit> {
: noIntro;
}

this.requestOptions = requestOptions;
if (requestOptions.persistedQueries !== false) {
if (!requestOptions.persistedQueries) {
//maxSize is the number of elements that can be stored inside of the cache
//https://github.com/withspectrum/spectrum has about 200 instances of gql`
//300 queries seems reasonable
const lru = new QuickLru({ maxSize: 300 });
requestOptions.persistedQueries = {
Copy link
Member

Choose a reason for hiding this comment

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

maybe clone requestOptions if we're going to mutate it?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

requestOptions is a clone of config, set in the destructuring. Are you thinking that we need to deep clone as opposed to the just having an internal reference

Copy link
Member

Choose a reason for hiding this comment

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

oops, missed that.

cache: new Keyv({ store: lru }),
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need Keyv here? I think we could just use QuickLru as the cache implementation directly.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Keyv adds the ability to have the same Promise based interface

Copy link
Member

Choose a reason for hiding this comment

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

nice find with keyv. odd that they don't have a memcached.

};
}
} else {
//the user does not want to use persisted queries, so we remove the field
delete requestOptions.persistedQueries;
}

this.requestOptions = requestOptions as GraphQLOptions;
this.context = context;

if (
Expand Down Expand Up @@ -380,7 +402,9 @@ const typeDefs = gql\`${startSchema}\`
try {
context =
typeof this.context === 'function'
? await this.context({ req: request })
? await this.context({
req: request,
})
: context;
} catch (error) {
//Defer context error resolution to inside of runQuery
Expand All @@ -397,6 +421,8 @@ const typeDefs = gql\`${startSchema}\`
// avoid a bad side effect of the otherwise useful noUnusedLocals option
// (https://github.com/Microsoft/TypeScript/issues/21673).
logFunction: this.requestOptions.logFunction as LogFunction,
persistedQueries: this.requestOptions
.persistedQueries as PersistedQueryOptions,
fieldResolver: this.requestOptions.fieldResolver as GraphQLFieldResolver<
any,
any
Expand Down
Loading