Skip to content

Commit 9b468ec

Browse files
[federation] add tests + handle failure + handle logs (#3328)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 2d800d9 commit 9b468ec

File tree

6 files changed

+349
-32
lines changed

6 files changed

+349
-32
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@graphql-yoga/apollo-managed-federation': patch
3+
---
4+
dependencies updates:
5+
- Updated dependency [`@graphql-tools/[email protected]`
6+
↗︎](https://www.npmjs.com/package/@graphql-tools/federation/v/2.1.0) (from `^2.0.0`, in
7+
`dependencies`)

packages/plugins/apollo-managed-federation/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,11 @@
5151
"graphql-yoga": "workspace:^"
5252
},
5353
"dependencies": {
54-
"@graphql-tools/federation": "^2.0.0",
54+
"@graphql-tools/federation": "2.1.0",
5555
"tslib": "^2.5.0"
5656
},
5757
"devDependencies": {
58+
"@whatwg-node/fetch": "^0.9.18",
5859
"graphql": "16.8.1",
5960
"graphql-yoga": "workspace:^",
6061
"typescript": "5.1.3"

packages/plugins/apollo-managed-federation/src/index.ts

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,83 @@
11
import type { Plugin } from 'graphql-yoga';
2-
import { SupergraphSchemaManager, SupergraphSchemaManagerOptions } from '@graphql-tools/federation';
2+
import { createGraphQLError } from 'graphql-yoga';
3+
import {
4+
FetchError,
5+
SupergraphSchemaManager,
6+
SupergraphSchemaManagerOptions,
7+
} from '@graphql-tools/federation';
38

4-
export type ManagedFederationPluginOptions = SupergraphSchemaManagerOptions;
5-
6-
export type Logger = {
7-
info: (message: string, ...args: unknown[]) => void;
8-
error: (message: string, error?: unknown, ...args: unknown[]) => void;
9+
export type ManagedFederationPluginOptions = (
10+
| (SupergraphSchemaManagerOptions & { supergraphManager?: never })
11+
| { supergraphManager: SupergraphSchemaManager }
12+
) & {
13+
/**
14+
* The manager to be used for fetching the schema and keeping it up to date
15+
* If not provided, on will be instantiated with the provided options
16+
*/
17+
supergraphManager?: SupergraphSchemaManager | never;
18+
/**
19+
* Allow to customize how a schema loading failure is handled.
20+
* A failure happens when the manager failed to load the schema more than the provided max retries
21+
* count.
22+
* By default, an error is logged and the polling is restarted.
23+
* @param error The error encountered during the last fetch tentative
24+
* @param delayInSeconds The delay in seconds indicated by GraphOS before a new try
25+
*/
26+
onFailure?: (error: FetchError | unknown, delayInSeconds: number) => void;
927
};
1028

1129
export function useManagedFederation(options: ManagedFederationPluginOptions = {}): Plugin {
12-
const supergraphManager = new SupergraphSchemaManager(options);
13-
14-
// Start as soon as possible to minimize the wait time of the first schema loading
15-
supergraphManager.start();
30+
const {
31+
supergraphManager = new SupergraphSchemaManager(options as SupergraphSchemaManagerOptions),
32+
} = options;
1633

1734
const plugin: Plugin = {
35+
onYogaInit({ yoga }) {
36+
supergraphManager.on('log', ({ level, message, source }) => {
37+
yoga.logger[level](
38+
`[ManagedFederation]${source === 'uplink' ? ' <UPLINK>' : ''} ${message}`,
39+
);
40+
});
41+
42+
supergraphManager.on(
43+
'failure',
44+
options.onFailure ??
45+
((error, delayInSeconds) => {
46+
const message = (error as { message: string })?.message ?? error;
47+
yoga.logger.error(
48+
`[ManagedFederation] Failed to load supergraph schema.${
49+
message ? ` Last error: ${message}` : ''
50+
}`,
51+
);
52+
yoga.logger.info(
53+
`[ManagedFederation] No failure handler provided. Retrying in ${delayInSeconds}s.`,
54+
);
55+
supergraphManager.start(delayInSeconds);
56+
}),
57+
);
58+
59+
supergraphManager.start();
60+
},
1861
onPluginInit({ setSchema }) {
1962
if (supergraphManager.schema) {
2063
setSchema(supergraphManager.schema);
2164
} else {
2265
// Wait for the first schema to be loaded before before allowing requests to be parsed
2366
// We can then remove the onRequestParse hook to avoid async cost on every request
24-
const waitForInitialization = new Promise<void>(resolve => {
25-
supergraphManager.once('schema', () => {
26-
plugin.onRequestParse = undefined;
67+
const waitForInitialization = new Promise<void>((resolve, reject) => {
68+
const onFailure = (err: unknown) => {
69+
reject(
70+
createGraphQLError('Supergraph failed to load', {
71+
originalError: err instanceof Error ? err : null,
72+
}),
73+
);
74+
};
75+
supergraphManager.once('failure', onFailure);
76+
supergraphManager.once('schema', schema => {
77+
setSchema(schema);
78+
supergraphManager.off('failure', onFailure);
2779
resolve();
80+
plugin.onRequestParse = undefined;
2881
});
2982
});
3083
plugin.onRequestParse = async () => {
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { createYoga } from 'graphql-yoga';
2+
import { SupergraphSchemaManager, SupergraphSchemaManagerOptions } from '@graphql-tools/federation';
3+
import { useManagedFederation } from '@graphql-yoga/plugin-apollo-managed-federation';
4+
import { Response } from '@whatwg-node/fetch';
5+
import { supergraphSdl } from './fixtures/supergraph';
6+
7+
describe('Apollo Managed Federation', () => {
8+
let manager: SupergraphSchemaManager;
9+
10+
afterEach(() => {
11+
manager?.stop();
12+
jest.clearAllMocks();
13+
});
14+
15+
it('should expose the managed federation schema from GraphOS', async () => {
16+
const yoga = createYoga({
17+
plugins: [
18+
useManagedFederation({
19+
supergraphManager: makeManager({
20+
fetch: mockSDL,
21+
}),
22+
}),
23+
],
24+
});
25+
26+
const response = await yoga.fetch('/graphql', {
27+
method: 'POST',
28+
headers: { 'Content-Type': 'application/json' },
29+
body: JSON.stringify({ query: '{ users { id, username, name } }' }),
30+
});
31+
32+
expect(response.status).toBe(200);
33+
expect(await response.json()).toEqual({
34+
data: {
35+
users: [
36+
{ id: '1', name: 'Alice', username: 'alice' },
37+
{ id: '2', name: 'Bob', username: 'bob' },
38+
],
39+
},
40+
});
41+
});
42+
43+
it('should wait for the schema before letting requests through', async () => {
44+
const yoga = createYoga({
45+
plugins: [
46+
useManagedFederation({
47+
supergraphManager: makeManager({
48+
fetch: async () => {
49+
await new Promise(resolve => setTimeout(resolve, 100));
50+
return mockSDL();
51+
},
52+
}),
53+
}),
54+
],
55+
});
56+
57+
const response = await yoga.fetch('/graphql', {
58+
method: 'POST',
59+
headers: { 'Content-Type': 'application/json' },
60+
body: JSON.stringify({ query: '{ users { id, username, name } }' }),
61+
});
62+
63+
expect(response.status).toBe(200);
64+
expect(await response.json()).toEqual({
65+
data: {
66+
users: [
67+
{ id: '1', name: 'Alice', username: 'alice' },
68+
{ id: '2', name: 'Bob', username: 'bob' },
69+
],
70+
},
71+
});
72+
});
73+
74+
it('should respond with an error if the schema failed to load', async () => {
75+
const yoga = createYoga({
76+
plugins: [
77+
useManagedFederation({
78+
supergraphManager: makeManager({
79+
fetch: mockFetchError,
80+
}),
81+
}),
82+
],
83+
});
84+
85+
const response = await yoga.fetch('/graphql', {
86+
method: 'POST',
87+
headers: { 'Content-Type': 'application/json' },
88+
body: JSON.stringify({ query: '{ users { id, username, name } }' }),
89+
});
90+
91+
expect(response.status).toBe(200);
92+
expect(await response.json()).toEqual({
93+
errors: [{ message: 'Supergraph failed to load' }],
94+
});
95+
});
96+
97+
it('should restart polling by default on failure', async () => {
98+
const yoga = createYoga({
99+
plugins: [
100+
useManagedFederation({
101+
supergraphManager: makeManager({
102+
fetch: mockFetchError,
103+
}),
104+
}),
105+
],
106+
});
107+
108+
const failure = jest.fn();
109+
manager.on('failure', failure);
110+
111+
const response = await yoga.fetch('/graphql', {
112+
method: 'POST',
113+
headers: { 'Content-Type': 'application/json' },
114+
body: JSON.stringify({ query: '{ users { id, username, name } }' }),
115+
});
116+
117+
expect(response.status).toBe(200);
118+
expect(await response.json()).toEqual({
119+
errors: [{ message: 'Supergraph failed to load' }],
120+
});
121+
122+
expect(mockFetchError).toBeCalledTimes(3);
123+
expect(failure).toBeCalledTimes(1);
124+
125+
// It should respect the backoff returned by the GraphOS API before restarting the polling
126+
await delay(0.35);
127+
128+
expect(mockFetchError).toBeCalledTimes(6);
129+
expect(failure).toBeCalledTimes(2);
130+
});
131+
132+
const mockSDL = jest.fn(async () =>
133+
Response.json({
134+
data: {
135+
routerConfig: {
136+
__typename: 'RouterConfigResult',
137+
minDelaySeconds: 0.1,
138+
id: 'test-id-1',
139+
supergraphSdl,
140+
messages: [],
141+
},
142+
},
143+
}),
144+
);
145+
146+
const mockFetchError = jest.fn(async () =>
147+
Response.json({
148+
data: {
149+
routerConfig: {
150+
__typename: 'FetchError',
151+
code: 'FETCH_ERROR',
152+
message: 'Test error message',
153+
minDelaySeconds: 0.1,
154+
},
155+
},
156+
}),
157+
);
158+
159+
function makeManager(options: SupergraphSchemaManagerOptions) {
160+
manager = new SupergraphSchemaManager({
161+
onSubschemaConfig(config) {
162+
config.executor = (async () => {
163+
if (config.name === 'SERVICE_AUTH') {
164+
return {
165+
data: {
166+
users: [
167+
{ id: '1', username: 'alice' },
168+
{ id: '2', username: 'bob' },
169+
],
170+
},
171+
};
172+
}
173+
if (config.name === 'SERVICE_IDENTITY') {
174+
return {
175+
data: {
176+
_entities: [{ name: 'Alice' }, { name: 'Bob' }],
177+
},
178+
};
179+
}
180+
return null;
181+
}) as typeof config.executor;
182+
},
183+
...options,
184+
});
185+
return manager;
186+
}
187+
188+
function delay(seconds: number) {
189+
return new Promise(resolve => setTimeout(resolve, seconds * 1000));
190+
}
191+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
export const supergraphSdl = /* GraphQL */ `
2+
schema
3+
@core(feature: "https://specs.apollo.dev/core/v0.2")
4+
@core(feature: "https://specs.apollo.dev/join/v0.1", for: EXECUTION) {
5+
query: Query
6+
}
7+
8+
directive @core(as: String, feature: String!, for: core__Purpose) repeatable on SCHEMA
9+
10+
directive @join__field(
11+
graph: join__Graph
12+
provides: join__FieldSet
13+
requires: join__FieldSet
14+
) on FIELD_DEFINITION
15+
16+
directive @join__graph(name: String!, url: String!) on ENUM_VALUE
17+
18+
directive @join__owner(graph: join__Graph!) on INTERFACE | OBJECT
19+
20+
directive @join__type(graph: join__Graph!, key: join__FieldSet) repeatable on INTERFACE | OBJECT
21+
22+
type Query {
23+
users: [User] @join__field(graph: SERVICE_AUTH)
24+
}
25+
26+
type User
27+
@join__owner(graph: SERVICE_AUTH)
28+
@join__type(graph: SERVICE_AUTH, key: "id")
29+
@join__type(graph: SERVICE_IDENTITY, key: "id") {
30+
id: ID! @join__field(graph: SERVICE_AUTH)
31+
name: String @join__field(graph: SERVICE_IDENTITY)
32+
username: String @join__field(graph: SERVICE_AUTH)
33+
}
34+
35+
enum core__Purpose {
36+
"""
37+
\`EXECUTION\` features provide metadata necessary to for operation execution.
38+
"""
39+
EXECUTION
40+
41+
"""
42+
\`SECURITY\` features provide metadata necessary to securely resolve fields.
43+
"""
44+
SECURITY
45+
}
46+
47+
scalar join__FieldSet
48+
49+
enum join__Graph {
50+
SERVICE_AUTH @join__graph(name: "service_auth", url: "http://auth.services.com")
51+
SERVICE_IDENTITY @join__graph(name: "service_identity", url: "http://identity.services.com")
52+
}
53+
`;

0 commit comments

Comments
 (0)