Skip to content

Commit 083a4a7

Browse files
n1ru4lgemini-code-assist[bot]jdolle
authored
fix: federation subgraph introspection does not require GraphQL introspection (#8018)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: jdolle <1841898+jdolle@users.noreply.github.com>
1 parent 842881c commit 083a4a7

7 files changed

Lines changed: 210 additions & 74 deletions

File tree

.changeset/brave-feet-occur.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@graphql-hive/cli': patch
3+
---
4+
5+
Correct fallback behavior for subgraph introspection. If subgraph introspection using graphql's standard introspection query fails, then it will fall back to Federation's Query._service query.

integration-tests/tests/cli/dev.spec.ts

Lines changed: 56 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -318,31 +318,60 @@ describe('dev --remote', () => {
318318
await expect(cmd).rejects.toThrowError('Non-shareable field');
319319
});
320320

321-
test.concurrent('correct error on failed introspection', async ({ expect }) => {
322-
const server = await createHTTPGraphQLServer();
323-
const { createOrg } = await initSeed().createOwner();
324-
const { createProject } = await createOrg();
325-
const { createTargetAccessToken } = await createProject(ProjectType.Federation);
326-
const { secret } = await createTargetAccessToken({});
327-
const cli = createCLI({ readwrite: secret, readonly: secret });
328-
329-
const supergraph = tmpFile('graphql');
330-
const cmd = cli.dev({
331-
remote: true,
332-
services: [
333-
{
334-
name: 'bar',
335-
url: server.url + '/graphql-federation-no-introspection',
336-
},
337-
],
338-
write: supergraph.filepath,
339-
});
340-
341-
await expect(cmd).rejects.toThrowError(
342-
`Could not get introspection result from the service 'bar'`,
343-
);
344-
// make sure correct error code is being thrown
345-
await expect(cmd).rejects.toThrowError('[116]');
346-
await expect(cmd).rejects.not.toThrow('[115]');
347-
});
321+
test.concurrent(
322+
'subgraph introspection via `Query._service` succeeds when graphql introspection is disabled',
323+
async ({ expect }) => {
324+
const server = await createHTTPGraphQLServer();
325+
const { createOrg } = await initSeed().createOwner();
326+
const { createProject } = await createOrg();
327+
const { createTargetAccessToken } = await createProject(ProjectType.Federation);
328+
const { secret } = await createTargetAccessToken({});
329+
const cli = createCLI({ readwrite: secret, readonly: secret });
330+
331+
const supergraph = tmpFile('graphql');
332+
const cmd = cli.dev({
333+
remote: true,
334+
services: [
335+
{
336+
name: 'bar',
337+
url: server.url + '/graphql-federation-no-introspection',
338+
},
339+
],
340+
write: supergraph.filepath,
341+
});
342+
343+
await expect(cmd).resolves.toContain('Composition successful');
344+
},
345+
);
346+
347+
test.concurrent(
348+
'subgraph introspection fails when target is not a subgraph',
349+
async ({ expect }) => {
350+
const server = await createHTTPGraphQLServer();
351+
const { createOrg } = await initSeed().createOwner();
352+
const { createProject } = await createOrg();
353+
const { createTargetAccessToken } = await createProject(ProjectType.Federation);
354+
const { secret } = await createTargetAccessToken({});
355+
const cli = createCLI({ readwrite: secret, readonly: secret });
356+
357+
const supergraph = tmpFile('graphql');
358+
const cmd = cli.dev({
359+
remote: true,
360+
services: [
361+
{
362+
name: 'bar',
363+
url: server.url + '/graphql',
364+
},
365+
],
366+
write: supergraph.filepath,
367+
});
368+
369+
await expect(cmd).rejects.toThrow(
370+
`The provided service URL does not point to a valid Federation subgraph.`,
371+
);
372+
// make sure correct error code is being thrown
373+
await expect(cmd).rejects.toThrow('Cannot query field "_service" on type "Query"');
374+
await expect(cmd).rejects.toThrow('[121]');
375+
},
376+
);
348377
});

integration-tests/tests/cli/introspect.spec.ts

Lines changed: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,43 @@ export async function createHTTPGraphQLServer() {
100100
});
101101

102102
const yogaNoIntrospection = createYoga({
103+
logging: false,
104+
graphqlEndpoint: '/graphql-no-introspection',
105+
schema: createSchema({
106+
typeDefs: /* GraphQL */ `
107+
type Query {
108+
hello: String!
109+
}
110+
`,
111+
}),
112+
plugins: [useDisableIntrospection()],
113+
});
114+
115+
const yogaBadService = createYoga({
116+
logging: false,
117+
graphqlEndpoint: '/graphql-bad-service',
118+
schema: createSchema({
119+
typeDefs: /* GraphQL */ `
120+
type _Service {
121+
sdl: String
122+
}
123+
124+
type Query {
125+
_service: _Service
126+
hello: String!
127+
}
128+
`,
129+
resolvers: {
130+
Query: {
131+
_service: () => {
132+
throw new Error('Something went wrong');
133+
},
134+
},
135+
},
136+
}),
137+
});
138+
139+
const yogaFederationNoIntrospection = createYoga({
103140
graphqlEndpoint: '/graphql-federation-no-introspection',
104141
logging: false,
105142
schema: buildSubgraphSchema({
@@ -150,11 +187,23 @@ export async function createHTTPGraphQLServer() {
150187

151188
server.route({
152189
// Bind to the Yoga's endpoint to avoid rendering on any path
190+
url: yogaFederationNoIntrospection.graphqlEndpoint,
191+
method: ['GET', 'POST', 'OPTIONS'],
192+
handler: (req, reply) => yogaFederationNoIntrospection.handleNodeRequestAndResponse(req, reply),
193+
});
194+
195+
server.route({
153196
url: yogaNoIntrospection.graphqlEndpoint,
154197
method: ['GET', 'POST', 'OPTIONS'],
155198
handler: (req, reply) => yogaNoIntrospection.handleNodeRequestAndResponse(req, reply),
156199
});
157200

201+
server.route({
202+
url: yogaBadService.graphqlEndpoint,
203+
method: ['GET', 'POST', 'OPTIONS'],
204+
handler: (req, reply) => yogaBadService.handleNodeRequestAndResponse(req, reply),
205+
});
206+
158207
await server.listen({
159208
port: 0,
160209
host: '0.0.0.0',
@@ -289,12 +338,46 @@ test.concurrent('can introspect protected federation with header', async ({ expe
289338
`);
290339
});
291340

292-
test.concurrent('error handling on server with no introspection enabled', async ({ expect }) => {
341+
test.concurrent(
342+
'error handling on server with introspection is disabled and _service does not respond',
343+
async ({ expect }) => {
344+
const server = await createHTTPGraphQLServer();
345+
const command = introspect([server.url + '/graphql-no-introspection']);
346+
await expect(command).rejects.toThrow('Could not get introspection result from the service.');
347+
await expect(command).rejects.toThrow('[116]');
348+
await expect(command).rejects.not.toThrow('[115]');
349+
},
350+
);
351+
352+
test.concurrent(
353+
'can introspect federated service even if introspection is disabled',
354+
async ({ expect }) => {
355+
const server = await createHTTPGraphQLServer();
356+
const command = introspect([server.url + '/graphql-federation-no-introspection']);
357+
358+
await expect(command).resolves.toContain('type Query {');
359+
},
360+
);
361+
362+
test.concurrent('error is thrown when _service exists but fails', async ({ expect }) => {
293363
const server = await createHTTPGraphQLServer();
294-
const command = introspect([server.url + '/graphql-federation-no-introspection']);
295-
await expect(command).rejects.toThrowError(
296-
'Could not get introspection result from the service.',
297-
);
364+
const command = introspect([server.url + '/graphql-bad-service']);
365+
366+
await expect(command).rejects.toThrow('Could not get introspection result from the service.');
298367
await expect(command).rejects.toThrow('[116]');
299368
await expect(command).rejects.not.toThrow('[115]');
300369
});
370+
371+
test.concurrent(
372+
'federation can be introspected when explicitly defined even if introspection is disabled',
373+
async ({ expect }) => {
374+
const server = await createHTTPGraphQLServer();
375+
const command = introspect([
376+
server.url + '/graphql-federation-no-introspection',
377+
'--type',
378+
'federation',
379+
]);
380+
381+
await expect(command).resolves.toContain('type Query {');
382+
},
383+
);

packages/libraries/cli/src/commands/dev.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -514,7 +514,7 @@ export default class Dev extends Command<typeof Dev> {
514514
logger: this.logger,
515515
}).catch(err => {
516516
this.logFailure(err);
517-
throw new IntrospectionError(serviceName);
517+
throw err;
518518
});
519519

520520
if (!sdl) {

packages/libraries/cli/src/helpers/errors.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,11 @@ export class NetworkError extends HiveCLIError {
228228
/** GraphQL Errors returned from an operation. Note that some GraphQL Errors that require specific steps to correct are handled through other error types. */
229229
export class APIError extends HiveCLIError {
230230
public ref?: string;
231-
constructor(cause: Error | string, requestId?: string) {
231+
constructor(
232+
cause: Error | string,
233+
requestId?: string,
234+
public graphQLErrors?: ReadonlyArray<GraphQLError>,
235+
) {
232236
super(
233237
ExitCode.ERROR,
234238
errorCode(ErrorCategory.GENERIC, 15),
@@ -396,6 +400,16 @@ export class InvalidTargetError extends HiveCLIError {
396400
}
397401
}
398402

403+
export class InvalidFederationSubgraphError extends HiveCLIError {
404+
constructor(reason?: string) {
405+
super(
406+
ExitCode.BAD_INIT,
407+
errorCode(ErrorCategory.GENERIC, 21),
408+
`The provided service URL does not point to a valid Federation subgraph.${reason ? `\n${reason}\n` : ''}`,
409+
);
410+
}
411+
}
412+
399413
export class SchemaNotFoundError extends HiveCLIError {
400414
constructor(commit?: string) {
401415
super(

packages/libraries/cli/src/helpers/graphql-request.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export function graphqlRequest(config: {
102102
throw new APIError(
103103
jsonData.errors.map(e => e.message).join('\n'),
104104
cleanRequestId(response?.headers?.get('x-request-id')),
105+
jsonData.errors,
105106
);
106107
}
107108

packages/libraries/cli/src/helpers/schema.ts

Lines changed: 44 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { BaseLoaderOptions, Loader, Source } from '@graphql-tools/utils';
99
import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
1010
import { FragmentType, graphql, useFragment as unmaskFragment, useFragment } from '../gql';
1111
import { SchemaWarningConnection, SeverityLevelType } from '../gql/graphql';
12+
import { APIError, IntrospectionError, InvalidFederationSubgraphError } from './errors';
1213
import { graphqlRequest } from './graphql-request';
1314
import { Texture } from './texture/texture';
1415

@@ -217,66 +218,69 @@ class FederationSubgraphUrlLoader implements Loader {
217218
},
218219
});
219220

220-
this.logger?.debug?.('Attempt "_Service" type lookup via "Query.__type".');
221-
222-
// We can check if the schema is a subgraph by looking for the `_Service` type.
223-
const isSubgraph = await client.request({
224-
operation: parse(/* GraphQL */ `
225-
query ${'LookupService'} {
226-
__type(name: "_Service") ${' '}{
227-
name
228-
}
229-
}
230-
`) as TypedDocumentNode<{ __type: null | { name: string } }, Record<string, never>>,
231-
});
232-
233-
if (isSubgraph.__type === null) {
234-
this.logger?.debug?.('Type not found, this is not a Federation subgraph.');
235-
return [];
236-
}
237-
238-
this.logger?.debug?.(
239-
'Resolved "_Service" type. Federation subgraph detected.' +
240-
'Attempt Federation introspection via "Query._service" field.',
241-
);
242-
243-
const response = await client.request({
244-
operation: parse(/* GraphQL */ `
221+
try {
222+
const response = await client.request({
223+
operation: parse(/* GraphQL */ `
245224
query ${'GetFederationSchema'} {
246225
_service {
247226
sdl
248227
}
249228
}
250229
`) as TypedDocumentNode<{ _service: { sdl: string } }, Record<string, never>>,
251-
});
230+
});
252231

253-
this.logger?.debug?.('Resolved subgraph SDL successfully.');
232+
this.logger?.debug?.('Resolved subgraph SDL successfully.');
254233

255-
const sdl = minifySchema(response._service.sdl);
234+
const sdl = minifySchema(response._service.sdl);
256235

257-
return [
258-
{
259-
document: parse(sdl),
260-
rawSDL: sdl,
261-
},
262-
];
236+
return [
237+
{
238+
document: parse(sdl),
239+
rawSDL: sdl,
240+
},
241+
];
242+
} catch (err) {
243+
if (
244+
err instanceof APIError &&
245+
err.graphQLErrors?.some(
246+
err =>
247+
err.message.includes('Cannot query field "_service" on type "Query"') ||
248+
err.message.includes('Cannot query field "sdl" on type "_Service"'),
249+
)
250+
) {
251+
throw new InvalidFederationSubgraphError(
252+
'The GraphQL server responded with the following errors:\n' +
253+
err.graphQLErrors.map(error => `- ${error.message}`).join('\n'),
254+
);
255+
}
256+
throw err;
257+
}
263258
}
264259
}
265260

266261
class FederationSubgraphIntrospectionThenGraphQLIntrospectionUrlLoader implements Loader {
267262
private urlLoader = new UrlLoader();
268263
private federationLoader: FederationSubgraphUrlLoader;
264+
269265
constructor(private logger?: LegacyLogger) {
270266
this.federationLoader = new FederationSubgraphUrlLoader(logger);
271267
}
272268

273269
async load(pointer: string, options: BaseLoaderOptions & { headers?: Record<string, string> }) {
274-
this.logger?.debug?.('Attempt federation introspection');
275-
let result = await this.federationLoader.load(pointer, options);
276-
if (!result.length) {
277-
this.logger?.debug?.('Attempt GraphQL introspection');
278-
result = await this.urlLoader.load(pointer, options);
270+
try {
271+
return await this.federationLoader.load(pointer, options);
272+
} catch (e) {
273+
// if this error is because because federated introspection isnt supported, then ignore and try
274+
// normal introspection.
275+
if (!(e instanceof IntrospectionError || e instanceof InvalidFederationSubgraphError)) {
276+
// otherwise, raise an introspection error because some unknown error happened during introspection.
277+
// this may be unintuitive, but we don't want to raise an API Error since users may believe our API is the one at fault.
278+
// We'd rather nudge them to look into their service's behavior.
279+
throw new IntrospectionError();
280+
}
279281
}
280-
return result;
282+
283+
this.logger?.debug?.('Query._service not found. This is a not a Federation subgraph.');
284+
return await this.urlLoader.load(pointer, options);
281285
}
282286
}

0 commit comments

Comments
 (0)