Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/lucky-beers-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@theguild/federation-composition": patch
---

Report composition errors instead of throwing when `@requires`, `@key`, `@provides`, or `@fromContext` selections cannot be resolved against the merged supergraph.

Selection-dependent validation now runs only after the other supergraph rules confirm the merged state is valid.
58 changes: 58 additions & 0 deletions __tests__/supergraph/errors/EXTERNAL_TYPE_MISMATCH.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,4 +192,62 @@ testVersions((api, version) => {
}
`);
});
test("EXTERNAL_TYPE_MISMATCH on a field selected by @requires", () => {
// The @requires selection set is written against "pricing"'s VariantV2,
// but the merged Product.variant points at VariantV1, which has no "sku".
// Composition has to report the mismatch, not blow up while resolving the selection set.
expect(
api.composeServices([
{
name: "products",
typeDefs: graphql`
extend schema @link(url: "https://specs.apollo.dev/federation/${version}", import: ["@key"])

type Query {
product: Product
}

type Product @key(fields: "id") {
id: ID!
variant: VariantV1!
}

type VariantV1 @key(fields: "id") {
id: ID!
}
`,
},
{
name: "pricing",
typeDefs: graphql`
extend schema @link(url: "https://specs.apollo.dev/federation/${version}", import: ["@key", "@external", "@requires"])

type Product @key(fields: "id") {
id: ID!
variant: VariantV2 @external
price: Float @requires(fields: "variant { sku }")
}

type VariantV2 @key(fields: "id") {
id: ID!
sku: String!
}
`,
},
]),
).toEqual(
expect.objectContaining({
errors: expect.arrayContaining([
expect.objectContaining({
message: expect.stringContaining(
`Type of field "Product.variant" is incompatible across subgraphs (where marked @external): it has type "VariantV1!" in subgraph "products" but type "VariantV2" in subgraph "pricing"`,
),
extensions: expect.objectContaining({
code: "EXTERNAL_TYPE_MISMATCH",
}),
}),
]),
}),
);
});
});
40 changes: 30 additions & 10 deletions src/supergraph/validation/validate-supergraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,29 +92,49 @@ export function validateSupergraph(
InterfaceKeyMissingImplementationTypeRule,
ExternalTypeMismatchRule,
InvalidFieldSharingRule,
SatisfiabilityRule,
SubgraphNameRule,
RequiredArgumentOrFieldIsNotInaccessibleRule,
InterfaceSubtypeRule,
NoInaccessibleOnImplementedInterfaceFieldsRule,
ListSizeSlicingArgumentsRule,
ContextualArgumentRule,
];

// These resolve @key/@requires/@provides/@fromContext selection sets against the merged
// supergraph state. A selection set is written against a single subgraph, so when subgraphs
// disagree on a field's type (FIELD_TYPE_MISMATCH, EXTERNAL_TYPE_MISMATCH, TYPE_KIND_MISMATCH...)
// it may point at fields the merged type does not have, and these rules throw instead of
// reporting. Composition already failed at that point, so run them only on a coherent state.
const selectionDependentRules = [
SatisfiabilityRule,
AuthOnRequiresRule,
AuthOnContextRule,
];

const supergraph = state.getSupergraphState();

visitSupergraphState(
supergraph,
postSupergraphRules.map((rule) => {
if (rulesToSkip.includes(rule.name)) {
return {};
}
function runRules(rules: typeof postSupergraphRules) {
visitSupergraphState(
supergraph,
rules.map((rule) => {
if (rulesToSkip.includes(rule.name)) {
return {};
}

return rule(context, supergraph);
}),
);
}

runRules(postSupergraphRules);

const errors = context.collectReportedErrors();

if (errors.length > 0) {
return errors;
}

return rule(context, supergraph);
}),
);
runRules(selectionDependentRules);

return context.collectReportedErrors();
}
Loading