@apollo/composition
Version:
Apollo Federation composition utilities
1,216 lines (1,103 loc) • 167 kB
text/typescript
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