UNPKG

@apollo/federation-internals

Version:
965 lines (849 loc) 37.3 kB
import { ASTNode, DirectiveLocation, GraphQLError, StringValueNode } from "graphql"; import { URL } from "url"; import { CoreFeature, Directive, DirectiveDefinition, EnumType, ErrGraphQLAPISchemaValidationFailed, ErrGraphQLValidationFailed, InputType, ListType, NamedType, NonNullType, ScalarType, Schema, SchemaDefinition, SchemaElement, sourceASTs } from "../definitions"; import { sameType } from "../types"; import { assert, findLast, firstOf, MapWithCachedArrays } from '../utils'; import { aggregateError, ERRORS } from "../error"; import { valueToString } from "../values"; import { coreFeatureDefinitionIfKnown, registerKnownFeature } from "../knownCoreFeatures"; import { didYouMean, suggestionList } from "../suggestions"; import { ArgumentSpecification, createDirectiveSpecification, createEnumTypeSpecification, createScalarTypeSpecification, DirectiveCompositionSpecification, DirectiveSpecification, TypeSpecification } from "../directiveAndTypeSpecification"; export const coreIdentity = 'https://specs.apollo.dev/core'; export const linkIdentity = 'https://specs.apollo.dev/link'; export const linkDirectiveDefaultName = 'link'; export const ErrCoreCheckFailed = (causes: GraphQLError[]) => aggregateError('CheckFailed', 'one or more checks failed', causes); function buildError(message: string): Error { // Maybe not the right error for this? return new Error(message); } export const corePurposes = [ 'SECURITY' as const, 'EXECUTION' as const, ]; export type CorePurpose = typeof corePurposes[number]; function purposesDescription(purpose: CorePurpose) { switch (purpose) { case 'SECURITY': return "`SECURITY` features provide metadata necessary to securely resolve fields."; case 'EXECUTION': return "`EXECUTION` features provide metadata necessary for operation execution."; } } export abstract class FeatureDefinition { readonly url: FeatureUrl; private readonly _directiveSpecs = new MapWithCachedArrays<string, DirectiveSpecification>(); private readonly _typeSpecs = new MapWithCachedArrays<string, TypeSpecification>(); // A minimumFederationVersion that's undefined would mean that we won't produce that version in the supergraph SDL. constructor(url: FeatureUrl | string, readonly minimumFederationVersion?: FeatureVersion) { this.url = typeof url === 'string' ? FeatureUrl.parse(url) : url; } protected registerDirective(spec: DirectiveSpecification) { this._directiveSpecs.set(spec.name, spec); } protected registerType(spec: TypeSpecification) { this._typeSpecs.set(spec.name, spec); } protected registerSubFeature(subFeature: FeatureDefinition) { for (const typeSpec of subFeature.typeSpecs()) { this.registerType(typeSpec); } for (const directiveSpec of subFeature.directiveSpecs()) { this.registerDirective(directiveSpec); } } directiveSpecs(): readonly DirectiveSpecification[] { return this._directiveSpecs.values(); } directiveSpec(name: string): DirectiveSpecification | undefined { return this._directiveSpecs.get(name); } typeSpecs(): readonly TypeSpecification[] { return this._typeSpecs.values(); } typeSpec(name: string): TypeSpecification | undefined { return this._typeSpecs.get(name); } get identity(): string { return this.url.identity; } get version(): FeatureVersion { return this.url.version; } isSpecType(type: NamedType): boolean { const nameInSchema = this.nameInSchema(type.schema()); return nameInSchema !== undefined && type.name.startsWith(`${nameInSchema}__`); } isSpecDirective(directive: DirectiveDefinition): boolean { const nameInSchema = this.nameInSchema(directive.schema()); return nameInSchema != undefined && (directive.name === nameInSchema || directive.name.startsWith(`${nameInSchema}__`)); } addElementsToSchema(schema: Schema): GraphQLError[] { const feature = this.featureInSchema(schema); assert(feature, () => `The ${this.url} specification should have been added to the schema before this is called`); let errors: GraphQLError[] = []; for (const type of this.typeSpecs()) { errors = errors.concat(type.checkOrAdd(schema, feature)); } for (const directive of this.directiveSpecs()) { errors = errors.concat(directive.checkOrAdd(schema, feature)); } return errors; } allElementNames(): string[] { return this.directiveSpecs().map((spec) => `@${spec.name}`) .concat(this.typeSpecs().map((spec) => spec.name)); } protected nameInSchema(schema: Schema): string | undefined { const feature = this.featureInSchema(schema); return feature?.nameInSchema; } protected directiveNameInSchema(schema: Schema, directiveName: string): string | undefined { const feature = this.featureInSchema(schema); return feature ? feature.directiveNameInSchema(directiveName) : undefined; } protected typeNameInSchema(schema: Schema, typeName: string): string | undefined { const feature = this.featureInSchema(schema); return feature ? feature.typeNameInSchema(typeName) : undefined; } protected rootDirective<TApplicationArgs extends { [key: string]: any }>(schema: Schema): DirectiveDefinition<TApplicationArgs> | undefined { const name = this.nameInSchema(schema); return name ? schema.directive(name) as DirectiveDefinition<TApplicationArgs> | undefined : undefined; } protected directive<TApplicationArgs extends { [key: string]: any }>(schema: Schema, elementName: string): DirectiveDefinition<TApplicationArgs> | undefined { const name = this.directiveNameInSchema(schema, elementName); return name ? schema.directive(name) as DirectiveDefinition<TApplicationArgs> | undefined : undefined; } protected type<T extends NamedType>(schema: Schema, elementName: string): T | undefined { const name = this.typeNameInSchema(schema, elementName); return name ? schema.type(name) as T : undefined; } protected addRootDirective(schema: Schema): DirectiveDefinition { return schema.addDirectiveDefinition(this.nameInSchema(schema)!); } protected addDirective(schema: Schema, name: string): DirectiveDefinition { return schema.addDirectiveDefinition(this.directiveNameInSchema(schema, name)!); } protected addScalarType(schema: Schema, name: string): ScalarType { return schema.addType(new ScalarType(this.typeNameInSchema(schema, name)!)); } protected addEnumType(schema: Schema, name: string): EnumType { return schema.addType(new EnumType(this.typeNameInSchema(schema, name)!)); } protected featureInSchema(schema: Schema): CoreFeature | undefined { const features = schema.coreFeatures; if (!features) { throw buildError(`Schema is not a core schema (add @link first)`); } return features.getByIdentity(this.identity); } get defaultCorePurpose(): CorePurpose | undefined { return undefined; } compositionSpecification(directiveNameInFeature: string): DirectiveCompositionSpecification | undefined { const spec = this._directiveSpecs.get(directiveNameInFeature); return spec?.composition; } toString(): string { return `${this.identity}/${this.version}` } } export type CoreDirectiveArgs = { url: undefined, feature: string, as?: string, for?: string, import: undefined, } export type LinkDirectiveArgs = { url: string, feature: undefined, as?: string, for?: string, import?: (string | CoreImport)[], } export type CoreOrLinkDirectiveArgs = CoreDirectiveArgs | LinkDirectiveArgs; export type CoreImport = { name: string, as?: string, }; export function extractCoreFeatureImports(url: FeatureUrl, directive: Directive<SchemaDefinition, CoreOrLinkDirectiveArgs>): CoreImport[] { // Note: up to this point, we've kind of cheated with typing and force-casted the arguments to `CoreOrLinkDirectiveArgs`, and while this // graphQL type validations ensure this is "mostly" true, the `import' arg is an exception becuse it uses the `link__Import` scalar, // and so there is no fine-grained graphQL-side validation of the values. So we'll need to double-check that the values are indeed // either a string or a valid `CoreImport` value. const args = directive.arguments(); if (!('import' in args) || !args.import) { return []; } const importArgValue = args.import; const definition = coreFeatureDefinitionIfKnown(url); const knownElements = definition?.allElementNames(); const errors: GraphQLError[] = []; const imports: CoreImport[] = []; importArgLoop: for (const elt of importArgValue) { if (typeof elt === 'string') { imports.push({ name: elt }); validateImportedName(elt, knownElements, errors, directive); continue; } if (typeof elt !== 'object') { errors.push(ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Invalid sub-value ${valueToString(elt)} for @link(import:) argument: values should be either strings or input object values of the form { name: "<importedElement>", as: "<alias>" }.`, { nodes: directive.sourceAST }, )); continue; } let name: string | undefined; for (const [key, value] of Object.entries(elt)) { switch (key) { case 'name': if (typeof value !== 'string') { errors.push(ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Invalid value for the "name" field for sub-value ${valueToString(elt)} of @link(import:) argument: must be a string.`, { nodes: directive.sourceAST }, )); continue importArgLoop; } name = value; break; case 'as': if (typeof value !== 'string') { errors.push(ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Invalid value for the "as" field for sub-value ${valueToString(elt)} of @link(import:) argument: must be a string.`, { nodes: directive.sourceAST }, )); continue importArgLoop; } break; default: errors.push(ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Unknown field "${key}" for sub-value ${valueToString(elt)} of @link(import:) argument.`, { nodes: directive.sourceAST }, )); continue importArgLoop; } } if (name) { const i = elt as CoreImport; imports.push(i); if (i.as) { if (i.name.charAt(0) === '@' && i.as.charAt(0) !== '@') { errors.push(ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Invalid @link import renaming: directive "${i.name}" imported name should start with a '@' character, but got "${i.as}".`, { nodes: directive.sourceAST }, )); } else if (i.name.charAt(0) !== '@' && i.as.charAt(0) === '@') { errors.push(ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Invalid @link import renaming: type "${i.name}" imported name should not start with a '@' character, but got "${i.as}" (or, if @${i.name} is a directive, then it should be referred to with a '@').`, { nodes: directive.sourceAST }, )); } } validateImportedName(name, knownElements, errors, directive); } else { errors.push(ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Invalid sub-value ${valueToString(elt)} for @link(import:) argument: missing mandatory "name" field.`, { nodes: directive.sourceAST }, )); } } if (errors.length > 0) { throw ErrGraphQLValidationFailed(errors); } return imports; } function validateImportedName(name: string, knownElements: string[] | undefined, errors: GraphQLError[], directive: Directive<SchemaDefinition>) { if (knownElements && !knownElements.includes(name)) { let details = ''; if (!name.startsWith('@') && knownElements.includes('@' + name)) { details = ` Did you mean directive "@${name}"?`; } else { const suggestions = suggestionList(name, knownElements); if (suggestions) { details = didYouMean(suggestions); } } errors.push(ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Cannot import unknown element "${name}".${details}`, { nodes: directive.sourceAST }, )); } } export function isCoreSpecDirectiveApplication(directive: Directive<SchemaDefinition, any>): directive is Directive<SchemaDefinition, CoreOrLinkDirectiveArgs> { const definition = directive.definition; if (!definition) { return false; } const asArg = definition.argument('as'); if (asArg && !sameType(asArg.type!, directive.schema().stringType())) { return false; } if (!definition.repeatable || definition.locations.length !== 1 || definition.locations[0] !== DirectiveLocation.SCHEMA) { return false; } const urlArg = definition.argument('url') ?? definition.argument('feature'); if (!urlArg || !isValidUrlArgumentType(urlArg.type!, directive.schema())) { return false; } const args = directive.arguments(); try { const url = FeatureUrl.parse(args[urlArg.name] as string); if (url.identity === coreIdentity) { return directive.name === (args.as ?? 'core'); } else { return url.identity === linkIdentity && directive.name === (args.as ?? linkDirectiveDefaultName); } } catch (err) { return false; } } function isValidUrlArgumentType(type: InputType, schema: Schema): boolean { // Note that the 'url' arg is defined as nullable (mostly for future proofing reasons) but we allow use to provide a definition // where it's non-nullable (and in practice, @core (which we never generate anymore, but recognize) definition technically uses // with a non-nullable argument, and some fed2 previews did if for @link, so this ensure we handle reading schema generated // by those versions just fine). return sameType(type, schema.stringType()) || sameType(type, new NonNullType(schema.stringType())); } const linkPurposeTypeSpec = createEnumTypeSpecification({ name: 'Purpose', values: corePurposes.map((name) => ({ name, description: purposesDescription(name) })) }); const linkImportTypeSpec = createScalarTypeSpecification({ name: 'Import' }); export class CoreSpecDefinition extends FeatureDefinition { private readonly directiveDefinitionSpec: DirectiveSpecification; constructor(version: FeatureVersion, minimumFederationVersion?: FeatureVersion, identity: string = linkIdentity, name: string = linkDirectiveDefaultName) { super(new FeatureUrl(identity, name, version), minimumFederationVersion); this.directiveDefinitionSpec = createDirectiveSpecification({ name, locations: [DirectiveLocation.SCHEMA], repeatable: true, args: this.createDefinitionArgumentSpecifications(), }); this.registerDirective(this.directiveDefinitionSpec); } private createDefinitionArgumentSpecifications(): ArgumentSpecification[] { const args: ArgumentSpecification[] = [ { name: this.urlArgName(), type: (schema) => schema.stringType() }, { name: 'as', type: (schema) => schema.stringType() }, ]; if (this.supportPurposes()) { args.push({ name: 'for', type: (schema, feature) => { assert(feature, "Shouldn't be added without being attached to a @link spec"); return schema.type(feature.typeNameInSchema(linkPurposeTypeSpec.name)) as InputType; }, }); } if (this.supportImport()) { args.push({ name: 'import', type: (schema, feature) => { assert(feature, "Shouldn't be added without being attached to a @link spec"); return new ListType(schema.type(feature.typeNameInSchema(linkImportTypeSpec.name))!); } }); } return args; } addElementsToSchema(_: Schema): GraphQLError[] { // Core is special and the @core directive is added in `addToSchema` below return []; } // TODO: we may want to allow some `import` as argument to this method. When we do, we need to watch for imports of // `Purpose` and `Import` and add the types under their imported name. addToSchema(schema: Schema, alias?: string): GraphQLError[] { const errors = this.addDefinitionsToSchema(schema, alias); if (errors.length > 0) { return errors; } // Note: we don't use `applyFeatureToSchema` because it would complain the schema is not a core schema, which it isn't // until the next line. const args = { [this.urlArgName()]: this.toString() } as unknown as CoreOrLinkDirectiveArgs; if (alias) { args.as = alias; } // This adds `@link(url: "https://specs.apollo.dev/link/v1.0")` to the "schema" definition. And we have // a choice to add it either the main definition, or to an `extend schema`. // // In theory, always adding it to the main definition should be safe since even if some root operations // can be defined in extensions, you shouldn't have an extension without a definition, and so we should // never be in a case where _all_ root operations are defined in extensions (which would be a problem // for printing the definition itsef since it's syntactically invalid to have a schema definition with // no operations). // // In practice however, graphQL-js has historically accepted extensions without definition for schema, // and we even abuse this a bit with federation out of convenience, so we could end up in the situation // where if we put the directive on the definition, it cannot be printed properly due to the user having // defined all its root operations in an extension. // // We could always add the directive to an extension, and that could kind of work but: // 1. the core/link spec says that the link-to-link application should be the first `@link` of the // schema, but if user put some `@link` on their schema definition but we always put the link-to-link // on an extension, then we're kind of not respecting our own spec (in practice, our own code can // actually handle this as it does not strongly rely on that "it should be the first" rule, but that // would set a bad example). // 2. earlier versions (pre-#1875) were always putting that directive on the definition, and we wanted // to avoid suprising users by changing that for not reason. // // So instead, we put the directive on the schema definition unless some extensions exists but no // definition does (that is, no non-extension elements are populated). const schemaDef = schema.schemaDefinition; // Side-note: this test must be done _before_ we call `applyDirective`, otherwise it would take it into // account. const hasDefinition = schemaDef.hasNonExtensionElements(); const directive = schemaDef.applyDirective(alias ?? this.url.name, args, true); if (!hasDefinition && schemaDef.hasExtensionElements()) { const extension = firstOf(schemaDef.extensions()); assert(extension, '`hasExtensionElements` should not have been `true`'); directive.setOfExtension(extension); } return []; } addDefinitionsToSchema(schema: Schema, as?: string, imports: CoreImport[] = []): GraphQLError[] { const existingCore = schema.coreFeatures; if (existingCore) { if (existingCore.coreItself.url.identity === this.identity) { // Already exists with the same version, let it be. return []; } else { return [ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Cannot add feature ${this} to the schema, it already uses ${existingCore.coreItself.url}` )]; } } const nameInSchema = as ?? this.url.name; // The @link spec is special in that it is the one that bootstrap everything, and by the time this method // is called, the `schema` may not yet have any `schema.coreFeatures` setup yet. To have `checkAndAdd` // calls below still work, we pass a temp feature object with the proper information (not that the // `Directive` we pass is not complete and not even attached to the schema, but that is not used // in practice so unused). const feature = new CoreFeature(this.url, nameInSchema, new Directive(nameInSchema), imports); let errors: GraphQLError[] = []; errors = errors.concat(linkPurposeTypeSpec.checkOrAdd(schema, feature)); errors = errors.concat(linkImportTypeSpec.checkOrAdd(schema, feature)); errors = errors.concat(this.directiveDefinitionSpec.checkOrAdd(schema, feature)); return errors; } /** * The list of all the element names that can be "imported" from this feature. Importantly, directive names * must start with a `@`. */ allElementNames(): string[] { const names = [`@${this.url.name}`]; if (this.supportPurposes()) { names.push('Purpose'); } if (this.supportImport()) { names.push('Import'); } return names; } private supportPurposes() { return this.version.strictlyGreaterThan(new FeatureVersion(0, 1)); } private supportImport() { return this.url.name === linkDirectiveDefaultName; } private extractFeature(schema: Schema): CoreFeature { const features = schema.coreFeatures; if (!features) { throw buildError(`Schema is not a core schema (add @core first)`); } if (!features.coreItself.url.version.equals(this.version)) { throw buildError(`Cannot use this version of @core (${this.version}), the schema uses version ${features.coreItself.url.version}`); } return features.coreItself; } coreDirective(schema: Schema): DirectiveDefinition<CoreOrLinkDirectiveArgs> { const feature = this.extractFeature(schema); const directive = schema.directive(feature.nameInSchema); return directive as DirectiveDefinition<CoreOrLinkDirectiveArgs>; } coreVersion(schema: Schema): FeatureVersion { const feature = this.extractFeature(schema); return feature.url.version; } applyFeatureToSchema( schema: Schema, feature: FeatureDefinition, as?: string, purpose?: CorePurpose, imports?: CoreImport[], ): GraphQLError[] { const coreDirective = this.coreDirective(schema); const args = { [this.urlArgName()]: feature.toString(), as, } as CoreOrLinkDirectiveArgs; if (purpose) { if (this.supportPurposes()) { args.for = purpose; } else { return [new GraphQLError( `Cannot apply feature ${feature} with purpose since the schema's @core/@link version does not support it.` )]; } } if (imports && imports.length > 0) { if (this.supportImport()) { args.import = imports.map(i => i.as ? i : i.name); } else { return [new GraphQLError( `Cannot apply feature ${feature} with imports since the schema's @core/@link version does not support it.` )]; } } schema.schemaDefinition.applyDirective(coreDirective, args); return feature.addElementsToSchema(schema); } extractFeatureUrl(args: CoreOrLinkDirectiveArgs): FeatureUrl { return FeatureUrl.parse(args[this.urlArgName()]!); } urlArgName(): 'feature' | 'url' { return this.url.name === 'core' ? 'feature' : 'url'; } } export class FeatureDefinitions<T extends FeatureDefinition = FeatureDefinition> { // The list of definition corresponding to the known version of the particular feature this object handles, // sorted by _decreased_ versions. private readonly _definitions: T[] = []; constructor(readonly identity: string) { } add(definition: T): FeatureDefinitions<T> { if (definition.identity !== this.identity) { throw buildError(`Cannot add definition for ${definition} to the versions of definitions for ${this.identity}`); } if (this._definitions.find(def => definition.version.equals(def.version))) { return this; } this._definitions.push(definition); // We sort by decreased versions sa it feels somewhat natural anyway to have more recent versions first. this._definitions.sort((def1, def2) => -def1.version.compareTo(def2.version)); return this; } /** * Returns the definition corresponding to the requested version if known. */ find(requested: FeatureVersion): T | undefined { return this._definitions.find((def) => def.version.equals(requested)); } versions(): FeatureVersion[] { return this._definitions.map(def => def.version); } latest(): T { assert(this._definitions.length > 0, 'Trying to get latest when no definitions exist'); return this._definitions[0]; } getMinimumRequiredVersion(fedVersion: FeatureVersion): T { // this._definitions is already sorted with the most recent first // get the first definition that is compatible with the federation version // if the minimum version is not present, assume that we won't look for an older version const def = this._definitions.find(def => def.minimumFederationVersion ? fedVersion.gte(def.minimumFederationVersion) : true); assert(def, `No compatible definition exists for federation version ${fedVersion}`); // note that it's necessary that we can only get versions that have the same major version as the latest, // because otherwise we can not guarantee compatibility. In this case, we want to return the oldest version with // the same major version as the latest. const latestMajor = this.latest().version.major; if (def.version.major !== latestMajor) { return findLast(this._definitions, def => def.version.major === latestMajor) ?? this.latest(); } return def; } } /** * Versions are a (major, minor) number pair. */ export class FeatureVersion { constructor(public readonly major: number, public readonly minor: number) { } /** * Parse a version specifier of the form "v(major).(minor)" or throw * * # Example * ``` * expect(FeatureVersion.parse('v1.0')).toEqual(new FeatureVersion(1, 0)) * expect(FeatureVersion.parse('v0.1')).toEqual(new FeatureVersion(0, 1)) * expect(FeatureVersion.parse("v987.65432")).toEqual(new FeatureVersion(987, 65432)) * ``` */ public static parse(input: string): FeatureVersion { const match = input.match(this.VERSION_RE) if (!match) { throw ERRORS.INVALID_LINK_IDENTIFIER.err(`Expected a version string (of the form v1.2), got ${input}`); } return new this(+match[1], +match[2]) } /** * Find the maximum version in a collection of versions, returning undefined in the case * that the collection is empty. * * # Example * ``` * expect(FeatureVersion.max([new FeatureVersion(1, 0), new FeatureVersion(2, 0)])).toBe(new FeatureVersion(2, 0)) * expect(FeatureVersion.max([])).toBe(undefined) * ``` */ public static max(versions: Iterable<FeatureVersion>): FeatureVersion | undefined { let max: FeatureVersion | undefined; for (const version of versions) { if (!max || version.gt(max)) { max = version; } } return max; } /** * Return true if and only if this FeatureVersion satisfies the `required` version * * # Example * ``` * expect(new FeatureVersion(1, 0).satisfies(new FeatureVersion(1, 0))).toBe(true) * expect(new FeatureVersion(1, 2).satisfies(new FeatureVersion(1, 0))).toBe(true) * expect(new FeatureVersion(2, 0).satisfies(new FeatureVersion(1, 9))).toBe(false) * expect(new FeatureVersion(0, 9).satisfies(new FeatureVersion(0, 8))).toBe(false) * ``` **/ public satisfies(required: FeatureVersion): boolean { const { major, minor } = this const { major: rMajor, minor: rMinor } = required return rMajor == major && ( major == 0 ? rMinor == minor : rMinor <= minor ) } /** * a string indicating this version's compatibility series. for release versions (>= 1.0), this * will be a string like "v1.x", "v2.x", and so on. experimental minor updates carry no expectation * of compatibility, so those will just return the same thing as `this.toString()`. */ public get series() { const { major } = this return major > 0 ? `${major}.x` : String(this) } /** * Compares this version to the provide one, returning 1 if it strictly greater, 0 if they are equals, and -1 if this * version is strictly smaller. The underlying ordering is that of major version and then minor versions. * * Be aware that this ordering does *not* imply compatibility. For example, `FeatureVersion(2, 0) > FeatureVersion(1, 9)`, * but an implementation of `FeatureVersion(2, 0)` *cannot* satisfy a request for `FeatureVersion(1, 9)`. To check for * version compatibility, use [the `satisfies` method](#satisfies). */ public compareTo(other: FeatureVersion): number { if (this.major > other.major) { return 1; } if (this.major < other.major) { return -1; } if (this.minor > other.minor) { return 1; } if (this.minor < other.minor) { return -1; } return 0; } public lt(other: FeatureVersion): boolean { return this.compareTo(other) < 0; } public lte(other: FeatureVersion): boolean { return this.compareTo(other) <= 0; } public gt(other: FeatureVersion): boolean { return this.compareTo(other) > 0; } public gte(other: FeatureVersion): boolean { return this.compareTo(other) >= 0; } /** * Return true if this FeatureVersion is strictly greater than the provided one, * where ordering is meant by major and then minor number. * * Be aware that this ordering does *not* imply compatibility. For * example, `FeatureVersion(2, 0) > FeatureVersion(1, 9)`, but an implementation of `FeatureVersion(2, 0)` * *cannot* satisfy a request for `FeatureVersion(1, 9)`. To check for version compatibility, * use [the `satisfies` method](#satisfies). */ public strictlyGreaterThan(version: FeatureVersion) { return this.compareTo(version) > 0; } /** * return the string version tag, like "v2.9" * * @returns a version tag */ public toString() { return `v${this.major}.${this.minor}` } /** * return true iff this version is exactly equal to the provided version * * @param other the version to compare * @returns true if versions are strictly equal */ public equals(other: FeatureVersion) { return this.major === other.major && this.minor === other.minor } private static VERSION_RE = /^v(\d+)\.(\d+)$/ } export class FeatureUrl { constructor( public readonly identity: string, public readonly name: string, public readonly version: FeatureVersion, public readonly element?: string, ) { } public static maybeParse(input: string, node?: ASTNode): FeatureUrl | undefined { try { return FeatureUrl.parse(input, node); } catch (err) { return undefined; } } /// Parse a spec URL or throw public static parse(input: string, node?: ASTNode): FeatureUrl { const url = new URL(input) if (!url.pathname || url.pathname === '/') { throw ERRORS.INVALID_LINK_IDENTIFIER.err(`Missing path in feature url '${url}'`, { nodes: node }) } const path = url.pathname.split('/') const verStr = path.pop() if (!verStr) { throw ERRORS.INVALID_LINK_IDENTIFIER.err(`Missing version component in feature url '${url}'`, { nodes: node }) } const version = FeatureVersion.parse(verStr) const name = path[path.length - 1] if (!name) { throw ERRORS.INVALID_LINK_IDENTIFIER.err(`Missing feature name component in feature url '${url}'`, { nodes: node }) } const element = url.hash ? url.hash.slice(1) : undefined url.hash = '' url.search = '' url.password = '' url.username = '' url.pathname = path.join('/') return new FeatureUrl(url.toString(), name, version, element) } /// Decode a StringValueNode containing a feature url public static decode(node: StringValueNode): FeatureUrl { return this.parse(node.value, node) } /** * Return true if and only if this spec satisfies the `requested` * spec. * * @param request */ public satisfies(requested: FeatureUrl): boolean { return requested.identity === this.identity && this.version.satisfies(requested.version) } public equals(other: FeatureUrl) { return this.identity === other.identity && this.version.equals(other.version) } get url() { return this.element ? `${this.identity}/${this.version}#${this.element}` : `${this.identity}/${this.version}` } get isDirective() { return this.element?.startsWith('@') } get elementName() { return this.isDirective ? this.element?.slice(1) : this.element } get base(): FeatureUrl { if (!this.element) return this return new FeatureUrl(this.identity, this.name, this.version) } toString() { return this.url } } export function findCoreSpecVersion(featureUrl: FeatureUrl): CoreSpecDefinition | undefined { return featureUrl.name === 'core' ? CORE_VERSIONS.find(featureUrl.version) : (featureUrl.name === linkDirectiveDefaultName ? LINK_VERSIONS.find(featureUrl.version) : undefined) } export const CORE_VERSIONS = new FeatureDefinitions<CoreSpecDefinition>(coreIdentity) .add(new CoreSpecDefinition(new FeatureVersion(0, 1), undefined, coreIdentity, 'core')) .add(new CoreSpecDefinition(new FeatureVersion(0, 2), new FeatureVersion(2, 0), coreIdentity, 'core')); export const LINK_VERSIONS = new FeatureDefinitions<CoreSpecDefinition>(linkIdentity) .add(new CoreSpecDefinition(new FeatureVersion(1, 0), new FeatureVersion(2, 0))); registerKnownFeature(CORE_VERSIONS); registerKnownFeature(LINK_VERSIONS); export function removeAllCoreFeatures(schema: Schema) { // Gather a list of core features up front, since we can't fetch them during // removal. (Also note that core being a feature itself, this will remove core // itself and mark the schema as 'not core'). const coreFeatures = [...(schema.coreFeatures?.allFeatures() ?? [])]; // Remove all feature elements, keeping track of any type references found // along the way. const typeReferences: { feature: CoreFeature; type: NamedType; references: SchemaElement<any, any>[]; }[] = []; for (const feature of coreFeatures) { // Remove feature directive definitions and their applications. const featureDirectiveDefs = schema.directives() .filter(d => feature.isFeatureDefinition(d)); featureDirectiveDefs.forEach(def => def.remove().forEach(application => application.remove()) ); // Remove feature types. const featureTypes = schema.types() .filter(t => feature.isFeatureDefinition(t)); featureTypes.forEach(type => { const references = type.remove(); if (references.length > 0) { typeReferences.push({ feature, type, references, }); } }); } // Now that we're finished with removals, for any referencers encountered, // check whether they're still attached to the schema (and fail if they are). // // We wait for after all removals are done, since it means we don't have to // worry about the ordering of removals (e.g. if one feature element refers // to a different feature's element) or any circular references. // // Note that we fail for ALL type referencers, regardless of whether removing // the type necessitates removal of the type referencer. E.g. even if some // non-core object type were to implement some core feature interface type, we // would still require removal of the non-core object type. Users don't have // to enact this removal by removing the object type from their supergraph // schema though; they could also just mark it @inaccessible (since this // function is called after removeInaccessibleElements()). // // In the future, we could potentially relax this validation once we determine // the appropriate semantics. (This validation has already been relaxed for // directive applications, since feature directive definition removal does not // necessitate removal of elements with directive applications.) const errors: GraphQLError[] = []; for (const { feature, type, references } of typeReferences) { const referencesInSchema = references.filter(r => r.isAttached()); if (referencesInSchema.length > 0) { // Note: using REFERENCED_INACCESSIBLE is slightly abusive because the reference element is not marked // @inacessible exactly. Instead, it is inacessible due to core elements being removed, but that's very // very close semantically. Overall, adding a publicly documented error code just to minor difference // doesn't feel worth it, especially since that case is super unlikely in the first place (and, as // the prior comment says, may one day be removed too). errors.push(ERRORS.REFERENCED_INACCESSIBLE.err( `Cannot remove elements of feature ${feature} as feature type ${type}` + ` is referenced by elements: ${referencesInSchema.join(', ')}`, { nodes: sourceASTs(...references) }, )); } } if (errors.length > 0) { throw ErrGraphQLAPISchemaValidationFailed(errors); } }