@apollo/federation-internals
Version:
Apollo Federation internal utilities
1,344 lines (1,152 loc) • 138 kB
text/typescript
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