@apollo/gateway 2.0.0-alpha.2 → 2.0.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/config.d.ts +2 -0
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js.map +1 -1
  4. package/dist/datasources/RemoteGraphQLDataSource.d.ts.map +1 -1
  5. package/dist/datasources/RemoteGraphQLDataSource.js +4 -1
  6. package/dist/datasources/RemoteGraphQLDataSource.js.map +1 -1
  7. package/dist/executeQueryPlan.d.ts.map +1 -1
  8. package/dist/executeQueryPlan.js +1 -1
  9. package/dist/executeQueryPlan.js.map +1 -1
  10. package/dist/index.d.ts +3 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +18 -6
  13. package/dist/index.js.map +1 -1
  14. package/dist/loadSupergraphSdlFromStorage.d.ts +13 -5
  15. package/dist/loadSupergraphSdlFromStorage.d.ts.map +1 -1
  16. package/dist/loadSupergraphSdlFromStorage.js +34 -7
  17. package/dist/loadSupergraphSdlFromStorage.js.map +1 -1
  18. package/dist/outOfBandReporter.d.ts +10 -12
  19. package/dist/outOfBandReporter.d.ts.map +1 -1
  20. package/dist/outOfBandReporter.js +70 -73
  21. package/dist/outOfBandReporter.js.map +1 -1
  22. package/package.json +4 -4
  23. package/src/__mocks__/make-fetch-happen-fetcher.ts +3 -1
  24. package/src/__tests__/executeQueryPlan.test.ts +598 -0
  25. package/src/__tests__/gateway/buildService.test.ts +1 -1
  26. package/src/__tests__/gateway/composedSdl.test.ts +1 -1
  27. package/src/__tests__/gateway/executor.test.ts +1 -1
  28. package/src/__tests__/gateway/reporting.test.ts +8 -5
  29. package/src/__tests__/integration/configuration.test.ts +44 -4
  30. package/src/__tests__/integration/networkRequests.test.ts +21 -19
  31. package/src/__tests__/integration/nockMocks.ts +12 -6
  32. package/src/__tests__/loadSupergraphSdlFromStorage.test.ts +101 -452
  33. package/src/__tests__/nockAssertions.ts +20 -0
  34. package/src/config.ts +3 -1
  35. package/src/datasources/RemoteGraphQLDataSource.ts +8 -2
  36. package/src/datasources/__tests__/RemoteGraphQLDataSource.test.ts +4 -4
  37. package/src/executeQueryPlan.ts +11 -1
  38. package/src/index.ts +26 -12
  39. package/src/loadSupergraphSdlFromStorage.ts +54 -8
  40. package/src/outOfBandReporter.ts +87 -89
@@ -0,0 +1,20 @@
1
+ import nock from 'nock';
2
+
3
+ // Ensures an active and clean nock before every test
4
+ export function nockBeforeEach() {
5
+ if (!nock.isActive()) {
6
+ nock.activate();
7
+ }
8
+ // Cleaning _before_ each test ensures that any mocks from a previous test
9
+ // which failed don't affect the current test.
10
+ nock.cleanAll();
11
+ }
12
+
13
+ // Ensures a test is complete (all expected requests were run) and a clean
14
+ // global state after each test.
15
+ export function nockAfterEach() {
16
+ // unmock HTTP interceptor
17
+ nock.restore();
18
+ // effectively nock.isDone() but with more helpful messages in test failures
19
+ expect(nock.activeMocks()).toEqual([]);
20
+ };
package/src/config.ts CHANGED
@@ -140,7 +140,9 @@ export interface ManagedGatewayConfig extends GatewayConfigBase {
140
140
  * This configuration option shouldn't be used unless by recommendation from
141
141
  * Apollo staff.
142
142
  */
143
- schemaConfigDeliveryEndpoint?: string;
143
+ schemaConfigDeliveryEndpoint?: string; // deprecated
144
+ uplinkEndpoints?: string[];
145
+ uplinkMaxRetries?: number;
144
146
  }
145
147
 
146
148
  interface ManuallyManagedServiceDefsGatewayConfig extends GatewayConfigBase {
@@ -17,18 +17,24 @@ import { isObject } from '../utilities/predicates';
17
17
  import { GraphQLDataSource, GraphQLDataSourceProcessOptions, GraphQLDataSourceRequestKind } from './types';
18
18
  import createSHA from 'apollo-server-core/dist/utils/createSHA';
19
19
  import { parseCacheControlHeader } from './parseCacheControlHeader';
20
-
20
+ import fetcher from 'make-fetch-happen';
21
21
  export class RemoteGraphQLDataSource<
22
22
  TContext extends Record<string, any> = Record<string, any>,
23
23
  > implements GraphQLDataSource<TContext>
24
24
  {
25
- fetcher: typeof fetch = fetch;
25
+ fetcher: typeof fetch;
26
26
 
27
27
  constructor(
28
28
  config?: Partial<RemoteGraphQLDataSource<TContext>> &
29
29
  object &
30
30
  ThisType<RemoteGraphQLDataSource<TContext>>,
31
31
  ) {
32
+ this.fetcher = fetcher.defaults({
33
+ // although this is the default, we want to take extra care and be very
34
+ // explicity to ensure that mutations cannot be retried. please leave this
35
+ // intact.
36
+ retry: false,
37
+ });
32
38
  if (config) {
33
39
  return Object.assign(this, config);
34
40
  }
@@ -1,5 +1,5 @@
1
- import { fetch } from '../../__mocks__/apollo-server-env';
2
- import { makeFetchHappenFetcher } from '../../__mocks__/make-fetch-happen-fetcher';
1
+ import { fetch as customFetcher } from '../../__mocks__/apollo-server-env';
2
+ import { fetch } from '../../__mocks__/make-fetch-happen-fetcher';
3
3
 
4
4
  import {
5
5
  ApolloError,
@@ -263,8 +263,8 @@ describe('fetcher', () => {
263
263
  expect(data).toEqual({ injected: true });
264
264
  });
265
265
 
266
- it('supports a custom fetcher, like `make-fetch-happen`', async () => {
267
- const injectedFetch = makeFetchHappenFetcher.mockJSONResponseOnce({
266
+ it('supports a custom fetcher, like `node-fetch`', async () => {
267
+ const injectedFetch = customFetcher.mockJSONResponseOnce({
268
268
  data: { me: 'james' },
269
269
  });
270
270
  const DataSource = new RemoteGraphQLDataSource({
@@ -489,7 +489,17 @@ function executeSelectionSet(
489
489
  const selections = (selection as QueryPlanFieldNode).selections;
490
490
 
491
491
  if (typeof source[responseName] === 'undefined') {
492
- throw new Error(`Field "${responseName}" was not found in response.`);
492
+ // This method is called to collect the inputs/requires of a fetch. So, assuming query plans are correct
493
+ // (and we have not reason to assume otherwise here), all inputs should be fetched beforehand and the
494
+ // only reason for not finding one of the inputs is that we had an error fetching it _and_ that field
495
+ // is non-nullable (it it was nullable, error fetching the input would have make that input `null`; not
496
+ // having the input means the field is non-nullable so the whole entity had to be nullified/ignored,
497
+ // leading use to not have the field at all).
498
+ // In any case, we don't have all the necessary inputs for this particular entity and should ignore it.
499
+ // Note that an error has already been logged for whichever issue happen while fetching the inputs we're
500
+ // missing here, and that error had much more context, so no reason to log a duplicate (less useful) error
501
+ // here.
502
+ return null;
493
503
  }
494
504
  if (Array.isArray(source[responseName])) {
495
505
  result[responseName] = source[responseName].map((value: any) =>
package/src/index.ts CHANGED
@@ -57,7 +57,7 @@ import {
57
57
  SupergraphSdlUpdate,
58
58
  CompositionUpdate,
59
59
  } from './config';
60
- import { loadSupergraphSdlFromStorage } from './loadSupergraphSdlFromStorage';
60
+ import {loadSupergraphSdlFromUplinks} from './loadSupergraphSdlFromStorage';
61
61
  import { SpanStatusCode } from '@opentelemetry/api';
62
62
  import { OpenTelemetrySpanNames, tracer } from './utilities/opentelemetry';
63
63
 
@@ -184,6 +184,8 @@ export class ApolloGateway implements GraphQLService {
184
184
  private fetcher: typeof fetch;
185
185
  private compositionId?: string;
186
186
  private state: GatewayState;
187
+ private errorReportingEndpoint: string | undefined =
188
+ process.env.APOLLO_OUT_OF_BAND_REPORTER_ENDPOINT ?? undefined;
187
189
 
188
190
  // Observe query plan, service info, and operation info prior to execution.
189
191
  // The information made available here will give insight into the resulting
@@ -201,10 +203,11 @@ export class ApolloGateway implements GraphQLService {
201
203
  private updateServiceDefinitions: Experimental_UpdateComposition;
202
204
  // how often service defs should be loaded/updated (in ms)
203
205
  private experimental_pollInterval?: number;
204
- // Configure the endpoint by which gateway will access its precomposed schema.
205
- // * `string` means use that endpoint
206
+ // Configure the endpoints by which gateway will access its precomposed schema.
207
+ // * An array of URLs means use these endpoints to obtain schema, if one is unavailable then try the next.
206
208
  // * `undefined` means the gateway is not using managed federation
207
- private schemaConfigDeliveryEndpoint?: string;
209
+ private uplinkEndpoints?: string[];
210
+ private uplinkMaxRetries?: number;
208
211
 
209
212
  constructor(config?: GatewayConfig) {
210
213
  this.config = {
@@ -233,13 +236,22 @@ export class ApolloGateway implements GraphQLService {
233
236
 
234
237
  // 1. If config is set to a `string`, use it
235
238
  // 2. If the env var is set, use that
236
- // 3. If config is `undefined`, use the default uplink URL
239
+ // 3. If config is `undefined`, use the default uplink URLs
237
240
  if (isManagedConfig(this.config)) {
238
- const envEndpoint = process.env.APOLLO_SCHEMA_CONFIG_DELIVERY_ENDPOINT;
239
- this.schemaConfigDeliveryEndpoint =
240
- this.config.schemaConfigDeliveryEndpoint ??
241
- envEndpoint ??
242
- 'https://uplink.api.apollographql.com/';
241
+ const rawEndpointsString = process.env.APOLLO_SCHEMA_CONFIG_DELIVERY_ENDPOINT;
242
+ const envEndpoints = rawEndpointsString?.split(",") ?? null;
243
+
244
+ if (this.config.schemaConfigDeliveryEndpoint && !this.config.uplinkEndpoints) {
245
+ this.uplinkEndpoints = [this.config.schemaConfigDeliveryEndpoint];
246
+ } else {
247
+ this.uplinkEndpoints = this.config.uplinkEndpoints ??
248
+ envEndpoints ?? [
249
+ 'https://uplink.api.apollographql.com/',
250
+ 'https://aws.uplink.api.apollographql.com/'
251
+ ];
252
+ }
253
+
254
+ this.uplinkMaxRetries = this.config.uplinkMaxRetries ?? this.uplinkEndpoints.length * 3;
243
255
  }
244
256
 
245
257
  if (isManuallyManagedConfig(this.config)) {
@@ -916,12 +928,14 @@ export class ApolloGateway implements GraphQLService {
916
928
  );
917
929
  }
918
930
 
919
- const result = await loadSupergraphSdlFromStorage({
931
+ const result = await loadSupergraphSdlFromUplinks({
920
932
  graphRef: this.apolloConfig!.graphRef!,
921
933
  apiKey: this.apolloConfig!.key!,
922
- endpoint: this.schemaConfigDeliveryEndpoint!,
934
+ endpoints: this.uplinkEndpoints!,
935
+ errorReportingEndpoint: this.errorReportingEndpoint,
923
936
  fetcher: this.fetcher,
924
937
  compositionId: this.compositionId ?? null,
938
+ maxRetries: this.uplinkMaxRetries!,
925
939
  });
926
940
 
927
941
  return (
@@ -1,6 +1,7 @@
1
1
  import { fetch, Response, Request } from 'apollo-server-env';
2
2
  import { GraphQLError } from 'graphql';
3
- import { OutOfBandReporter } from './outOfBandReporter';
3
+ import { SupergraphSdlUpdate } from './config';
4
+ import { submitOutOfBandReportIfConfigured } from './outOfBandReporter';
4
5
  import { SupergraphSdlQuery } from './__generated__/graphqlTypes';
5
6
 
6
7
  // Magic /* GraphQL */ comment below is for codegen, do not remove
@@ -38,19 +39,63 @@ const { name, version } = require('../package.json');
38
39
 
39
40
  const fetchErrorMsg = "An error occurred while fetching your schema from Apollo: ";
40
41
 
42
+ let fetchCounter = 0;
43
+
44
+ export async function loadSupergraphSdlFromUplinks({
45
+ graphRef,
46
+ apiKey,
47
+ endpoints,
48
+ errorReportingEndpoint,
49
+ fetcher,
50
+ compositionId,
51
+ maxRetries,
52
+ }: {
53
+ graphRef: string;
54
+ apiKey: string;
55
+ endpoints: string[];
56
+ errorReportingEndpoint: string | undefined,
57
+ fetcher: typeof fetch;
58
+ compositionId: string | null;
59
+ maxRetries: number
60
+ }) : Promise<SupergraphSdlUpdate | null> {
61
+ let retries = 0;
62
+ let lastException = null;
63
+ let result: SupergraphSdlUpdate | null = null;
64
+ while (retries++ <= maxRetries && result == null) {
65
+ try {
66
+ result = await loadSupergraphSdlFromStorage({
67
+ graphRef,
68
+ apiKey,
69
+ endpoint: endpoints[fetchCounter++ % endpoints.length],
70
+ errorReportingEndpoint,
71
+ fetcher,
72
+ compositionId
73
+ });
74
+ } catch (e) {
75
+ lastException = e;
76
+ }
77
+ }
78
+ if (result === null && lastException !== null) {
79
+ throw lastException;
80
+ }
81
+ return result;
82
+ }
83
+
41
84
  export async function loadSupergraphSdlFromStorage({
42
85
  graphRef,
43
86
  apiKey,
44
87
  endpoint,
88
+ errorReportingEndpoint,
45
89
  fetcher,
46
90
  compositionId,
47
91
  }: {
48
92
  graphRef: string;
49
93
  apiKey: string;
50
94
  endpoint: string;
95
+ errorReportingEndpoint?: string;
51
96
  fetcher: typeof fetch;
52
97
  compositionId: string | null;
53
- }) {
98
+ }) : Promise<SupergraphSdlUpdate | null> {
54
99
  let result: Response;
55
100
  const requestDetails = {
56
101
  method: 'POST',
@@ -72,19 +117,19 @@ export async function loadSupergraphSdlFromStorage({
72
117
 
73
118
  const request: Request = new Request(endpoint, requestDetails);
74
119
 
75
- const OOBReport = new OutOfBandReporter();
76
- const startTime = new Date()
120
+ const startTime = new Date();
77
121
  try {
78
122
  result = await fetcher(endpoint, requestDetails);
79
123
  } catch (e) {
80
124
  const endTime = new Date();
81
125
 
82
- await OOBReport.submitOutOfBandReportIfConfigured({
126
+ await submitOutOfBandReportIfConfigured({
83
127
  error: e,
84
128
  request,
129
+ endpoint: errorReportingEndpoint,
85
130
  startedAt: startTime,
86
131
  endedAt: endTime,
87
- fetcher
132
+ fetcher,
88
133
  });
89
134
 
90
135
  throw new Error(fetchErrorMsg + (e.message ?? e));
@@ -109,13 +154,14 @@ export async function loadSupergraphSdlFromStorage({
109
154
  );
110
155
  }
111
156
  } else {
112
- await OOBReport.submitOutOfBandReportIfConfigured({
157
+ await submitOutOfBandReportIfConfigured({
113
158
  error: new Error(fetchErrorMsg + result.status + ' ' + result.statusText),
114
159
  request,
160
+ endpoint: errorReportingEndpoint,
115
161
  response: result,
116
162
  startedAt: startTime,
117
163
  endedAt: endTime,
118
- fetcher
164
+ fetcher,
119
165
  });
120
166
  throw new Error(fetchErrorMsg + result.status + ' ' + result.statusText);
121
167
  }
@@ -27,102 +27,100 @@ interface OobReportMutationFailure {
27
27
  data?: OobReportMutation;
28
28
  errors: GraphQLError[];
29
29
  }
30
- export class OutOfBandReporter {
31
- static endpoint: string | null = process.env.APOLLO_OUT_OF_BAND_REPORTER_ENDPOINT || null;
32
30
 
33
- async submitOutOfBandReportIfConfigured({
34
- error,
35
- request,
36
- response,
37
- startedAt,
38
- endedAt,
39
- tags,
40
- fetcher,
41
- }: {
42
- error: Error;
43
- request: Request;
44
- response?: Response;
45
- startedAt: Date;
46
- endedAt: Date;
47
- tags?: string[];
48
- fetcher: typeof fetch;
49
- }) {
50
-
51
- // don't send report if the endpoint url is not configured
52
- if (!OutOfBandReporter.endpoint) {
53
- return;
54
- }
31
+ export async function submitOutOfBandReportIfConfigured({
32
+ error,
33
+ request,
34
+ endpoint,
35
+ response,
36
+ startedAt,
37
+ endedAt,
38
+ tags,
39
+ fetcher,
40
+ }: {
41
+ error: Error;
42
+ request: Request;
43
+ endpoint: string | undefined;
44
+ response?: Response;
45
+ startedAt: Date;
46
+ endedAt: Date;
47
+ tags?: string[];
48
+ fetcher: typeof fetch;
49
+ }) {
50
+ // don't send report if the endpoint url is not configured
51
+ if (!endpoint) {
52
+ return;
53
+ }
55
54
 
56
- let errorCode: ErrorCode;
57
- if (!response) {
58
- errorCode = ErrorCode.ConnectionFailed;
59
- } else {
60
- // possible error situations to check against
61
- switch (response.status) {
62
- case 400:
63
- case 413:
64
- case 422:
65
- errorCode = ErrorCode.InvalidBody;
66
- break;
67
- case 408:
68
- case 504:
69
- errorCode = ErrorCode.Timeout;
70
- break;
71
- case 502:
72
- case 503:
73
- errorCode = ErrorCode.ConnectionFailed;
74
- break;
75
- default:
76
- errorCode = ErrorCode.Other;
77
- }
55
+ let errorCode: ErrorCode;
56
+ if (!response) {
57
+ errorCode = ErrorCode.ConnectionFailed;
58
+ } else {
59
+ // possible error situations to check against
60
+ switch (response.status) {
61
+ case 400:
62
+ case 413:
63
+ case 422:
64
+ errorCode = ErrorCode.InvalidBody;
65
+ break;
66
+ case 408:
67
+ case 504:
68
+ errorCode = ErrorCode.Timeout;
69
+ break;
70
+ case 502:
71
+ case 503:
72
+ errorCode = ErrorCode.ConnectionFailed;
73
+ break;
74
+ default:
75
+ errorCode = ErrorCode.Other;
78
76
  }
77
+ }
79
78
 
80
- const responseBody: string | undefined = await response?.text();
79
+ const responseBody: string | undefined = await response?.text();
81
80
 
82
- const variables: OobReportMutationVariables = {
83
- input: {
84
- error: {
85
- code: errorCode,
86
- message: error.message,
87
- },
88
- request: {
89
- url: request.url,
90
- body: await request.text(),
91
- },
92
- response: response
93
- ? {
94
- httpStatusCode: response.status,
95
- body: responseBody,
96
- }
97
- : null,
98
- startedAt: startedAt.toISOString(),
99
- endedAt: endedAt.toISOString(),
100
- tags: tags,
81
+ const variables: OobReportMutationVariables = {
82
+ input: {
83
+ error: {
84
+ code: errorCode,
85
+ message: error.message,
86
+ },
87
+ request: {
88
+ url: request.url,
89
+ body: await request.text(),
101
90
  },
102
- };
91
+ response: response
92
+ ? {
93
+ httpStatusCode: response.status,
94
+ body: responseBody,
95
+ }
96
+ : null,
97
+ startedAt: startedAt.toISOString(),
98
+ endedAt: endedAt.toISOString(),
99
+ tags: tags,
100
+ },
101
+ };
103
102
 
104
- try {
105
- const oobResponse = await fetcher(OutOfBandReporter.endpoint, {
106
- method: 'POST',
107
- body: JSON.stringify({
108
- query: OUT_OF_BAND_REPORTER_QUERY,
109
- variables,
110
- }),
111
- headers: {
112
- 'apollographql-client-name': name,
113
- 'apollographql-client-version': version,
114
- 'user-agent': `${name}/${version}`,
115
- 'content-type': 'application/json',
116
- },
117
- });
118
- const parsedResponse: OobReportMutationResult = await oobResponse.json();
119
- if (!parsedResponse?.data?.reportError) {
120
- throw new Error(
121
- `Out-of-band error reporting failed: ${oobResponse.status} ${oobResponse.statusText}`,
122
- );
123
- }
124
- } catch (e) {
125
- throw new Error(`Out-of-band error reporting failed: ${e.message ?? e}`);
103
+ try {
104
+ const oobResponse = await fetcher(endpoint, {
105
+ method: 'POST',
106
+ body: JSON.stringify({
107
+ query: OUT_OF_BAND_REPORTER_QUERY,
108
+ variables,
109
+ }),
110
+ headers: {
111
+ 'apollographql-client-name': name,
112
+ 'apollographql-client-version': version,
113
+ 'user-agent': `${name}/${version}`,
114
+ 'content-type': 'application/json',
115
+ },
116
+ });
117
+ const parsedResponse: OobReportMutationResult = await oobResponse.json();
118
+ if (!parsedResponse?.data?.reportError) {
119
+ throw new Error(
120
+ `Out-of-band error reporting failed: ${oobResponse.status} ${oobResponse.statusText}`,
121
+ );
126
122
  }
123
+ } catch (e) {
124
+ throw new Error(`Out-of-band error reporting failed: ${e.message ?? e}`);
127
125
  }
128
126
  }