UNPKG

@apollo/federation-internals

Version:
1,288 lines (1,125 loc) 170 kB
import { ConstArgumentNode, ASTNode, buildASTSchema as buildGraphqlSchemaFromAST, DirectiveLocation, ConstDirectiveNode, ConstValueNode, DocumentNode, GraphQLError, GraphQLSchema, Kind, ListTypeNode, NamedTypeNode, parse, TypeNode, VariableDefinitionNode, VariableNode, SchemaDefinitionNode, TypeDefinitionNode, DefinitionNode, DirectiveDefinitionNode, DirectiveNode, } from "graphql"; import { CoreImport, CoreOrLinkDirectiveArgs, CoreSpecDefinition, extractCoreFeatureImports, FeatureUrl, FeatureVersion, findCoreSpecVersion, isCoreSpecDirectiveApplication, removeAllCoreFeatures, } from "./specs/coreSpec"; import { assert, assertUnreachable, mapValues, MapWithCachedArrays, removeArrayElement, SetMultiMap } from "./utils"; import { withDefaultValues, valueEquals, valueToString, valueToAST, valueFromAST, valueNodeToConstValueNode, argumentsEquals, collectVariablesInValue } from "./values"; import { tagIdentity } from "./specs/tagSpec"; import { inaccessibleIdentity, removeInaccessibleElements } from "./specs/inaccessibleSpec"; import { printDirectiveDefinition, printSchema } from './print'; import { sameType } from './types'; import { addIntrospectionFields, introspectionFieldNames, isIntrospectionName } from "./introspection"; import { validateSDL } from "graphql/validation/validate"; import { SDLValidationRule } from "graphql/validation/ValidationContext"; import { specifiedSDLRules } from "graphql/validation/specifiedRules"; import { validateSchema } from "./validate"; import { createDirectiveSpecification, createScalarTypeSpecification, DirectiveSpecification, TypeSpecification } from "./directiveAndTypeSpecification"; import { didYouMean, suggestionList } from "./suggestions"; import { aggregateError, ERRORS, withModifiedErrorMessage } from "./error"; import { coreFeatureDefinitionIfKnown } from "./knownCoreFeatures"; const validationErrorCode = 'GraphQLValidationFailed'; const DEFAULT_VALIDATION_ERROR_MESSAGE = 'The schema is not a valid GraphQL schema.'; const EMPTY_SET = new Set<never>(); export const ErrGraphQLValidationFailed = (causes: GraphQLError[], message: string = DEFAULT_VALIDATION_ERROR_MESSAGE) => aggregateError(validationErrorCode, message, causes); const apiSchemaValidationErrorCode = 'GraphQLAPISchemaValidationFailed'; export const ErrGraphQLAPISchemaValidationFailed = (causes: GraphQLError[]) => aggregateError(apiSchemaValidationErrorCode, 'The supergraph schema failed to produce a valid API schema', causes); export const typenameFieldName = '__typename'; export type QueryRootKind = 'query'; export type MutationRootKind = 'mutation'; export type SubscriptionRootKind = 'subscription'; export type SchemaRootKind = QueryRootKind | MutationRootKind | SubscriptionRootKind; export const allSchemaRootKinds: SchemaRootKind[] = ['query', 'mutation', 'subscription']; export function defaultRootName(rootKind: SchemaRootKind): string { return rootKind.charAt(0).toUpperCase() + rootKind.slice(1); } function checkDefaultSchemaRoot(type: NamedType): SchemaRootKind | undefined { if (type.kind !== 'ObjectType') { return undefined; } switch (type.name) { case 'Query': return 'query'; case 'Mutation': return 'mutation'; case 'Subscription': return 'subscription'; default: return undefined; } } export function isSchemaRootType(type: NamedType): boolean { return isObjectType(type) && type.isRootType(); } export type Type = NamedType | WrapperType; export type NamedType = ScalarType | ObjectType | InterfaceType | UnionType | EnumType | InputObjectType; export type OutputType = ScalarType | ObjectType | InterfaceType | UnionType | EnumType | ListType<any> | NonNullType<any>; export type InputType = ScalarType | EnumType | InputObjectType | ListType<any> | NonNullType<any>; export type WrapperType = ListType<any> | NonNullType<any>; export type AbstractType = InterfaceType | UnionType; export type CompositeType = ObjectType | InterfaceType | UnionType; export type OutputTypeReferencer = FieldDefinition<any>; export type InputTypeReferencer = InputFieldDefinition | ArgumentDefinition<any>; export type ObjectTypeReferencer = OutputTypeReferencer | UnionType | SchemaDefinition; export type InterfaceTypeReferencer = OutputTypeReferencer | ObjectType | InterfaceType; export type NullableType = NamedType | ListType<any>; export type NamedTypeKind = NamedType['kind']; export function isNamedType(type: Type): type is NamedType { return type instanceof BaseNamedType; } export function isWrapperType(type: Type): type is WrapperType { return isListType(type) || isNonNullType(type); } export function isListType(type: Type): type is ListType<any> { return type.kind == 'ListType'; } export function isNonNullType(type: Type): type is NonNullType<any> { return type.kind == 'NonNullType'; } export function isScalarType(type: Type): type is ScalarType { return type.kind == 'ScalarType'; } export function isCustomScalarType(type: Type): boolean { return isScalarType(type) && !graphQLBuiltInTypes.includes(type.name); } export function isIntType(type: Type): boolean { return type === type.schema().intType(); } export function isStringType(type: Type): boolean { return type === type.schema().stringType(); } export function isFloatType(type: Type): boolean { return type === type.schema().floatType(); } export function isBooleanType(type: Type): boolean { return type === type.schema().booleanType(); } export function isIDType(type: Type): boolean { return type === type.schema().idType(); } export function isObjectType(type: Type): type is ObjectType { return type.kind == 'ObjectType'; } export function isInterfaceType(type: Type): type is InterfaceType { return type.kind == 'InterfaceType'; } export function isEnumType(type: Type): type is EnumType { return type.kind == 'EnumType'; } export function isUnionType(type: Type): type is UnionType { return type.kind == 'UnionType'; } export function isInputObjectType(type: Type): type is InputObjectType { return type.kind == 'InputObjectType'; } export function isOutputType(type: Type): type is OutputType { switch (baseType(type).kind) { case 'ScalarType': case 'ObjectType': case 'UnionType': case 'EnumType': case 'InterfaceType': return true; default: return false; } } export function isInputType(type: Type): type is InputType { switch (baseType(type).kind) { case 'ScalarType': case 'EnumType': case 'InputObjectType': return true; default: return false; } } export function isTypeOfKind<T extends Type>(type: Type, kind: T['kind']): type is T { return type.kind === kind; } export function filterTypesOfKind<T extends Type>(types: readonly Type[], kind: T['kind']): T[] { return types.reduce( (acc: T[], type: Type) => { if (isTypeOfKind(type, kind)) { acc.push(type); } return acc; }, [], ); } export function baseType(type: Type): NamedType { return isWrapperType(type) ? type.baseType() : type; } export function isNullableType(type: Type): boolean { return !isNonNullType(type); } export function isAbstractType(type: Type): type is AbstractType { return isInterfaceType(type) || isUnionType(type); } export function isCompositeType(type: Type): type is CompositeType { return isObjectType(type) || isInterfaceType(type) || isUnionType(type); } export function possibleRuntimeTypes(type: CompositeType): readonly ObjectType[] { switch (type.kind) { case 'InterfaceType': return type.possibleRuntimeTypes(); case 'UnionType': return type.types(); case 'ObjectType': return [type]; } } export function runtimeTypesIntersects(t1: CompositeType, t2: CompositeType): boolean { if (t1 === t2) { return true; } const rt1 = possibleRuntimeTypes(t1); const rt2 = possibleRuntimeTypes(t2); for (const obj1 of rt1) { if (rt2.some(obj2 => obj1.name === obj2.name)) { return true; } } return false; } export function supertypes(type: CompositeType): readonly CompositeType[] { switch (type.kind) { case 'InterfaceType': return type.interfaces(); case 'UnionType': return []; case 'ObjectType': return (type.interfaces() as CompositeType[]).concat(type.unionsWhereMember()); } } export function isConditionalDirective(directive: Directive<any, any> | DirectiveDefinition<any>): boolean { return ['include', 'skip'].includes(directive.name); } export const executableDirectiveLocations: DirectiveLocation[] = [ DirectiveLocation.QUERY, DirectiveLocation.MUTATION, DirectiveLocation.SUBSCRIPTION, DirectiveLocation.FIELD, DirectiveLocation.FRAGMENT_DEFINITION, DirectiveLocation.FRAGMENT_SPREAD, DirectiveLocation.INLINE_FRAGMENT, DirectiveLocation.VARIABLE_DEFINITION, ]; const executableDirectiveLocationsSet = new Set(executableDirectiveLocations); export function isExecutableDirectiveLocation(loc: DirectiveLocation): boolean { return executableDirectiveLocationsSet.has(loc); } export const typeSystemDirectiveLocations: DirectiveLocation[] = [ DirectiveLocation.SCHEMA, DirectiveLocation.SCALAR, DirectiveLocation.OBJECT, DirectiveLocation.FIELD_DEFINITION, DirectiveLocation.ARGUMENT_DEFINITION, DirectiveLocation.INTERFACE, DirectiveLocation.UNION, DirectiveLocation.ENUM, DirectiveLocation.ENUM_VALUE, DirectiveLocation.INPUT_OBJECT, DirectiveLocation.INPUT_FIELD_DEFINITION, ]; const typeSystemDirectiveLocationsSet = new Set(typeSystemDirectiveLocations); export function isTypeSystemDirectiveLocation(loc: DirectiveLocation): boolean { return typeSystemDirectiveLocationsSet.has(loc); } /** * Converts a type to an AST of a "reference" to that type, one corresponding to the type `toString()` (and thus never a type definition). * * To print a type definition, see the `printTypeDefinitionAndExtensions` method. */ export function typeToAST(type: Type): TypeNode { switch (type.kind) { case 'ListType': return { kind: Kind.LIST_TYPE, type: typeToAST(type.ofType) }; case 'NonNullType': return { kind: Kind.NON_NULL_TYPE, type: typeToAST(type.ofType) as NamedTypeNode | ListTypeNode }; default: return { kind: Kind.NAMED_TYPE, name: { kind: Kind.NAME, value: type.name } }; } } export function typeFromAST(schema: Schema, node: TypeNode): Type { switch (node.kind) { case Kind.LIST_TYPE: return new ListType(typeFromAST(schema, node.type)); case Kind.NON_NULL_TYPE: return new NonNullType(typeFromAST(schema, node.type) as NullableType); default: const type = schema.type(node.name.value); if (!type) { throw ERRORS.INVALID_GRAPHQL.err(`Unknown type "${node.name.value}"`, { nodes: node }); } return type; } } export type LeafType = ScalarType | EnumType; export function isLeafType(type: Type): type is LeafType { return isScalarType(type) || isEnumType(type); } export interface Named { readonly name: string; } export type ExtendableElement = SchemaDefinition | NamedType; export class DirectiveTargetElement<T extends DirectiveTargetElement<T>> { readonly appliedDirectives: Directive<T>[]; constructor( private readonly _schema: Schema, directives: readonly Directive<any>[] = [], ) { this.appliedDirectives = directives.map((d) => this.attachDirective(d)); } schema(): Schema { return this._schema; } private attachDirective(directive: Directive<any>): Directive<T> { // if the directive is not attached, we can assume we're fine just attaching it to use. Otherwise, we're "copying" it. const toAdd = directive.isAttached() ? new Directive(directive.name, directive.arguments()) : directive; Element.prototype['setParent'].call(toAdd, this); return toAdd; } appliedDirectivesOf<TApplicationArgs extends {[key: string]: any} = {[key: string]: any}>(nameOrDefinition: string | DirectiveDefinition<TApplicationArgs>): Directive<T, TApplicationArgs>[] { const directiveName = typeof nameOrDefinition === 'string' ? nameOrDefinition : nameOrDefinition.name; return this.appliedDirectives.filter(d => d.name == directiveName) as Directive<T, TApplicationArgs>[]; } hasAppliedDirective(nameOrDefinition: string | DirectiveDefinition): boolean { const directiveName = typeof nameOrDefinition === 'string' ? nameOrDefinition : nameOrDefinition.name; return this.appliedDirectives.some(d => d.name == directiveName); } appliedDirectivesToDirectiveNodes() : ConstDirectiveNode[] | undefined { return directivesToDirectiveNodes(this.appliedDirectives); } appliedDirectivesToString(): string { return directivesToString(this.appliedDirectives); } collectVariablesInAppliedDirectives(collector: VariableCollector) { for (const applied of this.appliedDirectives) { collector.collectInArguments(applied.arguments()); } } } export function sourceASTs<TNode extends ASTNode = ASTNode>(...elts: ({ sourceAST?: TNode } | undefined)[]): TNode[] { return elts.map(elt => elt?.sourceAST).filter((elt): elt is TNode => elt !== undefined); } // Not exposed: mostly about avoid code duplication between SchemaElement and Directive (which is not a SchemaElement as it can't // have applied directives or a description abstract class Element<TParent extends SchemaElement<any, any> | Schema | DirectiveTargetElement<any>> { protected _parent?: TParent; sourceAST?: ASTNode; schema(): Schema { const schema = this.schemaInternal(); assert(schema, 'requested schema does not exist. Probably because the element is unattached'); return schema; } // this function exists because sometimes we can have an element that will be attached soon even though the current state is unattached // (mainly for callbacks). Sometimes these intermediate states need to get the schema if it exists, but it may not. // all external clients should use schema() protected schemaInternal(): Schema | undefined { if (!this._parent) { return undefined; } else if (this._parent instanceof Schema) { // Note: at the time of this writing, it seems like typescript type-checking breaks a bit around generics. // At this point of the code, `this._parent` is typed as 'TParent & Schema', but for some reason this is // "not assignable to type 'Schema | undefined'" (which sounds wrong: if my type theory is not too broken, // 'A & B' should always be assignable to both 'A' and 'B'). return this._parent as any; } else if (this._parent instanceof SchemaElement) { return this._parent.schemaInternal(); } else if (this._parent instanceof DirectiveTargetElement) { return this._parent.schema(); } assert(false, 'unreachable code. parent is of unknown type'); } get parent(): TParent { assert(this._parent, 'trying to access non-existent parent'); return this._parent; } isAttached(): boolean { return !!this._parent; } // Accessed only through Element.prototype['setParent'] (so we don't mark it protected as an override wouldn't be properly called). private setParent(parent: TParent) { assert(!this._parent, "Cannot set parent of an already attached element"); this._parent = parent; this.onAttached(); } protected onAttached() { // Nothing by default, but can be overriden. } protected checkUpdate() { // Allowing to add element to a detached element would get hairy. Because that would mean that when you do attach an element, // you have to recurse within that element to all children elements to check whether they are attached or not and to which // schema. And if they aren't attached, attaching them as side-effect could be surprising (think that adding a single field // to a schema could bring a whole hierarchy of types and directives for instance). If they are attached, it only work if // it's to the same schema, but you have to check. // Overall, it's simpler to force attaching elements before you add other elements to them. assert(this.isAttached(), () => `Cannot modify detached element ${this}`); } } export class Extension<TElement extends ExtendableElement> { protected _extendedElement?: TElement; sourceAST?: ASTNode; get extendedElement(): TElement | undefined { return this._extendedElement; } private setExtendedElement(element: TElement) { assert(!this._extendedElement, "Cannot attached already attached extension"); this._extendedElement = element; } } type UnappliedDirective = { nameOrDef: DirectiveDefinition<Record<string, any>> | string, args: Record<string, any>, extension?: Extension<any>, directive: DirectiveNode, }; // TODO: ideally, we should hide the ctor of this class as we rely in places on the fact the no-one external defines new implementations. export abstract class SchemaElement<TOwnType extends SchemaElement<any, TParent>, TParent extends SchemaElement<any, any> | Schema> extends Element<TParent> { protected _appliedDirectives: Directive<TOwnType>[] | undefined; protected _unappliedDirectives: UnappliedDirective[] | undefined; description?: string; addUnappliedDirective({ nameOrDef, args, extension, directive }: UnappliedDirective) { const toAdd = { nameOrDef, args: args ?? {}, extension, directive, }; if (this._unappliedDirectives) { this._unappliedDirectives.push(toAdd); } else { this._unappliedDirectives = [toAdd]; } } processUnappliedDirectives() { for (const { nameOrDef, args, extension, directive } of this._unappliedDirectives ?? []) { const d = this.applyDirective(nameOrDef, args); d.setOfExtension(extension); d.sourceAST = directive; } this._unappliedDirectives = undefined; } get appliedDirectives(): readonly Directive<TOwnType>[] { return this._appliedDirectives ?? []; } appliedDirectivesOf<TApplicationArgs extends {[key: string]: any} = {[key: string]: any}>(nameOrDefinition: string | DirectiveDefinition<TApplicationArgs>): Directive<TOwnType, TApplicationArgs>[] { const directiveName = typeof nameOrDefinition === 'string' ? nameOrDefinition : nameOrDefinition.name; return this.appliedDirectives.filter(d => d.name == directiveName) as Directive<TOwnType, TApplicationArgs>[]; } hasAppliedDirective(nameOrDefinition: string | DirectiveDefinition<any>): boolean { // From the type-system point of view, there is no `appliedDirectivesOf(_: string | DirectiveDefinition)` function, but rather 2 overloads, neither of // which can take 'string | DirectiveDefinition', hence the need for this surprisingly looking code. And we don't really want to remove the overloading // on `appliedDirectivesOf` because that would lose us the type-checking of arguments in the case where we pass a definition (or rather, we could // preserve it, but it would make is a bit too easy to mess up calls with the 'string' argument). return (typeof nameOrDefinition === 'string' ? this.appliedDirectivesOf(nameOrDefinition) : this.appliedDirectivesOf(nameOrDefinition) ).length !== 0; } applyDirective<TApplicationArgs extends {[key: string]: any} = {[key: string]: any}>( nameOrDef: DirectiveDefinition<TApplicationArgs> | string, args?: TApplicationArgs, asFirstDirective: boolean = false, ): Directive<TOwnType, TApplicationArgs> { let toAdd: Directive<TOwnType, TApplicationArgs>; if (typeof nameOrDef === 'string') { this.checkUpdate(); toAdd = new Directive<TOwnType, TApplicationArgs>(nameOrDef, args ?? Object.create(null)); const def = this.schema().directive(nameOrDef) ?? this.schema().blueprint.onMissingDirectiveDefinition(this.schema(), toAdd); if (!def) { throw this.schema().blueprint.onGraphQLJSValidationError( this.schema(), ERRORS.INVALID_GRAPHQL.err(`Unknown directive "@${nameOrDef}".`) ); } if (Array.isArray(def)) { throw ErrGraphQLValidationFailed(def); } } else { this.checkUpdate(nameOrDef); toAdd = new Directive<TOwnType, TApplicationArgs>(nameOrDef.name, args ?? Object.create(null)); } Element.prototype['setParent'].call(toAdd, this); // TODO: we should typecheck arguments or our TApplicationArgs business is just a lie. if (this._appliedDirectives) { if (asFirstDirective) { this._appliedDirectives.unshift(toAdd); } else { this._appliedDirectives.push(toAdd); } } else { this._appliedDirectives = [toAdd]; } DirectiveDefinition.prototype['addReferencer'].call(toAdd.definition!, toAdd); this.onModification(); return toAdd; } protected removeAppliedDirectives() { // We copy the array because this._appliedDirectives is modified in-place by `directive.remove()` if (!this._appliedDirectives) { return; } const applied = this._appliedDirectives.concat(); applied.forEach(d => d.remove()); } protected onModification() { const schema = this.schemaInternal(); if (schema) { Schema.prototype['onModification'].call(schema); } } protected isElementBuiltIn(): boolean { return false; } protected removeTypeReferenceInternal(type: BaseNamedType<any, any>) { // This method is a bit of a hack: we don't want to expose it and we call it from an other class, so we call it though // `SchemaElement.prototype`, but we also want this to abstract as it can only be implemented by each concrete subclass. // As we can't have both at the same time, this method just delegate to `remoteTypeReference` which is genuinely // abstract. This also allow to work around the typing issue that the type checker cannot tell that every BaseNamedType // is a NamedType (because in theory, someone could extend BaseNamedType without listing it in NamedType; but as // BaseNamedType is not exported and we don't plan to make that mistake ...). this.removeTypeReference(type as any); } protected abstract removeTypeReference(type: NamedType): void; protected checkRemoval() { assert(!this.isElementBuiltIn() || Schema.prototype['canModifyBuiltIn'].call(this.schema()), () => `Cannot modify built-in ${this}`); // We allow removals even on detached element because that doesn't particularly create issues (and we happen to do such // removals on detached internally; though of course we could refactor the code if we wanted). } protected checkUpdate(addedElement?: { schema(): Schema, isAttached(): boolean }) { super.checkUpdate(); if (!Schema.prototype['canModifyBuiltIn'].call(this.schema())) { // Ensure this element (the modified one), is not a built-in, or part of one. let thisElement: SchemaElement<TOwnType, any> | Schema | undefined = this; while (thisElement && thisElement instanceof SchemaElement) { assert(!thisElement.isElementBuiltIn(), () => `Cannot modify built-in (or part of built-in) ${this}`); thisElement = thisElement.parent; } } if (addedElement && addedElement.isAttached()) { const thatSchema = addedElement.schema(); assert(!thatSchema || thatSchema === this.schema(), () => `Cannot add element ${addedElement} to ${this} as it is attached to another schema`); } } } // TODO: ideally, we should hide the ctor of this class as we rely in places on the fact the no-one external defines new implementations. export abstract class NamedSchemaElement<TOwnType extends NamedSchemaElement<TOwnType, TParent, TReferencer>, TParent extends NamedSchemaElement<any, any, any> | Schema, TReferencer> extends SchemaElement<TOwnType, TParent> implements Named { // We want to be able to rename some elements, but we prefer offering that through a `rename` // method rather than exposing a name setter, as this feel more explicit (but that's arguably debatable). // We also currently only offer renames on types (because that's the only one we currently need), // though we could expand that. protected _name: string; constructor(name: string) { super(); this._name = name; } get name(): string { return this._name; } abstract coordinate: string; abstract remove(): TReferencer[]; } abstract class BaseNamedType<TReferencer, TOwnType extends NamedType & NamedSchemaElement<TOwnType, Schema, TReferencer>> extends NamedSchemaElement<TOwnType, Schema, TReferencer> { protected _referencers?: Set<TReferencer>; protected _extensions?: Extension<TOwnType>[]; public preserveEmptyDefinition: boolean = false; constructor(name: string, readonly isBuiltIn: boolean = false) { super(name); } private addReferencer(referencer: TReferencer) { this._referencers ??= new Set(); this._referencers.add(referencer); } private removeReferencer(referencer: TReferencer) { this._referencers?.delete(referencer) } get coordinate(): string { return this.name; } *allChildElements(): Generator<NamedSchemaElement<any, TOwnType, any>, void, undefined> { // Overriden by those types that do have children } extensions(): readonly Extension<TOwnType>[] { return this._extensions ?? []; } hasExtension(extension: Extension<any>): boolean { return this._extensions?.includes(extension) ?? false; } newExtension(): Extension<TOwnType> { return this.addExtension(new Extension<TOwnType>()); } addExtension(extension: Extension<TOwnType>): Extension<TOwnType> { this.checkUpdate(); // Let's be nice and not complaint if we add an extension already added. if (this.hasExtension(extension)) { return extension; } assert(!extension.extendedElement, () => `Cannot add extension to type ${this}: it is already added to another type`); if (this._extensions) { this._extensions.push(extension); } else { this._extensions = [ extension ]; } Extension.prototype['setExtendedElement'].call(extension, this); this.onModification(); return extension; } removeExtensions() { if (!this._extensions) { return; } this._extensions = undefined; for (const directive of this.appliedDirectives) { directive.removeOfExtension(); } this.removeInnerElementsExtensions(); } isIntrospectionType(): boolean { return isIntrospectionName(this.name); } hasExtensionElements(): boolean { return !!this._extensions; } hasNonExtensionElements(): boolean { return this.preserveEmptyDefinition || this.appliedDirectives.some(d => d.ofExtension() === undefined) || this.hasNonExtensionInnerElements(); } protected abstract hasNonExtensionInnerElements(): boolean; protected abstract removeInnerElementsExtensions(): void; protected isElementBuiltIn(): boolean { return this.isBuiltIn; } rename(newName: string) { // Mostly called to ensure we don't rename built-in types. It does mean we can't renamed detached // types while this wouldn't be dangerous, but it's probably not a big deal (the API is designed // in such a way that you probably should avoid reusing detached elements). this.checkUpdate(); const oldName = this._name; this._name = newName; Schema.prototype['renameTypeInternal'].call(this._parent, oldName, newName); this.onModification(); } /** * Removes this type definition from its parent schema. * * After calling this method, this type will be "detached": it will have no parent, schema, fields, * values, directives, etc... * * Note that it is always allowed to remove a type, but this may make a valid schema * invalid, and in particular any element that references this type will, after this call, have an undefined * reference. * * @returns an array of all the elements in the schema of this type (before the removal) that were * referencing this type (and have thus now an undefined reference). */ remove(): TReferencer[] { if (!this._parent) { return []; } this.checkRemoval(); this.onModification(); // Remove this type's children. this.sourceAST = undefined; this.removeAppliedDirectives(); this.removeInnerElements(); // Remove this type's references. const toReturn: TReferencer[] = []; this._referencers?.forEach(r => { SchemaElement.prototype['removeTypeReferenceInternal'].call(r, this); toReturn.push(r); }); this._referencers = undefined; // Remove this type from its parent schema. Schema.prototype['removeTypeInternal'].call(this._parent, this); this._parent = undefined; return toReturn; } /** * Removes this this definition _and_, recursively, any other elements that references this type and would be invalid * after the removal. * * Note that contrarily to `remove()` (which this method essentially call recursively), this method leaves the schema * valid (assuming it was valid beforehand) _unless_ all the schema ends up being removed through recursion (in which * case this leaves an empty schema, and that is not technically valid). * * Also note that this method does _not_ necessarily remove all the elements that reference this type: for instance, * if this type is an interface, objects implementing it will _not_ be removed, they will simply stop implementing * the interface. In practice, this method mainly remove fields that were using the removed type (in either argument or * return type), but it can also remove object/input object/interface if through such field removal some type ends up * empty, and it can remove unions if through that removal process and union becomes empty. */ removeRecursive(): void { this.remove().forEach(ref => this.removeReferenceRecursive(ref)); } protected abstract removeReferenceRecursive(ref: TReferencer): void; referencers(): ReadonlySet<TReferencer> { return this._referencers ?? EMPTY_SET; } isReferenced(): boolean { return !!this._referencers; } protected abstract removeInnerElements(): void; toString(): string { return this.name; } } // TODO: ideally, we should hide the ctor of this class as we rely in places on the fact the no-one external defines new implementations. export abstract class NamedSchemaElementWithType<TType extends Type, TOwnType extends NamedSchemaElementWithType<TType, TOwnType, P, Referencer>, P extends NamedSchemaElement<any, any, any> | Schema, Referencer> extends NamedSchemaElement<TOwnType, P, Referencer> { private _type?: TType; get type(): TType | undefined { return this._type; } set type(type: TType | undefined) { if (type) { this.checkUpdate(type); } else { this.checkRemoval(); } if (this._type) { removeReferenceToType(this, this._type); } this._type = type; if (type) { addReferenceToType(this, type); } } protected removeTypeReference(type: NamedType) { // We shouldn't have been listed as a reference if we're not one, so make it sure. assert(this._type && baseType(this._type) === type, () => `Cannot remove reference to type ${type} on ${this} as its type is ${this._type}`); this._type = undefined; } } abstract class BaseExtensionMember<TExtended extends ExtendableElement> extends Element<TExtended> { private _extension?: Extension<TExtended>; ofExtension(): Extension<TExtended> | undefined { return this._extension; } removeOfExtension() { this._extension = undefined; } setOfExtension(extension: Extension<TExtended> | undefined) { this.checkUpdate(); assert(!extension || this._parent?.hasExtension(extension), () => `Cannot set object as part of the provided extension: it is not an extension of parent ${this.parent}`); this._extension = extension; } remove() { this.removeInner(); Schema.prototype['onModification'].call(this.schema()); this._extension = undefined; this._parent = undefined; } protected abstract removeInner(): void; } export class SchemaBlueprint { onMissingDirectiveDefinition(_schema: Schema, _directive: Directive): DirectiveDefinition | GraphQLError[] | undefined { // No-op by default, but used for federation. return undefined; } onDirectiveDefinitionAndSchemaParsed(_: Schema): GraphQLError[] { // No-op by default, but used for federation. return []; } ignoreParsedField(_type: NamedType, _fieldName: string): boolean { // No-op by default, but used for federation. return false; } onConstructed(_: Schema) { // No-op by default, but used for federation. } onAddedCoreFeature(_schema: Schema, _feature: CoreFeature) { // No-op by default, but used for federation. } onInvalidation(_: Schema) { // No-op by default, but used for federation. } onValidation(_schema: Schema): GraphQLError[] { // No-op by default, but used for federation. return [] } validationRules(): readonly SDLValidationRule[] { return specifiedSDLRules; } /** * Allows to intercept some graphQL-js error messages when we can provide additional guidance to users. */ onGraphQLJSValidationError(schema: Schema, error: GraphQLError): GraphQLError { // For now, the main additional guidance we provide is around directives, where we could provide additional help in 2 main ways: // - if a directive name is likely misspelled (somehow, graphQL-js has methods to offer suggestions on likely mispelling, but don't use this (at the // time of this writting) for directive names). // - for fed 2 schema, if a federation directive is refered under it's "default" naming but is not properly imported (not enforced // in the method but rather in the `FederationBlueprint`). // // Note that intercepting/parsing error messages to modify them is never ideal, but pragmatically, it's probably better than rewriting the relevant // rules entirely (in that later case, our "copied" rule would stop getting any potential graphQL-js made improvements for instance). And while such // parsing is fragile, in that it'll break if the original message change, we have unit tests to surface any such breakage so it's not really a risk. const matcher = /^Unknown directive "@(?<directive>[_A-Za-z][_0-9A-Za-z]*)"\.$/.exec(error.message); const name = matcher?.groups?.directive; if (!name) { return error; } const allDefinedDirectiveNames = schema.allDirectives().map((d) => d.name); const suggestions = suggestionList(name, allDefinedDirectiveNames); if (suggestions.length === 0) { return this.onUnknownDirectiveValidationError(schema, name, error); } else { return withModifiedErrorMessage(error, `${error.message}${didYouMean(suggestions.map((s) => '@' + s))}`); } } onUnknownDirectiveValidationError(_schema: Schema, _unknownDirectiveName: string, error: GraphQLError): GraphQLError { return error; } applyDirectivesAfterParsing() { return false; } } export const defaultSchemaBlueprint = new SchemaBlueprint(); export class CoreFeature { constructor( readonly url: FeatureUrl, readonly nameInSchema: string, readonly directive: Directive<SchemaDefinition>, readonly imports: CoreImport[], readonly purpose?: string, ) { } isFeatureDefinition(element: NamedType | DirectiveDefinition): boolean { const importName = element.kind === 'DirectiveDefinition' ? '@' + element.name : element.name; return element.name.startsWith(this.nameInSchema + '__') || (element.kind === 'DirectiveDefinition' && element.name === this.nameInSchema) || !!this.imports.find((i) => importName === (i.as ?? i.name)); } directiveNameInSchema(name: string): string { return CoreFeature.directiveNameInSchemaForCoreArguments( this.url, this.nameInSchema, this.imports, name, ); } static directiveNameInSchemaForCoreArguments( specUrl: FeatureUrl, specNameInSchema: string, imports: CoreImport[], directiveNameInSpec: string, ): string { const elementImport = imports.find((i) => i.name.charAt(0) === '@' && i.name.slice(1) === directiveNameInSpec ); return elementImport ? (elementImport.as?.slice(1) ?? directiveNameInSpec) : (directiveNameInSpec === specUrl.name ? specNameInSchema : specNameInSchema + '__' + directiveNameInSpec ); } typeNameInSchema(name: string): string { const elementImport = this.imports.find((i) => i.name === name); return elementImport ? (elementImport.as ?? name) : this.nameInSchema + '__' + name; } minimumFederationVersion(): FeatureVersion | undefined { return coreFeatureDefinitionIfKnown(this.url)?.minimumFederationVersion; } } export type ImportConflictsByIdentity = Map< string, { self: Set<string>, other: Set<string> } >; export class CoreFeatures { readonly coreDefinition: CoreSpecDefinition; /** * For specs, a map from their name-in-schemas (a.k.a. aliases) to their * CoreFeatures. */ private readonly byAlias: Map<string, CoreFeature> = new Map(); /** * For specs, a map from their identities to their CoreFeatures plus another * map from imported type/directive name-in-specs to name-in-schemas. Like * imports, we distinguish types from directives by using a leading "@". */ private readonly byIdentity: Map<string, [CoreFeature, Map<string, string>]> = new Map(); /** * For imported types/directives, this is a map from their name-in-schemas to * their CoreFeatures plus name-in-specs. Like imports, we distinguish types * from directives by using a leading "@". */ private readonly byImportName: Map<string, [CoreFeature, string]> = new Map(); /** * For composed elements, merge will generally keep the name-in-schemas of * spec elements in subgraphs as a way to minimize conflicts while keeping * element names predictable for user-defined downstream code. However, merge * will also sometimes change the spec of certain spec elements (e.g. of a * federation spec directive). The result of this is that sometimes elements * using a default name of one spec may be imported using another spec, so we * need to permit e.g. the cost spec to import "@cost" as "@federation__cost" * in the supergraph schema. This kind of thing is generally fine, provided * the old spec alias is no longer in use in the supergraph schema. * * So whenever an import occurs with a name-in-schema that uses a spec alias * prefix that isn't in the schema, we store an entry here from the yet-unused * spec alias to the name-in-schema. This lets us easily lookup those elements * in `this.byImportName` if that spec alias ends up getting used later and * we need to generate an error message. (You might think we only need to * remember one example for error messages, but because we can remove features * we need to remember all of them.) */ private readonly conflictsByAlias: SetMultiMap<string, string> = new SetMultiMap(); constructor(readonly coreItself: CoreFeature) { this.add(coreItself); const coreDef = findCoreSpecVersion(coreItself.url); if (!coreDef) { throw ERRORS.UNKNOWN_LINK_VERSION.err(`Schema uses unknown version ${coreItself.url.version} of the ${coreItself.url.name} spec`); } this.coreDefinition = coreDef; } getByIdentity(identity: string): CoreFeature | undefined { return this.byIdentity.get(identity)?.[0]; } allFeatures(): CoreFeature[] { return [...this.byIdentity.values()].map(([feature]) => feature); } private removeFeature(featureIdentity: string) { const entry = this.byIdentity.get(featureIdentity); if (entry) { const [feature] = entry; this.byIdentity.delete(featureIdentity); const alias = feature.nameInSchema; this.byAlias.delete(alias); for (const { name: importInSpec, as } of feature.imports) { const importInSchema = as ?? importInSpec; const isDirective = importInSpec.charAt(0) === "@"; const nameInSchema = isDirective ? importInSchema.slice(1) : importInSchema; this.byImportName.delete(importInSchema); const split = CoreFeatures.splitPrefixedName(nameInSchema); if (!split) { continue; } const [splitAlias] = split; if (splitAlias === alias) { continue; } let conflicts = this.conflictsByAlias.get(importInSchema); if (!conflicts) { continue; } conflicts.delete(importInSchema); if (conflicts.size) { continue; } this.conflictsByAlias.delete(importInSchema); } } } private maybeAddFeature(directive: Directive<SchemaDefinition>): CoreFeature | undefined { if (directive.definition?.name !== this.coreItself.nameInSchema) { return undefined; } const typedDirective = directive as Directive<SchemaDefinition, CoreOrLinkDirectiveArgs> const args = typedDirective.arguments(); const url = this.coreDefinition.extractFeatureUrl(args); const imports = extractCoreFeatureImports(url, typedDirective); const feature = new CoreFeature(url, args.as ?? url.name, directive, imports, args.for); this.add(feature); directive.schema().blueprint.onAddedCoreFeature(directive.schema(), feature); return feature; } private add(feature: CoreFeature) { const identity = feature.url.identity; // The identity can't already be mapped to another @link/CoreFeature. (Even // when they're different major versions, they're usually describing the // same capabilities but in incompatible ways, so we don't want to allow // the same schema to try to use multiple of them.) if (this.byIdentity.has(identity)) { throw ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Cannot link feature "${identity}" since it has already been linked in the schema.`, ); } const alias = feature.nameInSchema; // Normally we'd always forbid "__" in aliases. However, there are some // older supergraph schemas that link the "tag" and "inaccessible" specs to // the aliases "federation__tag" and "federation__inaccessible". This is // due to bugs in older versions of composition, but is technically fine // since these specs have no types and directives other than the default // directive, so they never prefix anything with "__". So we make a very // specific exception here for that case. We may remove this exception in // the future, once support has been dropped for those bugged composition // versions. if ( !(identity === tagIdentity && alias === 'federation__tag' && feature.imports.length === 0) && !(identity === inaccessibleIdentity && alias === 'federation__inaccessible' && feature.imports.length === 0) ) { // Don't allow spec name-in-schemas/aliases to have "__" in them, as // namespace splitting splits on the earliest "__" (so a namespaced name // with an alias containing "__" would be erroneously split mid-alias). if (alias.indexOf('__') !== -1) { throw ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Cannot link feature "${identity}" as "${alias}" since it contains "__". Please rename to a compliant name via "as".`, ); } } // Don't allow spec name-in-schemas/aliases to end in "_", as namespace // splitting splits on the earliest "__" (so a namespaced name with an alias // ending with "_" would end up with "___", and be split before the ending // "_" instead of after). if (alias.charAt(alias.length - 1) === '_') { throw ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Cannot link feature "${identity}" as "${alias}" since it ends in "_". Please rename to a compliant name via "as".`, ); } // Ideally here, we wouldn't allow spec name-in-schemas/aliases to not be // valid GraphQL names. However, enough supergraph schemas use "." and "-" // after the first character that we can't impose that validation now. So // instead, we match using a slightly relaxed regex than allows "." and "-" // after the first character. For schemas that have "." or "-", they won't // be able to use namespaced names for their spec schema elements due to // GraphQL validation, but imports will still work. // // Note the error message below purposely says "not a valid GraphQL name" // because we want to encourage users to actually use GraphQL names and // avoid creating more exceptional cases. if (!aliasRegexp.test(alias)) { throw ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Cannot link feature "${identity}" as "${alias}" since it is not a valid GraphQL name. Please rename to a compliant name via "as".`, ); } // Don't allow spec name-in-schemas/aliases to conflict with previous // imports using "__" with that alias. const conflicts = this.conflictsByAlias.get(alias); if (conflicts) { const importInSchema = conflicts?.values()?.next()?.value; assert(importInSchema !== undefined, `Unexpectedly empty conflicts set`); const entry = this.byImportName.get(importInSchema); assert(entry, `Unexpectedly cannot find feature for import`); const [conflictFeature, importInSpec] = entry; const conflictIdentity = conflictFeature.url.identity; this.checkTagInaccessibleConflict(conflictIdentity, identity); const importInErrorMessage = importInSchema !== importInSpec ? `"${importInSpec}" as "${importInSchema}"` : `"${importInSpec}"`; throw ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Cannot import ${importInErrorMessage} from feature "${conflictIdentity}" since it can be confused with a namespaced name from another linked feature "${identity}". Please rename the import or feature to avoid conflicts via "as".`, ); } // Don't allow spec name-in-schemas/aliases to have default directive names // that conflict with previous imports. const importInSchema = "@" + alias; const entry = this.byImportName.get(importInSchema); if (entry) { const [conflictFeature, importInSpec] = entry; const conflictIdentity = conflictFeature.url.identity; this.checkTagInaccessibleConflict(conflictIdentity, identity); const importInErrorMessage = importInSchema !== importInSpec ? `"${importInSpec}" as "${importInSchema}"` : `"${importInSpec}"`; throw ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Cannot import ${importInErrorMessage} from feature "${conflictIdentity}" since it can be confused with a namespaced name from another linked feature "${identity}". Please rename the import or feature to avoid conflicts via "as".`, ); } // The alias can't be already mapped to another @link/CoreFeature. const existingFeature = this.byAlias.get(alias); if (existingFeature !== undefined) { const existingIdentity = existingFeature.url.identity; this.checkTagInaccessibleConflict(existingIdentity, identity); throw ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Cannot link feature ${identity} as "${alias}" since another feature "${existingIdentity}" already uses that alias. Please rename the feature to avoid conflicts via "as".`, ); } const importsMap: Map<string, string> = new Map(); for (const { name: importInSpec, as } of feature.imports) { const importInSchema = as ?? importInSpec; const importInErrorMessage = importInSchema !== importInSpec ? `"${importInSpec}" as "${importInSchema}"` : `"${importInSpec}"`; const isDirective = importInSpec.charAt(0) === "@"; const nameInSpec = isDirective ? importInSpec.slice(1) : importInSpec; const nameInSchema = isDirective ? importInSchema.slice(1) : importInSchema; // Only allow mapping to a name with "__" if it's a no-op import or if // it uses a non-existent spec alias. const split = CoreFeatures.splitPrefixedName(nameInSchema); if (split) { const [splitAlias, splitNameInSpec] = split; if (splitAlias === alias) { if (splitNameInSpec !== nameInSpec) { const splitImportInSpec = isDirective ? "@" + splitNameInSpec : splitNameInSpec; throw ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Cannot import ${importInErrorMessage} from feature "${identity}" since it can be confused with the namespaced name for "${splitImportInSpec}". Please rename the import to avoid conflicts via "as".`, ); } } else { const conflictFeature = this.byAlias.get(splitAlias); if (conflictFeature) { const conflictIdentity = conflictFeature.url.identity; this.checkTagInaccessibleConflict(conflictIdentity, identity); throw ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Cannot import ${importInErrorMessage} from feature "${identity}" since it can be confused with a namespaced name from another linked feature "${conflictIdentity}". Please rename the import or feature to avoid conflicts via "as".`, ); } else { // As mentioned in the docs for `this.conflictsByAlias`, we have to // record the import in case a feature gets added with the spec // alias later. this.conflictsByAlias.add(splitAlias, importInSchema); } } } //