mirror of
https://github.com/vale981/apollo-server
synced 2025-03-04 17:21:42 -05:00
Apollo Server 2:Automatic Persisted Queries (#1149)
This commit is contained in:
parent
ff90e5cf2f
commit
a7cd3a43e8
17 changed files with 558 additions and 69 deletions
|
@ -16,6 +16,7 @@ sidebar_categories:
|
|||
Features:
|
||||
- features/mocking
|
||||
- features/errors
|
||||
- features/apq
|
||||
- features/logging
|
||||
- features/scalars-enums
|
||||
- features/unions-interfaces
|
||||
|
|
BIN
docs/sketch/APQs.sketch
Normal file
BIN
docs/sketch/APQs.sketch
Normal file
Binary file not shown.
86
docs/source/features/apq.md
Normal file
86
docs/source/features/apq.md
Normal file
|
@ -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).
|
||||
|
||||
With Apollo Persisted Queries, the ID is a deterministic hash of the input query, so we 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.
|
||||
|
||||
<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.
|
||||
|
||||
<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>
|
||||
|
||||

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

|
||||
|
||||
|
BIN
docs/source/img/persistedQueries.newPath.png
Normal file
BIN
docs/source/img/persistedQueries.newPath.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 79 KiB |
BIN
docs/source/img/persistedQueries.optPath.png
Normal file
BIN
docs/source/img/persistedQueries.optPath.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 58 KiB |
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
@ -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';
|
||||
|
@ -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 => {
|
||||
|
@ -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();
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 {
|
||||
|
@ -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 = {
|
||||
cache: new Keyv({ store: lru }),
|
||||
};
|
||||
}
|
||||
} 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 (
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
|
4
packages/apollo-server-core/src/caching.ts
Normal file
4
packages/apollo-server-core/src/caching.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export interface PersistedQueryCache {
|
||||
set(key: string, data: string): Promise<any>;
|
||||
get(key: string): Promise<string | null>;
|
||||
}
|
|
@ -184,6 +184,32 @@ export class ForbiddenError extends ApolloError {
|
|||
}
|
||||
}
|
||||
|
||||
export class PersistedQueryNotFoundError extends ApolloError {
|
||||
constructor() {
|
||||
super('PersistedQueryNotFound', 'PERSISTED_QUERY_NOT_FOUND');
|
||||
|
||||
// Set the prototype explicitly.
|
||||
// https://stackoverflow.com/a/41102306
|
||||
Object.setPrototypeOf(this, PersistedQueryNotFoundError.prototype);
|
||||
Object.defineProperty(this, 'name', {
|
||||
value: 'PersistedQueryNotFoundError',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class PersistedQueryNotSupportedError extends ApolloError {
|
||||
constructor() {
|
||||
super('PersistedQueryNotSupported', 'PERSISTED_QUERY_NOT_SUPPORTED');
|
||||
|
||||
// Set the prototype explicitly.
|
||||
// https://stackoverflow.com/a/41102306
|
||||
Object.setPrototypeOf(this, PersistedQueryNotSupportedError.prototype);
|
||||
Object.defineProperty(this, 'name', {
|
||||
value: 'PersistedQueryNotSupportedError',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class BadUserInputError extends ApolloError {
|
||||
constructor(message: string, properties?: Record<string, any>) {
|
||||
super(message, 'BAD_USER_INPUT', properties);
|
||||
|
|
|
@ -4,6 +4,7 @@ import {
|
|||
GraphQLFieldResolver,
|
||||
} from 'graphql';
|
||||
import { LogFunction } from './logging';
|
||||
import { PersistedQueryCache } from './caching';
|
||||
import { GraphQLExtension } from 'graphql-extensions';
|
||||
|
||||
/*
|
||||
|
@ -41,6 +42,11 @@ export interface GraphQLServerOptions<
|
|||
// cacheControl?: boolean | CacheControlExtensionOptions;
|
||||
cacheControl?: boolean | any;
|
||||
extensions?: Array<() => GraphQLExtension>;
|
||||
persistedQueries?: PersistedQueryOptions;
|
||||
}
|
||||
|
||||
export interface PersistedQueryOptions {
|
||||
cache: PersistedQueryCache;
|
||||
}
|
||||
|
||||
export default GraphQLServerOptions;
|
||||
|
|
|
@ -4,6 +4,7 @@ export { runHttpQuery, HttpQueryRequest, HttpQueryError } from './runHttpQuery';
|
|||
export {
|
||||
default as GraphQLOptions,
|
||||
resolveGraphqlOptions,
|
||||
PersistedQueryOptions,
|
||||
} from './graphqlOptions';
|
||||
export {
|
||||
ApolloError,
|
||||
|
|
|
@ -1,10 +1,17 @@
|
|||
import { ExecutionResult } from 'graphql';
|
||||
import sha256 from 'hash.js/lib/hash/sha/256';
|
||||
|
||||
import { runQuery, QueryOptions } from './runQuery';
|
||||
import {
|
||||
default as GraphQLOptions,
|
||||
resolveGraphqlOptions,
|
||||
} from './graphqlOptions';
|
||||
import { formatApolloErrors } from './errors';
|
||||
import {
|
||||
formatApolloErrors,
|
||||
PersistedQueryNotSupportedError,
|
||||
PersistedQueryNotFoundError,
|
||||
} from './errors';
|
||||
import { LogAction, LogStep } from './logging';
|
||||
|
||||
export interface HttpQueryRequest {
|
||||
method: string;
|
||||
|
@ -20,6 +27,11 @@ export interface HttpQueryRequest {
|
|||
request: Pick<Request, 'url' | 'method' | 'headers'>;
|
||||
}
|
||||
|
||||
//The result of a curl does not appear well in the terminal, so we add an extra new line
|
||||
function prettyJSONStringify(toStringfy) {
|
||||
return JSON.stringify(toStringfy) + '\n';
|
||||
}
|
||||
|
||||
export class HttpQueryError extends Error {
|
||||
public statusCode: number;
|
||||
public isGraphQLError: boolean;
|
||||
|
@ -39,6 +51,27 @@ export class HttpQueryError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
function throwHttpGraphQLError(
|
||||
statusCode,
|
||||
errors: Array<Error>,
|
||||
optionsObject,
|
||||
) {
|
||||
throw new HttpQueryError(
|
||||
statusCode,
|
||||
prettyJSONStringify({
|
||||
errors: formatApolloErrors(errors, {
|
||||
debug: optionsObject.debug,
|
||||
formatter: optionsObject.formatError,
|
||||
logFunction: optionsObject.logFunction,
|
||||
}),
|
||||
}),
|
||||
true,
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function runHttpQuery(
|
||||
handlerArguments: Array<any>,
|
||||
request: HttpQueryRequest,
|
||||
|
@ -62,19 +95,11 @@ export async function runHttpQuery(
|
|||
if (!debugDefault) {
|
||||
e.warning = `To remove the stacktrace, set the NODE_ENV environment variable to production if the options creation can fail`;
|
||||
}
|
||||
throw new HttpQueryError(
|
||||
500,
|
||||
JSON.stringify({
|
||||
errors: formatApolloErrors([e], { debug: debugDefault }),
|
||||
}),
|
||||
true,
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
);
|
||||
throwHttpGraphQLError(500, [e], { debug: debugDefault });
|
||||
}
|
||||
if (optionsObject.debug === undefined) {
|
||||
optionsObject.debug = debugDefault;
|
||||
}
|
||||
const debug =
|
||||
optionsObject.debug !== undefined ? optionsObject.debug : debugDefault;
|
||||
let requestPayload;
|
||||
|
||||
switch (request.method) {
|
||||
|
@ -118,7 +143,7 @@ export async function runHttpQuery(
|
|||
|
||||
const requests = requestPayload.map(async requestParams => {
|
||||
try {
|
||||
const queryString: string | undefined = requestParams.query;
|
||||
let queryString: string | undefined = requestParams.query;
|
||||
let extensions = requestParams.extensions;
|
||||
|
||||
if (isGetRequest && extensions) {
|
||||
|
@ -132,31 +157,65 @@ export async function runHttpQuery(
|
|||
}
|
||||
}
|
||||
|
||||
if (
|
||||
queryString === undefined &&
|
||||
extensions &&
|
||||
extensions.persistedQuery
|
||||
) {
|
||||
// It looks like we've received an Apollo Persisted Query. Apollo Server
|
||||
// does not support persisted queries out of the box, so we should fail
|
||||
// fast with a clear error saying that we don't support APQs. (A future
|
||||
// version of Apollo Server may support APQs directly.)
|
||||
throw new HttpQueryError(
|
||||
if (extensions && extensions.persistedQuery) {
|
||||
// It looks like we've received an Apollo Persisted Query. Check if we
|
||||
// support them. In an ideal world, we always would, however since the
|
||||
// middleware options are created every request, it does not make sense
|
||||
// to create a default cache here and save a referrence to use across
|
||||
// requests
|
||||
if (
|
||||
!optionsObject.persistedQueries ||
|
||||
!optionsObject.persistedQueries.cache
|
||||
) {
|
||||
// Return 200 to simplify processing: we want this to be intepreted by
|
||||
// the client as data worth interpreting, not an error.
|
||||
200,
|
||||
JSON.stringify({
|
||||
errors: [
|
||||
{
|
||||
message: 'PersistedQueryNotSupported',
|
||||
},
|
||||
],
|
||||
}),
|
||||
true,
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
);
|
||||
throwHttpGraphQLError(
|
||||
200,
|
||||
[new PersistedQueryNotSupportedError()],
|
||||
optionsObject,
|
||||
);
|
||||
} else if (extensions.persistedQuery.version !== 1) {
|
||||
throw new HttpQueryError(400, 'Unsupported persisted query version');
|
||||
}
|
||||
|
||||
const sha = extensions.persistedQuery.sha256Hash;
|
||||
|
||||
if (queryString === undefined) {
|
||||
queryString = await optionsObject.persistedQueries.cache.get(sha);
|
||||
if (!queryString) {
|
||||
throwHttpGraphQLError(
|
||||
200,
|
||||
[new PersistedQueryNotFoundError()],
|
||||
optionsObject,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const calculatedSha = sha256()
|
||||
.update(queryString)
|
||||
.digest('hex');
|
||||
if (sha !== calculatedSha) {
|
||||
throw new HttpQueryError(400, 'provided sha does not match query');
|
||||
}
|
||||
|
||||
//Do the store completely asynchronously
|
||||
Promise.resolve()
|
||||
.then(() => {
|
||||
//We do not wait on the cache storage to complete
|
||||
return optionsObject.persistedQueries.cache.set(sha, queryString);
|
||||
})
|
||||
.catch(error => {
|
||||
if (optionsObject.logFunction) {
|
||||
optionsObject.logFunction({
|
||||
action: LogAction.setup,
|
||||
step: LogStep.status,
|
||||
key: 'error',
|
||||
data: error,
|
||||
});
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//We ensure that there is a queryString or parsedQuery after formatParams
|
||||
|
@ -216,20 +275,7 @@ export async function runHttpQuery(
|
|||
context = await context();
|
||||
} catch (e) {
|
||||
e.message = `Context creation failed: ${e.message}`;
|
||||
throw new HttpQueryError(
|
||||
500,
|
||||
JSON.stringify({
|
||||
errors: formatApolloErrors([e], {
|
||||
formatter: optionsObject.formatError,
|
||||
debug,
|
||||
logFunction: optionsObject.logFunction,
|
||||
}),
|
||||
}),
|
||||
true,
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
);
|
||||
throwHttpGraphQLError(500, [e], optionsObject);
|
||||
}
|
||||
} else if (isBatch) {
|
||||
context = Object.assign(
|
||||
|
@ -279,7 +325,7 @@ export async function runHttpQuery(
|
|||
return {
|
||||
errors: formatApolloErrors([e], {
|
||||
formatter: optionsObject.formatError,
|
||||
debug,
|
||||
debug: optionsObject.debug,
|
||||
logFunction: optionsObject.logFunction,
|
||||
}),
|
||||
};
|
||||
|
@ -293,12 +339,12 @@ export async function runHttpQuery(
|
|||
//This code is run on parse/validation errors and any other error that
|
||||
//doesn't reach GraphQL execution
|
||||
if (gqlResponse.errors && typeof gqlResponse.data === 'undefined') {
|
||||
throw new HttpQueryError(400, JSON.stringify(gqlResponse), true, {
|
||||
throw new HttpQueryError(400, prettyJSONStringify(gqlResponse), true, {
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
}
|
||||
return JSON.stringify(gqlResponse);
|
||||
return prettyJSONStringify(gqlResponse);
|
||||
}
|
||||
|
||||
return JSON.stringify(responses);
|
||||
return prettyJSONStringify(responses);
|
||||
}
|
||||
|
|
|
@ -6,7 +6,10 @@ import { ListenOptions as HttpListenOptions } from 'net';
|
|||
import { GraphQLExtension } from 'graphql-extensions';
|
||||
import { EngineReportingOptions } from 'apollo-engine-reporting';
|
||||
|
||||
import { GraphQLServerOptions as GraphQLOptions } from './graphqlOptions';
|
||||
import {
|
||||
GraphQLServerOptions as GraphQLOptions,
|
||||
PersistedQueryOptions,
|
||||
} from './graphqlOptions';
|
||||
|
||||
export type Context<T = any> = T;
|
||||
export type ContextFunction<T = any> = (
|
||||
|
@ -35,7 +38,6 @@ export interface Config
|
|||
| 'validationRules'
|
||||
| 'formatResponse'
|
||||
| 'fieldResolver'
|
||||
| 'debug'
|
||||
| 'cacheControl'
|
||||
| 'tracing'
|
||||
> {
|
||||
|
@ -48,6 +50,7 @@ export interface Config
|
|||
mocks?: boolean | IMocks;
|
||||
engine?: boolean | EngineReportingOptions;
|
||||
extensions?: Array<() => GraphQLExtension>;
|
||||
persistedQueries?: PersistedQueryOptions | false;
|
||||
}
|
||||
|
||||
// XXX export these directly from apollo-engine-js
|
||||
|
|
|
@ -32,6 +32,8 @@
|
|||
"definition": "dist/index.d.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"graphql-tag": "^2.9.2"
|
||||
"apollo-link-persisted-queries": "^0.2.0",
|
||||
"graphql-tag": "^2.9.2",
|
||||
"js-sha256": "^0.9.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,10 @@ import { expect } from 'chai';
|
|||
import { stub } from 'sinon';
|
||||
import 'mocha';
|
||||
|
||||
//persisted query tests
|
||||
import { sha256 } from 'js-sha256';
|
||||
import { VERSION } from 'apollo-link-persisted-queries';
|
||||
|
||||
import {
|
||||
GraphQLSchema,
|
||||
GraphQLObjectType,
|
||||
|
@ -419,9 +423,11 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => {
|
|||
});
|
||||
return req.then(res => {
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body).to.deep.equal({
|
||||
errors: [{ message: 'PersistedQueryNotSupported' }],
|
||||
});
|
||||
expect(res.body.errors).to.exist;
|
||||
expect(res.body.errors.length).to.equal(1);
|
||||
expect(res.body.errors[0].message).to.equal(
|
||||
'PersistedQueryNotSupported',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -440,9 +446,11 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => {
|
|||
});
|
||||
return req.then(res => {
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body).to.deep.equal({
|
||||
errors: [{ message: 'PersistedQueryNotSupported' }],
|
||||
});
|
||||
expect(res.body.errors).to.exist;
|
||||
expect(res.body.errors.length).to.equal(1);
|
||||
expect(res.body.errors[0].message).to.equal(
|
||||
'PersistedQueryNotSupported',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -1105,5 +1113,125 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Persisted Queries', () => {
|
||||
const query = '{testString}';
|
||||
|
||||
const hash = sha256
|
||||
.create()
|
||||
.update(query)
|
||||
.hex();
|
||||
const extensions = {
|
||||
persistedQuery: {
|
||||
version: VERSION,
|
||||
sha256Hash: hash,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const map = new Map<string, string>();
|
||||
const cache = {
|
||||
set: async (key, val) => {
|
||||
await map.set(key, val);
|
||||
},
|
||||
get: async key => map.get(key),
|
||||
};
|
||||
app = await createApp({
|
||||
graphqlOptions: {
|
||||
schema,
|
||||
persistedQueries: {
|
||||
cache,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns PersistedQueryNotFound on the first try', async () => {
|
||||
const result = await request(app)
|
||||
.post('/graphql')
|
||||
.send({
|
||||
extensions,
|
||||
});
|
||||
|
||||
expect(result.body.data).not.to.exist;
|
||||
expect(result.body.errors.length).to.equal(1);
|
||||
expect(result.body.errors[0].message).to.equal(
|
||||
'PersistedQueryNotFound',
|
||||
);
|
||||
expect(result.body.errors[0].extensions.code).to.equal(
|
||||
'PERSISTED_QUERY_NOT_FOUND',
|
||||
);
|
||||
});
|
||||
it('returns result on the second try', async () => {
|
||||
await request(app)
|
||||
.post('/graphql')
|
||||
.send({
|
||||
extensions,
|
||||
});
|
||||
const result = await request(app)
|
||||
.post('/graphql')
|
||||
.send({
|
||||
extensions,
|
||||
query,
|
||||
});
|
||||
|
||||
expect(result.body.data).to.deep.equal({ testString: 'it works' });
|
||||
expect(result.body.errors).not.to.exist;
|
||||
});
|
||||
|
||||
it('returns result on the persisted query', async () => {
|
||||
await request(app)
|
||||
.post('/graphql')
|
||||
.send({
|
||||
extensions,
|
||||
});
|
||||
await request(app)
|
||||
.post('/graphql')
|
||||
.send({
|
||||
extensions,
|
||||
query,
|
||||
});
|
||||
const result = await request(app)
|
||||
.post('/graphql')
|
||||
.send({
|
||||
extensions,
|
||||
});
|
||||
|
||||
expect(result.body.data).to.deep.equal({ testString: 'it works' });
|
||||
expect(result.body.errors).not.to.exist;
|
||||
});
|
||||
|
||||
it('returns error when hash does not match', async () => {
|
||||
const response = await request(app)
|
||||
.post('/graphql')
|
||||
.send({
|
||||
extensions: {
|
||||
persistedQuery: {
|
||||
version: VERSION,
|
||||
sha:
|
||||
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
},
|
||||
},
|
||||
query,
|
||||
});
|
||||
expect(response.status).to.equal(400);
|
||||
expect(response.error.text).to.match(/does not match query/);
|
||||
});
|
||||
|
||||
it('returns correct result using get request', async () => {
|
||||
await request(app)
|
||||
.post('/graphql')
|
||||
.send({
|
||||
extensions,
|
||||
query,
|
||||
});
|
||||
const result = await request(app)
|
||||
.get('/graphql')
|
||||
.query({
|
||||
extensions: JSON.stringify(extensions),
|
||||
});
|
||||
expect(result.body.data).to.deep.equal({ testString: 'it works' });
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue