-
Notifications
You must be signed in to change notification settings - Fork 575
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
apollo-inline-trace - tracing should not fail if non-nullable field throw an error #3455
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@graphql-yoga/plugin-apollo-usage-report": patch | ||
--- | ||
|
||
- fixed: get specific or the nearest possible trace node if something fails at `non-nullable` GraphQL query field |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
--- | ||
"@graphql-yoga/plugin-apollo-inline-trace": patch | ||
--- | ||
|
||
- updated: `@envelop/on-resolve@^4.1.1` dependency | ||
- fixed: package `@envelop/core@^5.0.2` was added to devDependencies and `@envelop/on-resolve` was removed |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
310 changes: 310 additions & 0 deletions
310
packages/plugins/apollo-inline-trace/__tests__/apollo-inline-trace.yoga-gateway.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,310 @@ | ||
import { FormattedExecutionResult, GraphQLFormattedError, versionInfo } from 'graphql'; | ||
import { createYoga, YogaServerInstance } from 'graphql-yoga'; | ||
import { Trace } from '@apollo/usage-reporting-protobuf'; | ||
import { useApolloInlineTrace } from '@graphql-yoga/plugin-apollo-inline-trace'; | ||
import { getStitchedSchemaFromLocalSchemas } from './fixtures/getStitchedSchemaFromLocalSchemas'; | ||
import { getSubgraph1Schema } from './fixtures/subgraph1'; | ||
import { getSubgraph2Schema } from './fixtures/subgraph2'; | ||
|
||
const describeIf = (condition: boolean) => (condition ? describe : describe.skip); | ||
|
||
describeIf(versionInfo.major >= 16)('Inline Trace - Yoga gateway', () => { | ||
let yoga: YogaServerInstance<Record<string, unknown>, Record<string, unknown>>; | ||
|
||
beforeAll(async () => { | ||
const gatewaySchema = await getStitchedSchemaFromLocalSchemas({ | ||
subgraph1: await getSubgraph1Schema(), | ||
subgraph2: await getSubgraph2Schema(), | ||
}); | ||
|
||
yoga = createYoga({ | ||
schema: gatewaySchema, | ||
plugins: [useApolloInlineTrace()], | ||
maskedErrors: false, | ||
}); | ||
}); | ||
|
||
function expectTrace(trace: Trace) { | ||
const expectedTrace: Partial<Trace> = { | ||
root: { | ||
child: expect.any(Array), | ||
}, | ||
startTime: { | ||
seconds: expect.any(Number), | ||
nanos: expect.any(Number), | ||
}, | ||
endTime: { | ||
seconds: expect.any(Number), | ||
nanos: expect.any(Number), | ||
}, | ||
durationNs: expect.any(Number), | ||
fieldExecutionWeight: expect.any(Number), | ||
}; | ||
|
||
expect(trace).toMatchObject(expectedTrace); | ||
// its ok to be "equal" since executions can happen in the same tick | ||
expect(trace.startTime!.seconds).toBeLessThanOrEqual(trace.endTime!.seconds!); | ||
if (trace.startTime!.seconds === trace.endTime!.seconds) { | ||
expect(trace.startTime!.nanos).toBeLessThanOrEqual(trace.endTime!.nanos!); | ||
} | ||
} | ||
|
||
function expectTraceNode( | ||
node: Trace.INode, | ||
responseName: string, | ||
type: string, | ||
parentType: string, | ||
) { | ||
const expectedTraceNode: Partial<Trace.INode> = { | ||
responseName, | ||
type, | ||
parentType, | ||
startTime: expect.any(Number), | ||
endTime: expect.any(Number), | ||
}; | ||
|
||
expect(node).toMatchObject(expectedTraceNode); | ||
// its ok to be "equal" since executions can happen in the same tick | ||
expect(node.startTime).toBeLessThanOrEqual(node.endTime!); | ||
} | ||
|
||
it('nullableFail - multi federated query - should return result with expected data and errors', async () => { | ||
const query = /* GraphQL */ ` | ||
query { | ||
testNestedField { | ||
nullableFail { | ||
id | ||
sub1 | ||
} | ||
subgraph2 { | ||
id | ||
sub2 | ||
} | ||
} | ||
} | ||
`; | ||
|
||
const expectedData = { | ||
testNestedField: { | ||
nullableFail: null, | ||
subgraph2: { | ||
email: 'user2@example.com', | ||
id: 'user2', | ||
sub2: true, | ||
}, | ||
}, | ||
}; | ||
|
||
const expectedErrors: GraphQLFormattedError[] = [ | ||
{ | ||
message: 'My original subgraph error!', | ||
path: ['testNestedField', 'nullableFail'], | ||
}, | ||
]; | ||
|
||
const response = await yoga.fetch('http://yoga/graphql', { | ||
method: 'POST', | ||
body: JSON.stringify({ query }), | ||
headers: { | ||
'content-type': 'application/json', | ||
'apollo-federation-include-trace': 'ftv1', | ||
}, | ||
}); | ||
|
||
const result: FormattedExecutionResult = await response.json(); | ||
|
||
expect(response.status).toBe(200); | ||
expect(result.errors).toMatchObject(expectedErrors); | ||
expect(result.data).toMatchObject(expectedData); | ||
expect(result.extensions?.ftv1).toEqual(expect.any(String)); | ||
|
||
const ftv1 = result.extensions?.ftv1 as string; | ||
const trace = Trace.decode(Buffer.from(ftv1, 'base64')); | ||
|
||
expectTrace(trace); | ||
|
||
const nullableFail = trace.root?.child?.[0].child?.[0] as Trace.INode; | ||
|
||
expectTraceNode(nullableFail, 'nullableFail', 'TestUser1', 'TestNestedField'); | ||
|
||
expect(nullableFail.error).toHaveLength(1); | ||
expect(JSON.parse(nullableFail.error![0].json!)).toMatchObject(expectedErrors[0]); | ||
}); | ||
|
||
it('nullableFail - simple federated query - should return result with expected data and errors', async () => { | ||
const query = /* GraphQL */ ` | ||
query { | ||
testNestedField { | ||
nullableFail { | ||
id | ||
sub1 | ||
} | ||
} | ||
} | ||
`; | ||
|
||
const expectedData = { | ||
testNestedField: { | ||
nullableFail: null, | ||
}, | ||
}; | ||
|
||
const expectedErrors: GraphQLFormattedError[] = [ | ||
{ | ||
message: 'My original subgraph error!', | ||
path: ['testNestedField', 'nullableFail'], | ||
}, | ||
]; | ||
|
||
const response = await yoga.fetch('http://yoga/graphql', { | ||
method: 'POST', | ||
body: JSON.stringify({ query }), | ||
headers: { | ||
'content-type': 'application/json', | ||
'apollo-federation-include-trace': 'ftv1', | ||
}, | ||
}); | ||
|
||
const result: FormattedExecutionResult = await response.json(); | ||
|
||
expect(response.status).toBe(200); | ||
expect(result.errors).toMatchObject(expectedErrors); | ||
expect(result.data).toMatchObject(expectedData); | ||
expect(result.extensions?.ftv1).toEqual(expect.any(String)); | ||
|
||
const ftv1 = result.extensions?.ftv1 as string; | ||
const trace = Trace.decode(Buffer.from(ftv1, 'base64')); | ||
|
||
expectTrace(trace); | ||
|
||
const nullableFail = trace.root?.child?.[0].child?.[0] as Trace.INode; | ||
|
||
expectTraceNode(nullableFail, 'nullableFail', 'TestUser1', 'TestNestedField'); | ||
|
||
expect(nullableFail.error).toHaveLength(1); | ||
expect(JSON.parse(nullableFail.error![0].json!)).toMatchObject(expectedErrors[0]); | ||
}); | ||
|
||
it('nonNullableFail - multi federated query - should return result with expected data and errors', async () => { | ||
const query = /* GraphQL */ ` | ||
query { | ||
testNestedField { | ||
nonNullableFail { | ||
id | ||
sub1 | ||
} | ||
subgraph2 { | ||
id | ||
sub2 | ||
} | ||
} | ||
} | ||
`; | ||
|
||
/** | ||
* The whole query result is { testNestedField: null } even if subgraph2 query not fail, but it should be ok according GraphQL documentation. | ||
* "If the field which experienced an error was declared as Non-Null, the null result will bubble up to the next nullable field." | ||
* https://spec.graphql.org/draft/#sel-GAPHRPTCAACEzBg6S | ||
*/ | ||
const expectedData = { | ||
testNestedField: null, | ||
}; | ||
|
||
const expectedErrors: GraphQLFormattedError[] = [ | ||
{ | ||
message: 'Cannot return null for non-nullable field TestNestedField.nonNullableFail.', | ||
path: ['testNestedField', 'nonNullableFail'], | ||
}, | ||
]; | ||
|
||
const response = await yoga.fetch('http://yoga/graphql', { | ||
method: 'POST', | ||
body: JSON.stringify({ query }), | ||
headers: { | ||
'content-type': 'application/json', | ||
'apollo-federation-include-trace': 'ftv1', | ||
}, | ||
}); | ||
|
||
const result: FormattedExecutionResult = await response.json(); | ||
|
||
expect(response.status).toBe(200); | ||
expect(result.errors).toMatchObject(expectedErrors); | ||
expect(result.data).toMatchObject(expectedData); | ||
expect(result.extensions?.ftv1).toEqual(expect.any(String)); | ||
|
||
const ftv1 = result.extensions?.ftv1 as string; | ||
const trace = Trace.decode(Buffer.from(ftv1, 'base64')); | ||
|
||
expectTrace(trace); | ||
|
||
const nonNullableFail = trace.root?.child?.[0].child?.[0] as Trace.INode; | ||
|
||
expectTraceNode(nonNullableFail, 'nonNullableFail', 'TestUser1!', 'TestNestedField'); | ||
|
||
expect(nonNullableFail.error).toHaveLength(1); | ||
expect(JSON.parse(nonNullableFail.error![0].json!)).toMatchObject(expectedErrors[0]); | ||
}); | ||
|
||
it('nonNullableFail - simple federated query - should return result with expected data and errors', async () => { | ||
const query = /* GraphQL */ ` | ||
query { | ||
testNestedField { | ||
nonNullableFail { | ||
id | ||
sub1 | ||
} | ||
} | ||
} | ||
`; | ||
|
||
const expectedData = { | ||
testNestedField: null, | ||
}; | ||
|
||
const expectedErrors: GraphQLFormattedError[] = [ | ||
{ | ||
message: 'My original subgraph error!', | ||
path: ['testNestedField', 'nonNullableFail'], | ||
}, | ||
]; | ||
|
||
const response = await yoga.fetch('http://yoga/graphql', { | ||
method: 'POST', | ||
body: JSON.stringify({ query }), | ||
headers: { | ||
'content-type': 'application/json', | ||
'apollo-federation-include-trace': 'ftv1', | ||
}, | ||
}); | ||
|
||
const result: FormattedExecutionResult = await response.json(); | ||
|
||
expect(response.status).toBe(200); | ||
expect(result.errors).toMatchObject(expectedErrors); | ||
expect(result.data).toMatchObject(expectedData); | ||
expect(result.extensions?.ftv1).toEqual(expect.any(String)); | ||
|
||
const ftv1 = result.extensions?.ftv1 as string; | ||
const trace = Trace.decode(Buffer.from(ftv1, 'base64')); | ||
|
||
expectTrace(trace); | ||
|
||
/** | ||
* NOTE: nonNullableFail field is missing here in case of "simple federated query" where only one subgraph is called | ||
* therefore the error is assigned to the nearest possible trace node which is testNestedField in this case. | ||
*/ | ||
const testNestedField = trace.root?.child?.[0] as Trace.INode; | ||
|
||
expectTraceNode(testNestedField, 'testNestedField', 'TestNestedField', 'Query'); | ||
|
||
expect(testNestedField.error).toHaveLength(1); | ||
expect(JSON.parse(testNestedField.error![0].json!)).toMatchObject(expectedErrors[0]); | ||
}); | ||
}); |
37 changes: 37 additions & 0 deletions
37
packages/plugins/apollo-inline-trace/__tests__/fixtures/getStitchedSchemaFromLocalSchemas.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
/* eslint-disable import/no-extraneous-dependencies */ | ||
import { GraphQLSchema } from 'graphql'; | ||
import { createDefaultExecutor } from '@graphql-tools/delegate'; | ||
import { getStitchedSchemaFromSupergraphSdl } from '@graphql-tools/federation'; | ||
|
||
export async function getStitchedSchemaFromLocalSchemas( | ||
localSchemas: Record<string, GraphQLSchema>, | ||
): Promise<GraphQLSchema> { | ||
// dynamic import is used only due to incompatibility with graphql@15 | ||
const { IntrospectAndCompose, LocalGraphQLDataSource } = await import('@apollo/gateway'); | ||
const introspectAndCompose = await new IntrospectAndCompose({ | ||
subgraphs: Object.keys(localSchemas).map(name => ({ name, url: `http://localhost/${name}` })), | ||
}).initialize({ | ||
healthCheck: async () => Promise.resolve(), | ||
update: () => undefined, | ||
getDataSource: ({ name }) => { | ||
const [_name, schema] = Object.entries(localSchemas).find(([key]) => key === name) ?? []; | ||
if (schema) { | ||
return new LocalGraphQLDataSource(schema); | ||
} | ||
throw new Error(`Unknown subgraph ${name}`); | ||
}, | ||
}); | ||
|
||
return getStitchedSchemaFromSupergraphSdl({ | ||
supergraphSdl: introspectAndCompose.supergraphSdl, | ||
onSubschemaConfig: cofig => { | ||
const [_name, schema] = | ||
Object.entries(localSchemas).find(([key]) => key === cofig.name.toLowerCase()) ?? []; | ||
if (schema) { | ||
cofig.executor = createDefaultExecutor(schema); | ||
} else { | ||
throw new Error(`Unknown subgraph ${cofig.name}`); | ||
} | ||
}, | ||
}); | ||
} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hello @EmrysMyrddin @ardatan,
I had to update the version of this package here as well, because it overrides the version for all other packages in this repository as well. In my case for the
@graphql-yoga/plugin-apollo-inline-trace
package.