Skip to content

Commit fa757d8

Browse files
authored
gateway: Allow use of APQ when querying downstream services. (apollographql/apollo-server#3744)
* gateway: Allow use of APQ when querying downstream services. This introduces support to Apollo Gateway which can leverage [Automated Persisted Queries] (APQ) when communicating with downstream implementing services in a federated architecture. In an identical way as APQ saves bytes on the wire (read: network) in a traditional handshake between a client and the server, this implements that behavior in the internal communication between federated servers and the gateway that fronts them. This is accomplished by first attempting to utilizing a SHA-256 hex hash (consistently, 32 bytes) of the operation which is destined for the downstream server, rather than the complete, typically much larger query itself. In the event that the downstream server supports APQ (Apollo Server does by default, unless it's been disabled), the downstream server will check its APQ registry for the full operation body. If it has it, it will use that cached body to process the request and return the results. If it does not find the query in its existing registry, it will return a message to the gateway that the query is not found in its registry, and the gateway will re-transmit the request with the full operation payload. On receipt of this full query, the downstream server will cache the operation for future requests (keyed by the SHA-256 hash), and return the expected result without further delay. This means that once a server has warmed up its APQ registry with repeated operations, subsequent network chatter will be greatly reduced. Furthermore, as noted in the attached documentation, the APQ registry can be backed by a distributed store, such as Memcached or Redis, allowing multiple downstream GraphQL servers to share the same cache, and persist it across restarts. By default, APQ behavior is disabled on `RemoteGraphQLDataSource`. To enable it, the `apq` property should be set to true. Future versions of the `RemoteGraphQLDataSource` could negotiate this capability on their own, but this does not attempt to make that negotiation right now. [Automated Persisted Queries]: https://www.apollographql.com/docs/apollo-server/performance/apq/ * tests: De-compose `toHaveFetched` in preparation for similar matchers. * tests: Introduce `toHaveFetchNth` with to test critical order of fetches. It's plausible that we should change every existing `toHaveFetch` to use this matcher which enforces order. Though it also seems plausible that custom matchers aren't as flexible as they need to be in practice, since in addition to the need to use Jest-built in methods (like the `nth`-call matchers) there are other specific usages of this which are just surfacing now (with APQ) that could be tested less precisely using `expect.objectContaining()`, rather than testing concerns which are not really necessary (like matching the hash). If this matcher still supported partial matches then this would be possible. However, since we're serializing the body into an `Request` before matching it (which I'm not sure why we do and there is no comment to indicate why) this isn't possible as Jest's matchers cannot survive that serialization. * tests: Make `Matcher` declaration merges match new Jest definitions. Without this change, every single usage of our `Matcher` is represented as a type error since Jest has changed their own implementation of `Matcher` to introduce a new generic type argument. It's too bad that this didn't fail tests at the time that that Jest package was updated! * Re-jigger `RemoteGraphQLDataSource`'s `process` with new `sendRequest` method. This introduces a new private `sendRequest` method that handles existing behavior which existed within `RemoteGraphQLDataSource`'s `process`. It should be a no-op change. An upcoming commit will make multiple requests to downstream services in its course of attempting APQ negotiation and this should facilitate that change and avoid repetition of logic. Apollo-Orig-Commit-AS: apollographql/apollo-server@7a8826a
1 parent d2b6cf2 commit fa757d8

File tree

7 files changed

+368
-48
lines changed

7 files changed

+368
-48
lines changed

gateway-js/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
> The changes noted within this `vNEXT` section have not been released yet. New PRs and commits which introduce changes should include an entry in this `vNEXT` section as part of their development. When a release is being prepared, a new header will be (manually) created below and the the appropriate changes within that release will be moved into the new section.
66
77
- __BREAKING__: The behavior and signature of `RemoteGraphQLDataSource`'s `didReceiveResponse` method has been changed. No changes are necessary _unless_ your implementation has overridden the default behavior of this method by either extending the class and overriding the method or by providing `didReceiveResponse` as a parameter to the `RemoteGraphQLDataSource`'s constructor options. Implementations which have provided their own `didReceiveResponse` using either of these methods should view the PR linked here for details on what has changed. [PR #3743](https://github.com/apollographql/apollo-server/pull/3743)
8+
- __NEW__: Setting the `apq` option to `true` on the `RemoteGraphQLDataSource` will enable the use of [automated persisted queries (APQ)](https://www.apollographql.com/docs/apollo-server/performance/apq/) when sending queries to downstream services. Depending on the complexity of queries sent to downstream services, this technique can greatly reduce the size of the payloads being transmitted over the network. Downstream implementing services must also support APQ functionality to participate in this feature (Apollo Server does by default unless it has been explicitly disabled). As with normal APQ behavior, a downstream server must have received and registered a query once before it will be able to serve an APQ request. [#3744](https://github.com/apollographql/apollo-server/pull/3744)
89

910
## v0.12.0
1011

gateway-js/src/__tests__/matchers/toCallService.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const prettyFormat = require('pretty-format');
66

77
declare global {
88
namespace jest {
9-
interface Matchers<R> {
9+
interface Matchers<R, T> {
1010
toCallService(service: string): R;
1111
}
1212
}

gateway-js/src/__tests__/matchers/toHaveBeenCalledBefore.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
export {};
44
declare global {
55
namespace jest {
6-
interface Matchers<R> {
6+
interface Matchers<R, T> {
77
toHaveBeenCalledBefore(spy: SpyInstance): R;
88
}
99
}

gateway-js/src/__tests__/matchers/toHaveFetched.ts

+37-8
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,16 @@ import { Request, RequestInit, Headers } from 'apollo-server-env';
55
export {};
66
declare global {
77
namespace jest {
8-
interface Matchers<R> {
8+
interface Matchers<R, T> {
99
toHaveFetched(spy: SpyInstance): R;
1010
}
1111
}
1212
}
1313

14-
function toHaveFetched(
15-
this: jest.MatcherUtils,
16-
fetch: jest.SpyInstance,
17-
request: RequestInit & { url: string },
18-
): { message(): string; pass: boolean } {
19-
let headers = new Headers();
14+
type ExtendedRequest = RequestInit & { url: string };
15+
16+
function prepareHttpRequest(request: ExtendedRequest): Request {
17+
const headers = new Headers();
2018
headers.set('Content-Type', 'application/json');
2119
if (request.headers) {
2220
for (let name in request.headers) {
@@ -30,8 +28,15 @@ function toHaveFetched(
3028
body: JSON.stringify(request.body),
3129
};
3230

33-
const httpRequest = new Request(request.url, options);
31+
return new Request(request.url, options);
32+
}
3433

34+
function toHaveFetched(
35+
this: jest.MatcherUtils,
36+
fetch: jest.SpyInstance,
37+
request: ExtendedRequest,
38+
): { message(): string; pass: boolean } {
39+
const httpRequest = prepareHttpRequest(request);
3540
let pass = false;
3641
let message = () => '';
3742
try {
@@ -47,6 +52,30 @@ function toHaveFetched(
4752
};
4853
}
4954

55+
function toHaveFetchedNth(
56+
this: jest.MatcherUtils,
57+
fetch: jest.SpyInstance,
58+
nthCall: number,
59+
request: ExtendedRequest,
60+
): { message(): string; pass: boolean } {
61+
const httpRequest = prepareHttpRequest(request);
62+
let pass = false;
63+
let message = () => '';
64+
try {
65+
expect(fetch).toHaveBeenNthCalledWith(nthCall, httpRequest);
66+
pass = true;
67+
} catch (e) {
68+
message = () => e.message;
69+
}
70+
71+
return {
72+
message,
73+
pass,
74+
};
75+
}
76+
77+
5078
expect.extend({
5179
toHaveFetched,
80+
toHaveFetchedNth,
5281
});

gateway-js/src/__tests__/matchers/toMatchAST.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ const diff = require('jest-diff');
33

44
declare global {
55
namespace jest {
6-
interface Matchers<R> {
6+
interface Matchers<R, T> {
77
toMatchAST(expected: ASTNode): R;
88
}
99
}

gateway-js/src/datasources/RemoteGraphQLDataSource.ts

+86-10
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ import {
1212
import {
1313
fetch,
1414
Request,
15-
RequestInit,
1615
Headers,
1716
Response,
1817
} from 'apollo-server-env';
1918
import { isObject } from '../utilities/predicates';
2019
import { GraphQLDataSource } from './types';
20+
import createSHA from 'apollo-server-core/dist/utils/createSHA';
2121

2222
export class RemoteGraphQLDataSource implements GraphQLDataSource {
2323
constructor(
@@ -32,6 +32,26 @@ export class RemoteGraphQLDataSource implements GraphQLDataSource {
3232

3333
url!: string;
3434

35+
/**
36+
* Whether the downstream request should be made with automated persisted
37+
* query (APQ) behavior enabled.
38+
*
39+
* @remarks When enabled, the request to the downstream service will first be
40+
* attempted using a SHA-256 hash of the operation rather than including the
41+
* operation itself. If the downstream server supports APQ and has this
42+
* operation registered in its APQ storage, it will be able to complete the
43+
* request without the entirety of the operation document being transmitted.
44+
*
45+
* In the event that the downstream service is unaware of the operation, it
46+
* will respond with an `PersistedQueryNotFound` error and it will be resent
47+
* with the full operation body for fulfillment.
48+
*
49+
* Generally speaking, when the downstream server is processing similar
50+
* operations repeatedly, APQ can offer substantial network savings in terms
51+
* of bytes transmitted over the wire between gateways and downstream servers.
52+
*/
53+
apq: boolean = false;
54+
3555
async process<TContext>({
3656
request,
3757
context,
@@ -52,19 +72,77 @@ export class RemoteGraphQLDataSource implements GraphQLDataSource {
5272
await this.willSendRequest({ request, context });
5373
}
5474

55-
const { http, ...graphqlRequest } = request;
56-
const options: RequestInit = {
57-
...http,
58-
body: JSON.stringify(graphqlRequest),
59-
};
75+
if (!request.query) {
76+
throw new Error("Missing query");
77+
}
78+
79+
const apqHash = createSHA('sha256')
80+
.update(request.query)
81+
.digest('hex');
6082

61-
const httpRequest = new Request(request.http.url, options);
83+
const { query, ...requestWithoutQuery } = request;
6284

6385
const respond = (response: GraphQLResponse, request: GraphQLRequest) =>
6486
typeof this.didReceiveResponse === "function"
6587
? this.didReceiveResponse({ response, request, context })
6688
: response;
6789

90+
if (this.apq) {
91+
// Take the original extensions and extend them with
92+
// the necessary "extensions" for APQ handshaking.
93+
requestWithoutQuery.extensions = {
94+
...request.extensions,
95+
persistedQuery: {
96+
version: 1,
97+
sha256Hash: apqHash,
98+
},
99+
};
100+
101+
const apqOptimisticResponse =
102+
await this.sendRequest(requestWithoutQuery, context);
103+
104+
// If we didn't receive notice to retry with APQ, then let's
105+
// assume this is the best result we'll get and return it!
106+
if (
107+
!apqOptimisticResponse.errors ||
108+
!apqOptimisticResponse.errors.find(error =>
109+
error.message === 'PersistedQueryNotFound')
110+
) {
111+
return respond(apqOptimisticResponse, requestWithoutQuery);
112+
}
113+
}
114+
115+
// If APQ was enabled, we'll run the same request again, but add in the
116+
// previously omitted `query`. If APQ was NOT enabled, this is the first
117+
// request (non-APQ, all the way).
118+
const requestWithQuery: GraphQLRequest = {
119+
query,
120+
...requestWithoutQuery,
121+
};
122+
const response = await this.sendRequest(requestWithQuery, context);
123+
return respond(response, requestWithQuery);
124+
}
125+
126+
private async sendRequest<TContext>(
127+
request: GraphQLRequest,
128+
context: TContext,
129+
): Promise<GraphQLResponse> {
130+
131+
// This would represent an internal programming error since this shouldn't
132+
// be possible in the way that this method is invoked right now.
133+
if (!request.http) {
134+
throw new Error("Internal error: Only 'http' requests are supported.")
135+
}
136+
137+
// We don't want to serialize the `http` properties into the body that is
138+
// being transmitted. Instead, we want those to be used to indicate what
139+
// we're accessing (e.g. url) and what we access it with (e.g. headers).
140+
const { http, ...requestWithoutHttp } = request;
141+
const httpRequest = new Request(http.url, {
142+
...http,
143+
body: JSON.stringify(requestWithoutHttp),
144+
});
145+
68146
try {
69147
const httpResponse = await fetch(httpRequest);
70148

@@ -78,12 +156,10 @@ export class RemoteGraphQLDataSource implements GraphQLDataSource {
78156
throw new Error(`Expected JSON response body, but received: ${body}`);
79157
}
80158

81-
const response: GraphQLResponse = {
159+
return {
82160
...body,
83161
http: httpResponse,
84162
};
85-
86-
return respond(response, request);
87163
} catch (error) {
88164
this.didEncounterError(error, httpRequest);
89165
throw error;

0 commit comments

Comments
 (0)