diff --git a/src/index.ts b/src/index.ts index dc570a83d2..0500d695fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -348,6 +348,7 @@ export type { UnionTypeExtensionNode, EnumTypeExtensionNode, InputObjectTypeExtensionNode, + DirectiveExtensionNode, SchemaCoordinateNode, TypeCoordinateNode, MemberCoordinateNode, diff --git a/src/language/__tests__/predicates-test.ts b/src/language/__tests__/predicates-test.ts index 57907d6aa6..08b090e44f 100644 --- a/src/language/__tests__/predicates-test.ts +++ b/src/language/__tests__/predicates-test.ts @@ -29,6 +29,7 @@ describe('AST node predicates', () => { it('isDefinitionNode', () => { expect(filterNodes(isDefinitionNode)).to.deep.equal([ 'DirectiveDefinition', + 'DirectiveExtension', 'EnumTypeDefinition', 'EnumTypeExtension', 'FragmentDefinition', @@ -122,6 +123,7 @@ describe('AST node predicates', () => { it('isTypeSystemExtensionNode', () => { expect(filterNodes(isTypeSystemExtensionNode)).to.deep.equal([ + 'DirectiveExtension', 'EnumTypeExtension', 'InputObjectTypeExtension', 'InterfaceTypeExtension', diff --git a/src/language/__tests__/printer-test.ts b/src/language/__tests__/printer-test.ts index 6b00f6cedd..7182d4d6d3 100644 --- a/src/language/__tests__/printer-test.ts +++ b/src/language/__tests__/printer-test.ts @@ -180,6 +180,21 @@ describe('Printer: Query document', () => { `); }); + it('Experimental: prints directives on directives', () => { + const queryASTWithDirectiveDirective = parse( + ` + directive @foo @bar on FIELD_DEFINITION + extend directive @foo @baz + `, + { experimentalDirectivesOnDirectiveDefinitions: true }, + ); + expect(print(queryASTWithDirectiveDirective)).to.equal(dedent` + directive @foo @bar on FIELD_DEFINITION + + extend directive @foo @baz + `); + }); + it('correctly prints fragment defined arguments', () => { const fragmentWithArgumentDefinition = parse( ` diff --git a/src/language/__tests__/schema-parser-test.ts b/src/language/__tests__/schema-parser-test.ts index 78eac58534..5a1b24f4e3 100644 --- a/src/language/__tests__/schema-parser-test.ts +++ b/src/language/__tests__/schema-parser-test.ts @@ -1032,6 +1032,7 @@ input Hello { { kind: 'DirectiveDefinition', description: undefined, + directives: undefined, name: { kind: 'Name', value: 'foo', @@ -1068,6 +1069,7 @@ input Hello { { kind: 'DirectiveDefinition', description: undefined, + directives: undefined, name: { kind: 'Name', value: 'foo', @@ -1103,6 +1105,47 @@ input Hello { }); }); + it('Directive extension with experimental option enabled', () => { + const doc = parse('extend directive @foo @bar', { + experimentalDirectivesOnDirectiveDefinitions: true, + }); + + expectJSON(doc).toDeepEqual({ + kind: 'Document', + definitions: [ + { + kind: 'DirectiveExtension', + name: { + kind: 'Name', + value: 'foo', + loc: { start: 18, end: 21 }, + }, + directives: [ + { + kind: 'Directive', + name: { + kind: 'Name', + value: 'bar', + loc: { start: 23, end: 26 }, + }, + arguments: undefined, + loc: { start: 22, end: 26 }, + }, + ], + loc: { start: 0, end: 26 }, + }, + ], + loc: { start: 0, end: 26 }, + }); + }); + + it('Directive extension requires experimental option', () => { + expectSyntaxError('extend directive @foo @bar').to.deep.equal({ + message: 'Syntax Error: Unexpected Name "directive".', + locations: [{ line: 1, column: 8 }], + }); + }); + it('parses kitchen sink schema', () => { expect(() => parse(kitchenSinkSDL)).to.not.throw(); }); diff --git a/src/language/ast.ts b/src/language/ast.ts index 76815b9f14..36caf78126 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -182,6 +182,7 @@ export type ASTNode = | UnionTypeExtensionNode | EnumTypeExtensionNode | InputObjectTypeExtensionNode + | DirectiveExtensionNode | TypeCoordinateNode | MemberCoordinateNode | ArgumentCoordinateNode @@ -290,9 +291,16 @@ export const QueryDocumentKeys: { EnumValueDefinition: ['description', 'name', 'directives'], InputObjectTypeDefinition: ['description', 'name', 'directives', 'fields'], - DirectiveDefinition: ['description', 'name', 'arguments', 'locations'], + DirectiveDefinition: [ + 'description', + 'name', + 'arguments', + 'directives', + 'locations', + ], SchemaExtension: ['directives', 'operationTypes'], + DirectiveExtension: ['name', 'directives'], ScalarTypeExtension: ['name', 'directives'], ObjectTypeExtension: ['name', 'interfaces', 'directives', 'fields'], @@ -719,13 +727,17 @@ export interface DirectiveDefinitionNode { readonly description?: StringValueNode | undefined; readonly name: NameNode; readonly arguments?: ReadonlyArray | undefined; + readonly directives?: ReadonlyArray | undefined; readonly repeatable: boolean; readonly locations: ReadonlyArray; } /** Type System Extensions */ -export type TypeSystemExtensionNode = SchemaExtensionNode | TypeExtensionNode; +export type TypeSystemExtensionNode = + | SchemaExtensionNode + | TypeExtensionNode + | DirectiveExtensionNode; export interface SchemaExtensionNode { readonly kind: KindTypeMap['SCHEMA_EXTENSION']; @@ -795,6 +807,13 @@ export interface InputObjectTypeExtensionNode { readonly fields?: ReadonlyArray | undefined; } +export interface DirectiveExtensionNode { + readonly kind: KindTypeMap['DIRECTIVE_EXTENSION']; + readonly loc?: Location | undefined; + readonly name: NameNode; + readonly directives?: ReadonlyArray | undefined; +} + /** Schema Coordinates */ export type SchemaCoordinateNode = diff --git a/src/language/directiveLocation.ts b/src/language/directiveLocation.ts index fd4871826d..ac30107893 100644 --- a/src/language/directiveLocation.ts +++ b/src/language/directiveLocation.ts @@ -23,6 +23,7 @@ export const DirectiveLocation = { ENUM_VALUE: 'ENUM_VALUE' as const, INPUT_OBJECT: 'INPUT_OBJECT' as const, INPUT_FIELD_DEFINITION: 'INPUT_FIELD_DEFINITION' as const, + DIRECTIVE_DEFINITION: 'DIRECTIVE_DEFINITION' as const, FRAGMENT_VARIABLE_DEFINITION: 'FRAGMENT_VARIABLE_DEFINITION' as const, } as const; // eslint-disable-next-line @typescript-eslint/no-redeclare diff --git a/src/language/index.ts b/src/language/index.ts index cf07c5cb6d..b870a0da6f 100644 --- a/src/language/index.ts +++ b/src/language/index.ts @@ -99,6 +99,7 @@ export type { UnionTypeExtensionNode, EnumTypeExtensionNode, InputObjectTypeExtensionNode, + DirectiveExtensionNode, SchemaCoordinateNode, TypeCoordinateNode, MemberCoordinateNode, diff --git a/src/language/kinds_.ts b/src/language/kinds_.ts index 57f2226c8a..06d1f5ec66 100644 --- a/src/language/kinds_.ts +++ b/src/language/kinds_.ts @@ -94,6 +94,8 @@ export type DIRECTIVE_DEFINITION = typeof DIRECTIVE_DEFINITION; /** Type System Extensions */ export const SCHEMA_EXTENSION = 'SchemaExtension'; export type SCHEMA_EXTENSION = typeof SCHEMA_EXTENSION; +export const DIRECTIVE_EXTENSION = 'DirectiveExtension'; +export type DIRECTIVE_EXTENSION = typeof DIRECTIVE_EXTENSION; /** Type Extensions */ export const SCALAR_TYPE_EXTENSION = 'ScalarTypeExtension'; diff --git a/src/language/parser.ts b/src/language/parser.ts index d9e6dd8ba2..96ccc4dc04 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -17,6 +17,7 @@ import type { DirectiveArgumentCoordinateNode, DirectiveCoordinateNode, DirectiveDefinitionNode, + DirectiveExtensionNode, DirectiveNode, DocumentNode, EnumTypeDefinitionNode, @@ -117,6 +118,18 @@ export interface ParseOptions { */ experimentalFragmentArguments?: boolean | undefined; + /** + * EXPERIMENTAL: + * + * If enabled, the parser will parse directives on directive definitions. + * This syntax is not part of the GraphQL specification and may change. + * + * ```graphql + * directive @foo @bar on FIELD + * ``` + */ + experimentalDirectivesOnDirectiveDefinitions?: boolean | undefined; + /** * You may override the Lexer class used to lex the source; this is used by * schema coordinates to introduce a lexer with a restricted syntax. @@ -1218,6 +1231,7 @@ export class Parser { * - UnionTypeExtension * - EnumTypeExtension * - InputObjectTypeDefinition + * - DirectiveDefinitionExtension */ parseTypeSystemExtension(): TypeSystemExtensionNode { const keywordToken = this._lexer.lookahead(); @@ -1238,6 +1252,11 @@ export class Parser { return this.parseEnumTypeExtension(); case 'input': return this.parseInputObjectTypeExtension(); + case 'directive': + if (this._options.experimentalDirectivesOnDirectiveDefinitions) { + return this.parseDirectiveDefinitionExtension(); + } + break; } } @@ -1420,6 +1439,23 @@ export class Parser { }); } + parseDirectiveDefinitionExtension(): DirectiveExtensionNode { + const start = this._lexer.token; + this.expectKeyword('extend'); + this.expectKeyword('directive'); + this.expectToken(TokenKind.AT); + const name = this.parseName(); + const directives = this.parseConstDirectives(); + if (directives === undefined) { + throw this.unexpected(); + } + return this.node(start, { + kind: Kind.DIRECTIVE_EXTENSION, + name, + directives, + }); + } + /** * ``` * DirectiveDefinition : @@ -1433,6 +1469,10 @@ export class Parser { this.expectToken(TokenKind.AT); const name = this.parseName(); const args = this.parseArgumentDefs(); + const directives = this._options + .experimentalDirectivesOnDirectiveDefinitions + ? this.parseConstDirectives() + : undefined; const repeatable = this.expectOptionalKeyword('repeatable'); this.expectKeyword('on'); const locations = this.parseDirectiveLocations(); @@ -1441,6 +1481,7 @@ export class Parser { description, name, arguments: args, + directives, repeatable, locations, }); @@ -1481,6 +1522,7 @@ export class Parser { * `ENUM_VALUE` * `INPUT_OBJECT` * `INPUT_FIELD_DEFINITION` + * `DIRECTIVE_DEFINITION` */ parseDirectiveLocation(): NameNode { const start = this._lexer.token; diff --git a/src/language/predicates.ts b/src/language/predicates.ts index c6428e46dc..d597e01f52 100644 --- a/src/language/predicates.ts +++ b/src/language/predicates.ts @@ -113,7 +113,11 @@ export function isTypeDefinitionNode( export function isTypeSystemExtensionNode( node: ASTNode, ): node is TypeSystemExtensionNode { - return node.kind === Kind.SCHEMA_EXTENSION || isTypeExtensionNode(node); + return ( + node.kind === Kind.SCHEMA_EXTENSION || + node.kind === Kind.DIRECTIVE_EXTENSION || + isTypeExtensionNode(node) + ); } export function isTypeExtensionNode(node: ASTNode): node is TypeExtensionNode { diff --git a/src/language/printer.ts b/src/language/printer.ts index 121e800917..b03152432f 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -252,13 +252,21 @@ const printDocASTReducer: ASTReducer = { }, DirectiveDefinition: { - leave: ({ description, name, arguments: args, repeatable, locations }) => + leave: ({ + description, + name, + arguments: args, + directives, + repeatable, + locations, + }) => wrap('', description, '\n') + 'directive @' + name + (hasMultilineItems(args) ? wrap('(\n', indent(join(args, '\n')), '\n)') : wrap('(', join(args, ', '), ')')) + + wrap(' ', join(directives, ' ')) + (repeatable ? ' repeatable' : '') + ' on ' + join(locations, ' | '), @@ -328,6 +336,11 @@ const printDocASTReducer: ASTReducer = { join(['extend input', name, join(directives, ' '), block(fields)], ' '), }, + DirectiveExtension: { + leave: ({ name, directives }) => + join(['extend directive @' + name, join(directives, ' ')], ' '), + }, + // Schema Coordinates TypeCoordinate: { leave: ({ name }) => name }, diff --git a/src/type/__tests__/directive-test.ts b/src/type/__tests__/directive-test.ts index 90510bd0f9..6712422db5 100644 --- a/src/type/__tests__/directive-test.ts +++ b/src/type/__tests__/directive-test.ts @@ -79,6 +79,22 @@ describe('Type System: Directive', () => { }); }); + it('defines a deprecated directive', () => { + const directive = new GraphQLDirective({ + name: 'Foo', + locations: [DirectiveLocation.QUERY], + deprecationReason: 'Some reason', + }); + + expect(directive).to.deep.include({ + name: 'Foo', + args: [], + isRepeatable: false, + locations: ['QUERY'], + deprecationReason: 'Some reason', + }); + }); + it('can be stringified, JSON.stringified and Object.toStringified', () => { const directive = new GraphQLDirective({ name: 'Foo', diff --git a/src/type/__tests__/introspection-test.ts b/src/type/__tests__/introspection-test.ts index d5157b9750..5943e26eb2 100644 --- a/src/type/__tests__/introspection-test.ts +++ b/src/type/__tests__/introspection-test.ts @@ -3,7 +3,9 @@ import { describe, it } from 'mocha'; import { expectJSON } from '../../__testUtils__/expectJSON.js'; -import { buildSchema } from '../../utilities/buildASTSchema.js'; +import { parse } from '../../language/parser.js'; + +import { buildASTSchema, buildSchema } from '../../utilities/buildASTSchema.js'; import { getIntrospectionQuery } from '../../utilities/getIntrospectionQuery.js'; import { graphqlSync } from '../../graphql.js'; @@ -156,7 +158,21 @@ describe('Introspection', () => { }, { name: 'directives', - args: [], + args: [ + { + name: 'includeDeprecated', + type: { + kind: 'NON_NULL', + name: null, + ofType: { + kind: 'SCALAR', + name: 'Boolean', + ofType: null, + }, + }, + defaultValue: 'false', + }, + ], type: { kind: 'NON_NULL', name: null, @@ -825,6 +841,32 @@ describe('Introspection', () => { isDeprecated: false, deprecationReason: null, }, + { + name: 'isDeprecated', + args: [], + type: { + kind: 'NON_NULL', + name: null, + ofType: { + kind: 'SCALAR', + name: 'Boolean', + ofType: null, + }, + }, + isDeprecated: false, + deprecationReason: null, + }, + { + name: 'deprecationReason', + args: [], + type: { + kind: 'SCALAR', + name: 'String', + ofType: null, + }, + isDeprecated: false, + deprecationReason: null, + }, ], inputFields: null, interfaces: [], @@ -939,6 +981,11 @@ describe('Introspection', () => { isDeprecated: false, deprecationReason: null, }, + { + name: 'DIRECTIVE_DEFINITION', + isDeprecated: false, + deprecationReason: null, + }, ], possibleTypes: null, }, @@ -992,6 +1039,7 @@ describe('Introspection', () => { 'ARGUMENT_DEFINITION', 'INPUT_FIELD_DEFINITION', 'ENUM_VALUE', + 'DIRECTIVE_DEFINITION', ], args: [ { @@ -1783,4 +1831,144 @@ describe('Introspection', () => { }); expect(result).to.not.have.property('errors'); }); + + it('identifies deprecated directives', () => { + const schema = buildASTSchema( + parse( + ` + type Query { + someField: String + } + directive @isNotDeprecated on FIELD_DEFINITION + directive @isDeprecated @deprecated(reason: "No longer supported") on FIELD_DEFINITION + directive @isDeprecatedWithEmptyReason @deprecated(reason: "") on FIELD_DEFINITION + `, + { experimentalDirectivesOnDirectiveDefinitions: true }, + ), + ); + + const source = ` + { + __schema { + directives(includeDeprecated: true) { + name + isDeprecated + deprecationReason + } + } + } + `; + + expect(graphqlSync({ schema, source })).to.deep.equal({ + data: { + __schema: { + directives: [ + { + name: 'isNotDeprecated', + isDeprecated: false, + deprecationReason: null, + }, + { + name: 'isDeprecated', + isDeprecated: true, + deprecationReason: 'No longer supported', + }, + { + name: 'isDeprecatedWithEmptyReason', + isDeprecated: true, + deprecationReason: '', + }, + { + name: 'include', + isDeprecated: false, + deprecationReason: null, + }, + { + name: 'skip', + isDeprecated: false, + deprecationReason: null, + }, + { + name: 'deprecated', + isDeprecated: false, + deprecationReason: null, + }, + { + name: 'specifiedBy', + isDeprecated: false, + deprecationReason: null, + }, + { + name: 'oneOf', + isDeprecated: false, + deprecationReason: null, + }, + ], + }, + }, + }); + }); + + it('respects the includeDeprecated parameter for directives', () => { + const schema = buildASTSchema( + parse( + ` + type Query { + someField: String + } + directive @isNotDeprecated on FIELD_DEFINITION + directive @isDeprecated @deprecated(reason: "No longer supported") on FIELD_DEFINITION + `, + { experimentalDirectivesOnDirectiveDefinitions: true }, + ), + ); + + const source = ` + { + __schema { + trueDirectives: directives(includeDeprecated: true) { + name + } + falseDirectives: directives(includeDeprecated: false) { + name + } + omittedDirectives: directives { + name + } + } + } + `; + + expect(graphqlSync({ schema, source })).to.deep.equal({ + data: { + __schema: { + trueDirectives: [ + { name: 'isNotDeprecated' }, + { name: 'isDeprecated' }, + { name: 'include' }, + { name: 'skip' }, + { name: 'deprecated' }, + { name: 'specifiedBy' }, + { name: 'oneOf' }, + ], + falseDirectives: [ + { name: 'isNotDeprecated' }, + { name: 'include' }, + { name: 'skip' }, + { name: 'deprecated' }, + { name: 'specifiedBy' }, + { name: 'oneOf' }, + ], + omittedDirectives: [ + { name: 'isNotDeprecated' }, + { name: 'include' }, + { name: 'skip' }, + { name: 'deprecated' }, + { name: 'specifiedBy' }, + { name: 'oneOf' }, + ], + }, + }, + }); + }); }); diff --git a/src/type/directives.ts b/src/type/directives.ts index 939bd55fd3..f1c3f2bceb 100644 --- a/src/type/directives.ts +++ b/src/type/directives.ts @@ -7,7 +7,10 @@ import type { Maybe } from '../jsutils/Maybe.js'; import type { ObjMap } from '../jsutils/ObjMap.js'; import { toObjMapWithSymbols } from '../jsutils/toObjMap.js'; -import type { DirectiveDefinitionNode } from '../language/ast.js'; +import type { + DirectiveDefinitionNode, + DirectiveExtensionNode, +} from '../language/ast.js'; import { DirectiveLocation } from '../language/directiveLocation.js'; import { assertName } from './assertName.js'; @@ -61,8 +64,10 @@ export class GraphQLDirective implements GraphQLSchemaElement { locations: ReadonlyArray; args: ReadonlyArray; isRepeatable: boolean; + deprecationReason: Maybe; extensions: Readonly; astNode: Maybe; + extensionASTNodes: ReadonlyArray; constructor(config: Readonly) { this.__kind = directiveSymbol; @@ -70,8 +75,10 @@ export class GraphQLDirective implements GraphQLSchemaElement { this.description = config.description; this.locations = config.locations; this.isRepeatable = config.isRepeatable ?? false; + this.deprecationReason = config.deprecationReason; this.extensions = toObjMapWithSymbols(config.extensions); this.astNode = config.astNode; + this.extensionASTNodes = config.extensionASTNodes ?? []; devAssert( Array.isArray(config.locations), @@ -104,8 +111,10 @@ export class GraphQLDirective implements GraphQLSchemaElement { (arg) => arg.toConfig(), ), isRepeatable: this.isRepeatable, + deprecationReason: this.deprecationReason, extensions: this.extensions, astNode: this.astNode, + extensionASTNodes: this.extensionASTNodes, }; } @@ -124,14 +133,17 @@ export interface GraphQLDirectiveConfig { locations: ReadonlyArray; args?: Maybe>; isRepeatable?: Maybe; + deprecationReason?: Maybe; extensions?: Maybe>; astNode?: Maybe; + extensionASTNodes?: Maybe>; } export interface GraphQLDirectiveNormalizedConfig extends GraphQLDirectiveConfig { args: GraphQLFieldNormalizedConfigArgumentMap; isRepeatable: boolean; extensions: Readonly; + extensionASTNodes: ReadonlyArray; } /** @@ -241,6 +253,7 @@ export const GraphQLDeprecatedDirective: GraphQLDirective = DirectiveLocation.ARGUMENT_DEFINITION, DirectiveLocation.INPUT_FIELD_DEFINITION, DirectiveLocation.ENUM_VALUE, + DirectiveLocation.DIRECTIVE_DEFINITION, ], args: { reason: { diff --git a/src/type/introspection.ts b/src/type/introspection.ts index 07ab23da57..8c8bd30065 100644 --- a/src/type/introspection.ts +++ b/src/type/introspection.ts @@ -72,7 +72,18 @@ export const __Schema: GraphQLObjectType = new GraphQLObjectType({ type: new GraphQLNonNull( new GraphQLList(new GraphQLNonNull(__Directive)), ), - resolve: (schema) => schema.getDirectives(), + args: { + includeDeprecated: { + type: new GraphQLNonNull(GraphQLBoolean), + default: { value: false }, + }, + }, + resolve: (schema, { includeDeprecated }) => + includeDeprecated === true + ? schema.getDirectives() + : schema + .getDirectives() + .filter((directive) => directive.deprecationReason == null), }, }) as GraphQLFieldConfigMap, }); @@ -117,6 +128,14 @@ export const __Directive: GraphQLObjectType = new GraphQLObjectType({ : field.args.filter((arg) => arg.deprecationReason == null); }, }, + isDeprecated: { + type: new GraphQLNonNull(GraphQLBoolean), + resolve: (directive) => directive.deprecationReason != null, + }, + deprecationReason: { + type: GraphQLString, + resolve: (directive) => directive.deprecationReason, + }, }) as GraphQLFieldConfigMap, }); @@ -205,6 +224,10 @@ export const __DirectiveLocation: GraphQLEnumType = new GraphQLEnumType({ value: DirectiveLocation.INPUT_FIELD_DEFINITION, description: 'Location adjacent to an input object field definition.', }, + DIRECTIVE_DEFINITION: { + value: DirectiveLocation.DIRECTIVE_DEFINITION, + description: 'Location adjacent to a directive definition.', + }, }, }); diff --git a/src/utilities/__tests__/buildASTSchema-test.ts b/src/utilities/__tests__/buildASTSchema-test.ts index 5a794a1203..0c732989e9 100644 --- a/src/utilities/__tests__/buildASTSchema-test.ts +++ b/src/utilities/__tests__/buildASTSchema-test.ts @@ -1088,6 +1088,24 @@ describe('Schema Builder', () => { buildSchema(sdl, { assumeValidSDL: true }); }); + it('Forwards parser options to buildSchema', () => { + const schema = buildSchema( + dedent` + type Query { + foo: String + } + + directive @bar @deprecated(reason: "Use another directive") on FIELD_DEFINITION + `, + { experimentalDirectivesOnDirectiveDefinitions: true }, + ); + + const barDirective = assertDirective(schema.getDirective('bar')); + expect(barDirective).to.include({ + deprecationReason: 'Use another directive', + }); + }); + it('Throws on unknown types', () => { const sdl = ` type Query { diff --git a/src/utilities/__tests__/buildClientSchema-test.ts b/src/utilities/__tests__/buildClientSchema-test.ts index 00d99a0c8a..de5678f5b0 100644 --- a/src/utilities/__tests__/buildClientSchema-test.ts +++ b/src/utilities/__tests__/buildClientSchema-test.ts @@ -3,11 +3,14 @@ import { describe, it } from 'mocha'; import { dedent } from '../../__testUtils__/dedent.js'; +import { DirectiveLocation } from '../../language/directiveLocation.js'; + import { assertEnumType, GraphQLEnumType, GraphQLObjectType, } from '../../type/definition.js'; +import { GraphQLDirective } from '../../type/directives.js'; import { GraphQLBoolean, GraphQLFloat, @@ -467,6 +470,32 @@ describe('Type System: build schema from introspection', () => { expect(cycleIntrospection(sdl)).to.equal(sdl); }); + it('builds a schema with deprecated directives', () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + string: { type: GraphQLString }, + }, + }), + directives: [ + new GraphQLDirective({ + name: 'someDirective', + locations: [DirectiveLocation.QUERY], + deprecationReason: 'Use another directive', + }), + ], + }); + const introspection = introspectionFromSchema(schema); + + const clientSchema = buildClientSchema(introspection); + + expect(clientSchema.getDirective('someDirective')).to.deep.include({ + name: 'someDirective', + deprecationReason: 'Use another directive', + }); + }); + it('builds a schema without directives', () => { const sdl = dedent` type Query { diff --git a/src/utilities/__tests__/extendSchema-test.ts b/src/utilities/__tests__/extendSchema-test.ts index 5e2d786f22..c6b86ede00 100644 --- a/src/utilities/__tests__/extendSchema-test.ts +++ b/src/utilities/__tests__/extendSchema-test.ts @@ -30,7 +30,7 @@ import { validateSchema } from '../../type/validate.js'; import { graphqlSync } from '../../graphql.js'; -import { buildSchema } from '../buildASTSchema.js'; +import { buildASTSchema, buildSchema } from '../buildASTSchema.js'; import { concatAST } from '../concatAST.js'; import { extendSchema } from '../extendSchema.js'; import { printSchema } from '../printSchema.js'; @@ -1342,5 +1342,105 @@ describe('extendSchema', () => { extend schema @foo `); }); + + it('extend directive to make it deprecated', () => { + const schema = buildSchema('directive @isDeprecated on FIELD_DEFINITION'); + const extendAST = parse( + ` + extend directive @isDeprecated @deprecated(reason: "use another directive") + `, + { experimentalDirectivesOnDirectiveDefinitions: true }, + ); + const extendedSchema = extendSchema(schema, extendAST); + + const isDeprecatedDirective = assertDirective( + extendedSchema.getDirective('isDeprecated'), + ); + expect(isDeprecatedDirective).to.include({ + deprecationReason: 'use another directive', + }); + }); + + it('preserves deprecated directives when extending other types', () => { + const schema = buildASTSchema( + parse( + dedent` + type Query { + foo: String + } + + directive @isDeprecated @deprecated(reason: "use another directive") on FIELD_DEFINITION + `, + { experimentalDirectivesOnDirectiveDefinitions: true }, + ), + ); + const extendAST = parse(dedent` + extend type Query { + bar: Int + } + `); + const extendedSchema = extendSchema(schema, extendAST); + + const isDeprecatedDirective = assertDirective( + extendedSchema.getDirective('isDeprecated'), + ); + expect(isDeprecatedDirective).to.include({ + deprecationReason: 'use another directive', + }); + }); + + it('applies directive extensions defined in the same document', () => { + const schema = buildASTSchema( + parse( + dedent` + directive @onDirective on DIRECTIVE_DEFINITION + directive @someDirective on FIELD_DEFINITION + + extend directive @someDirective @onDirective + `, + { experimentalDirectivesOnDirectiveDefinitions: true }, + ), + ); + + const someDirective = assertDirective( + schema.getDirective('someDirective'), + ); + expectExtensionASTNodes(someDirective).to.equal( + 'extend directive @someDirective @onDirective', + ); + }); + + it('applies multiple directive extensions defined in the same document', () => { + const schema = buildASTSchema( + parse( + dedent` + directive @onDirective on DIRECTIVE_DEFINITION + directive @otherDirective on DIRECTIVE_DEFINITION + directive @someDirective on FIELD_DEFINITION + + extend directive @someDirective @onDirective + extend directive @someDirective @otherDirective + `, + { experimentalDirectivesOnDirectiveDefinitions: true }, + ), + ); + + const someDirective = assertDirective( + schema.getDirective('someDirective'), + ); + expectExtensionASTNodes(someDirective).to.equal(dedent` + extend directive @someDirective @onDirective + + extend directive @someDirective @otherDirective + `); + }); + + it('extend directive without adding new directives is an error', () => { + expect(() => + parse('extend directive @isDeprecated', { + experimentalDirectivesOnDirectiveDefinitions: true, + }), + ).to.throw('Syntax Error: Unexpected .'); + }); }); }); diff --git a/src/utilities/__tests__/getIntrospectionQuery-test.ts b/src/utilities/__tests__/getIntrospectionQuery-test.ts index b57950841b..80e582efd2 100644 --- a/src/utilities/__tests__/getIntrospectionQuery-test.ts +++ b/src/utilities/__tests__/getIntrospectionQuery-test.ts @@ -21,18 +21,30 @@ function expectIntrospectionQuery(options?: IntrospectionOptions) { const validationErrors = validate(dummySchema, parse(query)); expect(validationErrors).to.deep.equal([]); - return { - toMatch(name: string, times: number = 1): void { + const helpers = { + toMatch: (name: string, times: number = 1) => { const pattern = toRegExp(name); expect(query).to.match(pattern); expect(query.match(pattern)).to.have.lengthOf(times); + return helpers; }, - toNotMatch(name: string): void { + toContain: (text: string) => { + expect(query).to.include(text); + return helpers; + }, + toNotMatch: (name: string) => { expect(query).to.not.match(toRegExp(name)); + return helpers; + }, + toNotContain: (text: string) => { + expect(query).to.not.include(text); + return helpers; }, }; + return helpers; + function toRegExp(name: string): RegExp { return new RegExp('\\b' + name + '\\b', 'g'); } @@ -138,4 +150,30 @@ describe('getIntrospectionQuery', () => { 2, ); }); + + it('include "isDeprecated" field on directives', () => { + expectIntrospectionQuery().toMatch('isDeprecated', 2); + + expectIntrospectionQuery({ + experimentalDirectiveDeprecation: true, + }).toMatch('isDeprecated', 3); + + expectIntrospectionQuery({ + experimentalDirectiveDeprecation: false, + }).toMatch('isDeprecated', 2); + }); + + it('include "deprecationReason" field on directives', () => { + expectIntrospectionQuery() + .toNotContain('directives(includeDeprecated: true) {') + .toMatch('deprecationReason', 2); + + expectIntrospectionQuery({ experimentalDirectiveDeprecation: true }) + .toContain('directives(includeDeprecated: true) {') + .toMatch('deprecationReason', 3); + + expectIntrospectionQuery({ experimentalDirectiveDeprecation: false }) + .toNotContain('directives(includeDeprecated: true) {') + .toMatch('deprecationReason', 2); + }); }); diff --git a/src/utilities/__tests__/introspectionFromSchema-test.ts b/src/utilities/__tests__/introspectionFromSchema-test.ts index 15ba9bdcb5..d888d69ce5 100644 --- a/src/utilities/__tests__/introspectionFromSchema-test.ts +++ b/src/utilities/__tests__/introspectionFromSchema-test.ts @@ -3,7 +3,10 @@ import { describe, it } from 'mocha'; import { dedent } from '../../__testUtils__/dedent.js'; +import { DirectiveLocation } from '../../language/directiveLocation.js'; + import { GraphQLObjectType } from '../../type/definition.js'; +import { GraphQLDirective } from '../../type/directives.js'; import { GraphQLString } from '../../type/scalars.js'; import { GraphQLSchema } from '../../type/schema.js'; @@ -63,4 +66,36 @@ describe('introspectionFromSchema', () => { } `); }); + + it('includes deprecated directives', () => { + const schemaWithDeprecatedDirective = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + string: { + type: GraphQLString, + }, + }, + }), + directives: [ + new GraphQLDirective({ + name: 'deprecatedDirective', + locations: [DirectiveLocation.QUERY], + deprecationReason: 'Use another directive', + }), + ], + }); + const introspection = introspectionFromSchema( + schemaWithDeprecatedDirective, + ); + const deprecatedDirective = introspection.__schema.directives.find( + ({ name }) => name === 'deprecatedDirective', + ); + + expect(deprecatedDirective).to.deep.include({ + name: 'deprecatedDirective', + isDeprecated: true, + deprecationReason: 'Use another directive', + }); + }); }); diff --git a/src/utilities/__tests__/printSchema-test.ts b/src/utilities/__tests__/printSchema-test.ts index 2dd10ef8d7..0cbc3bd290 100644 --- a/src/utilities/__tests__/printSchema-test.ts +++ b/src/utilities/__tests__/printSchema-test.ts @@ -620,6 +620,22 @@ describe('Type System Printer', () => { `); }); + it('Prints deprecated directives', () => { + const schema = new GraphQLSchema({ + directives: [ + new GraphQLDirective({ + name: 'deprecatedDirective', + locations: [DirectiveLocation.FIELD], + deprecationReason: 'Use another directive', + }), + ], + }); + + expect(printSchema(schema)).to.equal(dedent` + directive @deprecatedDirective @deprecated(reason: "Use another directive") on FIELD + `); + }); + it('Prints an empty descriptions', () => { const args = { someArg: { description: '', type: GraphQLString }, @@ -806,7 +822,7 @@ describe('Type System Printer', () => { Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formatted using the Markdown syntax, as specified by [CommonMark](https://commonmark.org/). """ reason: String! = "No longer supported" - ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | ENUM_VALUE + ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | ENUM_VALUE | DIRECTIVE_DEFINITION """Exposes a URL that specifies the behavior of this scalar.""" directive @specifiedBy( @@ -842,7 +858,7 @@ describe('Type System Printer', () => { subscriptionType: __Type """A list of all directives supported by this server.""" - directives: [__Directive!]! + directives(includeDeprecated: Boolean! = false): [__Directive!]! } """ @@ -946,6 +962,8 @@ describe('Type System Printer', () => { isRepeatable: Boolean! locations: [__DirectiveLocation!]! args(includeDeprecated: Boolean! = false): [__InputValue!]! + isDeprecated: Boolean! + deprecationReason: String } """ @@ -1011,6 +1029,9 @@ describe('Type System Printer', () => { """Location adjacent to an input object field definition.""" INPUT_FIELD_DEFINITION + + """Location adjacent to a directive definition.""" + DIRECTIVE_DEFINITION } `); }); diff --git a/src/utilities/buildASTSchema.ts b/src/utilities/buildASTSchema.ts index 5be0b6e421..bb20051e9b 100644 --- a/src/utilities/buildASTSchema.ts +++ b/src/utilities/buildASTSchema.ts @@ -94,6 +94,8 @@ export function buildSchema( const document = parse(source, { noLocation: options?.noLocation, experimentalFragmentArguments: options?.experimentalFragmentArguments, + experimentalDirectivesOnDirectiveDefinitions: + options?.experimentalDirectivesOnDirectiveDefinitions, }); return buildASTSchema(document, { diff --git a/src/utilities/buildClientSchema.ts b/src/utilities/buildClientSchema.ts index 6890ddec7c..4a48202b4e 100644 --- a/src/utilities/buildClientSchema.ts +++ b/src/utilities/buildClientSchema.ts @@ -405,6 +405,7 @@ export function buildClientSchema( name: directiveIntrospection.name, description: directiveIntrospection.description, isRepeatable: directiveIntrospection.isRepeatable, + deprecationReason: directiveIntrospection.deprecationReason, locations: directiveIntrospection.locations.slice(), args: buildInputValueDefMap(directiveIntrospection.args), }); diff --git a/src/utilities/extendSchema.ts b/src/utilities/extendSchema.ts index 93b3776533..fc2da2f8e6 100644 --- a/src/utilities/extendSchema.ts +++ b/src/utilities/extendSchema.ts @@ -4,6 +4,7 @@ import type { Maybe } from '../jsutils/Maybe.js'; import type { DirectiveDefinitionNode, + DirectiveExtensionNode, DocumentNode, EnumTypeDefinitionNode, EnumTypeExtensionNode, @@ -134,6 +135,10 @@ export function extendSchemaImpl( string, InputObjectTypeExtensionNode >(); + const directiveExtensions = new AccumulatorMap< + string, + DirectiveExtensionNode + >(); // New directives and types are separate because a directives and types can // have the same name. For example, a type named "skip". @@ -155,6 +160,9 @@ export function extendSchemaImpl( case Kind.DIRECTIVE_DEFINITION: directiveDefs.push(def); break; + case Kind.DIRECTIVE_EXTENSION: + directiveExtensions.add(def.name.value, def); + break; // Type Definitions case Kind.SCALAR_TYPE_DEFINITION: @@ -229,7 +237,7 @@ export function extendSchemaImpl( ...operationTypes, types: getNamedTypes(), directives: [ - ...config.directives, + ...config.directives.map(extendDirective), ...directiveDefs.map(buildDirective), ], extensions: config.extensions, @@ -355,6 +363,13 @@ export function extendSchemaImpl( } function buildDirective(node: DirectiveDefinitionNode): GraphQLDirective { + const extensionASTNodes = directiveExtensions.get(node.name.value) ?? []; + const deprecationReason = + getDeprecationReason(node) ?? + extensionASTNodes + .map((extensionNode) => getDeprecationReason(extensionNode)) + .find((reason) => reason != null); + return new GraphQLDirective({ name: node.name.value, description: node.description?.value, @@ -362,7 +377,28 @@ export function extendSchemaImpl( locations: node.locations.map(({ value }) => value), isRepeatable: node.repeatable, args: buildArgumentMap(node.arguments), + deprecationReason, astNode: node, + extensionASTNodes, + }); + } + + function extendDirective(directive: GraphQLDirective): GraphQLDirective { + const extensionASTNodes = directiveExtensions.get(directive.name) ?? []; + if (extensionASTNodes.length === 0) { + return directive; + } + const deprecationReason = + directive.deprecationReason ?? + extensionASTNodes + .map((extensionNode) => getDeprecationReason(extensionNode)) + .find((reason) => reason != null); + + return new GraphQLDirective({ + ...directive.toConfig(), + deprecationReason, + extensionASTNodes: + directive.extensionASTNodes.concat(extensionASTNodes), }); } @@ -586,7 +622,9 @@ function getDeprecationReason( node: | EnumValueDefinitionNode | FieldDefinitionNode - | InputValueDefinitionNode, + | InputValueDefinitionNode + | DirectiveDefinitionNode + | DirectiveExtensionNode, ): Maybe { const deprecated = getDirectiveValues(GraphQLDeprecatedDirective, node); // @ts-expect-error validated by `getDirectiveValues` diff --git a/src/utilities/getIntrospectionQuery.ts b/src/utilities/getIntrospectionQuery.ts index 94fdfe59d7..e83bb1b9e1 100644 --- a/src/utilities/getIntrospectionQuery.ts +++ b/src/utilities/getIntrospectionQuery.ts @@ -35,6 +35,12 @@ export interface IntrospectionOptions { */ inputValueDeprecation?: boolean; + /** + * Whether target GraphQL server supports deprecation of directives. + * Default: false + */ + experimentalDirectiveDeprecation?: boolean; + /** * Whether target GraphQL server supports `@oneOf` input objects. * Default: false @@ -53,6 +59,7 @@ export function getIntrospectionQuery(options?: IntrospectionOptions): string { directiveIsRepeatable: false, schemaDescription: false, inputValueDeprecation: false, + experimentalDirectiveDeprecation: false, oneOf: false, ...options, }; @@ -71,6 +78,9 @@ export function getIntrospectionQuery(options?: IntrospectionOptions): string { function inputDeprecation(str: string) { return optionsWithDefault.inputValueDeprecation ? str : ''; } + function experimentalDirectiveDeprecation(str: string) { + return optionsWithDefault.experimentalDirectiveDeprecation ? str : ''; + } const oneOf = optionsWithDefault.oneOf ? 'isOneOf' : ''; return ` @@ -83,10 +93,14 @@ export function getIntrospectionQuery(options?: IntrospectionOptions): string { types { ...FullType } - directives { + directives${experimentalDirectiveDeprecation( + '(includeDeprecated: true)', + )} { name ${descriptions} ${directiveIsRepeatable} + ${experimentalDirectiveDeprecation('isDeprecated')} + ${experimentalDirectiveDeprecation('deprecationReason')} locations args${inputDeprecation('(includeDeprecated: true)')} { ...InputValue @@ -346,6 +360,8 @@ export interface IntrospectionDirective { readonly name: string; readonly description?: Maybe; readonly isRepeatable?: boolean; + readonly isDeprecated?: boolean; + readonly deprecationReason?: Maybe; readonly locations: ReadonlyArray; readonly args: ReadonlyArray; } diff --git a/src/utilities/introspectionFromSchema.ts b/src/utilities/introspectionFromSchema.ts index 375d53f119..b08c489a4f 100644 --- a/src/utilities/introspectionFromSchema.ts +++ b/src/utilities/introspectionFromSchema.ts @@ -30,6 +30,7 @@ export function introspectionFromSchema( directiveIsRepeatable: true, schemaDescription: true, inputValueDeprecation: true, + experimentalDirectiveDeprecation: true, oneOf: true, ...options, }; diff --git a/src/utilities/printSchema.ts b/src/utilities/printSchema.ts index 5287c6d957..93ec052991 100644 --- a/src/utilities/printSchema.ts +++ b/src/utilities/printSchema.ts @@ -276,6 +276,7 @@ export function printDirective(directive: GraphQLDirective): string { printDescription(directive) + `directive ${directive}` + printArgs(directive.args) + + printDeprecated(directive.deprecationReason) + (directive.isRepeatable ? ' repeatable' : '') + ' on ' + directive.locations.join(' | ') diff --git a/src/validation/__tests__/KnownDirectivesRule-test.ts b/src/validation/__tests__/KnownDirectivesRule-test.ts index 7c91de4870..898c8d5781 100644 --- a/src/validation/__tests__/KnownDirectivesRule-test.ts +++ b/src/validation/__tests__/KnownDirectivesRule-test.ts @@ -59,6 +59,7 @@ const schemaWithSDLDirectives = buildSchema(` directive @onEnumValue on ENUM_VALUE directive @onInputObject on INPUT_OBJECT directive @onInputFieldDefinition on INPUT_FIELD_DEFINITION + directive @onDirective on DIRECTIVE_DEFINITION `); describe('Validate: Known directives', () => { @@ -360,6 +361,8 @@ describe('Validate: Known directives', () => { directive @myDirective2(arg:String @myDirective) on FIELD extend schema @onSchema + + directive @myDirective3 on OBJECT `, schemaWithSDLDirectives, ); @@ -393,6 +396,8 @@ describe('Validate: Known directives', () => { } extend schema @onObject + + extend type MyObj @onDirective `, schemaWithSDLDirectives, ).toDeepEqual([ @@ -457,6 +462,10 @@ describe('Validate: Known directives', () => { message: 'Directive "@onObject" may not be used on SCHEMA.', locations: [{ line: 26, column: 25 }], }, + { + message: 'Directive "@onDirective" may not be used on OBJECT.', + locations: [{ line: 28, column: 29 }], + }, ]); }); }); diff --git a/src/validation/__tests__/UniqueDirectivesPerLocationRule-test.ts b/src/validation/__tests__/UniqueDirectivesPerLocationRule-test.ts index fd67ff8719..e6ecbdacd0 100644 --- a/src/validation/__tests__/UniqueDirectivesPerLocationRule-test.ts +++ b/src/validation/__tests__/UniqueDirectivesPerLocationRule-test.ts @@ -1,5 +1,7 @@ import { describe, it } from 'mocha'; +import { expectJSON } from '../../__testUtils__/expectJSON.js'; + import { parse } from '../../language/parser.js'; import type { GraphQLSchema } from '../../type/schema.js'; @@ -7,6 +9,7 @@ import type { GraphQLSchema } from '../../type/schema.js'; import { extendSchema } from '../../utilities/extendSchema.js'; import { UniqueDirectivesPerLocationRule } from '../rules/UniqueDirectivesPerLocationRule.js'; +import { validateSDL } from '../validate.js'; import { expectSDLValidationErrors, @@ -42,6 +45,14 @@ function expectSDLErrors(sdlStr: string, schema?: GraphQLSchema) { ); } +function expectExperimentalSDLErrors(sdlStr: string, schema?: GraphQLSchema) { + const doc = parse(sdlStr, { + experimentalDirectivesOnDirectiveDefinitions: true, + }); + const errors = validateSDL(doc, schema, [UniqueDirectivesPerLocationRule]); + return expectJSON(errors); +} + describe('Validate: Directives Are Unique Per Location', () => { it('no directives', () => { expectValid(` @@ -391,4 +402,74 @@ describe('Validate: Directives Are Unique Per Location', () => { }, ]); }); + + it('duplicate directives on directive definitions', () => { + expectExperimentalSDLErrors(` + directive @nonRepeatable on DIRECTIVE_DEFINITION + + directive @testDirective @nonRepeatable @nonRepeatable on FIELD_DEFINITION + `).toDeepEqual([ + { + message: + 'The directive "@nonRepeatable" can only be used once at this location.', + locations: [ + { line: 4, column: 32 }, + { line: 4, column: 47 }, + ], + }, + ]); + }); + + it('duplicate directives on directive extensions', () => { + expectExperimentalSDLErrors(` + directive @nonRepeatable on DIRECTIVE_DEFINITION + + extend directive @testDirective @nonRepeatable @nonRepeatable + `).toDeepEqual([ + { + message: + 'The directive "@nonRepeatable" can only be used once at this location.', + locations: [ + { line: 4, column: 39 }, + { line: 4, column: 54 }, + ], + }, + ]); + }); + + it('duplicate directives between directive definitions and extensions', () => { + expectExperimentalSDLErrors(` + directive @nonRepeatable on DIRECTIVE_DEFINITION + + directive @testDirective @nonRepeatable on FIELD_DEFINITION + extend directive @testDirective @nonRepeatable + `).toDeepEqual([ + { + message: + 'The directive "@nonRepeatable" can only be used once at this location.', + locations: [ + { line: 4, column: 32 }, + { line: 5, column: 39 }, + ], + }, + ]); + }); + + it('duplicate directives between directive extensions', () => { + expectExperimentalSDLErrors(` + directive @nonRepeatable on DIRECTIVE_DEFINITION + + extend directive @testDirective @nonRepeatable + extend directive @testDirective @nonRepeatable + `).toDeepEqual([ + { + message: + 'The directive "@nonRepeatable" can only be used once at this location.', + locations: [ + { line: 4, column: 39 }, + { line: 5, column: 39 }, + ], + }, + ]); + }); }); diff --git a/src/validation/rules/KnownDirectivesRule.ts b/src/validation/rules/KnownDirectivesRule.ts index 9a1e87d029..2b49a77ac5 100644 --- a/src/validation/rules/KnownDirectivesRule.ts +++ b/src/validation/rules/KnownDirectivesRule.ts @@ -128,6 +128,9 @@ function getDirectiveLocationForASTPath( ? DirectiveLocation.INPUT_FIELD_DEFINITION : DirectiveLocation.ARGUMENT_DEFINITION; } + case Kind.DIRECTIVE_DEFINITION: + case Kind.DIRECTIVE_EXTENSION: + return DirectiveLocation.DIRECTIVE_DEFINITION; // Not reachable, all possible types have been considered. /* c8 ignore next 2 */ default: diff --git a/src/validation/rules/UniqueDirectivesPerLocationRule.ts b/src/validation/rules/UniqueDirectivesPerLocationRule.ts index 795448488d..9fd05bd039 100644 --- a/src/validation/rules/UniqueDirectivesPerLocationRule.ts +++ b/src/validation/rules/UniqueDirectivesPerLocationRule.ts @@ -45,6 +45,7 @@ export function UniqueDirectivesPerLocationRule( const schemaDirectives = new Map(); const typeDirectivesMap = new Map>(); + const directiveDirectivesMap = new Map>(); return { // Many different AST nodes may contain directives. Rather than listing @@ -68,6 +69,16 @@ export function UniqueDirectivesPerLocationRule( seenDirectives = new Map(); typeDirectivesMap.set(typeName, seenDirectives); } + } else if ( + node.kind === Kind.DIRECTIVE_DEFINITION || + node.kind === Kind.DIRECTIVE_EXTENSION + ) { + const directiveName = node.name.value; + seenDirectives = directiveDirectivesMap.get(directiveName); + if (seenDirectives === undefined) { + seenDirectives = new Map(); + directiveDirectivesMap.set(directiveName, seenDirectives); + } } else { seenDirectives = new Map(); }