Skip to content

Commit

Permalink
chore: apply fix for non-nullable fields
Browse files Browse the repository at this point in the history
  • Loading branch information
Tomas Kroupa committed Nov 5, 2024
1 parent 708d908 commit 4b38cd3
Show file tree
Hide file tree
Showing 8 changed files with 969 additions and 260 deletions.
6 changes: 6 additions & 0 deletions .changeset/changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@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
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { versionInfo } from 'graphql';
import { createYoga, YogaServerInstance } from 'graphql-yoga';
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,
});
});

it('nullableFail - federated query - should return result with expected data and errors', async () => {
const query = /* GraphQL */ `
query {
testNestedField {
nullableFail {
id
email
sub1
}
subgraph2 {
id
email
sub2
}
}
}
`;

const expectedData = {
testNestedField: {
nullableFail: null,
subgraph2: {
email: 'user2@example.com',
id: 'user2',
sub2: true,
},
},
};

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 = await response.json();

expect(response.status).toBe(200);
expect(result.errors).toMatchObject([
{
message: 'My original subgraph error!',
path: ['testNestedField', 'nullableFail'],
},
]);
expect(result.data).toMatchObject(expectedData);
expect(result.extensions.ftv1).toEqual(expect.any(String));
});

it('nullableFail - simple query - should return result with expected data and errors', async () => {
const query = /* GraphQL */ `
query {
testNestedField {
nullableFail {
id
email
sub1
}
}
}
`;

const expectedData = {
testNestedField: {
nullableFail: null,
},
};

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 = await response.json();

expect(response.status).toBe(200);
expect(result.errors).toMatchObject([
{
message: 'My original subgraph error!',
path: ['testNestedField', 'nullableFail'],
},
]);
expect(result.data).toMatchObject(expectedData);
expect(result.extensions.ftv1).toEqual(expect.any(String));
});

it('nonNullableFail - federated query - should return result with expected data and errors', async () => {
const query = /* GraphQL */ `
query {
testNestedField {
nonNullableFail {
id
email
sub1
}
subgraph2 {
id
email
sub2
}
}
}
`;

/**
* the whole query result is { testNestedField: null } even if subgraph2 query not fail, but it is probably 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 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 = await response.json();

expect(response.status).toBe(200);
expect(result.errors).toMatchObject([
{
message: 'Cannot return null for non-nullable field TestNestedField.nonNullableFail.',
path: ['testNestedField', 'nonNullableFail'],
},
]);
expect(result.data).toMatchObject(expectedData);
expect(result.extensions.ftv1).toEqual(expect.any(String));
});

it('nonNullableFail - simple query - should return result with expected data and errors', async () => {
const query = /* GraphQL */ `
query {
testNestedField {
nonNullableFail {
id
email
sub1
}
}
}
`;

const expectedData = {
testNestedField: null,
};

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 = await response.json();

expect(response.status).toBe(200);
expect(result.errors).toMatchObject([
{
message: 'My original subgraph error!',
path: ['testNestedField', 'nonNullableFail'],
},
]);
expect(result.data).toMatchObject(expectedData);
expect(result.extensions.ftv1).toEqual(expect.any(String));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/* eslint-disable import/no-extraneous-dependencies */
import { GraphQLSchema } from 'graphql';
import { IntrospectAndCompose, LocalGraphQLDataSource } from '@apollo/gateway';
import { createDefaultExecutor } from '@graphql-tools/delegate';
import { getStitchedSchemaFromSupergraphSdl } from '@graphql-tools/federation';

export async function getStitchedSchemaFromLocalSchemas(
localSchemas: Record<string, GraphQLSchema>,
): Promise<GraphQLSchema> {
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}`);
}
},
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/* eslint-disable import/no-extraneous-dependencies */
import { GraphQLSchema, parse } from 'graphql';
import { createGraphQLError } from 'graphql-yoga';
import { buildSubgraphSchema } from '@apollo/subgraph';

const typeDefs = parse(/* GraphQL */ `
type Query {
testNestedField: TestNestedField
}
type TestNestedField {
subgraph1: TestUser1
nonNullableFail: TestUser1!
nullableFail: TestUser1
}
type TestUser1 {
id: String!
email: String!
sub1: Boolean!
}
`);

const resolvers = {
Query: {
testNestedField: () => ({
subgraph1: () => ({
id: 'user1',
email: 'user1@example.com',
sub1: true,
}),
nonNullableFail: () => {
throw createGraphQLError('My original subgraph error!', {
extensions: {
code: 'BAD_REQUEST',
},
});
},
nullableFail: () => {
throw createGraphQLError('My original subgraph error!', {
extensions: {
code: 'BAD_REQUEST',
},
});
},
}),
},
};

export function getSubgraph1Schema(): GraphQLSchema {
return buildSubgraphSchema({ typeDefs, resolvers });
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/* eslint-disable import/no-extraneous-dependencies */
import { GraphQLSchema, parse } from 'graphql';
import { buildSubgraphSchema } from '@apollo/subgraph';

const typeDefs = parse(/* GraphQL */ `
type Query {
testNestedField: TestNestedField
}
type TestNestedField {
subgraph2: TestUser2
}
type TestUser2 {
id: String!
email: String!
sub2: Boolean!
}
`);

const resolvers = {
Query: {
testNestedField: () => ({
subgraph2: () => ({
id: 'user2',
email: 'user2@example.com',
sub2: true,
}),
}),
},
};

export function getSubgraph2Schema(): GraphQLSchema {
return buildSubgraphSchema({ typeDefs, resolvers });
}
4 changes: 4 additions & 0 deletions packages/plugins/apollo-inline-trace/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@
"tslib": "^2.5.2"
},
"devDependencies": {
"@apollo/gateway": "^2.9.3",
"@apollo/subgraph": "^2.9.3",
"@envelop/on-resolve": "^4.0.0",
"@graphql-tools/delegate": "^10.1.1",
"@graphql-tools/federation": "^2.2.24",
"@whatwg-node/fetch": "^0.9.22",
"graphql": "^16.6.0",
"graphql-yoga": "workspace:*"
Expand Down
22 changes: 21 additions & 1 deletion packages/plugins/apollo-inline-trace/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ function handleErrors(
let node = ctx.rootNode;

if (Array.isArray(errToReport.path)) {
const specificNode = ctx.nodes.get(errToReport.path.join('.'));
const specificNode = getSpecificOrNearestNode(ctx.nodes, errToReport.path);
if (specificNode) {
node = specificNode;
} else {
Expand All @@ -376,3 +376,23 @@ function handleErrors(
);
}
}

/**
* If something fails at "non-nullable" GQL field, the error path will not be in trace nodes
* but the error should be assigned to the nearest possible trace node
*/
function getSpecificOrNearestNode(
nodes: Map<string, Trace.Node>,
path: (string | number)[],
): Trace.Node | undefined {
// iterates through "path" backwards
for (let i = path.length; i > 0; i--) {
const pathString = path.slice(0, i).join('.');
const node = nodes.get(pathString);
if (node) {
return node;
}
}

return;
}
Loading

0 comments on commit 4b38cd3

Please sign in to comment.