UNPKG

@apollo/composition

Version:
1,207 lines (1,132 loc) 48.5 kB
import { addSubgraphToASTNode, assert, CompositeType, DirectiveDefinition, ERRORS, Field, FieldDefinition, FieldSelection, FragmentElement, InputType, isAbstractType, isCompositeType, isDefined, isInterfaceType, isLeafType, isNullableType, isObjectType, isUnionType, joinStrings, MultiMap, newDebugLogger, Operation, operationToDocument, printHumanReadableList, printSubgraphNames, Schema, SchemaRootKind, Selection, selectionOfElement, SelectionSet, SubgraphASTNode, selectionSetOf, typenameFieldName, validateSupergraph, VariableDefinitions, isOutputType, JoinFieldDirectiveArguments, ContextSpecDefinition, CONTEXT_VERSIONS, NamedSchemaElement, NamedType, } from "@apollo/federation-internals"; import { Edge, federatedGraphRootTypeName, QueryGraph, subgraphEnteringTransition, GraphPath, RootPath, advancePathWithTransition, Transition, QueryGraphState, Unadvanceables, Unadvanceable, noConditionsResolution, TransitionPathWithLazyIndirectPaths, RootVertex, simpleValidationConditionResolver, ConditionResolver, UnadvanceableClosures, isUnadvanceableClosures, Vertex, } from "@apollo/query-graphs"; import { CompositionHint, HINTS } from "./hints"; import { ASTNode, GraphQLError, print } from "graphql"; import { CompositionOptions } from './compose'; const debug = newDebugLogger('validation'); export class ValidationError extends Error { constructor( message: string, readonly supergraphUnsatisfiablePath: RootPath<Transition>, readonly subgraphsPaths: RootPath<Transition>[], readonly witness: Operation ) { super(message); this.name = 'ValidationError'; } } function satisfiabilityError( unsatisfiablePath: RootPath<Transition>, subgraphsPaths: RootPath<Transition>[], subgraphsPathsUnadvanceables: Unadvanceables[] ): GraphQLError { const witness = buildWitnessOperation(unsatisfiablePath); const operation = print(operationToDocument(witness)); const message = `The following supergraph API query:\n${operation}\n` + 'cannot be satisfied by the subgraphs because:\n' + displayReasons(subgraphsPathsUnadvanceables); const error = new ValidationError(message, unsatisfiablePath, subgraphsPaths, witness); return ERRORS.SATISFIABILITY_ERROR.err(error.message, { originalError: error, }); } 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.allSubgraphPathInfos().map((p) => p.path.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, field, subgraphNodes(state, (s) => (s.type(field.parent.name) as CompositeType | undefined)?.field(field.name)?.sourceAST), ); } function displayReasons(reasons: Unadvanceables[]): string { const bySubgraph = new MultiMap<string, Unadvanceable>(); for (const reason of reasons) { for (const unadvanceable of reason.reasons) { bySubgraph.add(unadvanceable.sourceSubgraph, unadvanceable); } } return [...bySubgraph.entries()].map(([subgraph, reasons]) => { let msg = `- from subgraph "${subgraph}":`; if (reasons.length === 1) { msg += ' ' + reasons[0].details + '.'; } else { // We put all the reasons into a set because it's possible multiple paths of the algorithm // had the same "dead end". Typically, without this, there is cases where we end up with // multiple "cannot find field x" messages (for the same "x"). const allDetails = new Set(reasons.map((r) => r.details)); for (const details of allDetails) { msg += '\n - ' + details + '.'; } } return msg; }).join('\n'); } function buildWitnessOperation(witness: RootPath<Transition>): Operation { assert(witness.size > 0, "unsatisfiablePath should contain at least one edge/transition"); const root = witness.root; const schema = witness.graph.sources.get(root.source)!; return new Operation( schema, root.rootKind, buildWitnessNextStep([...witness].map(e => e[0]), 0)!, new VariableDefinitions(), ); } function buildWitnessNextStep(edges: Edge[], index: number): SelectionSet | undefined { if (index >= edges.length) { // We're at the end of our counter-example, meaning that we're at a point of traversing the supergraph where we know // there is no valid equivalent subgraph traversals. // That said, we may well not be on a terminal vertex (the type may not be a leaf), meaning that returning 'undefined' // may be invalid. // In that case, we instead return an empty SelectionSet. This is, strictly speaking, equally invalid, but we use // this as a convention to means "there is supposed to be a selection but we don't have it" and the code // in `SelectionSet.toSelectionNode` handles this an prints an ellipsis (a '...'). // // Note that, as an alternative, we _could_ generate a random valid witness: while the current type is not terminal // we would randomly pick a valid choice (if it's an abstract type, we'd "cast" to any implementation; if it's an // object, we'd pick the first field and recurse on its type). However, while this would make sure our "witness" // is always a fully valid query, this is probably less user friendly in practice because you'd have to follow // the query manually to figure out at which point the query stop being satisfied by subgraphs. Putting the // ellipsis instead make it immediately clear after which part of the query there is an issue. const lastType = edges[edges.length -1].tail.type; // Note that vertex types are named type and output ones, so if it's not a leaf it is guaranteed to be selectable. assert(isOutputType(lastType), 'Should not have input types as vertex types'); return isLeafType(lastType) ? undefined : new SelectionSet(lastType); } const edge = edges[index]; let selection: Selection; const subSelection = buildWitnessNextStep(edges, index + 1); switch (edge.transition.kind) { case 'DownCast': const type = edge.transition.castedType; selection = selectionOfElement( new FragmentElement(edge.transition.sourceType, type.name), subSelection! ); break; case 'FieldCollection': const field = edge.transition.definition; selection = new FieldSelection(buildWitnessField(field), subSelection); break case 'SubgraphEnteringTransition': case 'KeyResolution': case 'RootTypeResolution': case 'InterfaceObjectFakeDownCast': // Witnesses are build from a path on the supergraph, so we shouldn't have any of those edges. assert(false, `Invalid edge ${edge} found in supergraph path`); } // If we get here, the edge is either a downcast or a field, so the edge head must be selectable. return selectionSetOf(edge.head.type as CompositeType, selection); } function buildWitnessField(definition: FieldDefinition<any>): Field { if (definition.arguments().length === 0) { return new Field(definition); } const args = Object.create(null); for (const argDef of definition.arguments()) { args[argDef.name] = generateWitnessValue(argDef.type!); } return new Field(definition, args); } function generateWitnessValue(type: InputType): any { switch (type.kind) { case 'ScalarType': switch (type.name) { case 'Int': return 0; case 'Float': return 3.14; case 'Boolean': return true; case 'String': return 'A string value'; case 'ID': // Users probably expect a particular format of ID at any particular place, but we have zero info on // the context, so we just throw a string that hopefully make things clear. return '<any id>'; default: // It's a custom scalar, but we don't know anything about that scalar so providing some random string. This // will technically probably not be a valid value for that scalar, but hopefully that won't be enough to // throw users off. return '<some value>'; } case 'EnumType': return type.values[0].name; case 'InputObjectType': const obj = Object.create(null); for (const field of type.fields()) { // We don't bother with non-mandatory fields. if (field.defaultValue || isNullableType(field.type!)) { continue; } obj[field.name] = generateWitnessValue(field.type!); } return obj; case 'ListType': return []; case 'NonNullType': // None of our generated witness values are null so... return generateWitnessValue(type.ofType); default: assert(false, `Unhandled input type ${type}`); } } /** * Validates that all the queries expressible on the API schema resulting of the composition of the provided subgraphs can be executed * on those subgraphs. * * @param supergraphSchema the schema of the supergraph that composing `subgraphs` generated. Note this *must* be the full supergraph, not * just it's API schema (because it may be used to find the definition of elements that are marked `@inaccessible`). Note that this _not_ * the same schema that the one reference inside `supergraphAPI` in particular. * @param supergraphAPI the `QueryGraph` corresponding to the `supergraphSchema` API schema. * @param federatedQueryGraph the (federated) `QueryGraph` corresponding the subgraphs having been composed to obtain `supergraphSchema`. */ export function validateGraphComposition( supergraphSchema: Schema, subgraphNameToGraphEnumValue: Map<string, string>, supergraphAPI: QueryGraph, federatedQueryGraph: QueryGraph, compositionOptions: CompositionOptions = {}, ): { errors? : GraphQLError[], hints? : CompositionHint[], } { const { errors, hints } = new ValidationTraversal( supergraphSchema, subgraphNameToGraphEnumValue, supergraphAPI, federatedQueryGraph, compositionOptions, ).validate(); return errors.length > 0 ? { errors, hints } : { hints }; } function initialSubgraphPaths(kind: SchemaRootKind, subgraphs: QueryGraph): RootPath<Transition>[] { const root = subgraphs.root(kind); assert(root, () => `The supergraph shouldn't have a ${kind} root if no subgraphs have one`); assert( root.type.name == federatedGraphRootTypeName(kind), () => `Unexpected type ${root.type} for subgraphs root type (expected ${federatedGraphRootTypeName(kind)}`); const initialState = GraphPath.fromGraphRoot<Transition>(subgraphs, kind)!; return subgraphs.outEdges(root).map(e => initialState.add(subgraphEnteringTransition, e, noConditionsResolution)); } 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<JoinFieldDirectiveArguments>; private readonly typesToContexts: Map<string, Set<string>> constructor( readonly supergraphSchema: Schema, readonly subgraphNameToGraphEnumValue: Map<string, string>, ) { const [_, joinSpec] = validateSupergraph(supergraphSchema); this.joinTypeDirective = joinSpec.typeDirective(supergraphSchema); this.joinFieldDirective = joinSpec.fieldDirective(supergraphSchema); this.typesToContexts = new Map(); let contextDirective: DirectiveDefinition<{ name: string }> | undefined; const contextFeature = supergraphSchema.coreFeatures?.getByIdentity(ContextSpecDefinition.identity); if (contextFeature) { const contextSpec = CONTEXT_VERSIONS.find(contextFeature.url.version); assert(contextSpec, `Unexpected context spec version ${contextFeature.url.version}`); contextDirective = contextSpec.contextDirective(supergraphSchema); } for (const application of contextDirective?.applications() ?? []) { const { name: context } = application.arguments(); assert( application.parent instanceof NamedSchemaElement, "Unexpectedly found unnamed element with @context" ); const type = supergraphSchema.type(application.parent.name); assert(type, `Type ${application.parent.name} unexpectedly doesn't exist`); const typeNames = [type.name]; if (isInterfaceType(type)) { typeNames.push(...type.allImplementations().map((t) => t.name)); } else if (isUnionType(type)) { typeNames.push(...type.types().map((t) => t.name)); } for (const typeName of typeNames) { if (this.typesToContexts.has(typeName)) { this.typesToContexts.get(typeName)!.add(context); } else { this.typesToContexts.set(typeName, new Set([context])); } } } } 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); } matchingContexts(typeName: string): string[] { return [...(this.typesToContexts.get(typeName) ?? [])]; } } type SubgraphPathInfo = { path: TransitionPathWithLazyIndirectPaths<RootVertex>, // The key for this map is the context name in the supergraph schema. contexts: Map<string, { subgraphName: string, typeName: string }>, } class SubgraphPathInfos { constructor( readonly paths: SubgraphPathInfo[] ) {} } class TopLevelMutationFieldSubgraphPathInfos { constructor( readonly mutationField: FieldDefinition<CompositeType>, readonly paths: Map<string, SubgraphPathInfo[]>, ) {} } export class ValidationState { constructor( // Path in the supergraph corresponding to the current state. public readonly supergraphPath: RootPath<Transition>, // All the possible paths we could be in the subgraph. When the supergraph // path's top-level selection is a mutation field, the possible paths are // instead partitioned by the name of the subgraph containing the mutation // field. public readonly subgraphPathInfos: | SubgraphPathInfos | TopLevelMutationFieldSubgraphPathInfos, // When we encounter an `@override`n field with a label condition, we record // its value (T/F) as we traverse the graph. This allows us to ignore paths // that can never be taken by the query planner (i.e. a path where the // condition is T in one case and F in another). public selectedOverrideConditions: Map<string, boolean> = new Map(), ) { } static initial({ supergraphAPI, kind, federatedQueryGraph, conditionResolver, overrideConditions, }: { supergraphAPI: QueryGraph, kind: SchemaRootKind, federatedQueryGraph: QueryGraph, conditionResolver: ConditionResolver, overrideConditions: Map<string, boolean>, }) { return new ValidationState( GraphPath.fromGraphRoot(supergraphAPI, kind)!, new SubgraphPathInfos(initialSubgraphPaths(kind, federatedQueryGraph).map((p) => TransitionPathWithLazyIndirectPaths.initial( p, conditionResolver, overrideConditions, ), ).map((p) => ({ path: p, contexts: new Map(), }))), ); } // Returns whether the entire entire visit to this state can be skipped. If // the state is partitioned, note that each individual partition must be // skippable for the state to be skippable. canSkipVisit( subgraphNameToGraphEnumValue: Map<string, string>, previousVisits: QueryGraphState<VertexVisit[]>, ): boolean { const vertex = this.supergraphPath.tail; if (this.subgraphPathInfos instanceof SubgraphPathInfos) { return this.canSkipVisitForSubgraphPaths( vertex, this.subgraphPathInfos.paths, subgraphNameToGraphEnumValue, previousVisits, ); } else { let canSkip = true; for (const subgraphPathInfos of this.subgraphPathInfos.paths.values()) { // Note that this method mutates the set of previous visits, so we // purposely do not short-circuit return here. if (!this.canSkipVisitForSubgraphPaths( vertex, subgraphPathInfos, subgraphNameToGraphEnumValue, previousVisits, )) { canSkip = false; } } return canSkip; } } canSkipVisitForSubgraphPaths( supergraphPathTail: Vertex, subgraphPathInfos: SubgraphPathInfo[], subgraphNameToGraphEnumValue: Map<string, string>, previousVisits: QueryGraphState<VertexVisit[]>, ): boolean { const currentVertexVisit: VertexVisit = { subgraphContextKeys: this.currentSubgraphContextKeys( subgraphNameToGraphEnumValue, subgraphPathInfos, ), overrideConditions: this.selectedOverrideConditions }; const previousVisitsForVertex = previousVisits.getVertexState(supergraphPathTail); if (previousVisitsForVertex) { for (const previousVisit of previousVisitsForVertex) { if (isSupersetOrEqual(currentVertexVisit, previousVisit)) { // This means that we've already seen the type we're currently on in the supergraph, and when saw it we could be in // one of `previousSources`, and we validated that we could reach anything from there. We're now on the same // type, and have strictly more options regarding subgraphs. So whatever comes next, we can handle in the exact // same way we did previously, and there is thus no way to bother. debug.groupEnd(`Has already validated this vertex.`); return true; } } // We're gonna have to validate, but we can save the new set of sources here to hopefully save work later. previousVisitsForVertex.push(currentVertexVisit); } else { // We save the current sources but do validate. previousVisits.setVertexState(supergraphPathTail, [currentVertexVisit]); } return false; } /** * 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, matchingContexts: string[], validationErrors: GraphQLError[], satisfiabilityErrorsByMutationFieldAndSubgraph: Map< string, Map<string, GraphQLError[]> >, ): { state?: ValidationState, hint?: CompositionHint, } { assert(!supergraphEdge.conditions, () => `Supergraph edges should not have conditions (${supergraphEdge})`); const transition = supergraphEdge.transition; const targetType = supergraphEdge.tail.type; // If the edge has an override condition, we should capture it in the state so // that we can ignore later edges that don't satisfy the condition. const newOverrideConditions = new Map([...this.selectedOverrideConditions]); if (supergraphEdge.overrideCondition) { newOverrideConditions.set( supergraphEdge.overrideCondition.label, supergraphEdge.overrideCondition.condition ); } const newPath = this.supergraphPath.add( transition, supergraphEdge, noConditionsResolution, ); let updatedState: ValidationState; if (this.subgraphPathInfos instanceof SubgraphPathInfos) { const { newSubgraphPathInfos, error, } = this.validateTransitionForSubgraphPaths( this.subgraphPathInfos.paths, newOverrideConditions, transition, targetType, matchingContexts, newPath, ); if (error) { validationErrors.push(error); return {}; } // As noted in `validateTransitionforSubgraphPaths()`, this being empty // means that the edge is a type condition and that if we follow the path // to this subgraph, we're guaranteed that handling that type condition // gives us no matching results, and so we can handle whatever comes next // really. if (newSubgraphPathInfos.length === 0) { return {}; } const mutationField = ValidationState.fieldIfTopLevelMutation( this.supergraphPath, supergraphEdge, ); if (mutationField) { // If we just added a top-level mutation field, we partition the created // state by the subgraph of the field. const partitionedSubgraphPathInfos = new Map<string, SubgraphPathInfo[]>(); for (const subgraphPathInfo of newSubgraphPathInfos) { let subgraph = ValidationState.subgraphOfTopLevelMutation( subgraphPathInfo ); let subgraphPathInfos = partitionedSubgraphPathInfos.get(subgraph); if (!subgraphPathInfos) { subgraphPathInfos = []; partitionedSubgraphPathInfos.set(subgraph, subgraphPathInfos); } subgraphPathInfos.push(subgraphPathInfo); } if (partitionedSubgraphPathInfos.size <= 1) { // If there's not more than one subgraph, then the mutation field was // never really shared, and we can continue with non-partitioned // state. updatedState = new ValidationState( newPath, new SubgraphPathInfos( [...partitionedSubgraphPathInfos.values()].flat(), ), newOverrideConditions, ); } else { // Otherwise, we need the partitioning, and we set up the error stacks // for each (field, subgraph) pair. let errorsBySubgraph = satisfiabilityErrorsByMutationFieldAndSubgraph .get(mutationField.coordinate); if (!errorsBySubgraph) { errorsBySubgraph = new Map(); satisfiabilityErrorsByMutationFieldAndSubgraph.set( mutationField.coordinate, errorsBySubgraph, ); } for (const subgraph of partitionedSubgraphPathInfos.keys()) { if (!errorsBySubgraph.has(subgraph)) { errorsBySubgraph.set(subgraph, []); } } updatedState = new ValidationState( newPath, { mutationField, paths: partitionedSubgraphPathInfos, }, newOverrideConditions, ); } } else { updatedState = new ValidationState( newPath, new SubgraphPathInfos(newSubgraphPathInfos), newOverrideConditions, ); } } else { const partitionedSubgraphPathInfos = new Map<string, SubgraphPathInfo[]>(); for (const [subgraph, subgraphPathInfos] of this.subgraphPathInfos.paths) { // The setup we do above when we enter a mutation field ensures these // map entries exist. const errors = satisfiabilityErrorsByMutationFieldAndSubgraph .get(this.subgraphPathInfos.mutationField.coordinate)! .get(subgraph)!; const { newSubgraphPathInfos, error, } = this.validateTransitionForSubgraphPaths( subgraphPathInfos, newOverrideConditions, transition, targetType, matchingContexts, newPath, ); if (error) { errors.push(error); continue; } // As noted in `validateTransitionforSubgraphPaths()`, this being empty // means that the edge is a type condition and that if we follow the // path to this subgraph, we're guaranteed that handling that type // condition gives us no matching results, and so we can handle whatever // comes next really. if (newSubgraphPathInfos.length === 0) { return {}; } partitionedSubgraphPathInfos.set(subgraph, newSubgraphPathInfos); } if (partitionedSubgraphPathInfos.size === 0) { return {}; } updatedState = new ValidationState( newPath, { mutationField: this.subgraphPathInfos.mutationField, paths: partitionedSubgraphPathInfos, }, newOverrideConditions, ); } // 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 allSubgraphPathInfos = updatedState.allSubgraphPathInfos(); let hint: CompositionHint | undefined = undefined; if ( allSubgraphPathInfos.length > 1 && transition.kind === 'FieldCollection' && isAbstractType(newPath.tail.type) && context.isShareable(transition.definition) ) { const filteredPaths = allSubgraphPathInfos.map((p) => p.path.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 allSubgraphPathInfos) { const subgraph = path.path.tail.source; const typeNames = possibleRuntimeTypeNamesSorted(path.path); // if we see a type here that is not included in the list of all // runtime types, it is safe to assume that it is an interface // behaving like a runtime type (i.e. an @interfaceObject) and // we should allow it to stand in for any runtime type if (typeNames.length === 1 && !allRuntimeTypes.includes(typeNames[0])) { continue; } 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) { validationErrors.push(shareableFieldNonIntersectingRuntimeTypesError(updatedState, transition.definition, runtimeTypesToSubgraphs)); return {}; } // 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 }; } validateTransitionForSubgraphPaths( subgraphPathInfos: SubgraphPathInfo[], newOverrideConditions: Map<string, boolean>, transition: Transition, targetType: NamedType, matchingContexts: string[], newPath: RootPath<Transition>, ): { newSubgraphPathInfos: SubgraphPathInfo[], error?: never, } | { newSubgraphPathInfos?: never, error: GraphQLError, } { const newSubgraphPathInfos: SubgraphPathInfo[] = []; const deadEnds: UnadvanceableClosures[] = []; for (const { path, contexts } of subgraphPathInfos) { const options = advancePathWithTransition( path, transition, targetType, newOverrideConditions, ); if (isUnadvanceableClosures(options)) { deadEnds.push(options); continue; } if (options.length === 0) { // This means that the edge is a type condition and that if we follow // the path to this subgraph, we're guaranteed that handling that type // condition give us no matching results, and so we can handle whatever // comes next really. return { newSubgraphPathInfos: [] }; } let newContexts = contexts; if (matchingContexts.length) { const subgraphName = path.path.tail.source; const typeName = path.path.tail.type.name; newContexts = new Map([...contexts]); for (const matchingContext in matchingContexts) { newContexts.set( matchingContext, { subgraphName, typeName, } ) } } newSubgraphPathInfos.push( ...options.map((p) => ({ path: p, contexts: newContexts })) ); } return newSubgraphPathInfos.length === 0 ? { error: satisfiabilityError( newPath, subgraphPathInfos.map((p) => p.path.path), deadEnds.map((d) => d.toUnadvanceables()) ) } : { newSubgraphPathInfos }; } private static fieldIfTopLevelMutation( supergraphPath: RootPath<Transition>, edge: Edge, ): FieldDefinition<CompositeType> | null { if (supergraphPath.size !== 0) { return null; } if (edge.transition.kind !== 'FieldCollection') { return null; } if (supergraphPath.root !== supergraphPath.graph.root('mutation')) { return null; } return edge.transition.definition; } private static subgraphOfTopLevelMutation( subgraphPathInfo: SubgraphPathInfo ): string { const lastEdge = subgraphPathInfo.path.path.lastEdge(); assert(lastEdge, "Path unexpectedly missing edge"); return lastEdge.head.source; } allSubgraphPathInfos(): SubgraphPathInfo[] { return this.subgraphPathInfos instanceof SubgraphPathInfos ? this.subgraphPathInfos.paths : Array.from(this.subgraphPathInfos.paths.values()).flat(); } allSubgraphPathsCount(): number { if (this.subgraphPathInfos instanceof SubgraphPathInfos) { return this.subgraphPathInfos.paths.length; } else { let count = 0; for (const subgraphPathInfos of this.subgraphPathInfos.paths.values()) { count += subgraphPathInfos.length; } return count; } } currentSubgraphNames(): string[] { const subgraphs: string[] = []; for (const pathInfo of this.allSubgraphPathInfos()) { const source = pathInfo.path.path.tail.source; if (!subgraphs.includes(source)) { subgraphs.push(source); } } return subgraphs; } currentSubgraphContextKeys( subgraphNameToGraphEnumValue: Map<string, string>, subgraphPathInfos: SubgraphPathInfo[], ): Set<string> { const subgraphContextKeys: Set<string> = new Set(); for (const pathInfo of subgraphPathInfos) { const tailSubgraphName = pathInfo.path.path.tail.source; const tailSubgraphEnumValue = subgraphNameToGraphEnumValue.get(tailSubgraphName); const tailTypeName = pathInfo.path.path.tail.type.name; const entryKeys = []; const contexts = Array.from(pathInfo.contexts.entries()); contexts.sort((a, b) => a[0].localeCompare(b[0])); for (const [context, { subgraphName, typeName }] of contexts) { const subgraphEnumValue = subgraphNameToGraphEnumValue.get(subgraphName); entryKeys.push(`${context}=${subgraphEnumValue}.${typeName}`); } subgraphContextKeys.add( `${tailSubgraphEnumValue}.${tailTypeName}[${entryKeys.join(',')}]` ); } return subgraphContextKeys; } currentSubgraphs(): { name: string, schema: Schema }[] { const allSubgraphPathInfos = this.allSubgraphPathInfos(); if (allSubgraphPathInfos.length === 0) { return []; } const sources = allSubgraphPathInfos[0].path.path.graph.sources; return this.currentSubgraphNames().map((name) => ({ name, schema: sources.get(name)!})); } toString(): string { if (this.subgraphPathInfos instanceof SubgraphPathInfos) { return `${this.supergraphPath} <=> [${this.subgraphPathInfos.paths.map( p => p.path.toString() ).join(', ')}]`; } else { return `${this.supergraphPath} <=> {${ Array.from(this.subgraphPathInfos.paths.entries()).map( ([s, p]) => `${s}: [${p.map(p => p.path.toString()).join(', ')}]` ).join(', ') }}`; } } } // `maybeSuperset` is a superset (or equal) if it contains all of `other`'s // subgraphs and all of `other`'s labels (with matching conditions). function isSupersetOrEqual(maybeSuperset: VertexVisit, other: VertexVisit): boolean { const includesAllSubgraphs = [...other.subgraphContextKeys] .every((s) => maybeSuperset.subgraphContextKeys.has(s)); const includesAllOverrideConditions = [...other.overrideConditions.entries()].every(([label, value]) => maybeSuperset.overrideConditions.get(label) === value ); return includesAllSubgraphs && includesAllOverrideConditions; } interface VertexVisit { subgraphContextKeys: Set<string>; overrideConditions: Map<string, boolean>; } class ValidationTraversal { private readonly conditionResolver: ConditionResolver; // The stack contains all states that aren't terminal. private readonly stack: ValidationState[] = []; // For each vertex in the supergraph, records if we've already visited that vertex and in which subgraphs we were. // For a vertex, we may have multiple "sets of subgraphs", hence the double-array. private readonly previousVisits: QueryGraphState<VertexVisit[]>; private readonly validationErrors: GraphQLError[] = []; private readonly validationHints: CompositionHint[] = []; // When we discover a shared top-level mutation field, we track satisfiability // errors for each subgraph containing the field separately. This is because // the query planner needs to avoid calling these fields more than once, which // means there must be no satisfiability errors for (at least) one subgraph. // The first Map key is the field coordinate, and the second Map key is the // subgraph name. private readonly satisfiabilityErrorsByMutationFieldAndSubgraph: Map< string, Map<string, GraphQLError[]> > = new Map(); private readonly context: ValidationContext; private totalValidationSubgraphPaths = 0; private maxValidationSubgraphPaths: number; private static DEFAULT_MAX_VALIDATION_SUBGRAPH_PATHS = 1000000; constructor( supergraphSchema: Schema, subgraphNameToGraphEnumValue: Map<string, string>, supergraphAPI: QueryGraph, federatedQueryGraph: QueryGraph, compositionOptions: CompositionOptions, ) { this.maxValidationSubgraphPaths = compositionOptions.maxValidationSubgraphPaths ?? ValidationTraversal.DEFAULT_MAX_VALIDATION_SUBGRAPH_PATHS; this.conditionResolver = simpleValidationConditionResolver({ supergraph: supergraphSchema, queryGraph: federatedQueryGraph, withCaching: true, }); supergraphAPI.rootKinds().forEach((kind) => this.pushStack(ValidationState.initial({ supergraphAPI, kind, federatedQueryGraph, conditionResolver: this.conditionResolver, overrideConditions: new Map(), }))); this.previousVisits = new QueryGraphState(); this.context = new ValidationContext( supergraphSchema, subgraphNameToGraphEnumValue, ); } pushStack(state: ValidationState): { error?: GraphQLError } { this.totalValidationSubgraphPaths += state.allSubgraphPathsCount(); this.stack.push(state); if (this.totalValidationSubgraphPaths > this.maxValidationSubgraphPaths) { return { error: ERRORS.MAX_VALIDATION_SUBGRAPH_PATHS_EXCEEDED.err( `Maximum number of validation subgraph paths exceeded: ${this.totalValidationSubgraphPaths}` ) }; } return {}; } popStack() { const state = this.stack.pop(); if (state) { this.totalValidationSubgraphPaths -= state.allSubgraphPathsCount(); } return state; } validate(): { errors: GraphQLError[], hints: CompositionHint[], } { while (this.stack.length > 0) { const { error } = this.handleState(this.popStack()!); if (error) { return { errors: [error], hints: this.validationHints }; } } for (const [ fieldCoordinate, errorsBySubgraph, ] of this.satisfiabilityErrorsByMutationFieldAndSubgraph) { // Check if some subgraph has no satisfiability errors. If so, then that // subgraph can be used to satisfy all queries to the top-level mutation // field, and we can ignore the errors in other subgraphs. let someSubgraphHasNoErrors = false; for (const errors of errorsBySubgraph.values()) { if (errors.length === 0) { someSubgraphHasNoErrors = true; break; } } if (someSubgraphHasNoErrors) { continue; } // Otherwise, queries on the top-level mutation field can't be satisfied // through only one call to that field. let messageParts = [ `Supergraph API queries using the mutation field "${fieldCoordinate}"` + ` at top-level must be satisfiable without needing to call that` + ` field from multiple subgraphs, but every subgraph with that field` + ` encounters satisfiability errors. Please fix these satisfiability` + ` errors for (at least) one of the following subgraphs with the` + ` mutation field:` ]; for (const [subgraph, errors] of errorsBySubgraph) { messageParts.push( `- When calling "${fieldCoordinate}" at top-level from subgraph` +` "${subgraph}":` ); for (const error of errors) { for (const line of error.message.split("\n")) { if (line.length === 0) { messageParts.push(line); } else { messageParts.push(" " + line); } } } } this.validationErrors.push( ERRORS.SATISFIABILITY_ERROR.err(messageParts.join("\n")) ); } return { errors: this.validationErrors, hints: this.validationHints }; } private handleState(state: ValidationState): { error?: GraphQLError } { debug.group(() => `Validation: ${this.stack.length + 1} open states. Validating ${state}`); if (state.canSkipVisit( this.context.subgraphNameToGraphEnumValue, this.previousVisits )) { return {}; } // Note that if supergraphPath is terminal, this method is a no-op, which is expected/desired as // it means we've successfully "validate" a path to its end. for (const edge of state.supergraphPath.nextEdges()) { if (edge.isEdgeForField(typenameFieldName)) { // There is no point in validating __typename edges: we know we can always get those. continue; } // `state.selectedOverrideConditions` indicates the labels (and their // respective conditions) that we've selected so far in our traversal // (i.e. "foo" -> true). There's no need to validate edges that share the // same label with the opposite condition since they're unreachable during // query planning. if ( edge.overrideCondition && state.selectedOverrideConditions.has(edge.overrideCondition.label) && !edge.satisfiesOverrideConditions(state.selectedOverrideConditions) ) { debug.groupEnd(`Edge ${edge} doesn't satisfy label condition: ${edge.overrideCondition?.label}(${state.selectedOverrideConditions.get(edge.overrideCondition?.label ?? "")}), no need to validate further`); continue; } const matchingContexts = edge.transition.kind === 'FieldCollection' ? this.context.matchingContexts(edge.head.type.name) : []; debug.group(() => `Validating supergraph edge ${edge}`); const { state: newState, hint } = state.validateTransition( this.context, edge, matchingContexts, this.validationErrors, this.satisfiabilityErrorsByMutationFieldAndSubgraph, ); if (!newState) { debug.groupEnd(`Validation error!`); continue; } if (hint) { this.validationHints.push(hint); } // The check for `isTerminal` is not strictly necessary as if we add a terminal // state to the stack this method, `handleState`, will do nothing later. But it's // worth checking it now and save some memory/cycles. if (newState && !newState.supergraphPath.isTerminal()) { const { error } = this.pushStack(newState); if (error) { return { error }; } debug.groupEnd(() => `Reached new state ${newState}`); } else { debug.groupEnd(`Reached terminal vertex/cycle`); } } debug.groupEnd(); return {}; } }