@apollo/composition
Advanced tools
Comparing version 2.3.0-alpha.0 to 2.3.0-beta.1
@@ -10,2 +10,11 @@ # CHANGELOG for `@apollo/composition` | ||
- Preserves source of union members and enum values in supergraph [PR #2288](https://github.com/apollographql/federation/pull/2288). | ||
- __BREAKING__: composition now rejects `@override` on interface fields. The `@override` directive was not | ||
meant to be supported on interfaces and was not having any impact whatsoever. If an existing subgraph does have a | ||
`@override` on an interface field, this will now be rejected, but the `@override` can simply and safely be removed | ||
since it previously was ignored. | ||
- Error on composition when a `@shareable` field runtime types don't intersect between subgraphs: a `@shareable` field | ||
must resolve the same way in all the subgraphs, but this is impossible if the concrete runtime types have no | ||
intersection at all [PR #1556](https://github.com/apollographql/federation/pull/1556). | ||
- Uses the 0.3 version of the tag spec in the supergraph, which adds `@tag` directive support for the `SCHEMA` location [PR #2314](https://github.com/apollographql/federation/pull/2314). | ||
- Fixes composition issues with `@interfaceObject` [PR #2318](https://github.com/apollographql/federation/pull/2318). | ||
@@ -12,0 +21,0 @@ ## 2.2.0 |
@@ -25,5 +25,5 @@ "use strict"; | ||
const federatedQueryGraph = (0, query_graphs_1.buildFederatedQueryGraph)(supergraphSchema, false); | ||
const validationResult = (0, validate_1.validateGraphComposition)(supergraphSchema, supergraphQueryGraph, federatedQueryGraph); | ||
if (validationResult.errors) { | ||
return { errors: validationResult.errors.map(e => federation_internals_1.ERRORS.SATISFIABILITY_ERROR.err(e.message)) }; | ||
const { errors, hints } = (0, validate_1.validateGraphComposition)(supergraphSchema, supergraphQueryGraph, federatedQueryGraph); | ||
if (errors) { | ||
return { errors }; | ||
} | ||
@@ -40,3 +40,3 @@ let supergraphSdl; | ||
supergraphSdl, | ||
hints: mergeResult.hints | ||
hints: mergeResult.hints.concat(hints !== null && hints !== void 0 ? hints : []), | ||
}; | ||
@@ -43,0 +43,0 @@ } |
@@ -41,2 +41,3 @@ import { SubgraphASTNode } from "@apollo/federation-internals"; | ||
DIRECTIVE_COMPOSITION_WARN: HintCodeDefinition; | ||
INCONSISTENT_RUNTIME_TYPES_FOR_SHAREABLE_RETURN: HintCodeDefinition; | ||
}; | ||
@@ -43,0 +44,0 @@ export declare class CompositionHint { |
@@ -149,2 +149,7 @@ "use strict"; | ||
}); | ||
const INCONSISTENT_RUNTIME_TYPES_FOR_SHAREABLE_RETURN = makeCodeDefinition({ | ||
code: 'INCONSISTENT_RUNTIME_TYPES_FOR_SHAREABLE_RETURN', | ||
level: HintLevel.WARN, | ||
description: 'Indicates that a @shareable field returns different sets of runtime types in the different subgraphs in which it is defined.', | ||
}); | ||
exports.HINTS = { | ||
@@ -176,2 +181,3 @@ INCONSISTENT_BUT_COMPATIBLE_FIELD_TYPE, | ||
DIRECTIVE_COMPOSITION_WARN, | ||
INCONSISTENT_RUNTIME_TYPES_FOR_SHAREABLE_RETURN, | ||
}; | ||
@@ -178,0 +184,0 @@ class CompositionHint { |
@@ -1,3 +0,5 @@ | ||
import { Operation, Schema, SchemaRootKind } from "@apollo/federation-internals"; | ||
import { CompositeType, FieldDefinition, Operation, Schema, SchemaRootKind } from "@apollo/federation-internals"; | ||
import { Edge, QueryGraph, RootPath, Transition, ConditionResolver, TransitionPathWithLazyIndirectPaths, RootVertex } from "@apollo/query-graphs"; | ||
import { CompositionHint } from "./hints"; | ||
import { GraphQLError } from "graphql"; | ||
export declare class ValidationError extends Error { | ||
@@ -10,3 +12,4 @@ readonly supergraphUnsatisfiablePath: RootPath<Transition>; | ||
export declare function validateGraphComposition(supergraphSchema: Schema, supergraphAPI: QueryGraph, subgraphs: QueryGraph): { | ||
errors?: ValidationError[]; | ||
errors?: GraphQLError[]; | ||
hints?: CompositionHint[]; | ||
}; | ||
@@ -16,4 +19,12 @@ export declare function computeSubgraphPaths(supergraphSchema: Schema, supergraphPath: RootPath<Transition>, subgraphs: QueryGraph): { | ||
isComplete?: boolean; | ||
error?: ValidationError; | ||
error?: GraphQLError; | ||
}; | ||
export declare function extractValidationError(error: any): ValidationError | undefined; | ||
export declare class ValidationContext { | ||
readonly supergraphSchema: Schema; | ||
private readonly joinTypeDirective; | ||
private readonly joinFieldDirective; | ||
constructor(supergraphSchema: Schema); | ||
isShareable(field: FieldDefinition<CompositeType>): boolean; | ||
} | ||
export declare class ValidationState { | ||
@@ -29,4 +40,12 @@ readonly supergraphPath: RootPath<Transition>; | ||
}): ValidationState; | ||
validateTransition(supergraphEdge: Edge): ValidationState | undefined | ValidationError; | ||
currentSubgraphs(): string[]; | ||
validateTransition(context: ValidationContext, supergraphEdge: Edge): { | ||
state?: ValidationState; | ||
error?: GraphQLError; | ||
hint?: CompositionHint; | ||
}; | ||
currentSubgraphNames(): string[]; | ||
currentSubgraphs(): { | ||
name: string; | ||
schema: Schema; | ||
}[]; | ||
toString(): string; | ||
@@ -33,0 +52,0 @@ } |
"use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.ValidationState = exports.computeSubgraphPaths = exports.validateGraphComposition = exports.ValidationError = void 0; | ||
exports.ValidationState = exports.ValidationContext = exports.extractValidationError = exports.computeSubgraphPaths = exports.validateGraphComposition = exports.ValidationError = void 0; | ||
const federation_internals_1 = require("@apollo/federation-internals"); | ||
const query_graphs_1 = require("@apollo/query-graphs"); | ||
const hints_1 = require("./hints"); | ||
const graphql_1 = require("graphql"); | ||
@@ -18,3 +19,3 @@ const debug = (0, federation_internals_1.newDebugLogger)('validation'); | ||
exports.ValidationError = ValidationError; | ||
function validationError(unsatisfiablePath, subgraphsPaths, subgraphsPathsUnadvanceables) { | ||
function satisfiabilityError(unsatisfiablePath, subgraphsPaths, subgraphsPathsUnadvanceables) { | ||
const witness = buildWitnessOperation(unsatisfiablePath); | ||
@@ -25,7 +26,48 @@ const operation = (0, graphql_1.print)((0, federation_internals_1.operationToDocument)(witness)); | ||
+ displayReasons(subgraphsPathsUnadvanceables); | ||
return new ValidationError(message, unsatisfiablePath, subgraphsPaths, witness); | ||
const error = new ValidationError(message, unsatisfiablePath, subgraphsPaths, witness); | ||
return federation_internals_1.ERRORS.SATISFIABILITY_ERROR.err(error.message, { | ||
originalError: error, | ||
}); | ||
} | ||
function isValidationError(e) { | ||
return e instanceof ValidationError; | ||
function subgraphNodes(state, extractNode) { | ||
return state.currentSubgraphs().map(({ name, schema }) => { | ||
const node = extractNode(schema); | ||
return node ? (0, federation_internals_1.addSubgraphToASTNode)(node, name) : undefined; | ||
}).filter(federation_internals_1.isDefined); | ||
} | ||
function shareableFieldNonIntersectingRuntimeTypesError(invalidState, field, runtimeTypesToSubgraphs) { | ||
const witness = buildWitnessOperation(invalidState.supergraphPath); | ||
const operation = (0, graphql_1.print)((0, federation_internals_1.operationToDocument)(witness)); | ||
const typeStrings = [...runtimeTypesToSubgraphs].map(([ts, subgraphs]) => ` - in ${(0, federation_internals_1.printSubgraphNames)(subgraphs)}, ${ts}`); | ||
const message = `For the following supergraph API query:\n${operation}` | ||
+ `\nShared field "${field.coordinate}" return type "${field.type}" has a non-intersecting set of possible runtime types across subgraphs. Runtime types in subgraphs are:` | ||
+ `\n${typeStrings.join(';\n')}.` | ||
+ `\nThis is not allowed as shared fields must resolve the same way in all subgraphs, and that imply at least some common runtime types between the subgraphs.`; | ||
const error = new ValidationError(message, invalidState.supergraphPath, invalidState.subgraphPaths.map((p) => p.path), witness); | ||
return federation_internals_1.ERRORS.SHAREABLE_HAS_MISMATCHED_RUNTIME_TYPES.err(error.message, { | ||
nodes: subgraphNodes(invalidState, (s) => { var _a, _b; return (_b = (_a = s.type(field.parent.name)) === null || _a === void 0 ? void 0 : _a.field(field.name)) === null || _b === void 0 ? void 0 : _b.sourceAST; }), | ||
}); | ||
} | ||
function shareableFieldMismatchedRuntimeTypesHint(state, field, commonRuntimeTypes, runtimeTypesPerSubgraphs) { | ||
const witness = buildWitnessOperation(state.supergraphPath); | ||
const operation = (0, graphql_1.print)((0, federation_internals_1.operationToDocument)(witness)); | ||
const allSubgraphs = state.currentSubgraphNames(); | ||
const printTypes = (ts) => (0, federation_internals_1.printHumanReadableList)(ts.map((t) => '"' + t + '"'), { | ||
prefix: 'type', | ||
prefixPlural: 'types' | ||
}); | ||
const subgraphsWithTypeNotInIntersectionString = allSubgraphs.map((s) => { | ||
const typesToNotImplement = runtimeTypesPerSubgraphs.get(s).filter((t) => !commonRuntimeTypes.includes(t)); | ||
if (typesToNotImplement.length === 0) { | ||
return undefined; | ||
} | ||
return ` - subgraph "${s}" should never resolve "${field.coordinate}" to an object of ${printTypes(typesToNotImplement)}`; | ||
}).filter(federation_internals_1.isDefined); | ||
const message = `For the following supergraph API query:\n${operation}` | ||
+ `\nShared field "${field.coordinate}" return type "${field.type}" has different sets of possible runtime types across subgraphs.` | ||
+ `\nSince a shared field must be resolved the same way in all subgraphs, make sure that ${(0, federation_internals_1.printSubgraphNames)(allSubgraphs)} only resolve "${field.coordinate}" to objects of ${printTypes(commonRuntimeTypes)}. In particular:` | ||
+ `\n${subgraphsWithTypeNotInIntersectionString.join(';\n')}.` | ||
+ `\nOtherwise the @shareable contract will be broken.`; | ||
return new hints_1.CompositionHint(hints_1.HINTS.INCONSISTENT_RUNTIME_TYPES_FOR_SHAREABLE_RETURN, message, subgraphNodes(state, (s) => { var _a, _b; return (_b = (_a = s.type(field.parent.name)) === null || _a === void 0 ? void 0 : _a.field(field.name)) === null || _b === void 0 ? void 0 : _b.sourceAST; })); | ||
} | ||
function displayReasons(reasons) { | ||
@@ -128,4 +170,4 @@ const bySubgraph = new federation_internals_1.MultiMap(); | ||
function validateGraphComposition(supergraphSchema, supergraphAPI, subgraphs) { | ||
const errors = new ValidationTraversal(supergraphSchema, supergraphAPI, subgraphs).validate(); | ||
return errors.length > 0 ? { errors } : {}; | ||
const { errors, hints } = new ValidationTraversal(supergraphSchema, supergraphAPI, subgraphs).validate(); | ||
return errors.length > 0 ? { errors, hints } : { hints }; | ||
} | ||
@@ -138,6 +180,10 @@ exports.validateGraphComposition = validateGraphComposition; | ||
const initialState = ValidationState.initial({ supergraphAPI: supergraphPath.graph, kind: supergraphPath.root.rootKind, subgraphs, conditionResolver }); | ||
const context = new ValidationContext(supergraphSchema); | ||
let state = initialState; | ||
let isIncomplete = false; | ||
for (const [edge] of supergraphPath) { | ||
const updated = state.validateTransition(edge); | ||
const { state: updated, error } = state.validateTransition(context, edge); | ||
if (error) { | ||
throw error; | ||
} | ||
if (!updated) { | ||
@@ -147,5 +193,2 @@ isIncomplete = true; | ||
} | ||
if (isValidationError(updated)) { | ||
throw updated; | ||
} | ||
state = updated; | ||
@@ -156,3 +199,3 @@ } | ||
catch (error) { | ||
if (error instanceof ValidationError) { | ||
if (error instanceof graphql_1.GraphQLError) { | ||
return { error }; | ||
@@ -171,2 +214,39 @@ } | ||
} | ||
function possibleRuntimeTypeNamesSorted(path) { | ||
const types = path.tailPossibleRuntimeTypes().map((o) => o.name); | ||
types.sort((a, b) => a.localeCompare(b)); | ||
return types; | ||
} | ||
function extractValidationError(error) { | ||
if (!(error instanceof graphql_1.GraphQLError) || !(error.originalError instanceof ValidationError)) { | ||
return undefined; | ||
} | ||
return error.originalError; | ||
} | ||
exports.extractValidationError = extractValidationError; | ||
class ValidationContext { | ||
constructor(supergraphSchema) { | ||
this.supergraphSchema = supergraphSchema; | ||
const [_, joinSpec] = (0, federation_internals_1.validateSupergraph)(supergraphSchema); | ||
this.joinTypeDirective = joinSpec.typeDirective(supergraphSchema); | ||
this.joinFieldDirective = joinSpec.fieldDirective(supergraphSchema); | ||
} | ||
isShareable(field) { | ||
const typeInSupergraph = this.supergraphSchema.type(field.parent.name); | ||
(0, federation_internals_1.assert)(typeInSupergraph && (0, federation_internals_1.isCompositeType)(typeInSupergraph), () => `${field.parent.name} should exists in the supergraph and be a composite`); | ||
if (!(0, federation_internals_1.isObjectType)(typeInSupergraph)) { | ||
return false; | ||
} | ||
const fieldInSupergraph = typeInSupergraph.field(field.name); | ||
(0, federation_internals_1.assert)(fieldInSupergraph, () => `${field.coordinate} should exists in the supergraph`); | ||
const joinFieldApplications = fieldInSupergraph.appliedDirectivesOf(this.joinFieldDirective); | ||
return joinFieldApplications.length === 0 | ||
? typeInSupergraph.appliedDirectivesOf(this.joinTypeDirective).length > 1 | ||
: (joinFieldApplications.filter((application) => { | ||
const args = application.arguments(); | ||
return !args.external && !args.usedOverridden; | ||
}).length > 1); | ||
} | ||
} | ||
exports.ValidationContext = ValidationContext; | ||
class ValidationState { | ||
@@ -180,3 +260,3 @@ constructor(supergraphPath, subgraphPaths) { | ||
} | ||
validateTransition(supergraphEdge) { | ||
validateTransition(context, supergraphEdge) { | ||
(0, federation_internals_1.assert)(!supergraphEdge.conditions, () => `Supergraph edges should not have conditions (${supergraphEdge})`); | ||
@@ -194,3 +274,3 @@ const transition = supergraphEdge.transition; | ||
if (options.length === 0) { | ||
return undefined; | ||
return { state: undefined }; | ||
} | ||
@@ -201,7 +281,42 @@ newSubgraphPaths.push(...options); | ||
if (newSubgraphPaths.length === 0) { | ||
return validationError(newPath, this.subgraphPaths.map((p) => p.path), deadEnds); | ||
return { error: satisfiabilityError(newPath, this.subgraphPaths.map((p) => p.path), deadEnds) }; | ||
} | ||
return new ValidationState(newPath, newSubgraphPaths); | ||
const updatedState = new ValidationState(newPath, newSubgraphPaths); | ||
let hint = undefined; | ||
if (newSubgraphPaths.length > 1 | ||
&& transition.kind === 'FieldCollection' | ||
&& (0, federation_internals_1.isAbstractType)(newPath.tail.type) | ||
&& context.isShareable(transition.definition)) { | ||
const filteredPaths = newSubgraphPaths.map((p) => p.path).filter((p) => (0, federation_internals_1.isAbstractType)(p.tail.type)); | ||
if (filteredPaths.length > 1) { | ||
const allRuntimeTypes = possibleRuntimeTypeNamesSorted(newPath); | ||
let intersection = allRuntimeTypes; | ||
const runtimeTypesToSubgraphs = new federation_internals_1.MultiMap(); | ||
const runtimeTypesPerSubgraphs = new federation_internals_1.MultiMap(); | ||
let hasAllEmpty = true; | ||
for (const path of newSubgraphPaths) { | ||
const subgraph = path.path.tail.source; | ||
const typeNames = possibleRuntimeTypeNamesSorted(path.path); | ||
runtimeTypesPerSubgraphs.set(subgraph, typeNames); | ||
let typeNamesStr = 'no runtime type is defined'; | ||
if (typeNames.length > 0) { | ||
typeNamesStr = (typeNames.length > 1 ? 'types ' : 'type ') + (0, federation_internals_1.joinStrings)(typeNames.map((n) => `"${n}"`)); | ||
hasAllEmpty = false; | ||
} | ||
runtimeTypesToSubgraphs.add(typeNamesStr, subgraph); | ||
intersection = intersection.filter((t) => typeNames.includes(t)); | ||
} | ||
if (!hasAllEmpty) { | ||
if (intersection.length === 0) { | ||
return { error: shareableFieldNonIntersectingRuntimeTypesError(updatedState, transition.definition, runtimeTypesToSubgraphs) }; | ||
} | ||
if (runtimeTypesToSubgraphs.size > 1) { | ||
hint = shareableFieldMismatchedRuntimeTypesHint(updatedState, transition.definition, intersection, runtimeTypesPerSubgraphs); | ||
} | ||
} | ||
} | ||
} | ||
return { state: updatedState, hint }; | ||
} | ||
currentSubgraphs() { | ||
currentSubgraphNames() { | ||
const subgraphs = []; | ||
@@ -216,2 +331,9 @@ for (const path of this.subgraphPaths) { | ||
} | ||
currentSubgraphs() { | ||
if (this.subgraphPaths.length === 0) { | ||
return []; | ||
} | ||
const sources = this.subgraphPaths[0].path.graph.sources; | ||
return this.currentSubgraphNames().map((name) => ({ name, schema: sources.get(name) })); | ||
} | ||
toString() { | ||
@@ -229,2 +351,3 @@ return `${this.supergraphPath} <=> [${this.subgraphPaths.map(s => s.toString()).join(', ')}]`; | ||
this.validationErrors = []; | ||
this.validationHints = []; | ||
this.conditionResolver = new ConditionValidationResolver(supergraphSchema, subgraphs); | ||
@@ -238,2 +361,3 @@ supergraphAPI.rootKinds().forEach((kind) => this.stack.push(ValidationState.initial({ | ||
this.previousVisits = new query_graphs_1.QueryGraphState(supergraphAPI); | ||
this.context = new ValidationContext(supergraphSchema); | ||
} | ||
@@ -244,3 +368,3 @@ validate() { | ||
} | ||
return this.validationErrors; | ||
return { errors: this.validationErrors, hints: this.validationHints }; | ||
} | ||
@@ -250,3 +374,3 @@ handleState(state) { | ||
const vertex = state.supergraphPath.tail; | ||
const currentSources = state.currentSubgraphs(); | ||
const currentSources = state.currentSubgraphNames(); | ||
const previousSeenSources = this.previousVisits.getVertexState(vertex); | ||
@@ -270,8 +394,11 @@ if (previousSeenSources) { | ||
debug.group(() => `Validating supergraph edge ${edge}`); | ||
const newState = state.validateTransition(edge); | ||
if (isValidationError(newState)) { | ||
const { state: newState, error, hint } = state.validateTransition(this.context, edge); | ||
if (error) { | ||
debug.groupEnd(`Validation error!`); | ||
this.validationErrors.push(newState); | ||
this.validationErrors.push(error); | ||
continue; | ||
} | ||
if (hint) { | ||
this.validationHints.push(hint); | ||
} | ||
if (newState && !newState.supergraphPath.isTerminal()) { | ||
@@ -278,0 +405,0 @@ this.stack.push(newState); |
{ | ||
"name": "@apollo/composition", | ||
"version": "2.3.0-alpha.0", | ||
"version": "2.3.0-beta.1", | ||
"description": "Apollo Federation composition utilities", | ||
@@ -30,9 +30,8 @@ "main": "dist/index.js", | ||
"dependencies": { | ||
"@apollo/federation-internals": "^2.3.0-alpha.0", | ||
"@apollo/query-graphs": "^2.3.0-alpha.0" | ||
"@apollo/federation-internals": "file:../internals-js", | ||
"@apollo/query-graphs": "file:../query-graphs-js" | ||
}, | ||
"peerDependencies": { | ||
"graphql": "^16.5.0" | ||
}, | ||
"gitHead": "b723008e72e58277bb366eb176b9f06e1949eff8" | ||
} | ||
} |
@@ -867,2 +867,3 @@ import { assert, FEDERATION2_LINK_WITH_FULL_IMPORTS, printSchema, Schema } from '@apollo/federation-internals'; | ||
DirectiveLocation.INPUT_FIELD_DEFINITION, | ||
DirectiveLocation.SCHEMA, | ||
], ['name']); | ||
@@ -919,2 +920,3 @@ | ||
DirectiveLocation.INPUT_FIELD_DEFINITION, | ||
DirectiveLocation.SCHEMA, | ||
], ['name']); | ||
@@ -928,3 +930,3 @@ | ||
const feature = schema.coreFeatures?.getByIdentity('https://specs.apollo.dev/tag'); | ||
expect(feature?.url.toString()).toBe('https://specs.apollo.dev/tag/v0.2'); | ||
expect(feature?.url.toString()).toBe('https://specs.apollo.dev/tag/v0.3'); | ||
expect(feature?.imports).toEqual([]); | ||
@@ -931,0 +933,0 @@ expect(feature?.nameInSchema).toEqual('mytag'); |
@@ -9,2 +9,4 @@ import { asFed2SubgraphDocument, buildSubgraph, Subgraphs } from '@apollo/federation-internals'; | ||
import { MergeResult, mergeSubgraphs } from '../merging'; | ||
import { composeAsFed2Subgraphs } from './testHelper'; | ||
import { formatExpectedToMatchReceived } from './matchers/toMatchString'; | ||
@@ -35,3 +37,3 @@ function mergeDocuments(...documents: DocumentNode[]): MergeResult { | ||
expect.extend({ | ||
toRaiseHint(mergeResult: MergeResult, expectedDefinition: HintCodeDefinition, message: string) { | ||
toRaiseHint(mergeResult: MergeResult, expectedDefinition: HintCodeDefinition, expectedMessage: string) { | ||
if (mergeResult.errors) { | ||
@@ -57,13 +59,39 @@ return { | ||
for (const hint of matchingHints) { | ||
if (hint.message === message) { | ||
const received = hint.message; | ||
const expected = formatExpectedToMatchReceived(expectedMessage, received); | ||
if (this.equals(expected, received)) { | ||
return { | ||
message: () => `Expected subgraphs merging to not raise hint ${expectedCode} with message '${message}', but it did`, | ||
pass: true | ||
message: () => `Expected subgraphs merging to not raise hint ${expectedCode} with message '${expected}', but it did`, | ||
pass: true, | ||
} | ||
} | ||
} | ||
if (matchingHints.length === 1) { | ||
const received = matchingHints[0].message; | ||
const expected = formatExpectedToMatchReceived(expectedMessage, received); | ||
return { | ||
message: () => ( | ||
this.utils.matcherHint('toRaiseHint', undefined, undefined,) | ||
+ '\n\n' | ||
+ `Found hint matching code ${expectedCode}, but messages don't match:\n` | ||
+ this.utils.printDiffOrStringify(expected, received, 'Expected', 'Received', true) | ||
), | ||
pass: false, | ||
}; | ||
} | ||
return { | ||
message: () => `Subgraphs merging did raise ${matchingHints.length} hint(s) with code ${expectedCode}, but none had the expected message:\n ${message}\n` | ||
+ `Instead, received messages:\n ${matchingHints.map(h => h.message).join('\n ')}`, | ||
pass: false | ||
message: () => ( | ||
this.utils.matcherHint('toRaiseHint', undefined, undefined,) | ||
+ '\n\n' | ||
+ `Found ${matchingHints.length} hint(s) matching code ${expectedCode}, but none had the expected message:\n` | ||
+ matchingHints.map((h, i) => { | ||
const received = h.message; | ||
const expected = formatExpectedToMatchReceived(expectedMessage, received); | ||
return `Hint ${i}:\n` | ||
+ this.utils.printDiffOrStringify(expected, received, 'Expected', 'Received', true) | ||
}).join('\n\n') | ||
), | ||
pass: false, | ||
} | ||
@@ -130,2 +158,6 @@ }, | ||
type Impl implements I @shareable { | ||
v: Int | ||
} | ||
type T @shareable { | ||
@@ -141,3 +173,3 @@ f: I | ||
type Impl implements I { | ||
type Impl implements I @shareable { | ||
v: Int | ||
@@ -945,1 +977,142 @@ } | ||
}); | ||
describe('when shared field has intersecting but non equal runtime types in different subgraphs', () => { | ||
it('hints for interfaces', () => { | ||
const subgraphA = { | ||
name: 'A', | ||
typeDefs: gql` | ||
type Query { | ||
a: A @shareable | ||
} | ||
interface A { | ||
x: Int | ||
} | ||
type I1 implements A { | ||
x: Int | ||
i1: Int | ||
} | ||
type I2 implements A @shareable { | ||
x: Int | ||
i1: Int | ||
} | ||
` | ||
}; | ||
const subgraphB = { | ||
name: 'B', | ||
typeDefs: gql` | ||
type Query { | ||
a: A @shareable | ||
} | ||
interface A { | ||
x: Int | ||
} | ||
type I2 implements A @shareable { | ||
x: Int | ||
i2: Int | ||
} | ||
type I3 implements A @shareable { | ||
x: Int | ||
i3: Int | ||
} | ||
` | ||
}; | ||
// Note that hints in this case are generate by the post-merge validation, so we need to full-compose, not just merge. | ||
const result = composeAsFed2Subgraphs([subgraphA, subgraphB]); | ||
expect(result.errors).toBeUndefined(); | ||
expect(result).toRaiseHint( | ||
HINTS.INCONSISTENT_RUNTIME_TYPES_FOR_SHAREABLE_RETURN, | ||
` | ||
For the following supergraph API query: | ||
{ | ||
a { | ||
... | ||
} | ||
} | ||
Shared field "Query.a" return type "A" has different sets of possible runtime types across subgraphs. | ||
Since a shared field must be resolved the same way in all subgraphs, make sure that subgraphs "A" and "B" only resolve "Query.a" to objects of type "I2". In particular: | ||
- subgraph "A" should never resolve "Query.a" to an object of type "I1"; | ||
- subgraph "B" should never resolve "Query.a" to an object of type "I3". | ||
Otherwise the @shareable contract will be broken. | ||
`, | ||
); | ||
}); | ||
it('hints for unions', () => { | ||
const subgraphA = { | ||
name: 'A', | ||
typeDefs: gql` | ||
type Query { | ||
e: E! @shareable | ||
} | ||
type E @key(fields: "id") { | ||
id: ID! | ||
s: U! @shareable | ||
} | ||
union U = A | B | ||
type A @shareable { | ||
a: Int | ||
} | ||
type B @shareable { | ||
b: Int | ||
} | ||
` | ||
}; | ||
const subgraphB = { | ||
name: 'B', | ||
typeDefs: gql` | ||
type E @key(fields: "id") { | ||
id: ID! | ||
s: U! @shareable | ||
} | ||
union U = A | B | C | ||
type A @shareable { | ||
a: Int | ||
} | ||
type B @shareable { | ||
b: Int | ||
} | ||
type C { | ||
c: Int | ||
} | ||
` | ||
}; | ||
// Note that hints in this case are generate by the post-merge validation, so we need to full-compose, not just merge. | ||
const result = composeAsFed2Subgraphs([subgraphA, subgraphB]); | ||
expect(result.errors).toBeUndefined(); | ||
expect(result).toRaiseHint( | ||
HINTS.INCONSISTENT_RUNTIME_TYPES_FOR_SHAREABLE_RETURN, | ||
` | ||
For the following supergraph API query: | ||
{ | ||
e { | ||
s { | ||
... | ||
} | ||
} | ||
} | ||
Shared field "E.s" return type "U!" has different sets of possible runtime types across subgraphs. | ||
Since a shared field must be resolved the same way in all subgraphs, make sure that subgraphs "A" and "B" only resolve "E.s" to objects of types "A" and "B". In particular: | ||
- subgraph "B" should never resolve "E.s" to an object of type "C". | ||
Otherwise the @shareable contract will be broken. | ||
`, | ||
); | ||
}); | ||
}); |
@@ -32,10 +32,19 @@ // Make this file a module (See: https://github.com/microsoft/TypeScript/issues/17736) | ||
export function formatExpectedToMatchReceived(expected: string, received: string): string { | ||
let formatted = deIndent(expected); | ||
// If the expected string as a trailing '\n', add one since we removed it. | ||
if (received.charAt(received.length - 1) === '\n') { | ||
formatted = formatted + '\n'; | ||
} | ||
return formatted; | ||
} | ||
expect.extend({ | ||
toMatchString(expected: string, received: string) { | ||
received = deIndent(received); | ||
const pass = this.equals(expected, received); | ||
toMatchString(received: string, expected: string) { | ||
expected = formatExpectedToMatchReceived(expected, received); | ||
const pass = this.equals(received, expected); | ||
const message = pass | ||
? () => this.utils.matcherHint('toMatchString', undefined, undefined) | ||
+ '\n\n' | ||
+ `Expected: not ${this.printExpected(expected)}` | ||
+ `Expected: not ${this.printExpected(received)}` | ||
: () => { | ||
@@ -50,4 +59,4 @@ return ( | ||
toMatchStringArray(expected: string[], received: string[]) { | ||
if (expected.length !== received.length) { | ||
toMatchStringArray(received: string[], expected: string[]) { | ||
if (received.length !== expected.length) { | ||
const message = () => | ||
@@ -63,4 +72,4 @@ this.utils.matcherHint('toMatchStringArray', undefined, undefined,) | ||
for (let i = 0; i < expected.length; i++) { | ||
const exp = expected[i]; | ||
const rec = deIndent(received[i]); | ||
const rec = received[i]; | ||
const exp = formatExpectedToMatchReceived(expected[i], rec); | ||
if (!this.equals(exp, rec)) { | ||
@@ -67,0 +76,0 @@ pass = false; |
@@ -760,2 +760,110 @@ import { printSchema, printType } from "@apollo/federation-internals"; | ||
}); | ||
// At the moment, we've punted on @override support when interacting with @interfaceObject, so the | ||
// following tests mainly cover the various possible use and show that it currently correcly raise | ||
// some validation errors. We may lift some of those limitation in the future. | ||
describe('@interfaceObject', () => { | ||
it("does not allow @override on @interfaceObject fields", () => { | ||
// We currently rejects @override on fields of an @interfaceObject type. We could lift | ||
// that limitation in the future, and that would mean such override overrides the field | ||
// in _all_ the implementations of the target subtype, but that would imply generalizing | ||
// the handling overriden fields and the override error messages, so we keep that for | ||
// later. | ||
// Note that it would be a tad simpler to support @override on an @interfaceObject if | ||
// the `from` subgraph is also an @interfaceObject, as we can essentially ignore that | ||
// we have @interfaceObject in such case, but it's a corner case and it's clearer for | ||
// not to just always reject @override on @interfaceObject. | ||
const subgraph1 = { | ||
name: "Subgraph1", | ||
url: "https://Subgraph1", | ||
typeDefs: gql` | ||
type Query { | ||
i1: I | ||
} | ||
type I @interfaceObject @key(fields: "k") { | ||
k: ID | ||
a: Int @override(from: "Subgraph2") | ||
} | ||
`, | ||
}; | ||
const subgraph2 = { | ||
name: "Subgraph2", | ||
url: "https://Subgraph2", | ||
typeDefs: gql` | ||
type Query { | ||
i2: I | ||
} | ||
interface I @key(fields: "k") { | ||
k: ID | ||
a: Int | ||
} | ||
type A implements I @key(fields: "k") { | ||
k: ID | ||
a: Int | ||
} | ||
`, | ||
}; | ||
const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); | ||
expect(result.errors).toBeDefined(); | ||
expect(errors(result)).toContainEqual([ | ||
"OVERRIDE_COLLISION_WITH_ANOTHER_DIRECTIVE", | ||
'@override is not yet supported on fields of @interfaceObject types: cannot be used on field "I.a" on subgraph "Subgraph1".', | ||
]); | ||
}); | ||
it("does not allow @override when overriden field is an @interfaceObject field", () => { | ||
// We don't allow @override on a concrete type field when the `from` subgraph has | ||
// an @interfaceObject field "covering" that field. In theory, this could have some | ||
// use if one wanted to move a field from an @interfaceObject into all its implementations | ||
// (in another subgraph) but it's also a bit hard to validate/use because we would have | ||
// to check that all the implementations have an @override for it to be correct and | ||
// it's unclear how useful that gets. | ||
const subgraph1 = { | ||
name: "Subgraph1", | ||
url: "https://Subgraph1", | ||
typeDefs: gql` | ||
type Query { | ||
i1: I | ||
} | ||
type I @interfaceObject @key(fields: "k") { | ||
k: ID | ||
a: Int | ||
} | ||
`, | ||
}; | ||
const subgraph2 = { | ||
name: "Subgraph2", | ||
url: "https://Subgraph2", | ||
typeDefs: gql` | ||
type Query { | ||
i2: I | ||
} | ||
interface I @key(fields: "k") { | ||
k: ID | ||
a: Int | ||
} | ||
type A implements I @key(fields: "k") { | ||
k: ID | ||
a: Int @override(from: "Subgraph1") | ||
} | ||
`, | ||
}; | ||
const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); | ||
expect(result.errors).toBeDefined(); | ||
expect(errors(result)).toContainEqual([ | ||
"OVERRIDE_COLLISION_WITH_ANOTHER_DIRECTIVE", | ||
'Invalid @override on field "A.a" of subgraph "Subgraph2": source subgraph "Subgraph1" does not have field "A.a" but abstract it in type "I" and overriding abstracted fields is not supported.', | ||
]); | ||
}); | ||
}); | ||
}); |
@@ -93,1 +93,41 @@ import { assertCompositionSuccess, composeAsFed2Subgraphs } from "./testHelper"; | ||
}); | ||
describe('@interfaceObject', () => { | ||
it('correctly extract external fields of concrete type only provided by an @interfaceObject', () => { | ||
const s1 = { | ||
typeDefs: gql` | ||
type Query { | ||
iFromS1: I | ||
} | ||
interface I @key(fields: "id") { | ||
id: ID! | ||
x: Int | ||
} | ||
type T implements I @key(fields: "id") { | ||
id: ID! | ||
x: Int @external | ||
y: Int @requires(fields: "x") | ||
} | ||
`, | ||
name: 'S1', | ||
}; | ||
const s2 = { | ||
typeDefs: gql` | ||
type Query { | ||
iFromS2: I | ||
} | ||
type I @interfaceObject @key(fields: "id") { | ||
id: ID! | ||
x: Int | ||
} | ||
`, | ||
name: 'S2', | ||
}; | ||
composeAndTestReversibility([s1, s2]); | ||
}); | ||
}); |
@@ -293,1 +293,124 @@ import { CompositionResult } from '../compose'; | ||
}); | ||
describe('when shared field has non-intersecting runtime types in different subgraphs', () => { | ||
it('errors for interfaces', () => { | ||
const subgraphA = { | ||
name: 'A', | ||
typeDefs: gql` | ||
type Query { | ||
a: A @shareable | ||
} | ||
interface A { | ||
x: Int | ||
} | ||
type I1 implements A { | ||
x: Int | ||
i1: Int | ||
} | ||
` | ||
}; | ||
const subgraphB = { | ||
name: 'B', | ||
typeDefs: gql` | ||
type Query { | ||
a: A @shareable | ||
} | ||
interface A { | ||
x: Int | ||
} | ||
type I2 implements A { | ||
x: Int | ||
i2: Int | ||
} | ||
` | ||
}; | ||
const result = composeAsFed2Subgraphs([subgraphA, subgraphB]); | ||
expect(result.errors).toBeDefined(); | ||
expect(errorMessages(result)).toMatchStringArray([ | ||
` | ||
For the following supergraph API query: | ||
{ | ||
a { | ||
... | ||
} | ||
} | ||
Shared field "Query.a" return type "A" has a non-intersecting set of possible runtime types across subgraphs. Runtime types in subgraphs are: | ||
- in subgraph "A", type "I1"; | ||
- in subgraph "B", type "I2". | ||
This is not allowed as shared fields must resolve the same way in all subgraphs, and that imply at least some common runtime types between the subgraphs. | ||
` | ||
]); | ||
}); | ||
it('errors for unions', () => { | ||
const subgraphA = { | ||
name: 'A', | ||
typeDefs: gql` | ||
type Query { | ||
e: E! @shareable | ||
} | ||
type E @key(fields: "id") { | ||
id: ID! | ||
s: U! @shareable | ||
} | ||
union U = A | B | ||
type A { | ||
a: Int | ||
} | ||
type B { | ||
b: Int | ||
} | ||
` | ||
}; | ||
const subgraphB = { | ||
name: 'B', | ||
typeDefs: gql` | ||
type E @key(fields: "id") { | ||
id: ID! | ||
s: U! @shareable | ||
} | ||
union U = C | D | ||
type C { | ||
c: Int | ||
} | ||
type D { | ||
d: Int | ||
} | ||
` | ||
}; | ||
const result = composeAsFed2Subgraphs([subgraphA, subgraphB]); | ||
expect(result.errors).toBeDefined(); | ||
expect(errorMessages(result)).toMatchStringArray([ | ||
` | ||
For the following supergraph API query: | ||
{ | ||
e { | ||
s { | ||
... | ||
} | ||
} | ||
} | ||
Shared field "E.s" return type "U!" has a non-intersecting set of possible runtime types across subgraphs. Runtime types in subgraphs are: | ||
- in subgraph "A", types "A" and "B"; | ||
- in subgraph "B", types "C" and "D". | ||
This is not allowed as shared fields must resolve the same way in all subgraphs, and that imply at least some common runtime types between the subgraphs. | ||
` | ||
]); | ||
}); | ||
}); |
@@ -9,3 +9,2 @@ import { | ||
subgraphsFromServiceList, | ||
ERRORS, | ||
upgradeSubgraphsIfNecessary, | ||
@@ -55,5 +54,5 @@ } from "@apollo/federation-internals"; | ||
const federatedQueryGraph = buildFederatedQueryGraph(supergraphSchema, false); | ||
const validationResult = validateGraphComposition(supergraphSchema, supergraphQueryGraph, federatedQueryGraph); | ||
if (validationResult.errors) { | ||
return { errors: validationResult.errors.map(e => ERRORS.SATISFIABILITY_ERROR.err(e.message)) }; | ||
const { errors, hints } = validateGraphComposition(supergraphSchema, supergraphQueryGraph, federatedQueryGraph); | ||
if (errors) { | ||
return { errors }; | ||
} | ||
@@ -75,3 +74,3 @@ | ||
supergraphSdl, | ||
hints: mergeResult.hints | ||
hints: mergeResult.hints.concat(hints ?? []), | ||
}; | ||
@@ -78,0 +77,0 @@ } |
@@ -190,2 +190,8 @@ import { SubgraphASTNode } from "@apollo/federation-internals"; | ||
const INCONSISTENT_RUNTIME_TYPES_FOR_SHAREABLE_RETURN = makeCodeDefinition({ | ||
code: 'INCONSISTENT_RUNTIME_TYPES_FOR_SHAREABLE_RETURN', | ||
level: HintLevel.WARN, | ||
description: 'Indicates that a @shareable field returns different sets of runtime types in the different subgraphs in which it is defined.', | ||
}); | ||
export const HINTS = { | ||
@@ -217,2 +223,3 @@ INCONSISTENT_BUT_COMPATIBLE_FIELD_TYPE, | ||
DIRECTIVE_COMPOSITION_WARN, | ||
INCONSISTENT_RUNTIME_TYPES_FOR_SHAREABLE_RETURN, | ||
} | ||
@@ -219,0 +226,0 @@ |
import { | ||
addSubgraphToASTNode, | ||
assert, | ||
CompositeType, | ||
DirectiveDefinition, | ||
ERRORS, | ||
Field, | ||
@@ -9,4 +12,9 @@ FieldDefinition, | ||
InputType, | ||
isAbstractType, | ||
isCompositeType, | ||
isDefined, | ||
isLeafType, | ||
isNullableType, | ||
isObjectType, | ||
joinStrings, | ||
MultiMap, | ||
@@ -16,2 +24,4 @@ newDebugLogger, | ||
operationToDocument, | ||
printHumanReadableList, | ||
printSubgraphNames, | ||
Schema, | ||
@@ -22,3 +32,5 @@ SchemaRootKind, | ||
SelectionSet, | ||
SubgraphASTNode, | ||
typenameFieldName, | ||
validateSupergraph, | ||
VariableDefinitions | ||
@@ -55,3 +67,4 @@ } from "@apollo/federation-internals"; | ||
} from "@apollo/query-graphs"; | ||
import { print } from "graphql"; | ||
import { CompositionHint, HINTS } from "./hints"; | ||
import { ASTNode, GraphQLError, print } from "graphql"; | ||
@@ -72,12 +85,8 @@ const debug = newDebugLogger('validation'); | ||
function validationError( | ||
function satisfiabilityError( | ||
unsatisfiablePath: RootPath<Transition>, | ||
subgraphsPaths: RootPath<Transition>[], | ||
subgraphsPathsUnadvanceables: Unadvanceables[] | ||
): ValidationError { | ||
): GraphQLError { | ||
const witness = buildWitnessOperation(unsatisfiablePath); | ||
// TODO: we should build a more detailed error message, not just the unsatisfiable query. Doing that well is likely a tad | ||
// involved though as there may be a lot of different reason why it doesn't validate. But by looking at the last edge on the | ||
// supergraph and the subgraphsPath, we should be able to roughly infer what's going on. | ||
const operation = print(operationToDocument(witness)); | ||
@@ -87,9 +96,69 @@ const message = `The following supergraph API query:\n${operation}\n` | ||
+ displayReasons(subgraphsPathsUnadvanceables); | ||
return new ValidationError(message, unsatisfiablePath, subgraphsPaths, witness); | ||
const error = new ValidationError(message, unsatisfiablePath, subgraphsPaths, witness); | ||
return ERRORS.SATISFIABILITY_ERROR.err(error.message, { | ||
originalError: error, | ||
}); | ||
} | ||
function isValidationError(e: any): e is ValidationError { | ||
return e instanceof ValidationError; | ||
function subgraphNodes(state: ValidationState, extractNode: (schema: Schema) => ASTNode | undefined): SubgraphASTNode[] { | ||
return state.currentSubgraphs().map(({name, schema}) => { | ||
const node = extractNode(schema); | ||
return node ? addSubgraphToASTNode(node, name) : undefined; | ||
}).filter(isDefined); | ||
} | ||
function shareableFieldNonIntersectingRuntimeTypesError( | ||
invalidState: ValidationState, | ||
field: FieldDefinition<CompositeType>, | ||
runtimeTypesToSubgraphs: MultiMap<string, string>, | ||
): GraphQLError { | ||
const witness = buildWitnessOperation(invalidState.supergraphPath); | ||
const operation = print(operationToDocument(witness)); | ||
const typeStrings = [...runtimeTypesToSubgraphs].map(([ts, subgraphs]) => ` - in ${printSubgraphNames(subgraphs)}, ${ts}`); | ||
const message = `For the following supergraph API query:\n${operation}` | ||
+ `\nShared field "${field.coordinate}" return type "${field.type}" has a non-intersecting set of possible runtime types across subgraphs. Runtime types in subgraphs are:` | ||
+ `\n${typeStrings.join(';\n')}.` | ||
+ `\nThis is not allowed as shared fields must resolve the same way in all subgraphs, and that imply at least some common runtime types between the subgraphs.`; | ||
const error = new ValidationError(message, invalidState.supergraphPath, invalidState.subgraphPaths.map((p) => p.path), witness); | ||
return ERRORS.SHAREABLE_HAS_MISMATCHED_RUNTIME_TYPES.err(error.message, { | ||
nodes: subgraphNodes(invalidState, (s) => (s.type(field.parent.name) as CompositeType | undefined)?.field(field.name)?.sourceAST), | ||
}); | ||
} | ||
function shareableFieldMismatchedRuntimeTypesHint( | ||
state: ValidationState, | ||
field: FieldDefinition<CompositeType>, | ||
commonRuntimeTypes: string[], | ||
runtimeTypesPerSubgraphs: MultiMap<string, string>, | ||
): CompositionHint { | ||
const witness = buildWitnessOperation(state.supergraphPath); | ||
const operation = print(operationToDocument(witness)); | ||
const allSubgraphs = state.currentSubgraphNames(); | ||
const printTypes = (ts: string[]) => printHumanReadableList( | ||
ts.map((t) => '"' + t + '"'), | ||
{ | ||
prefix: 'type', | ||
prefixPlural: 'types' | ||
} | ||
); | ||
const subgraphsWithTypeNotInIntersectionString = allSubgraphs.map((s) => { | ||
const typesToNotImplement = runtimeTypesPerSubgraphs.get(s)!.filter((t) => !commonRuntimeTypes.includes(t)); | ||
if (typesToNotImplement.length === 0) { | ||
return undefined; | ||
} | ||
return ` - subgraph "${s}" should never resolve "${field.coordinate}" to an object of ${printTypes(typesToNotImplement)}`; | ||
}).filter(isDefined); | ||
const message = `For the following supergraph API query:\n${operation}` | ||
+ `\nShared field "${field.coordinate}" return type "${field.type}" has different sets of possible runtime types across subgraphs.` | ||
+ `\nSince a shared field must be resolved the same way in all subgraphs, make sure that ${printSubgraphNames(allSubgraphs)} only resolve "${field.coordinate}" to objects of ${printTypes(commonRuntimeTypes)}. In particular:` | ||
+ `\n${subgraphsWithTypeNotInIntersectionString.join(';\n')}.` | ||
+ `\nOtherwise the @shareable contract will be broken.`; | ||
return new CompositionHint( | ||
HINTS.INCONSISTENT_RUNTIME_TYPES_FOR_SHAREABLE_RETURN, | ||
message, | ||
subgraphNodes(state, (s) => (s.type(field.parent.name) as CompositeType | undefined)?.field(field.name)?.sourceAST), | ||
); | ||
} | ||
function displayReasons(reasons: Unadvanceables[]): string { | ||
@@ -245,6 +314,7 @@ const bySubgraph = new MultiMap<string, Unadvanceable>(); | ||
): { | ||
errors? : ValidationError[] | ||
errors? : GraphQLError[], | ||
hints? : CompositionHint[], | ||
} { | ||
const errors = new ValidationTraversal(supergraphSchema, supergraphAPI, subgraphs).validate(); | ||
return errors.length > 0 ? {errors} : {}; | ||
const { errors, hints } = new ValidationTraversal(supergraphSchema, supergraphAPI, subgraphs).validate(); | ||
return errors.length > 0 ? { errors, hints } : { hints }; | ||
} | ||
@@ -259,3 +329,3 @@ | ||
isComplete?: boolean, | ||
error?: ValidationError | ||
error?: GraphQLError | ||
} { | ||
@@ -266,6 +336,10 @@ try { | ||
const initialState = ValidationState.initial({supergraphAPI: supergraphPath.graph, kind: supergraphPath.root.rootKind, subgraphs, conditionResolver}); | ||
const context = new ValidationContext(supergraphSchema); | ||
let state = initialState; | ||
let isIncomplete = false; | ||
for (const [edge] of supergraphPath) { | ||
const updated = state.validateTransition(edge); | ||
const { state: updated, error } = state.validateTransition(context, edge); | ||
if (error) { | ||
throw error; | ||
} | ||
if (!updated) { | ||
@@ -275,5 +349,2 @@ isIncomplete = true; | ||
} | ||
if (isValidationError(updated)) { | ||
throw updated; | ||
} | ||
state = updated; | ||
@@ -283,3 +354,3 @@ } | ||
} catch (error) { | ||
if (error instanceof ValidationError) { | ||
if (error instanceof GraphQLError) { | ||
return {error}; | ||
@@ -301,2 +372,50 @@ } | ||
function possibleRuntimeTypeNamesSorted(path: RootPath<Transition>): string[] { | ||
const types = path.tailPossibleRuntimeTypes().map((o) => o.name); | ||
types.sort((a, b) => a.localeCompare(b)); | ||
return types; | ||
} | ||
export function extractValidationError(error: any): ValidationError | undefined { | ||
if (!(error instanceof GraphQLError) || !(error.originalError instanceof ValidationError)) { | ||
return undefined; | ||
} | ||
return error.originalError; | ||
} | ||
export class ValidationContext { | ||
private readonly joinTypeDirective: DirectiveDefinition; | ||
private readonly joinFieldDirective: DirectiveDefinition<{ external?: boolean, usedOverridden?: boolean }>; | ||
constructor( | ||
readonly supergraphSchema: Schema, | ||
) { | ||
const [_, joinSpec] = validateSupergraph(supergraphSchema); | ||
this.joinTypeDirective = joinSpec.typeDirective(supergraphSchema); | ||
this.joinFieldDirective = joinSpec.fieldDirective(supergraphSchema); | ||
} | ||
isShareable(field: FieldDefinition<CompositeType>): boolean { | ||
const typeInSupergraph = this.supergraphSchema.type(field.parent.name); | ||
assert(typeInSupergraph && isCompositeType(typeInSupergraph), () => `${field.parent.name} should exists in the supergraph and be a composite`); | ||
if (!isObjectType(typeInSupergraph)) { | ||
return false; | ||
} | ||
const fieldInSupergraph = typeInSupergraph.field(field.name); | ||
assert(fieldInSupergraph, () => `${field.coordinate} should exists in the supergraph`); | ||
const joinFieldApplications = fieldInSupergraph.appliedDirectivesOf(this.joinFieldDirective); | ||
// A field is shareable if either: | ||
// 1) there is not join__field, but multiple join__type | ||
// 2) there is more than one join__field where the field is neither external nor overriden. | ||
return joinFieldApplications.length === 0 | ||
? typeInSupergraph.appliedDirectivesOf(this.joinTypeDirective).length > 1 | ||
: (joinFieldApplications.filter((application) => { | ||
const args = application.arguments(); | ||
return !args.external && !args.usedOverridden; | ||
}).length > 1); | ||
} | ||
} | ||
export class ValidationState { | ||
@@ -328,7 +447,18 @@ constructor( | ||
// Either return an error (we've found a path that cannot be validated), a new state (we've successfully handled the edge | ||
// and can continue validation from this new state) or 'undefined' if we can handle that edge by returning no results | ||
// as it gets us in a (valid) situation where we can guarantee there will be no results (in other words, the edge correspond | ||
// to a type condition for which there cannot be any runtime types, and so no point in continuing this "branch"). | ||
validateTransition(supergraphEdge: Edge): ValidationState | undefined | ValidationError { | ||
/** | ||
* Validates that the current state can always be advanced for the provided supergraph edge, and returns the updated state if | ||
* so. | ||
* | ||
* @param supergraphEdge - the edge to try to advance from the current state. | ||
* @return an object with `error` set if the state _cannot_ be properly advanced (and if so, `state` and `hint` will be `undefined`). | ||
* If the state can be successfully advanced, then `state` contains the updated new state. This *can* be `undefined` to signal | ||
* that the state _can_ be successfully advanced (no error) but is guaranteed to yield no results (in other words, the edge corresponds | ||
* to a type condition for which there cannot be any runtime types), in which case not further validation is necessary "from that branch". | ||
* Additionally, when the state can be successfully advanced, an `hint` can be optionally returned. | ||
*/ | ||
validateTransition(context: ValidationContext, supergraphEdge: Edge): { | ||
state?: ValidationState, | ||
error?: GraphQLError, | ||
hint?: CompositionHint, | ||
} { | ||
assert(!supergraphEdge.conditions, () => `Supergraph edges should not have conditions (${supergraphEdge})`); | ||
@@ -338,3 +468,3 @@ | ||
const targetType = supergraphEdge.tail.type; | ||
const newSubgraphPaths = []; | ||
const newSubgraphPaths: TransitionPathWithLazyIndirectPaths<RootVertex>[] = []; | ||
const deadEnds: Unadvanceables[] = []; | ||
@@ -354,3 +484,3 @@ for (const path of this.subgraphPaths) { | ||
// type condition give us no matching results, and so we can handle whatever comes next really. | ||
return undefined; | ||
return { state: undefined }; | ||
} | ||
@@ -361,8 +491,79 @@ newSubgraphPaths.push(...options); | ||
if (newSubgraphPaths.length === 0) { | ||
return validationError(newPath, this.subgraphPaths.map((p) => p.path), deadEnds); | ||
return { error: satisfiabilityError(newPath, this.subgraphPaths.map((p) => p.path), deadEnds) }; | ||
} | ||
return new ValidationState(newPath, newSubgraphPaths); | ||
const updatedState = new ValidationState(newPath, newSubgraphPaths); | ||
// When handling a @shareable field, we also compare the set of runtime types for each subgraphs involved. | ||
// If there is no common intersection between those sets, then we record an error: a @shareable field should resolve | ||
// the same way in all the subgraphs in which it is resolved, and there is no way this can be true if each subgraph | ||
// returns runtime objects that we know can never be the same. | ||
// | ||
// Additionally, if those sets of runtime types are not the same, we let it compose, but we log a warning. Indeed, | ||
// having different runtime types is a red flag: it would be incorrect for a subgraph to resolve to an object of a | ||
// type that the other subgraph cannot possible return, so having some subgraph having types that the other | ||
// don't know feels like something is worth double checking on the user side. Of course, as long as there is | ||
// some runtime types intersection and the field resolvers only return objects of that intersection, then this | ||
// could be a valid implementation. And this case can in particular happen temporarily as subgraphs evolve (potentially | ||
// independently), but it is well worth warning in general. | ||
// Note that we ignore any path when the type is not an abstract type, because in practice this means an @interfaceObject | ||
// and this should not be considered as an implementation type. Besides @interfaceObject always "stand-in" for every | ||
// implementations so they never are a problem for this check and can be ignored. | ||
let hint: CompositionHint | undefined = undefined; | ||
if ( | ||
newSubgraphPaths.length > 1 | ||
&& transition.kind === 'FieldCollection' | ||
&& isAbstractType(newPath.tail.type) | ||
&& context.isShareable(transition.definition) | ||
) { | ||
const filteredPaths = newSubgraphPaths.map((p) => p.path).filter((p) => isAbstractType(p.tail.type)); | ||
if (filteredPaths.length > 1) { | ||
// We start our intersection by using all the supergraph types, both because it's a convenient "max" set to start our intersection, | ||
// but also because that means we will ignore @inaccessible types in our checks (which is probably not very important because | ||
// I believe the rules of @inacessible kind of exclude having some here, but if that ever change, it makes more sense this way). | ||
const allRuntimeTypes = possibleRuntimeTypeNamesSorted(newPath); | ||
let intersection = allRuntimeTypes; | ||
const runtimeTypesToSubgraphs = new MultiMap<string, string>(); | ||
const runtimeTypesPerSubgraphs = new MultiMap<string, string>(); | ||
let hasAllEmpty = true; | ||
for (const path of newSubgraphPaths) { | ||
const subgraph = path.path.tail.source; | ||
const typeNames = possibleRuntimeTypeNamesSorted(path.path); | ||
runtimeTypesPerSubgraphs.set(subgraph, typeNames); | ||
// Note: we're formatting the elements in `runtimeTYpesToSubgraphs` because we're going to use it if we display an error. This doesn't | ||
// impact our set equality though since the formatting is consistent betweeen elements and type names syntax is sufficiently restricted | ||
// in graphQL to not create issues (no quote or weird character to escape in particular). | ||
let typeNamesStr = 'no runtime type is defined'; | ||
if (typeNames.length > 0) { | ||
typeNamesStr = (typeNames.length > 1 ? 'types ' : 'type ') + joinStrings(typeNames.map((n) => `"${n}"`)); | ||
hasAllEmpty = false; | ||
} | ||
runtimeTypesToSubgraphs.add(typeNamesStr, subgraph); | ||
intersection = intersection.filter((t) => typeNames.includes(t)); | ||
} | ||
// If `hasAllEmpty`, then it means that none of the subgraph defines any runtime types. Typically, all subgraphs defines a given interface, | ||
// but none have implementations. In that case, the intersection will be empty but it's actually fine (which is why we special case). In | ||
// fact, assuming valid graphQL subgraph servers (and it's not the place to sniff for non-compliant subgraph servers), the only value to | ||
// which each subgraph can resolve is `null` and so that essentially guaranttes that all subgraph do resolve the same way. | ||
if (!hasAllEmpty) { | ||
if (intersection.length === 0) { | ||
return { error: shareableFieldNonIntersectingRuntimeTypesError(updatedState, transition.definition, runtimeTypesToSubgraphs) }; | ||
} | ||
// As said, we accept it if there is an intersection, but if the runtime types are not all the same, we still emit a warning to make it clear that | ||
// the fields should not resolve any of the types not in the intersection. | ||
if (runtimeTypesToSubgraphs.size > 1) { | ||
hint = shareableFieldMismatchedRuntimeTypesHint(updatedState, transition.definition, intersection, runtimeTypesPerSubgraphs); | ||
} | ||
} | ||
} | ||
} | ||
return { state: updatedState, hint }; | ||
} | ||
currentSubgraphs(): string[] { | ||
currentSubgraphNames(): string[] { | ||
const subgraphs: string[] = []; | ||
@@ -378,2 +579,10 @@ for (const path of this.subgraphPaths) { | ||
currentSubgraphs(): { name: string, schema: Schema }[] { | ||
if (this.subgraphPaths.length === 0) { | ||
return []; | ||
} | ||
const sources = this.subgraphPaths[0].path.graph.sources; | ||
return this.currentSubgraphNames().map((name) => ({ name, schema: sources.get(name)!})); | ||
} | ||
toString(): string { | ||
@@ -398,4 +607,7 @@ return `${this.supergraphPath} <=> [${this.subgraphPaths.map(s => s.toString()).join(', ')}]`; | ||
private readonly validationErrors: ValidationError[] = []; | ||
private readonly validationErrors: GraphQLError[] = []; | ||
private readonly validationHints: CompositionHint[] = []; | ||
private readonly context: ValidationContext; | ||
constructor( | ||
@@ -414,9 +626,13 @@ supergraphSchema: Schema, | ||
this.previousVisits = new QueryGraphState(supergraphAPI); | ||
this.context = new ValidationContext(supergraphSchema); | ||
} | ||
validate(): ValidationError[] { | ||
validate(): { | ||
errors: GraphQLError[], | ||
hints: CompositionHint[], | ||
} { | ||
while (this.stack.length > 0) { | ||
this.handleState(this.stack.pop()!); | ||
} | ||
return this.validationErrors; | ||
return { errors: this.validationErrors, hints: this.validationHints }; | ||
} | ||
@@ -427,3 +643,3 @@ | ||
const vertex = state.supergraphPath.tail; | ||
const currentSources = state.currentSubgraphs(); | ||
const currentSources = state.currentSubgraphNames(); | ||
const previousSeenSources = this.previousVisits.getVertexState(vertex); | ||
@@ -457,8 +673,11 @@ if (previousSeenSources) { | ||
debug.group(() => `Validating supergraph edge ${edge}`); | ||
const newState = state.validateTransition(edge); | ||
if (isValidationError(newState)) { | ||
const { state: newState, error, hint } = state.validateTransition(this.context, edge); | ||
if (error) { | ||
debug.groupEnd(`Validation error!`); | ||
this.validationErrors.push(newState); | ||
this.validationErrors.push(error); | ||
continue; | ||
} | ||
if (hint) { | ||
this.validationHints.push(hint); | ||
} | ||
@@ -465,0 +684,0 @@ // The check for `isTerminal` is not strictly necessary as if we add a terminal |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
836382
14856
- Removed@apollo/federation-internals@2.9.3(transitive)
- Removed@apollo/query-graphs@2.9.3(transitive)
- Removed@types/uuid@9.0.8(transitive)
- Removedansi-styles@4.3.0(transitive)
- Removedarray-buffer-byte-length@1.0.1(transitive)
- Removedavailable-typed-arrays@1.0.7(transitive)
- Removedcall-bind@1.0.7(transitive)
- Removedchalk@4.1.2(transitive)
- Removedcolor-convert@2.0.1(transitive)
- Removedcolor-name@1.1.4(transitive)
- Removeddeep-equal@2.2.3(transitive)
- Removeddefine-data-property@1.1.4(transitive)
- Removeddefine-properties@1.2.1(transitive)
- Removedes-define-property@1.0.0(transitive)
- Removedes-errors@1.3.0(transitive)
- Removedes-get-iterator@1.1.3(transitive)
- Removedfor-each@0.3.3(transitive)
- Removedfunction-bind@1.1.2(transitive)
- Removedfunctions-have-names@1.2.3(transitive)
- Removedget-intrinsic@1.2.4(transitive)
- Removedgopd@1.0.1(transitive)
- Removedhas-bigints@1.0.2(transitive)
- Removedhas-flag@4.0.0(transitive)
- Removedhas-property-descriptors@1.0.2(transitive)
- Removedhas-proto@1.0.3(transitive)
- Removedhas-symbols@1.0.3(transitive)
- Removedhas-tostringtag@1.0.2(transitive)
- Removedhasown@2.0.2(transitive)
- Removedinternal-slot@1.0.7(transitive)
- Removedis-arguments@1.1.1(transitive)
- Removedis-array-buffer@3.0.4(transitive)
- Removedis-bigint@1.0.4(transitive)
- Removedis-boolean-object@1.1.2(transitive)
- Removedis-callable@1.2.7(transitive)
- Removedis-date-object@1.0.5(transitive)
- Removedis-map@2.0.3(transitive)
- Removedis-number-object@1.0.7(transitive)
- Removedis-regex@1.1.4(transitive)
- Removedis-set@2.0.3(transitive)
- Removedis-shared-array-buffer@1.0.3(transitive)
- Removedis-string@1.0.7(transitive)
- Removedis-symbol@1.0.4(transitive)
- Removedis-weakmap@2.0.2(transitive)
- Removedis-weakset@2.0.3(transitive)
- Removedisarray@2.0.5(transitive)
- Removedjs-levenshtein@1.1.6(transitive)
- Removedobject-inspect@1.13.2(transitive)
- Removedobject-is@1.1.6(transitive)
- Removedobject-keys@1.1.1(transitive)
- Removedobject.assign@4.1.5(transitive)
- Removedpossible-typed-array-names@1.0.0(transitive)
- Removedregexp.prototype.flags@1.5.3(transitive)
- Removedset-function-length@1.2.2(transitive)
- Removedset-function-name@2.0.2(transitive)
- Removedside-channel@1.0.6(transitive)
- Removedstop-iteration-iterator@1.0.0(transitive)
- Removedsupports-color@7.2.0(transitive)
- Removedts-graphviz@1.8.2(transitive)
- Removeduuid@9.0.1(transitive)
- Removedwhich-boxed-primitive@1.0.2(transitive)
- Removedwhich-collection@1.0.2(transitive)
- Removedwhich-typed-array@1.1.15(transitive)