UNPKG

@apollo/composition

Version:
1,216 lines (1,103 loc) 167 kB
import { ArgumentDefinition, assert, arrayEquals, DirectiveDefinition, EnumType, FieldDefinition, InputObjectType, InterfaceType, NamedType, newNamedType, ObjectType, Schema, SchemaDefinition, SchemaElement, UnionType, sameType, isStrictSubtype, ListType, NonNullType, Type, NullableType, NamedSchemaElementWithType, valueEquals, valueToString, InputFieldDefinition, allSchemaRootKinds, Directive, isFederationField, SchemaRootKind, CompositeType, Subgraphs, JOIN_VERSIONS, NamedSchemaElement, errorCauses, isObjectType, SubgraphASTNode, addSubgraphToASTNode, firstOf, Extension, isInterfaceType, sourceASTs, ERRORS, FederationMetadata, printSubgraphNames, federationIdentity, linkIdentity, coreIdentity, FEDERATION_OPERATION_TYPES, LINK_VERSIONS, federationMetadata, errorCode, withModifiedErrorNodes, didYouMean, suggestionList, EnumValue, baseType, isEnumType, isNonNullType, isExecutableDirectiveLocation, parseFieldSetArgument, isCompositeType, isDefined, addSubgraphToError, printHumanReadableList, ArgumentMerger, JoinSpecDefinition, CoreSpecDefinition, FeatureVersion, FEDERATION_VERSIONS, LinkDirectiveArgs, connectIdentity, FeatureUrl, isFederationDirectiveDefinedInSchema, parseContext, CoreFeature, Subgraph, StaticArgumentsTransform, isNullableType, isFieldDefinition, Post20FederationDirectiveDefinition, coreFeatureDefinitionIfKnown, FeatureDefinition, DirectiveCompositionSpecification, CoreImport, inaccessibleIdentity, FeatureDefinitions, CONNECT_VERSIONS, } from "@apollo/federation-internals"; import { ASTNode, GraphQLError, DirectiveLocation } from "graphql"; import { CompositionHint, HintCodeDefinition, HINTS, } from "../hints"; import { ComposeDirectiveManager } from '../composeDirectiveManager'; import { MismatchReporter } from './reporter'; import { inspect } from "util"; import { collectCoreDirectivesToCompose, CoreDirectiveInSubgraphs } from "./coreDirectiveCollector"; import { CompositionOptions } from "../compose"; // A Sources<T> map represents the contributions from each subgraph of the given // element type T. The numeric keys correspond to the indexes of the subgraphs // in the original Subgraphs/names/subgraphsSchema arrays. When merging a // specific type or field, this Map will ideally contain far fewer entries than // the total number of subgraphs, though it will sometimes need to contain // explicitly undefined entries (hence T | undefined). export type Sources<T> = Map<number, T | undefined>; // Like Array.prototype.map, but for Sources<T> maps. function mapSources<T, R>( sources: Sources<T>, mapper: (source: T | undefined, index: number) => R, ): Sources<R> { const result: Sources<R> = new Map; sources.forEach((source, idx) => { result.set(idx, mapper(source, idx)); }); return result; } // Removes all undefined sources from a given Sources<T> map. In other words, // this is not the same as Array.prototype.filter, which takes an arbitrary // boolean predicate. function filterSources<T>(sources: Sources<T>): Sources<T> { const result: Sources<T> = new Map; sources.forEach((source, idx) => { if (typeof source !== 'undefined') { result.set(idx, source); } }); return result; } // Like Array.prototype.some, but for Sources<T> maps. function someSources<T>(sources: Sources<T>, predicate: (source: T | undefined, index: number) => boolean | undefined): boolean { for (const [idx, source] of sources.entries()) { if (predicate(source, idx)) { return true; } } return false; } // Converts an array of T | undefined into a dense Sources<T> map. export function sourcesFromArray<T>(array: (T | undefined)[]): Sources<T> { const sources: Sources<T> = new Map; array.forEach((source, idx) => { sources.set(idx, source); }); return sources; } export type MergeResult = MergeSuccess | MergeFailure; type FieldMergeContextProperties = { usedOverridden: boolean, unusedOverridden: boolean, overrideWithUnknownTarget: boolean, overrideLabel: string | undefined, } // for each source, specify additional properties that validate functions can set class FieldMergeContext { _props: Map<number, FieldMergeContextProperties>; constructor(sources: Sources<FieldDefinition<any> | InputFieldDefinition>) { this._props = new Map; sources.forEach((_, i) => { this._props.set(i, { usedOverridden: false, unusedOverridden: false, overrideWithUnknownTarget: false, overrideLabel: undefined, }); }); } isUsedOverridden(idx: number) { return !!this._props.get(idx)?.usedOverridden; } isUnusedOverridden(idx: number) { return !!this._props.get(idx)?.unusedOverridden; } hasOverrideWithUnknownTarget(idx: number) { return !!this._props.get(idx)?.overrideWithUnknownTarget; } overrideLabel(idx: number) { return this._props.get(idx)?.overrideLabel; } setUsedOverridden(idx: number) { this._props.get(idx)!.usedOverridden = true; } setUnusedOverridden(idx: number) { this._props.get(idx)!.unusedOverridden = true; } setOverrideWithUnknownTarget(idx: number) { this._props.get(idx)!.overrideWithUnknownTarget = true; } setOverrideLabel(idx: number, label: string) { this._props.get(idx)!.overrideLabel = label; } some(predicate: (props: FieldMergeContextProperties, index: number) => boolean) { for (const [i, props] of this._props.entries()) { if (predicate(props, i)) { return true; } } return false; } } export interface MergeSuccess { supergraph: Schema; hints: CompositionHint[]; errors?: undefined; } export interface MergeFailure { errors: GraphQLError[]; supergraph?: undefined; hints?: undefined; } export function isMergeSuccessful(mergeResult: MergeResult): mergeResult is MergeSuccess { return !isMergeFailure(mergeResult); } export function isMergeFailure(mergeResult: MergeResult): mergeResult is MergeFailure { return !!mergeResult.errors; } export function mergeSubgraphs(subgraphs: Subgraphs, options: CompositionOptions = {}): MergeResult { assert(subgraphs.values().every((s) => s.isFed2Subgraph()), 'Merging should only be applied to federation 2 subgraphs'); return new Merger(subgraphs, options).merge(); } function copyTypeReference(source: Type, dest: Schema): Type { switch (source.kind) { case 'ListType': return new ListType(copyTypeReference(source.ofType, dest)); case 'NonNullType': return new NonNullType(copyTypeReference(source.ofType, dest) as NullableType); default: const type = dest.type(source.name); assert(type, () => `Cannot find type ${source} in destination schema (with types: ${dest.types().join(', ')})`); return type; } } const NON_MERGED_CORE_FEATURES = [ federationIdentity, linkIdentity, coreIdentity, connectIdentity ]; function isMergedType(type: NamedType): boolean { if (type.isIntrospectionType() || FEDERATION_OPERATION_TYPES.map((s) => s.name).includes(type.name)) { return false; } const coreFeatures = type.schema().coreFeatures; const typeFeature = coreFeatures?.sourceFeature(type)?.feature.url.identity; return !(typeFeature && NON_MERGED_CORE_FEATURES.includes(typeFeature)); } function isMergedField(field: InputFieldDefinition | FieldDefinition<CompositeType>): boolean { return field.kind !== 'FieldDefinition' || !isFederationField(field); } function isGraphQLBuiltInDirective(def: DirectiveDefinition): boolean { // `def.isBuiltIn` is not entirely reliable here because if it will be `false` // if the user has manually redefined the built-in directive (if they do, // we validate the definition is "compabitle" with the built-in version, but // otherwise return the use one). But when merging, we want to essentially // ignore redefinitions, so we instead just check if the "name" is that of // built-in directive. return !!def.schema().builtInDirective(def.name); } function printTypes<T extends NamedType>(types: T[]): string { return printHumanReadableList( types.map((t) => `"${t.coordinate}"`), { prefix: 'type', prefixPlural: 'types', } ); } // Access the type set as a particular root in the provided `SchemaDefinition`, but ignoring "query" type // that only exists due to federation operations. In other words, if a subgraph don't have a query type, // but one was automatically added for _entities and _services, this method returns 'undefined'. // This mainly avoid us trying to set the supergraph root in the rare case where the supergraph has // no actual queries (knowing that subgraphs will _always_ have a queries since they have at least // the federation ones). function filteredRoot(def: SchemaDefinition, rootKind: SchemaRootKind): ObjectType | undefined { const type = def.root(rootKind)?.type; return type && hasMergedFields(type) ? type : undefined; } function hasMergedFields(type: ObjectType): boolean { for (const field of type.fields()) { if (isMergedField(field)) { return true; } } return false; } function indexOfMax(arr: number[]): number { if (arr.length === 0) { return -1; } let indexOfMax = 0; for (let i = 1; i < arr.length; i++) { if (arr[i] > arr[indexOfMax]) { indexOfMax = i; } } return indexOfMax; } function descriptionString(toIndent: string, indentation: string): string { return indentation + '"""\n' + indentation + toIndent.replace('\n', '\n' + indentation) + '\n' + indentation + '"""'; } function locationString(locations: DirectiveLocation[]): string { if (locations.length === 0) { return ""; } return (locations.length === 1 ? 'location ' : 'locations ') + '"' + locations.join(', ') + '"'; } type EnumTypeUsagePosition = 'Input' | 'Output' | 'Both'; type EnumTypeUsage = { position: EnumTypeUsagePosition, examples: { Input?: {coordinate: string, sourceAST?: SubgraphASTNode}, Output?: {coordinate: string, sourceAST?: SubgraphASTNode}, }, } interface OverrideArgs { from: string; label?: string; } interface MergedDirectiveInfo { definition: DirectiveDefinition; argumentsMerger?: ArgumentMerger; staticArgumentTransform?: StaticArgumentsTransform; } class Merger { readonly names: readonly string[]; readonly subgraphsSchema: readonly Schema[]; readonly errors: GraphQLError[] = []; readonly hints: CompositionHint[] = []; readonly merged: Schema = new Schema(); readonly subgraphNamesToJoinSpecName: Map<string, string>; readonly mergedFederationDirectiveNames = new Set<string>(); readonly mergedFederationDirectiveInSupergraphByDirectiveName = new Map<string, MergedDirectiveInfo>(); readonly enumUsages = new Map<string, EnumTypeUsage>(); private composeDirectiveManager: ComposeDirectiveManager; private mismatchReporter: MismatchReporter; private appliedDirectivesToMerge: { names: Set<string>, sources: Sources<SchemaElement<any, any>>, dest: SchemaElement<any, any>, }[]; private joinSpec: JoinSpecDefinition; private linkSpec: CoreSpecDefinition; private inaccessibleDirectiveInSupergraph?: DirectiveDefinition; private latestFedVersionUsed: FeatureVersion; private joinDirectiveFeatureDefinitionsByIdentity = new Map<string, FeatureDefinitions>(); private schemaToImportNameToFeatureUrl = new Map<Schema, Map<string, FeatureUrl>>(); private fieldsWithFromContext: Set<string>; private fieldsWithOverride: Set<string>; constructor(readonly subgraphs: Subgraphs, readonly options: CompositionOptions) { this.latestFedVersionUsed = this.getLatestFederationVersionUsed(); this.joinSpec = JOIN_VERSIONS.getMinimumRequiredVersion(this.latestFedVersionUsed); this.linkSpec = LINK_VERSIONS.getMinimumRequiredVersion(this.latestFedVersionUsed); this.fieldsWithFromContext = this.getFieldsWithFromContextDirective(); this.fieldsWithOverride = this.getFieldsWithOverrideDirective(); this.names = subgraphs.names(); this.composeDirectiveManager = new ComposeDirectiveManager( this.subgraphs, (error: GraphQLError) => { this.errors.push(error) }, (hint: CompositionHint) => { this.hints.push(hint) }, ); this.mismatchReporter = new MismatchReporter( this.names, (error: GraphQLError) => { this.errors.push(error); }, (hint: CompositionHint) => { this.hints.push(hint); }, ); this.subgraphsSchema = subgraphs.values().map(({ schema }) => { if (!this.schemaToImportNameToFeatureUrl.has(schema)) { this.schemaToImportNameToFeatureUrl.set( schema, this.computeMapFromImportNameToIdentityUrl(schema), ); } return schema; }); this.subgraphNamesToJoinSpecName = this.prepareSupergraph(); this.appliedDirectivesToMerge = []; // Represent any applications of directives imported from these spec URLs // using @join__directive in the merged supergraph. this.joinDirectiveFeatureDefinitionsByIdentity.set(CONNECT_VERSIONS.identity, CONNECT_VERSIONS); } private getLatestFederationVersionUsed(): FeatureVersion { const versions = this.subgraphs.values() .map((s) => this.getLatestFederationVersionUsedInSubgraph(s)) .filter(isDefined); return FeatureVersion.max(versions) ?? FEDERATION_VERSIONS.latest().version; } private getLatestFederationVersionUsedInSubgraph(subgraph: Subgraph): FeatureVersion | undefined { const linkedFederationVersion = subgraph.metadata()?.federationFeature()?.url.version; if (!linkedFederationVersion) { return undefined; } // Check if any of the directives imply a newer version of federation than is explicitly linked const versionsFromFeatures: FeatureVersion[] = []; for (const feature of subgraph.schema.coreFeatures?.allFeatures() ?? []) { const version = feature.minimumFederationVersion(); if (version) { versionsFromFeatures.push(version); } } const impliedFederationVersion = FeatureVersion.max(versionsFromFeatures); if (!impliedFederationVersion?.satisfies(linkedFederationVersion) || linkedFederationVersion.gte(impliedFederationVersion)) { return linkedFederationVersion; } // If some of the directives are causing an implicit upgrade, put one in the hint let featureCausingUpgrade: CoreFeature | undefined; for (const feature of subgraph.schema.coreFeatures?.allFeatures() ?? []) { if (feature.minimumFederationVersion() == impliedFederationVersion) { featureCausingUpgrade = feature; break; } } if (featureCausingUpgrade) { this.hints.push(new CompositionHint( HINTS.IMPLICITLY_UPGRADED_FEDERATION_VERSION, `Subgraph ${subgraph.name} has been implicitly upgraded from federation ${linkedFederationVersion} to ${impliedFederationVersion}`, featureCausingUpgrade.directive.definition, featureCausingUpgrade.directive.sourceAST ? addSubgraphToASTNode(featureCausingUpgrade.directive.sourceAST, subgraph.name) : undefined )); } return impliedFederationVersion; } private prepareSupergraph(): Map<string, string> { // TODO: we will soon need to look for name conflicts for @core and @join with potentially user-defined directives and // pass a `as` to the methods below if necessary. However, as we currently don't propagate any subgraph directives to // the supergraph outside of a few well-known ones, we don't bother yet. this.linkSpec.addToSchema(this.merged); const errors = this.linkSpec.applyFeatureToSchema(this.merged, this.joinSpec, undefined, this.joinSpec.defaultCorePurpose); assert(errors.length === 0, "We shouldn't have errors adding the join spec to the (still empty) supergraph schema"); const directivesMergeInfo = collectCoreDirectivesToCompose(this.subgraphs); this.validateAndMaybeAddSpecs(directivesMergeInfo); return this.joinSpec.populateGraphEnum(this.merged, this.subgraphs); } private validateAndMaybeAddSpecs(directivesMergeInfo: CoreDirectiveInSubgraphs[]) { const supergraphInfoByIdentity = new Map< string, { specInSupergraph: FeatureDefinition; directives: { nameInFeature: string; nameInSupergraph: string; compositionSpec: DirectiveCompositionSpecification; }[]; } >; for (const {url, name, definitionsPerSubgraph, compositionSpec} of directivesMergeInfo) { // No composition specification means that it shouldn't be composed. if (!compositionSpec) { return; } let nameInSupergraph: string | undefined; for (const subgraph of this.subgraphs) { const directive = definitionsPerSubgraph.get(subgraph.name); if (!directive) { continue; } if (!nameInSupergraph) { nameInSupergraph = directive.name; } else if (nameInSupergraph !== directive.name) { this.mismatchReporter.reportMismatchError( ERRORS.LINK_IMPORT_NAME_MISMATCH, `The "@${name}" directive (from ${url}) is imported with mismatched name between subgraphs: it is imported as `, directive, sourcesFromArray(this.subgraphs.values().map((s) => definitionsPerSubgraph.get(s.name))), (def) => `"@${def.name}"`, ); return; } } // If we get here with `nameInSupergraph` unset, it means there is no usage for the directive at all and we // don't bother adding the spec to the supergraph. if (nameInSupergraph) { const specInSupergraph = compositionSpec.supergraphSpecification(this.latestFedVersionUsed); let supergraphInfo = supergraphInfoByIdentity.get(specInSupergraph.url.identity); if (supergraphInfo) { assert( specInSupergraph.url.equals(supergraphInfo.specInSupergraph.url), `Spec ${specInSupergraph.url} directives disagree on version for supergraph`, ); } else { supergraphInfo = { specInSupergraph, directives: [], }; supergraphInfoByIdentity.set(specInSupergraph.url.identity, supergraphInfo); } supergraphInfo.directives.push({ nameInFeature: name, nameInSupergraph, compositionSpec, }); } } for (const { specInSupergraph, directives } of supergraphInfoByIdentity.values()) { const imports: CoreImport[] = []; for (const { nameInFeature, nameInSupergraph } of directives) { const defaultNameInSupergraph = CoreFeature.directiveNameInSchemaForCoreArguments( specInSupergraph.url, specInSupergraph.url.name, [], nameInFeature, ); if (nameInSupergraph !== defaultNameInSupergraph) { imports.push(nameInFeature === nameInSupergraph ? { name: `@${nameInFeature}` } : { name: `@${nameInFeature}`, as: `@${nameInSupergraph}` } ); } } const errors = this.linkSpec.applyFeatureToSchema( this.merged, specInSupergraph, undefined, specInSupergraph.defaultCorePurpose, imports, ); assert( errors.length === 0, "We shouldn't have errors adding the join spec to the (still empty) supergraph schema" ); const feature = this.merged.coreFeatures?.getByIdentity(specInSupergraph.url.identity); assert(feature, 'Should have found the feature we just added'); for (const { nameInFeature, nameInSupergraph, compositionSpec } of directives) { const argumentsMerger = compositionSpec.argumentsMerger?.call(null, this.merged, feature); if (argumentsMerger instanceof GraphQLError) { // That would mean we made a mistake in the declaration of a hard-coded directive, // so we just throw right away so this can be caught and corrected. throw argumentsMerger; } this.mergedFederationDirectiveNames.add(nameInSupergraph); this.mergedFederationDirectiveInSupergraphByDirectiveName.set(nameInSupergraph, { definition: this.merged.directive(nameInSupergraph)!, argumentsMerger, staticArgumentTransform: compositionSpec.staticArgumentTransform, }); // If we encounter the @inaccessible directive, we need to record its // definition so certain merge validations that care about @inaccessible // can act accordingly. if ( specInSupergraph.identity === inaccessibleIdentity && nameInFeature === specInSupergraph.url.name ) { this.inaccessibleDirectiveInSupergraph = this.merged.directive(nameInSupergraph)!; } } } } private joinSpecName(subgraphIndex: number): string { return this.subgraphNamesToJoinSpecName.get(this.names[subgraphIndex])!; } private metadata(idx: number): FederationMetadata { return this.subgraphs.values()[idx].metadata(); } private isMergedDirective(subgraphName: string, definition: DirectiveDefinition | Directive): boolean { // If it's a directive application, then we skip it unless it's a graphQL built-in // (even if the definition itself allows executable locations, this particular // application is an type-system element and we don't want to merge it). if (this.composeDirectiveManager.shouldComposeDirective({ subgraphName, directiveName: definition.name })) { return true; } if (definition instanceof Directive) { // We have special code in `Merger.prepareSupergraph` to include the _definition_ of merged federation // directives in the supergraph, so we don't have to merge those _definition_, but we *do* need to merge // the applications. // Note that this is a temporary solution: a more principled way to have directive propagated // is coming and will remove the hard-coding. return this.mergedFederationDirectiveNames.has(definition.name) || isGraphQLBuiltInDirective(definition.definition!); } else if (isGraphQLBuiltInDirective(definition)) { // We never "merge" graphQL built-in definitions, since they are built-in and // don't need to be defined. return false; } return definition.hasExecutableLocations(); } merge(): MergeResult { this.composeDirectiveManager.validate(); this.addCoreFeatures(); // We first create empty objects for all the types and directives definitions that will exists in the // supergraph. This allow to be able to reference those from that point on. this.addTypesShallow(); this.addDirectivesShallow(); const objectTypes: ObjectType[] = []; const interfaceTypes: InterfaceType[] = []; const unionTypes: UnionType[] = []; const enumTypes: EnumType[] = []; const nonUnionEnumTypes: NamedType[] = []; this.merged.types().forEach(type => { if ( this.linkSpec.isSpecType(type) || this.joinSpec.isSpecType(type) ) { return; } switch (type.kind) { case 'UnionType': unionTypes.push(type); return; case 'EnumType': enumTypes.push(type); return; case 'ObjectType': objectTypes.push(type); break; case 'InterfaceType': interfaceTypes.push(type); break; } nonUnionEnumTypes.push(type); }); // Then, for object and interface types, we merge the 'implements' relationship, and we merge the unions. // We do this first because being able to know if a type is a subtype of another one (which relies on those // 2 things) is used when merging fields. for (const objectType of objectTypes) { this.mergeImplements(this.subgraphsTypes(objectType), objectType); } for (const interfaceType of interfaceTypes) { this.mergeImplements(this.subgraphsTypes(interfaceType), interfaceType); } for (const unionType of unionTypes) { this.mergeType(this.subgraphsTypes(unionType), unionType); } // We merge the roots first as it only depend on the type existing, not being fully merged, and when // we merge types next, we actually rely on this having been called to detect "root types" // (in order to skip the _entities and _service fields on that particular type, and to avoid // calling root type a "value type" when hinting). this.mergeSchemaDefinition( sourcesFromArray(this.subgraphsSchema.map(s => s.schemaDefinition)), this.merged.schemaDefinition, ); // We've already merged unions above and we've going to merge enums last for (const type of nonUnionEnumTypes) { this.mergeType(this.subgraphsTypes(type), type); } for (const definition of this.merged.directives()) { // we should skip the supergraph specific directives, that is the @core and @join directives. if (this.linkSpec.isSpecDirective(definition) || this.joinSpec.isSpecDirective(definition)) { continue; } this.mergeDirectiveDefinition( sourcesFromArray(this.subgraphsSchema.map(s => s.directive(definition.name))), definition, ); } // We merge enum dead last because enums can be used as both input and output types and the merging behavior // depends on their usage and it's easier to check said usage if everything else has been merge (at least // anything that may use an enum type, so all fields and arguments). for (const enumType of enumTypes) { this.mergeType(this.subgraphsTypes(enumType), enumType); } if (!this.merged.schemaDefinition.rootType('query')) { this.errors.push(ERRORS.NO_QUERIES.err("No queries found in any subgraph: a supergraph must have a query root type.")); } this.mergeAllAppliedDirectives(); // When @interfaceObject is used in a subgraph, then that subgraph essentially provides fields both // to the interface but also to all its implementations. But so far, we only merged the type definition // itself, so we now need to potentially add the field to the implementations if missing. // Note that we do this after everything else have been merged because this method will essentially // copy things from interface in the merged schema into their implementation in that same schema so // we want to make sure everything is ready. this.addMissingInterfaceObjectFieldsToImplementations(); // If we already encountered errors, `this.merged` is probably incomplete. Let's not risk adding errors that // are only an artifact of that incompleteness as it's confusing. if (this.errors.length === 0) { this.postMergeValidations(); if (this.errors.length === 0) { try { // TODO: Errors thrown by the `validate` below are likely to be confusing for users, because they // refer to a document they don't know about (the merged-but-not-returned supergraph) and don't // point back to the subgraphs in any way. // Given the subgraphs are valid and given how merging works (it takes the union of what is in the // subgraphs), there is only so much things that can be invalid in the supergraph at this point. We // should make sure we add all such validation to `postMergeValidations` with good error messages (that points // to subgraphs appropriately). and then simply _assert_ that `Schema.validate()` doesn't throw as a sanity // check. this.merged.validate(); // Lastly, we validate that the API schema of the supergraph can be successfully compute, which currently will surface issues around // misuses of `@inaccessible` (there should be other errors in theory, but if there is, better find it now rather than later). this.merged.toAPISchema(); } catch (e) { const causes = errorCauses(e); if (causes) { this.errors.push(...this.updateInaccessibleErrorsWithLinkToSubgraphs(causes)); } else { // Not a GraphQLError, so probably a programming error. Let's re-throw so it can be more easily tracked down. throw e; } } } } if (this.errors.length > 0) { return { errors: this.errors }; } else { return { supergraph: this.merged, hints: this.hints } } } // Amongst other thing, this will ensure all the definitions of a given name are of the same kind // and report errors otherwise. private addTypesShallow() { const mismatchedTypes = new Set<string>(); const typesWithInterfaceObject = new Set<string>(); for (const subgraph of this.subgraphs) { const metadata = subgraph.metadata(); // We include the built-ins in general (even if we skip some federation specific ones): if a subgraph built-in // is not a supergraph built-in, we should add it as a normal type. for (const type of subgraph.schema.allTypes()) { if (!isMergedType(type)) { continue; } let expectedKind = type.kind; if (metadata.isInterfaceObjectType(type)) { expectedKind = 'InterfaceType'; typesWithInterfaceObject.add(type.name); } const previous = this.merged.type(type.name); if (!previous) { this.merged.addType(newNamedType(expectedKind, type.name)); } else if (previous.kind !== expectedKind) { mismatchedTypes.add(type.name); } } } mismatchedTypes.forEach(t => this.reportMismatchedTypeDefinitions(t)); // Most invalid use of @interfaceObject are reported as a mismatch above, but one exception is the // case where a type is used only with @interfaceObject, but there is no corresponding interface // definition in any subgraph. for (const itfObjectType of typesWithInterfaceObject) { if (mismatchedTypes.has(itfObjectType)) { continue; } if (!this.subgraphsSchema.some((s) => s.type(itfObjectType)?.kind === 'InterfaceType')) { const subgraphsWithType = this.subgraphs.values().filter((s) => s.schema.type(itfObjectType) !== undefined); // Note that there is meaningful way in which the supergraph could work in this situation, expect maybe if // the type is unused, because validation composition would complain it cannot find the `__typename` in path // leading to that type. But the error here is a bit more "direct"/user friendly than what post-merging // validation would return, so we make this a hard error, not just a warning. this.errors.push(ERRORS.INTERFACE_OBJECT_USAGE_ERROR.err( `Type "${itfObjectType}" is declared with @interfaceObject in all the subgraphs in which is is defined (it is defined in ${printSubgraphNames(subgraphsWithType.map((s) => s.name))} but should be defined as an interface in at least one subgraph)`, { nodes: sourceASTs(...subgraphsWithType.map((s) => s.schema.type(itfObjectType))) }, )); } } } private addCoreFeatures() { const features = this.composeDirectiveManager.allComposedCoreFeatures(); for (const [feature, directives] of features) { const imports = directives.map(([asName, origName]) => { if (asName === origName) { return `@${asName}`; } else { return { name: `@${origName}`, as: `@${asName}`, }; } }); this.merged.schemaDefinition.applyDirective('link', { url: feature.url.toString(), import: imports, }); } } private addDirectivesShallow() { // Like for types, we initially add all the directives that are defined in any subgraph. // However, in practice and for "execution" directives, we will only keep the the ones // that are in _all_ subgraphs. But we're do the remove later, and while this is all a // bit round-about, it's a tad simpler code-wise to do this way. this.subgraphsSchema.forEach((subgraph, idx) => { for (const directive of subgraph.allDirectives()) { if (!this.isMergedDirective(this.names[idx], directive)) { continue; } if (!this.merged.directive(directive.name)) { this.merged.addDirectiveDefinition(new DirectiveDefinition(directive.name)); } } }); } private reportMismatchedTypeDefinitions(mismatchedType: string) { const supergraphType = this.merged.type(mismatchedType)!; const typeKindToString = (t: NamedType) => { const metadata = federationMetadata(t.schema()); if (metadata?.isInterfaceObjectType(t)) { return 'Interface Object Type (Object Type with @interfaceObject)'; } else { return t.kind.replace("Type", " Type"); } }; this.mismatchReporter.reportMismatchError( ERRORS.TYPE_KIND_MISMATCH, `Type "${mismatchedType}" has mismatched kind: it is defined as `, supergraphType, sourcesFromArray(this.subgraphsSchema.map(s => s.type(mismatchedType))), typeKindToString ); } private subgraphsTypes<T extends NamedType>(supergraphType: T): Sources<T> { return sourcesFromArray(this.subgraphs.values().map(subgraph => { const type = subgraph.schema.type(supergraphType.name); if (!type) { return; } // At this point, we have already reported errors for type mismatches (and so composition // will fail, we just try to gather more errors), so simply ignore versions of the type // that don't have the "proper" kind. const kind = subgraph.metadata().isInterfaceObjectType(type) ? 'InterfaceType' : type.kind; if (kind !== supergraphType.kind) { return; } return type as T; })); } private mergeImplements<T extends ObjectType | InterfaceType>(sources: Sources<T>, dest: T) { const implemented = new Set<string>(); const joinImplementsDirective = this.joinSpec.implementsDirective(this.merged)!; for (const [idx, source] of sources.entries()) { if (source) { const name = this.joinSpecName(idx); for (const itf of source.interfaces()) { implemented.add(itf.name); dest.applyDirective(joinImplementsDirective, { graph: name, interface: itf.name }); } } } implemented.forEach(itf => dest.addImplementedInterface(itf)); } private mergeDescription<T extends SchemaElement<any, any>>(sources: Sources<T>, dest: T) { const descriptions: string[] = []; const counts: number[] = []; for (const source of sources.values()) { if (!source || source.description === undefined) { continue; } const idx = descriptions.indexOf(source.description); if (idx < 0) { descriptions.push(source.description); // Very much a hack but simple enough: while we do merge 'empty-string' description if that's all we have (debatable behavior in the first place, // but graphQL-js does print such description and fed 1 has historically merged them so ...), we really don't want to favor those if we // have any non-empty description, even if we have more empty ones across subgraphs. So we use a super-negative base count if the description // is empty so that our `indexOfMax` below never pick them if there is a choice. counts.push(source.description === '' ? Number.MIN_SAFE_INTEGER : 1); } else { counts[idx]++; } } if (descriptions.length > 0) { // we don't want to raise a hint if a description is "" const nonEmptyDescriptions = descriptions.filter(desc => desc !== ''); if (descriptions.length === 1) { dest.description = descriptions[0]; } else if (nonEmptyDescriptions.length === 1) { dest.description = nonEmptyDescriptions[0]; } else { const idx = indexOfMax(counts); dest.description = descriptions[idx]; // TODO: Currently showing full descriptions in the hint messages, which is probably fine in some cases. However // this might get less helpful if the description appears to differ by a very small amount (a space, a single character typo) // and even more so the bigger the description is, and we could improve the experience here. For instance, we could // print the supergraph description but then show other descriptions as diffs from that (using, say, https://www.npmjs.com/package/diff). // And we could even switch between diff/non-diff modes based on the levenshtein distances between the description we found. // That said, we should decide if we want to bother here: maybe we can leave it to studio so handle a better experience (as // it can more UX wise). const name = dest instanceof NamedSchemaElement ? `Element "${dest.coordinate}"` : 'The schema definition'; this.mismatchReporter.reportMismatchHint({ code: HINTS.INCONSISTENT_DESCRIPTION, message: `${name} has inconsistent descriptions across subgraphs. `, supergraphElement: dest, subgraphElements: sources, elementToString: elt => elt.description, supergraphElementPrinter: (desc, subgraphs) => `The supergraph will use description (from ${subgraphs}):\n${descriptionString(desc, ' ')}`, otherElementsPrinter: (desc: string, subgraphs) => `\nIn ${subgraphs}, the description is:\n${descriptionString(desc, ' ')}`, ignorePredicate: elt => elt?.description === undefined, noEndOfMessageDot: true, // Skip the end-of-message '.' since it would look ugly in that specific case }); } } } // Note that we know when we call this method that all the types in sources and dest have the same kind. // We could express this through a generic argument, but typescript is not smart enough to save us // type-casting even if we do, and in fact, using a generic forces a case on `dest` for some reason. // So we don't bother. private mergeType(sources: Sources<NamedType>, dest: NamedType) { this.checkForExtensionWithNoBase(sources, dest); this.mergeDescription(sources, dest); this.addJoinType(sources, dest); this.recordAppliedDirectivesToMerge(sources, dest); this.addJoinDirectiveDirectives(sources, dest); switch (dest.kind) { case 'ScalarType': // Since we don't handle applied directives yet, we have nothing specific to do for scalars. break; case 'ObjectType': this.mergeObject(sources as Sources<ObjectType>, dest); break; case 'InterfaceType': // Note that due to @interfaceObject, we can have some ObjectType in the sources, not just interfaces. this.mergeInterface(sources as Sources<InterfaceType | ObjectType>, dest); break; case 'UnionType': this.mergeUnion(sources as Sources<UnionType>, dest); break; case 'EnumType': this.mergeEnum(sources as Sources<EnumType>, dest); break; case 'InputObjectType': this.mergeInput(sources as Sources<InputObjectType>, dest); break; } } private checkForExtensionWithNoBase(sources: Sources<NamedType>, dest: NamedType) { if (isObjectType(dest) && dest.isRootType()) { return; } const defSubgraphs: string[] = []; const extensionSubgraphs: string[] = []; const extensionASTs: (ASTNode|undefined)[] = []; for (const [i, source] of sources.entries()) { if (!source) { continue; } if (source.hasNonExtensionElements()) { defSubgraphs.push(this.names[i]); } if (source.hasExtensionElements()) { extensionSubgraphs.push(this.names[i]); extensionASTs.push(firstOf<Extension<any>>(source.extensions().values())!.sourceAST); } } if (extensionSubgraphs.length > 0 && defSubgraphs.length === 0) { for (const [i, subgraph] of extensionSubgraphs.entries()) { this.errors.push(ERRORS.EXTENSION_WITH_NO_BASE.err( `[${subgraph}] Type "${dest}" is an extension type, but there is no type definition for "${dest}" in any subgraph.`, { nodes: extensionASTs[i] }, )); } } } private addJoinType(sources: Sources<NamedType>, dest: NamedType) { const joinTypeDirective = this.joinSpec.typeDirective(this.merged); for (const [idx, source] of sources.entries()) { if (!source) { continue; } // There is either 1 join__type per-key, or if there is no key, just one for the type. const sourceMetadata = this.subgraphs.values()[idx].metadata(); // Note that mechanically we don't need to substitute `undefined` for `false` below (`false` is the // default value), but doing so 1) yield smaller supergraph (because the parameter isn't included) // and 2) this avoid needless discrepancies compared to supergraphs generated before @interfaceObject was added. const isInterfaceObject = sourceMetadata.isInterfaceObjectType(source) ? true : undefined; const keys = source.appliedDirectivesOf(sourceMetadata.keyDirective()); const name = this.joinSpecName(idx); if (!keys.length) { dest.applyDirective(joinTypeDirective, { graph: name, isInterfaceObject }); } else { for (const key of keys) { const extension = key.ofExtension() || source.hasAppliedDirective(sourceMetadata.extendsDirective()) ? true : undefined; const { resolvable } = key.arguments(); dest.applyDirective(joinTypeDirective, { graph: name, key: key.arguments().fields, extension, resolvable, isInterfaceObject }); } } } } private mergeObject(sources: Sources<ObjectType>, dest: ObjectType) { const isEntity = this.hintOnInconsistentEntity(sources, dest); const isValueType = !isEntity && !dest.isRootType(); const isSubscription = dest.isSubscriptionRootType(); const added = this.addFieldsShallow(sources, dest); if (!added.size) { // This can happen for a type that existing in the subgraphs but had only non-merged fields // (currently, this can only be the 'Query' type, in the rare case where the federated schema // exposes no queries) . dest.remove(); } else { added.forEach((subgraphFields, destField) => { if (isValueType) { this.hintOnInconsistentValueTypeField(sources, dest, destField); } const mergeContext = this.validateOverride(subgraphFields, destField); if (isSubscription) { this.validateSubscriptionField(subgraphFields); } this.mergeField({ sources: subgraphFields, dest: destField, mergeContext, }); this.validateFieldSharing(subgraphFields, destField, mergeContext); }); } } // Return whether the type is an entity in at least one subgraph. private hintOnInconsistentEntity(sources: Sources<ObjectType>, dest: ObjectType): boolean { const sourceAsEntity: ObjectType[] = []; const sourceAsNonEntity: ObjectType[] = []; for (const [idx, source] of sources.entries()) { if (!source) { continue; } const sourceMetadata = this.subgraphs.values()[idx].metadata(); const keyDirective = sourceMetadata.keyDirective(); if (source.hasAppliedDirective(keyDirective)) { sourceAsEntity.push(source); } else { sourceAsNonEntity.push(source); } } if (sourceAsEntity.length > 0 && sourceAsNonEntity.length > 0) { this.mismatchReporter.reportMismatchHint({ code: HINTS.INCONSISTENT_ENTITY, message: `Type "${dest}" is declared as an entity (has a @key applied) in some but not all defining subgraphs: `, supergraphElement: dest, subgraphElements: sources, // All we use the string of the next line for is to categorize source with a @key of the others. elementToString: type => sourceAsEntity.find(entity => entity === type) ? 'yes' : 'no', // Note that the first callback is for element that are "like the supergraph". As the supergraph has no @key ... supergraphElementPrinter: (_, subgraphs) => `it has no @key in ${subgraphs}`, otherElementsPrinter: (_, subgraphs) => ` but has some @key in ${subgraphs}`, }); } return sourceAsEntity.length > 0; } // Assume it is called on a field of a value type private hintOnInconsistentValueTypeField( sources: Sources<ObjectType | InterfaceType>, dest: ObjectType | InterfaceType, field: FieldDefinition<any>, ) { let hintId: HintCodeDefinition; let typeDescription: string; switch (dest.kind) { case 'ObjectType': hintId = HINTS.INCONSISTENT_OBJECT_VALUE_TYPE_FIELD; typeDescription = 'non-entity object' break; case 'InterfaceType': hintId = HINTS.INCONSISTENT_INTERFACE_VALUE_TYPE_FIELD; typeDescription = 'interface' break; } for (const [index, source] of sources.entries()) { // As soon as we find a subgraph that has the type but not the field, we hint. if (source && !source.field(field.name) && !this.areAllFieldsExternal(index, source)) { this.mismatchReporter.reportMismatchHint({ code: hintId, message: `Field "${field.coordinate}" of ${typeDescription} type "${dest}" is defined in some but not all subgraphs that define "${dest}": `, supergraphElement: dest, subgraphElements: sources, elementToString: type => type.field(field.name) ? 'yes' : 'no', supergraphElementPrinter: (_, subgraphs) => `"${field.coordinate}" is defined in ${subgraphs}`, otherElementsPrinter: (_, subgraphs) => ` but not in ${subgraphs}`, }); break; } } } private addMissingInterfaceObjectFieldsToImplementations() { // For each merged object types, we check if we're missing a field from one of the implemented interface. // If we do, then we look if one of the subgraph provides that field as a (non-external) interface object // type, and if that's the case, we add the field to the object. for (const type of this.merged.objectTypes()) { for (const implementedItf of type.interfaces()) { for (const itfField of implementedItf.fields()) { if (type.field(itfField.name)) { continue; } // Note that we don't blindly add the field yet, that would be incorrect in many cases (and we // have a specific validation that return a user-friendly error in such incorrect cases, see // `postMergeValidations`). We must first check that there is some subgraph that implement // that field as an "interface object", since in that case the field will genuinely be provided // by that subgraph at runtime. if (this.isFieldProvidedByAnInterfaceObject(itfField.name, implementedItf.name)) { // Note it's possible that interface is abstracted away (as an interface object) in multiple // subgraphs, so we don't bother with the field definition in those subgraphs, but rather // just copy the merged definition from the interface. const implemField = type.addField(itfField.name, itfField.type); // Cases could probably be made for both either copying or not copying the description // and applied directives from the interface field, but we copy both here as it feels // more likely to be what user expects (assume they care either way). It's unlikely // this will be an issue to anyone, but we can always make this behaviour tunable // "somehow" later if the need arise. Feels highly overkill at this point though. implemField.description = itfField.description; this.copyNonJoinAppliedDirectives(itfField, implemField); for (const itfArg of itfField.arguments()) { const implemArg = implemField.addArgument(itfArg.name, itfArg.type, itfArg.defaultValue); implemArg.description = itfArg.description; this.copyNonJoinAppliedDirectives(itfArg, implemArg); } // We add a special @join__field for those added field with no `graph` target. This // clarify to the later extraction process that this particular field doesn't come // from any particular subgraph (it comes indirectly from an @interfaceObject type, // but it's very much indirect so ...). implemField.applyDirective(this.joinSpec.fieldDirective(this.merged), { graph: undefined }); // If we had to add a field here, it means that, for this particular implementation, the // field is only provided through the @interfaceObject. But because the field wasn't // merged, it also mean we haven't validated field sharing for that field, and we could // have field sharing concerns if the field is provided by multiple @interfaceObject. // So we validate field sharing now (it's convenient to wait until now as now that // the field is part of the supergraph, we can just call `validateFieldSharing` with // all sources `undefined` and it wil still find and check the `@interfaceObject`). const sources: Sources<FieldDefinition<ObjectType>> = new Map; for (let i = 0; i < this.names.length; ++i) { // We don't usually want undefined sources in our Sources maps, // but both validateFieldSharing and Fi