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

CDN cache-control headers #1138

Merged
merged 29 commits into from
Jun 21, 2018
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
d85275a
core: return response object from runHttpQuery
evans Jun 4, 2018
b805493
core: change gqlResponse to graphqlResponse and add custom RequestIni…
evans Jun 6, 2018
e5986a2
core: add cache-control headers based on the calcualted maxAge
evans Jun 12, 2018
156981b
core: add extensions check during cache-control header creation
evans Jun 12, 2018
dc8dba8
core: create headers when cacheControl is not enabled otherwise pass …
evans Jun 13, 2018
d0368cb
express: initial tests of CDN cach-contol headers
evans Jun 13, 2018
6890156
core: fixed tests with applyMiddleware and pass cacheControl config
evans Jun 14, 2018
534de91
Merge branch 'version-2' into server-2.0/cdn
evans Jun 18, 2018
92fba23
core: cache hint fixes, ignore when no maxAge, and check for rootKeys
evans Jun 18, 2018
d0e2625
core: check for hints of length 0
evans Jun 18, 2018
cdffba8
core: node 10 fails file upload test for some stream reason
evans Jun 18, 2018
025a271
docs: add cdn caching section to features
evans Jun 18, 2018
7c35dc2
add space after // in comments
evans Jun 19, 2018
fab0c2f
fix feedback: proxy alignment and response creation
evans Jun 19, 2018
3333695
fix links in comments
evans Jun 19, 2018
631a0d4
fix tests with null dereference
evans Jun 19, 2018
e1750d0
update cdn docs and migration guide to include latest cdn configuration
evans Jun 19, 2018
fc7de7f
add not for engine migration to set engine to false
evans Jun 20, 2018
bd547f7
Merge branch 'version-2' into server-2.0/cdn
evans Jun 20, 2018
44a6e4b
add engine set to false in migration guide
evans Jun 20, 2018
c90bfea
Merge branch 'version-2' into server-2.0/cdn
evans Jun 20, 2018
39d3872
Merge branch 'version-2' into server-2.0/cdn
evans Jun 20, 2018
d5c0de9
express: fixed tests
evans Jun 21, 2018
e7190fa
address feedback to use omit and documentation
evans Jun 21, 2018
7300cac
Merge branch 'version-2' into server-2.0/response-construction
evans Jun 21, 2018
b421821
Merge branch 'version-2' into server-2.0/response-construction
evans Jun 21, 2018
55835e2
docs: cdn caching is alternative to full response caching
evans Jun 21, 2018
ccc4357
add back epipe check in upload tests
evans Jun 21, 2018
a5e6c41
Merge branch 'version-2' into server-2.0/response-construction
evans Jun 21, 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 @@ -20,6 +20,7 @@ sidebar_categories:
- features/errors
- features/apq
- features/data-sources
- features/cdn
- features/subscriptions
- features/logging
- features/scalars-enums
Expand Down
74 changes: 74 additions & 0 deletions docs/source/features/cdn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
title: CDN Integration
description: Getting content delivery networks to cache GraphQL responses
---

Content-delivery networks such as [fly.io](https://fly.io), [Cloudflare](https://www.cloudflare.com/), [Akamai](https://www.akamai.com/) or [Fastly](https://www.fastly.com/) allow content caching close to clients, delivering data with low latency from a nearby server. Apollo Server makes it straightforward to use CDNs with GraphQL queries to cache full responses while still executing more dynamic queries.

To use Apollo Server behind a CDN, we define which GraphQL responses the CDN is allowed to cache. On the client, we set up [automatic persisted queries](./apq.html) to ensure that GraphQL requests are in a format that a CDN can understand.

<h2 id="cache-hints" title="1. Add cache hints">Step 1: Add cache hints to the GraphQL schema</h2>

Add cache hints as [directives](./directives.html) to GraphQL schema so that Apollo Server knows which fields and types are cacheable and for how long. For example, this schema indicates that all fields that return an `Author` should be cached for 60 seconds, and that the `posts` field should itself be cached for 180 seconds:

```graphql
type Author @cacheControl(maxAge: 60) {
id: Int
firstName: String
lastName: String
posts: [Post] @cacheControl(maxAge: 180)
}
```

See [the cache control documentation](https://github.com/apollographql/apollo-cache-control-js#add-cache-hints-to-your-schema) for more details, including how to specify hints dynamically inside resolvers, how to set a default `maxAge` for all fields, and how to specify that a field should be cached for specific users only (in which case CDNs should ignore it). For example, to set a default max age other than 0 modify the Apollo Server constructor to include `cacheControl`:

```js
const server = new ApolloServer({
typeDefs,
resolvers,
// The max age is calculated in seconds
cacheControl: { defaultMaxAge: 5 },
});
```

After this step, Apollo Server will serve the HTTP `Cache-Control` header on fully cacheable responses, so that any CDN in front of Apollo Server will know which responses can be cached and for how long! A "fully cacheable" response contains only data with non-zero `maxAge`; the header will refer to the minimum `maxAge` value across the whole response, and it will be `public` unless some of the data is tagged `scope: PRIVATE`. To observe this header, use any browser's network tab in its dev tools.

<h2 id="enable-apq" title="2. Enable persisted queries">Step 2: Enable automatic persisted queries</h2>

Often, GraphQL requests are big POST requests and most CDNs will only cache GET requests. Additionally, GET requests generally work best when the URL has a bounded size. Enabling automatic persisted queries means that short hashes are sent over the wire instead of full queries, and Apollo Client can be configured to use GET requests for those hashed queries.

To do this, update the **client** code. First, add the package:

```
npm install apollo-link-persisted-queries
```

Then, add the persisted queries link to the Apollo Client constructor 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 { ApolloLink } from "apollo-link";
import ApolloClient from "apollo-client";

ApolloLink.from([
createPersistedQueryLink({ useGETForHashedQueries: true }),
createHttpLink({ uri: "/graphql" })
]);

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

Make sure to include `useGETForHashedQueries: true`. Note that the client will still use POSTs for mutations, because it's generally best to avoid GETs for non-idempotent requests.

If configured correctly, browser's dev tools should verify that queries are now sent as GET requests, and receive appropriate `Cache-Control` response headers.

<h2 id="setup-cdn" title="3. Set up a CDN">Step 3: Set up a CDN!</h2>

How exactly this works depends on exactly which CDN you chose. Configure your CDN to send requests to Apollo Server. Some CDNs may need to be specially configured to honor origin Cache-Control headers; for example, here is [Akamai's documentation on that setting](https://learn.akamai.com/en-us/webhelp/ion/oca/GUID-57C31126-F745-4FFB-AA92-6A5AAC36A8DA.html). If all is well, cacheable queries should now be saved by the CDN!

> Note that requests served directly by a CDN will not show up in the Engine dashboard.
35 changes: 27 additions & 8 deletions docs/source/migration-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@ Apollo Server provides reporting and persisted queries in native javascript by d

## Stand-alone Apollo Server

With ENGINE_API_KEY set as an environment variable, Apollo Server creates a reporting agent that sends execution traces to the Engine UI. In addition by default, Apollo Server supports [persisted queries](./features/apq.html).

<!-- FIXME add something about CDN headers-->
Apollo Server 2 is able to completely replace the Engine Proxy. To enable metrics reporting, add `ENGINE_API_KEY` as an environment variable. Apollo Server will then create a reporting agent that sends execution traces to the Engine UI. In addition by default, Apollo Server supports [persisted queries](./features/apq.html) without needing the proxy's cache. Apollo Server also provides cache-control headers for consumption by a [CDN](./features/cdn.html). Integration with a CDN provides a replacement for the full response caching in Engine Proxy.
Copy link
Member

Choose a reason for hiding this comment

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

I don't think that last sentence really is true. After all that was already a feature of the Proxy.


```js
const { ApolloServer } = require('apollo-server');
Expand All @@ -24,9 +22,9 @@ server.listen().then(({ url }) => {
});
```

## Starting Engine Proxy
## Starting Engine Proxy as a Sidecar
Copy link
Member

Choose a reason for hiding this comment

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

I think we moved away from the "sidecar" terminology because it never made sense to anybody


The `apollo-engine` package provides integrations with many [node frameworks](/docs/engine/setup-node.html#not-express), including [express](/docs/engine/setup-node.html#setup-guide), that starts the Engine Proxy alongside the framework. The following code demonstrates how to start the proxy with Apollo Server 2, assuming that the `ENGINE_API_KEY` environment variable is set to the api key of the service.
Some applications require the Engine Proxy for full response caching, so it is necessary to run the proxy as a process alongside Apollo Server. The `apollo-engine` package provides integrations with many [node frameworks](/docs/engine/setup-node.html#not-express), including [express](/docs/engine/setup-node.html#setup-guide), and starts the Engine Proxy alongside Apollo Server. The following code demonstrates how to start the proxy with Apollo Server 2. It assumes that the `ENGINE_API_KEY` environment variable is set to the api key of the service.

```js
const { ApolloEngine } = require('apollo-engine');
Expand All @@ -38,7 +36,9 @@ const server = new ApolloServer({
typeDefs,
resolvers,
tracing: true,
cacheControl: true
cacheControl: true,
// We set `engine` to false, so that the new agent is not used.
engine: false,
});

server.applyMiddlware({ app });
Expand All @@ -59,9 +59,26 @@ engine.listen({
});
```

To set the default max age inside of cacheControl, some additional options must be specified:

```js
const server = new ApolloServer({
typeDefs,
resolvers,
tracing: true,
cacheControl: {
defaultMaxAge: 5,
stripFormattedExtensions: false,
calculateCacheControlHeaders: false,
},
// We set `engine` to false, so that the new agent is not used.
engine: false,
});
```

## With a Running Engine Proxy

If the engine proxy is already running in a container in front of Apollo Server, then set `tracing` and `cacheControl` to true. These options will provide the extensions information to the proxy to create traces and ensure caching.
If the engine proxy is already running in a container in front of Apollo Server, then set `tracing` and `cacheControl` to true. These options will provide the extensions information to the proxy to create traces and ensure caching. We set `engine` to false, so that the new metrics reporting pipeline is not activated.

```js
const { ApolloServer } = require('apollo-server');
Expand All @@ -70,7 +87,9 @@ const server = new ApolloServer({
typeDefs,
resolvers,
tracing: true,
cacheControl: true
cacheControl: true,
// We set `engine` to false, so that the new agent is not used.
engine: false,
});

server.listen().then(({ url }) => {
Expand Down
6 changes: 3 additions & 3 deletions packages/apollo-server-cloudflare/src/ApolloServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ export { GraphQLOptions, GraphQLExtension } from 'apollo-server-core';
import { GraphQLOptions } from 'apollo-server-core';

export class ApolloServer extends ApolloServerBase {
//This translates the arguments from the middleware into graphQL options It
//provides typings for the integration specific behavior, ideally this would
//be propagated with a generic to the super class
// This translates the arguments from the middleware into graphQL options It
// provides typings for the integration specific behavior, ideally this would
// be propagated with a generic to the super class
async createGraphQLServerOptions(request: Request): Promise<GraphQLOptions> {
return super.graphQLServerOptions({ request });
}
Expand Down
7 changes: 2 additions & 5 deletions packages/apollo-server-cloudflare/src/cloudflareApollo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,8 @@ export function graphqlCloudflare(options: GraphQLOptions) {
query,
request: req as Request,
}).then(
gqlResponse =>
new Response(gqlResponse, {
status: 200,
headers: { 'content-type': 'application/json' },
}),
({ graphqlResponse, responseInit }) =>
new Response(graphqlResponse, responseInit),
(error: HttpQueryError) => {
if ('HttpQueryError' !== error.name) throw error;

Expand Down
38 changes: 19 additions & 19 deletions packages/apollo-server-core/src/ApolloServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
ExecutionParams,
} from 'subscriptions-transport-ws';

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

Expand Down Expand Up @@ -70,7 +70,7 @@ export class ApolloServerBase {
// set by installSubscriptionHandlers.
private subscriptionServer?: SubscriptionServer;

//The constructor should be universal across all environments. All environment specific behavior should be set in an exported registerServer or in by overriding listen
// The constructor should be universal across all environments. All environment specific behavior should be set in an exported registerServer or in by overriding listen
constructor(config: Config) {
if (!config) throw new Error('ApolloServer requires options.');
const {
Expand All @@ -87,11 +87,11 @@ export class ApolloServerBase {
...requestOptions
} = config;

//While reading process.env is slow, a server should only be constructed
//once per run, so we place the env check inside the constructor. If env
//should be used outside of the constructor context, place it as a private
//or protected field of the class instead of a global. Keeping the read in
//the contructor enables testing of different environments
// While reading process.env is slow, a server should only be constructed
// once per run, so we place the env check inside the constructor. If env
// should be used outside of the constructor context, place it as a private
// or protected field of the class instead of a global. Keeping the read in
// the contructor enables testing of different environments
const isDev = process.env.NODE_ENV !== 'production';

// if this is local dev, introspection should turned on
Expand All @@ -109,16 +109,16 @@ export class ApolloServerBase {

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
// 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 = {
cache: new Keyv({ store: lru }),
};
}
} else {
//the user does not want to use persisted queries, so we remove the field
// the user does not want to use persisted queries, so we remove the field
delete requestOptions.persistedQueries;
}

Expand All @@ -132,8 +132,8 @@ export class ApolloServerBase {
this.schema = schema
? schema
: makeExecutableSchema({
//we add in the upload scalar, so that schemas that don't include it
//won't error when we makeExecutableSchema
// we add in the upload scalar, so that schemas that don't include it
// won't error when we makeExecutableSchema
typeDefs: [
gql`
scalar Upload
Expand Down Expand Up @@ -191,8 +191,8 @@ export class ApolloServerBase {
}
}

//used by integrations to synchronize the path with subscriptions, some
//integrations do not have paths, such as lambda
// used by integrations to synchronize the path with subscriptions, some
// integrations do not have paths, such as lambda
public setGraphQLPath(path: string) {
this.graphqlPath = path;
}
Expand Down Expand Up @@ -283,9 +283,9 @@ export class ApolloServerBase {
return false;
}

//This function is used by the integrations to generate the graphQLOptions
//from an object containing the request and other integration specific
//options
// This function is used by the integrations to generate the graphQLOptions
// from an object containing the request and other integration specific
// options
protected async graphQLServerOptions(
integrationContextArgument?: Record<string, any>,
) {
Expand All @@ -297,7 +297,7 @@ export class ApolloServerBase {
? await this.context(integrationContextArgument || {})
: context;
} catch (error) {
//Defer context error resolution to inside of runQuery
// Defer context error resolution to inside of runQuery
context = () => {
throw error;
};
Expand Down
73 changes: 73 additions & 0 deletions packages/apollo-server-core/src/caching.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,77 @@
import { ExecutionResult } from 'graphql';
import { CacheControlFormat } from 'apollo-cache-control';

export interface PersistedQueryCache {
set(key: string, data: string): Promise<any>;
get(key: string): Promise<string | null>;
}

export type HttpHeaderCalculation = (
responses: Array<ExecutionResult & { extensions?: Record<string, any> }>,
) => Record<string, string>;

export function calculateCacheControlHeaders(
responses: Array<ExecutionResult & { extensions?: Record<string, any> }>,
): Record<string, string> {
let lowestMaxAge = Number.MAX_VALUE;
let publicOrPrivate = 'public';

// Because of the early exit, we are unable to use forEach. While a reduce
// loop might be possible, a for loop is more readable
for (let i = 0; i < responses.length; i++) {
Copy link
Member

Choose a reason for hiding this comment

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

So the key thing in the engineproxy calculation of cacheControl is you need to put a hint on every top-level data piece in order for it to count. See the rootDataKey loop in parseCacheControl.

const response = responses[i];

const cacheControl: CacheControlFormat =
response.extensions && response.extensions.cacheControl;

// If there are no extensions or hints, then the headers should not be present
if (
!cacheControl ||
!cacheControl.hints ||
cacheControl.hints.length === 0 ||
cacheControl.version !== 1
) {
if (cacheControl && cacheControl.version !== 1) {
console.warn('Invalid cacheControl version.');
}
return {};
}

const rootHints = new Set<string>();
for (let j = 0; j < cacheControl.hints.length; j++) {
const hint = cacheControl.hints[j];
if (hint.scope && hint.scope.toLowerCase() === 'private') {
publicOrPrivate = 'private';
}

// If no maxAge is present, then we ignore the hint
if (hint.maxAge === undefined) {
continue;
}

// if there is a hint with max age of 0, we don't need to process more
if (hint.maxAge === 0) {
return {};
}

if (hint.maxAge < lowestMaxAge) {
lowestMaxAge = hint.maxAge;
}

// If this is a root path, store that the root is cacheable:
if (hint.path.length === 1) {
rootHints.add(hint.path[0] as string);
}
}

// If a root field inside of data does not have a cache hint, then we do not
// cache the response
if (Object.keys(response.data).find(rootKey => !rootHints.has(rootKey))) {
return {};
}
}

return {
'Cache-Control': `max-age=${lowestMaxAge}, ${publicOrPrivate}`,
};
}
Loading