UNPKG

@apollo/federation-internals

Version:
1,344 lines (1,152 loc) 138 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, mapValues, MapWithCachedArrays, removeArrayElement } from "./utils"; import { withDefaultValues, valueEquals, valueToString, valueToAST, valueFromAST, valueNodeToConstValueNode, argumentsEquals, collectVariablesInValue } from "./values"; import { 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 class CoreFeatures { readonly coreDefinition: CoreSpecDefinition; private readonly byAlias: Map<string, CoreFeature> = new Map(); private readonly byIdentity: Map<string, CoreFeature> = new Map(); 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); } allFeatures(): IterableIterator<CoreFeature> { return this.byIdentity.values(); } private removeFeature(featureIdentity: string) { const feature = this.byIdentity.get(featureIdentity); if (feature) { this.byIdentity.delete(featureIdentity); this.byAlias.delete(feature.nameInSchema); } } 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 existing = this.byIdentity.get(url.identity); if (existing) { // TODO: we may want to lossen that limitation at some point. Including the same feature for 2 different major versions should be ok. throw ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err(`Duplicate inclusion of feature ${url.identity}`); } 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) { this.byAlias.set(feature.nameInSchema, feature); this.byIdentity.set(feature.url.identity, feature); } sourceFeature(element: DirectiveDefinition | Directive | NamedType): { feature: CoreFeature, nameInFeature: string, isImported: boolean } | undefined { const isDirective = element instanceof DirectiveDefinition || element instanceof Directive; const splitted = element.name.split('__'); if (splitted.length > 1) { const feature = this.byAlias.get(splitted[0]); return feature ? { feature, nameInFeature: splitted.slice(1).join('__'), isImported: false, } : undefined; } else { // Let's first see if it's an import, as this would take precedence over directive implicitely named like their feature. const importName = isDirective ? '@' + element.name : element.name; const allFeatures = [this.coreItself, ...this.byIdentity.values()]; for (const feature of allFeatures) { for (const { as, name } of feature.imports) { if ((as ?? name) === importName) { return { feature, nameInFeature: isDirective ? name.slice(1) : name, isImported: true, }; } } } // Otherwise, this may be the special directive having the same name as its feature. const directFeature = this.byAlias.get(element.name); if (directFeature && isDirective) { return { feature: directFeature, nameInFeature: element.name, isImported: false, }; } return undefined; } } } const graphQLBuiltInTypes: readonly string[] = [ 'Int', 'Float', 'String', 'Boolean', 'ID' ]; const graphQLBuiltInTypesSpecifications: readonly TypeSpecification[] = graphQLBuiltInTypes.map((name) => createScalarTypeSpecification({ name })); const graphQLBuiltInDirectivesSpecifications: readonly DirectiveSpecification[] = [ createDirectiveSpecification({ name: 'include', locations: [DirectiveLocation.FIELD, DirectiveLocation.FRAGMENT_SPREAD, DirectiveLocation.INLINE_FRAGMENT], args: [{ name: 'if', type: (schema) => new NonNullType(schema.booleanType()) }], }), createDirectiveSpecification({ name: 'skip', locations: [DirectiveLocation.FIELD, DirectiveLocation.FRAGMENT_SPREAD, DirectiveLocation.INLINE_FRAGMENT], args: [{ name: 'if', type: (schema) => new NonNullType(schema.booleanType()) }], }), createDirectiveSpecification({ name: 'deprecated', locations: [DirectiveLocation.FIELD_DEFINITION, DirectiveLocation.ENUM_VALUE, DirectiveLocation.ARGUMENT_DEFINITION, DirectiveLocation.INPUT_FIELD_DEFINITION], args: [{ name: 'reason', type: (schema) => schema.stringType(), defaultValue: 'No longer supported' }], }), createDirectiveSpecification({ name: 'specifiedBy', locations: [DirectiveLocation.SCALAR], args: [{ name: 'url', type: (schema) => new NonNullType(schema.stringType()) }], }), // Note that @defer and @stream are unconditionally added to `Schema` even if they are technically "optional" built-in. _But_, // the `Schema#toGraphQLJSSchema` method has an option to decide if @defer/@stream should be included or not in the resulting // schema, which is how the gateway and router can, at runtime, decide to include or not include them based on actual support. createDirectiveSpecification({ name: 'defer', locations: [DirectiveLocation.FRAGMENT_SPREAD, DirectiveLocation.INLINE_FRAGMENT], args: [ { name: 'label', type: (schema) => schema.stringType() }, { name: 'if', type: (schema) => new NonNullType(schema.booleanType()), defaultValue: true }, ], }), // Adding @stream too so that it's know and we don't error out if it is queries. It feels like it would be weird to do so for @stream but not // @defer when both are defined in the same spec. That said, that does *not* mean we currently _implement_ @stream, we don't, and so putting // it in a query will be a no-op at the moment (which technically is valid according to the spec so ...). createDirectiveSpecification({ name: 'stream', locations: [DirectiveLocation.FIELD], args: [ { name: 'label', type: (schema) => schema.stringType() }, { name: 'initialCount', type: (schema) => schema.intType(), defaultValue: 0 }, { name: 'if', type: (schema) => new NonNullType(schema.booleanType()), defaultValue: true }, ], }), ]; export type DeferDirectiveArgs = { label?: string, if?: boolean | Variable, } export type StreamDirectiveArgs = { label?: string, initialCount: number, if?: boolean, } // A coordinate is up to 3 "graphQL name" ([_A-Za-z][_0-9A-Za-z]*). const coordinateRegexp = /^@?[_A-Za-z][_0-9A-Za-z]*(\.[_A-Za-z][_0-9A-Za-z]*)?(\([_A-Za-z][_0-9A-Za-z]*:\))?$/; export type SchemaConfig = { cacheAST?: boolean, } export class Schema { private _schemaDefinition: SchemaDefinition; private readonly _builtInTypes = new MapWithCachedArrays<string, NamedType>(); private readonly _types = new MapWithCachedArrays<string, NamedType>(); private readonly _builtInDirectives = new MapWithCachedArrays<string, DirectiveDefinition>(); private readonly _directives = new MapWithCachedArrays<string, DirectiveDefinition>(); private _coreFeatures?: CoreFeatures; private isConstructed: boolean = false; public isValidated: boolean = false; private cachedDocument?: DocumentNode; private apiSchema?: Schema; constructor( readonly blueprint: SchemaBlueprint = defaultSchemaBlueprint, readonly config: SchemaConfig = {}, ) { this._schemaDefinition = new SchemaDefinition(); Element.prototype['setParent'].call(this._schemaDefinition, this); graphQLBuiltInTypesSpecifications.forEach((spec) => spec.checkOrAdd(this, undefined, true)); graphQLBuiltInDirectivesSpecifications.forEach((spec) => spec.checkOrAdd(this, undefined, true)); blueprint.onConstructed(this); this.isConstructed = true; } private canModifyBuiltIn(): boolean { return !this.isConstructed; } private runWithBuiltInModificationAllowed(fct: () => void) { const wasConstructed = this.isConstructed; this.isConstructed = false; fct(); this.isConstructed = wasConstructed; } private renameTypeInternal(oldName: string, newName: string) { this._types.set(newName, this._types.get(oldName)!); this._types.delete(oldName); } private removeTypeInternal(type: BaseNamedType<any, any>) { this._types.delete(type.name); } private removeDirectiveInternal(definition: DirectiveDefinition) { this._directives.delete(definition.name); } private markAsCoreSchema(coreItself: CoreFeature) { this._coreFeatures = new CoreFeatures(coreItself); } private unmarkAsCoreSchema() { this._coreFeatures = undefined; } private onModification() { // The only stuffs that are added while !isConstructed are built-in, and those shouldn't invalidate everything. if (this.isConstructed) { this.invalidate(); this.cachedDocument = undefined; this.apiSchema = undefined; } } isCoreSchema(): boolean { return this.coreFeatures !== undefined; } get coreFeatures(): CoreFeatures | undefined { return this._coreFeatures; } toAST(): DocumentNode { if (!this.cachedDocument) { // As we're not building the document from a file, having locations info might be more confusing that not. const ast = parse(printSchema(this), { noLocation: true }); const shouldCache = this.config.cacheAST ?? false; if (!shouldCache) { return ast; } this.cachedDocument = ast; } return this.cachedDocument!; } toAPISchema(): Schema { if (!this.apiSchema) { this.validate(); const apiSchema = this.clone(undefined, false); // As we compute the API schema of a supergraph, we want to ignore explicit definitions of `@defer` and `@stream` because // those correspond to the merging of potential definitions from the subgraphs, but whether the supergraph API schema // supports defer or not is unrelated to the subgraph capacity. As far as gateway/router support goes, whether the defer/stream // definitions end up being provided or not will depend on the runtime `config` argument of the `toGraphQLJSSchema` that // is the called on the API schema (the schema resulting from that method). for (const toRemoveIfCustom of ['defer', 'stream']) { const directive = apiSchema.directive(toRemoveIfCustom); if (directive && !directive.isBuiltIn) { directive.removeRecursive(); } } removeInaccessibleElements(apiSchema); removeAllCoreFeatures(apiSchema); assert(!apiSchema.isCoreSchema(), "The API schema shouldn't be a core schema") apiSchema.validate(); this.apiSchema = apiSchema; } return this.apiSchema; } private emptyASTDefinitionsForExtensionsWithoutDefinition(): DefinitionNode[] { const nodes = []; if (this.schemaDefinition.hasExtensionElements() && !this.schemaDefinition.hasNonExtensionElements()) { const node: SchemaDefinitionNode = { kind: Kind.SCHEMA_DEFINITION, operationTypes: [] }; nodes.push(node); } for (const type of this.types()) { if (type.hasExtensionElements() && !type.hasNonExtensionElements()) { const node: TypeDefinitionNode = { kind: type.astDefinitionKind, name: { kind: Kind.NAME, value: type.name }, }; nodes.push(node); } } return nodes; } toGraphQLJSSchema(config?: { includeDefer?: boolean, includeStream?: boolean }): GraphQLSchema { const includeDefer = config?.includeDefer ?? false; const includeStream = config?.includeStream ?? false; let ast = this.toAST(); // Note that AST generated by `this.toAST()` may not be fully graphQL valid because, in federation subgraphs, we accept // extensions that have no corresponding definitions. This won't fly however if we try to build a `GraphQLSchema`, so // we need to "fix" that problem. For that, we add empty definitions for every element that has extensions without // definitions (which is also what `fed1` was effectively doing). const additionalNodes = this.emptyASTDefinitionsForExtensionsWithoutDefinition(); if (includeDefer) { additionalNodes.push(this.deferDirective().toAST()); } if (includeStream) { additionalNodes.push(this.streamDirective().toAST()); } if (additionalNodes.length > 0) { ast = { kind: Kind.DOCUMENT, definitions: ast.definitions.concat(additionalNodes), } } const graphQLSchema = buildGraphqlSchemaFromAST(ast); if (additionalNodes.length > 0) { // As mentionned, if we have extensions without definition, we _have_ to add an empty definition to be able to // build a `GraphQLSchema` object. But that also m