UNPKG

@apollo/composition

Version:
846 lines (781 loc) 36.7 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, } from "@apollo/federation-internals"; import { Edge, federatedGraphRootTypeName, QueryGraph, subgraphEnteringTransition, GraphPath, RootPath, advancePathWithTransition, Transition, QueryGraphState, Unadvanceables, Unadvanceable, noConditionsResolution, TransitionPathWithLazyIndirectPaths, RootVertex, simpleValidationConditionResolver, ConditionResolver, UnadvanceableClosures, isUnadvanceableClosures, } 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.subgraphPathInfos.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 }>, } 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. public readonly subgraphPathInfos: SubgraphPathInfo[], // 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)!, initialSubgraphPaths(kind, federatedQueryGraph).map((p) => TransitionPathWithLazyIndirectPaths.initial( p, conditionResolver, overrideConditions, ), ).map((p) => ({ path: p, contexts: new Map(), })), ); } /** * 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[]): { state?: ValidationState, error?: GraphQLError, hint?: CompositionHint, } { assert(!supergraphEdge.conditions, () => `Supergraph edges should not have conditions (${supergraphEdge})`); const transition = supergraphEdge.transition; const targetType = supergraphEdge.tail.type; const newSubgraphPathInfos: SubgraphPathInfo[] = []; const deadEnds: UnadvanceableClosures[] = []; // 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 ); } for (const { path, contexts } of this.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 { state: undefined }; } 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 }))); } const newPath = this.supergraphPath.add(transition, supergraphEdge, noConditionsResolution); if (newSubgraphPathInfos.length === 0) { return { error: satisfiabilityError(newPath, this.subgraphPathInfos.map((p) => p.path.path), deadEnds.map((d) => d.toUnadvanceables())) }; } const updatedState = new ValidationState( newPath, newSubgraphPathInfos, 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 hint: CompositionHint | undefined = undefined; if ( newSubgraphPathInfos.length > 1 && transition.kind === 'FieldCollection' && isAbstractType(newPath.tail.type) && context.isShareable(transition.definition) ) { const filteredPaths = newSubgraphPathInfos.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 newSubgraphPathInfos) { 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) { 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 }; } currentSubgraphNames(): string[] { const subgraphs: string[] = []; for (const pathInfo of this.subgraphPathInfos) { const source = pathInfo.path.path.tail.source; if (!subgraphs.includes(source)) { subgraphs.push(source); } } return subgraphs; } currentSubgraphContextKeys(subgraphNameToGraphEnumValue: Map<string, string>): Set<string> { const subgraphContextKeys: Set<string> = new Set(); for (const pathInfo of this.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 }[] { if (this.subgraphPathInfos.length === 0) { return []; } const sources = this.subgraphPathInfos[0].path.path.graph.sources; return this.currentSubgraphNames().map((name) => ({ name, schema: sources.get(name)!})); } toString(): string { return `${this.supergraphPath} <=> [${this.subgraphPathInfos.map(s => s.path.toString()).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[] = []; 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.subgraphPathInfos.length; 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.subgraphPathInfos.length; } 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 }; } } return { errors: this.validationErrors, hints: this.validationHints }; } private handleState(state: ValidationState): { error?: GraphQLError } { debug.group(() => `Validation: ${this.stack.length + 1} open states. Validating ${state}`); const vertex = state.supergraphPath.tail; const currentVertexVisit: VertexVisit = { subgraphContextKeys: state.currentSubgraphContextKeys(this.context.subgraphNameToGraphEnumValue), overrideConditions: state.selectedOverrideConditions }; const previousVisitsForVertex = this.previousVisits.getVertexState(vertex); 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 {}; } } // 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. this.previousVisits.setVertexState(vertex, [currentVertexVisit]); } // 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, error, hint } = state.validateTransition(this.context, edge, matchingContexts); if (error) { debug.groupEnd(`Validation error!`); this.validationErrors.push(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 {}; } }