diff --git a/src/index.ts b/src/index.ts index 54ab38437f..317c9f8ca9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -312,6 +312,7 @@ export type { UnionTypeExtensionNode, EnumTypeExtensionNode, InputObjectTypeExtensionNode, + DirectiveExtensionNode, // Schema Coordinates SchemaCoordinateNode, TypeCoordinateNode, diff --git a/src/language/__tests__/predicates-test.ts b/src/language/__tests__/predicates-test.ts index 4cf0057abe..427b424606 100644 --- a/src/language/__tests__/predicates-test.ts +++ b/src/language/__tests__/predicates-test.ts @@ -39,6 +39,7 @@ describe('AST node predicates', () => { 'InputObjectTypeDefinition', 'DirectiveDefinition', 'SchemaExtension', + 'DirectiveExtension', 'ScalarTypeExtension', 'ObjectTypeExtension', 'InterfaceTypeExtension', @@ -123,6 +124,7 @@ describe('AST node predicates', () => { it('isTypeSystemExtensionNode', () => { expect(filterNodes(isTypeSystemExtensionNode)).to.deep.equal([ 'SchemaExtension', + 'DirectiveExtension', 'ScalarTypeExtension', 'ObjectTypeExtension', 'InterfaceTypeExtension', diff --git a/src/language/__tests__/printer-test.ts b/src/language/__tests__/printer-test.ts index 050cd5bdfa..81824dd7be 100644 --- a/src/language/__tests__/printer-test.ts +++ b/src/language/__tests__/printer-test.ts @@ -122,6 +122,21 @@ describe('Printer: Query document', () => { `); }); + it('Experimental: prints directives on directives', () => { + const queryASTWithVariableDirective = parse( + ` + directive @foo @bar on FIELD_DEFINITION + extend directive @foo @baz + `, + { experimentalDirectivesOnDirectiveDefinitions: true }, + ); + expect(print(queryASTWithVariableDirective)).to.equal(dedent` + directive @foo @bar on FIELD_DEFINITION + + extend directive @foo @baz + `); + }); + it('Legacy: correctly prints fragment defined variables', () => { const fragmentWithVariable = parse( ` diff --git a/src/language/__tests__/schema-parser-test.ts b/src/language/__tests__/schema-parser-test.ts index 5159939cfd..38358a676b 100644 --- a/src/language/__tests__/schema-parser-test.ts +++ b/src/language/__tests__/schema-parser-test.ts @@ -1029,6 +1029,7 @@ input Hello { { kind: 'DirectiveDefinition', description: undefined, + directives: [], name: { kind: 'Name', value: 'foo', @@ -1065,6 +1066,7 @@ input Hello { { kind: 'DirectiveDefinition', description: undefined, + directives: [], name: { kind: 'Name', value: 'foo', diff --git a/src/language/ast.ts b/src/language/ast.ts index 9b80a86206..6810b73961 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -180,6 +180,7 @@ export type ASTNode = | UnionTypeExtensionNode | EnumTypeExtensionNode | InputObjectTypeExtensionNode + | DirectiveExtensionNode | TypeCoordinateNode | MemberCoordinateNode | ArgumentCoordinateNode @@ -280,10 +281,18 @@ 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'], InterfaceTypeExtension: ['name', 'interfaces', 'directives', 'fields'], @@ -686,13 +695,17 @@ export interface DirectiveDefinitionNode { readonly description?: StringValueNode; readonly name: NameNode; readonly arguments?: ReadonlyArray; + readonly directives?: ReadonlyArray; 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: Kind.SCHEMA_EXTENSION; @@ -760,6 +773,13 @@ export interface InputObjectTypeExtensionNode { readonly fields?: ReadonlyArray; } +export interface DirectiveExtensionNode { + readonly kind: Kind.DIRECTIVE_EXTENSION; + readonly loc?: Location; + readonly name: NameNode; + readonly directives?: ReadonlyArray; +} + /** Schema Coordinates */ export type SchemaCoordinateNode = diff --git a/src/language/directiveLocation.ts b/src/language/directiveLocation.ts index 5c8aeb7240..ac99f2aeea 100644 --- a/src/language/directiveLocation.ts +++ b/src/language/directiveLocation.ts @@ -23,6 +23,7 @@ enum DirectiveLocation { ENUM_VALUE = 'ENUM_VALUE', INPUT_OBJECT = 'INPUT_OBJECT', INPUT_FIELD_DEFINITION = 'INPUT_FIELD_DEFINITION', + DIRECTIVE_DEFINITION = 'DIRECTIVE_DEFINITION', } export { DirectiveLocation }; diff --git a/src/language/index.ts b/src/language/index.ts index 28d6400bc4..615ca1287a 100644 --- a/src/language/index.ts +++ b/src/language/index.ts @@ -96,6 +96,7 @@ export type { UnionTypeExtensionNode, EnumTypeExtensionNode, InputObjectTypeExtensionNode, + DirectiveExtensionNode, // Schema Coordinates SchemaCoordinateNode, TypeCoordinateNode, diff --git a/src/language/kinds.ts b/src/language/kinds.ts index 9c10348a32..fb5ecb35f2 100644 --- a/src/language/kinds.ts +++ b/src/language/kinds.ts @@ -58,6 +58,7 @@ enum Kind { /** Type System Extensions */ SCHEMA_EXTENSION = 'SchemaExtension', + DIRECTIVE_EXTENSION = 'DirectiveExtension', /** Type Extensions */ SCALAR_TYPE_EXTENSION = 'ScalarTypeExtension', diff --git a/src/language/parser.ts b/src/language/parser.ts index f489027b6b..96e083e1dc 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -17,6 +17,7 @@ import type { DirectiveArgumentCoordinateNode, DirectiveCoordinateNode, DirectiveDefinitionNode, + DirectiveExtensionNode, DirectiveNode, DocumentNode, EnumTypeDefinitionNode, @@ -112,6 +113,18 @@ export interface ParseOptions { */ allowLegacyFragmentVariables?: boolean; + /** + * 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; + /** * 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. @@ -1184,6 +1197,7 @@ export class Parser { * - UnionTypeExtension * - EnumTypeExtension * - InputObjectTypeDefinition + * - DirectiveDefinitionExtension */ parseTypeSystemExtension(): TypeSystemExtensionNode { const keywordToken = this._lexer.lookahead(); @@ -1204,6 +1218,11 @@ export class Parser { return this.parseEnumTypeExtension(); case 'input': return this.parseInputObjectTypeExtension(); + case 'directive': + if (this._options.experimentalDirectivesOnDirectiveDefinitions) { + return this.parseDirectiveDefinitionExtension(); + } + break; } } @@ -1386,6 +1405,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.length === 0) { + throw this.unexpected(); + } + return this.node(start, { + kind: Kind.DIRECTIVE_EXTENSION, + name, + directives, + }); + } + /** * ``` * DirectiveDefinition : @@ -1399,6 +1435,10 @@ export class Parser { this.expectToken(TokenKind.AT); const name = this.parseName(); const args = this.parseArgumentDefs(); + const directives = this._options + .experimentalDirectivesOnDirectiveDefinitions + ? this.parseConstDirectives() + : []; const repeatable = this.expectOptionalKeyword('repeatable'); this.expectKeyword('on'); const locations = this.parseDirectiveLocations(); @@ -1407,6 +1447,7 @@ export class Parser { description, name, arguments: args, + directives, repeatable, locations, }); @@ -1447,6 +1488,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 dd53709c61..afc861c9d8 100644 --- a/src/language/predicates.ts +++ b/src/language/predicates.ts @@ -98,7 +98,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 fde1cbcf2f..84746181ab 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -235,13 +235,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, ' | '), @@ -311,6 +319,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 110a3cc940..4a0793d483 100644 --- a/src/type/__tests__/directive-test.ts +++ b/src/type/__tests__/directive-test.ts @@ -73,6 +73,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 8c5cacba0d..ccdcae7ddd 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'; -import { buildSchema } from '../../utilities/buildASTSchema'; +import { parse } from '../../language/parser'; + +import { buildASTSchema, buildSchema } from '../../utilities/buildASTSchema'; import { getIntrospectionQuery } from '../../utilities/getIntrospectionQuery'; import { graphqlSync } from '../../graphql'; @@ -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, @@ -805,6 +821,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: [], @@ -914,6 +956,11 @@ describe('Introspection', () => { isDeprecated: false, deprecationReason: null, }, + { + name: 'DIRECTIVE_DEFINITION', + isDeprecated: false, + deprecationReason: null, + }, ], possibleTypes: null, }, @@ -967,6 +1014,7 @@ describe('Introspection', () => { 'ARGUMENT_DEFINITION', 'INPUT_FIELD_DEFINITION', 'ENUM_VALUE', + 'DIRECTIVE_DEFINITION', ], args: [ { @@ -1754,4 +1802,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 6881f20532..9c293ae411 100644 --- a/src/type/directives.ts +++ b/src/type/directives.ts @@ -5,7 +5,10 @@ import { isObjectLike } from '../jsutils/isObjectLike'; import type { Maybe } from '../jsutils/Maybe'; import { toObjMap } from '../jsutils/toObjMap'; -import type { DirectiveDefinitionNode } from '../language/ast'; +import type { + DirectiveDefinitionNode, + DirectiveExtensionNode, +} from '../language/ast'; import { DirectiveLocation } from '../language/directiveLocation'; import { assertName } from './assertName'; @@ -59,16 +62,20 @@ export class GraphQLDirective { locations: ReadonlyArray; args: ReadonlyArray; isRepeatable: boolean; + deprecationReason: Maybe; extensions: Readonly; astNode: Maybe; + extensionASTNodes: ReadonlyArray; constructor(config: Readonly) { this.name = assertName(config.name); this.description = config.description; this.locations = config.locations; this.isRepeatable = config.isRepeatable ?? false; + this.deprecationReason = config.deprecationReason; this.extensions = toObjMap(config.extensions); this.astNode = config.astNode; + this.extensionASTNodes = config.extensionASTNodes ?? []; devAssert( Array.isArray(config.locations), @@ -95,8 +102,10 @@ export class GraphQLDirective { locations: this.locations, args: argsToArgsConfig(this.args), isRepeatable: this.isRepeatable, + deprecationReason: this.deprecationReason, extensions: this.extensions, astNode: this.astNode, + extensionASTNodes: this.extensionASTNodes, }; } @@ -115,14 +124,17 @@ export interface GraphQLDirectiveConfig { locations: ReadonlyArray; args?: Maybe; isRepeatable?: Maybe; + deprecationReason?: Maybe; extensions?: Maybe>; astNode?: Maybe; + extensionASTNodes?: Maybe>; } interface GraphQLDirectiveNormalizedConfig extends GraphQLDirectiveConfig { args: GraphQLFieldConfigArgumentMap; isRepeatable: boolean; extensions: Readonly; + extensionASTNodes: ReadonlyArray; } /** @@ -182,6 +194,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 2c66ca5098..86ad2218f5 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), + defaultValue: false, + }, + }, + resolve: (schema, { includeDeprecated }) => + includeDeprecated + ? 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), }); @@ -201,6 +220,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 29280474ec..21de5434c2 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 e8cf046921..24d0c2e9b0 100644 --- a/src/utilities/__tests__/buildClientSchema-test.ts +++ b/src/utilities/__tests__/buildClientSchema-test.ts @@ -5,11 +5,14 @@ import { dedent } from '../../__testUtils__/dedent'; import { invariant } from '../../jsutils/invariant'; +import { DirectiveLocation } from '../../language/directiveLocation'; + import { assertEnumType, GraphQLEnumType, GraphQLObjectType, } from '../../type/definition'; +import { GraphQLDirective } from '../../type/directives'; import { GraphQLBoolean, GraphQLFloat, @@ -465,6 +468,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 86baf0e699..7891a47793 100644 --- a/src/utilities/__tests__/extendSchema-test.ts +++ b/src/utilities/__tests__/extendSchema-test.ts @@ -31,7 +31,7 @@ import { validateSchema } from '../../type/validate'; import { graphqlSync } from '../../graphql'; -import { buildSchema } from '../buildASTSchema'; +import { buildASTSchema, buildSchema } from '../buildASTSchema'; import { concatAST } from '../concatAST'; import { extendSchema } from '../extendSchema'; import { printSchema } from '../printSchema'; @@ -1318,5 +1318,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 86d1c549db..656412022c 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 2ba66348d3..ae8a0df6bf 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'; +import { DirectiveLocation } from '../../language/directiveLocation'; + import { GraphQLObjectType } from '../../type/definition'; +import { GraphQLDirective } from '../../type/directives'; import { GraphQLString } from '../../type/scalars'; import { GraphQLSchema } from '../../type/schema'; @@ -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 37af4a60f7..5b92f43b3c 100644 --- a/src/utilities/__tests__/printSchema-test.ts +++ b/src/utilities/__tests__/printSchema-test.ts @@ -612,6 +612,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 description', () => { const schema = buildSingleFieldSchema({ type: GraphQLString, @@ -681,7 +697,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( @@ -717,7 +733,7 @@ describe('Type System Printer', () => { subscriptionType: __Type """A list of all directives supported by this server.""" - directives: [__Directive!]! + directives(includeDeprecated: Boolean! = false): [__Directive!]! } """ @@ -821,6 +837,8 @@ describe('Type System Printer', () => { isRepeatable: Boolean! locations: [__DirectiveLocation!]! args(includeDeprecated: Boolean = false): [__InputValue!]! + isDeprecated: Boolean! + deprecationReason: String } """ @@ -883,6 +901,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 eeff08e6ed..64494fbcd0 100644 --- a/src/utilities/buildASTSchema.ts +++ b/src/utilities/buildASTSchema.ts @@ -102,6 +102,8 @@ export function buildSchema( const document = parse(source, { noLocation: options?.noLocation, allowLegacyFragmentVariables: options?.allowLegacyFragmentVariables, + experimentalDirectivesOnDirectiveDefinitions: + options?.experimentalDirectivesOnDirectiveDefinitions, }); return buildASTSchema(document, { diff --git a/src/utilities/buildClientSchema.ts b/src/utilities/buildClientSchema.ts index 83f6abada8..0c0566beda 100644 --- a/src/utilities/buildClientSchema.ts +++ b/src/utilities/buildClientSchema.ts @@ -400,6 +400,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 d53752d919..9dc9fc77ee 100644 --- a/src/utilities/extendSchema.ts +++ b/src/utilities/extendSchema.ts @@ -7,6 +7,7 @@ import type { Maybe } from '../jsutils/Maybe'; import type { DirectiveDefinitionNode, + DirectiveExtensionNode, DocumentNode, EnumTypeDefinitionNode, EnumTypeExtensionNode, @@ -138,6 +139,9 @@ export function extendSchemaImpl( // Collect the type definitions and extensions found in the document. const typeDefs: Array = []; const typeExtensionsMap = Object.create(null); + const directiveExtensionsMap: { + [key: string]: Array; + } = Object.create(null); // New directives and types are separate because a directives and types can // have the same name. For example, a type named "skip". @@ -162,6 +166,14 @@ export function extendSchemaImpl( : [def]; } else if (def.kind === Kind.DIRECTIVE_DEFINITION) { directiveDefs.push(def); + } else if (def.kind === Kind.DIRECTIVE_EXTENSION) { + const extendedDirectiveName = def.name.value; + const existingDirectiveExtensions = + directiveExtensionsMap[extendedDirectiveName]; + directiveExtensionsMap[extendedDirectiveName] = + existingDirectiveExtensions + ? existingDirectiveExtensions.concat([def]) + : [def]; } } @@ -170,6 +182,7 @@ export function extendSchemaImpl( if ( Object.keys(typeExtensionsMap).length === 0 && typeDefs.length === 0 && + Object.keys(directiveExtensionsMap).length === 0 && directiveDefs.length === 0 && schemaExtensions.length === 0 && schemaDef == null @@ -187,6 +200,11 @@ export function extendSchemaImpl( typeMap[name] = stdTypeMap[name] ?? buildType(typeNode); } + const directiveMap: { [key: string]: GraphQLDirective } = Object.create(null); + for (const existingDirective of schemaConfig.directives) { + directiveMap[existingDirective.name] = extendDirective(existingDirective); + } + const operationTypes = { // Get the extended root operation types. query: schemaConfig.query && replaceNamedType(schemaConfig.query), @@ -199,12 +217,14 @@ export function extendSchemaImpl( }; // Then produce and return a Schema config with these types. + const directives = Object.values(directiveMap); + // will be `Array` return { description: schemaDef?.description?.value, ...operationTypes, types: Object.values(typeMap), directives: [ - ...schemaConfig.directives.map(replaceDirective), + ...directives.map(replaceDirective), ...directiveDefs.map(buildDirective), ], extensions: Object.create(null), @@ -415,6 +435,22 @@ export function extendSchemaImpl( return opTypes; } + function extendDirective(directive: GraphQLDirective): GraphQLDirective { + const config = directive.toConfig(); + const extensions = directiveExtensionsMap[config.name] ?? []; + const deprecationReason = + config.deprecationReason ?? + extensions + .map((ext) => getDeprecationReason(ext)) + .find((reason) => reason != null); + + return new GraphQLDirective({ + ...config, + deprecationReason, + extensionASTNodes: config.extensionASTNodes.concat(extensions), + }); + } + function getNamedType(node: NamedTypeNode): GraphQLNamedType { const name = node.name.value; const type = stdTypeMap[name] ?? typeMap[name]; @@ -436,6 +472,13 @@ export function extendSchemaImpl( } function buildDirective(node: DirectiveDefinitionNode): GraphQLDirective { + const extensions = directiveExtensionsMap[node.name.value] ?? []; + const deprecationReason = + getDeprecationReason(node) ?? + extensions + .map((ext) => getDeprecationReason(ext)) + .find((reason) => reason != null); + return new GraphQLDirective({ name: node.name.value, description: node.description?.value, @@ -443,7 +486,9 @@ export function extendSchemaImpl( locations: node.locations.map(({ value }) => value), isRepeatable: node.repeatable, args: buildArgumentMap(node.arguments), + deprecationReason, astNode: node, + extensionASTNodes: extensions, }); } @@ -667,7 +712,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 373b474ed5..d4bca051c1 100644 --- a/src/utilities/getIntrospectionQuery.ts +++ b/src/utilities/getIntrospectionQuery.ts @@ -33,6 +33,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 @@ -51,6 +57,7 @@ export function getIntrospectionQuery(options?: IntrospectionOptions): string { directiveIsRepeatable: false, schemaDescription: false, inputValueDeprecation: false, + experimentalDirectiveDeprecation: false, oneOf: false, ...options, }; @@ -69,6 +76,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 ` @@ -81,10 +91,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 @@ -344,6 +358,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 5f58248363..e6b4a50db1 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 edac6262c5..6285c8630d 100644 --- a/src/utilities/printSchema.ts +++ b/src/utilities/printSchema.ts @@ -280,6 +280,7 @@ function printDirective(directive: GraphQLDirective): string { 'directive @' + directive.name + 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 4cb6e225c1..2ad1b8c70e 100644 --- a/src/validation/__tests__/KnownDirectivesRule-test.ts +++ b/src/validation/__tests__/KnownDirectivesRule-test.ts @@ -58,6 +58,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', () => { @@ -349,6 +350,8 @@ describe('Validate: Known directives', () => { } extend schema @onSchema + + directive @myDirective on OBJECT `, schemaWithSDLDirectives, ); @@ -382,6 +385,8 @@ describe('Validate: Known directives', () => { } extend schema @onObject + + extend type MyObj @onDirective `, schemaWithSDLDirectives, ).toDeepEqual([ @@ -446,6 +451,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 d57a3df684..964d27282a 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'; + import { parse } from '../../language/parser'; import type { GraphQLSchema } from '../../type/schema'; @@ -7,6 +9,7 @@ import type { GraphQLSchema } from '../../type/schema'; import { extendSchema } from '../../utilities/extendSchema'; import { UniqueDirectivesPerLocationRule } from '../rules/UniqueDirectivesPerLocationRule'; +import { validateSDL } from '../validate'; 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 f24dbe7d28..162b9e9680 100644 --- a/src/validation/rules/KnownDirectivesRule.ts +++ b/src/validation/rules/KnownDirectivesRule.ts @@ -120,6 +120,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 */ default: diff --git a/src/validation/rules/UniqueDirectivesPerLocationRule.ts b/src/validation/rules/UniqueDirectivesPerLocationRule.ts index a4fc54690a..9ceeb8ecb0 100644 --- a/src/validation/rules/UniqueDirectivesPerLocationRule.ts +++ b/src/validation/rules/UniqueDirectivesPerLocationRule.ts @@ -44,6 +44,7 @@ export function UniqueDirectivesPerLocationRule( const schemaDirectives = Object.create(null); const typeDirectivesMap = Object.create(null); + const directiveDirectivesMap = Object.create(null); return { // Many different AST nodes may contain directives. Rather than listing @@ -66,6 +67,16 @@ export function UniqueDirectivesPerLocationRule( if (seenDirectives === undefined) { typeDirectivesMap[typeName] = seenDirectives = Object.create(null); } + } else if ( + node.kind === Kind.DIRECTIVE_DEFINITION || + node.kind === Kind.DIRECTIVE_EXTENSION + ) { + const directiveName = node.name.value; + seenDirectives = directiveDirectivesMap[directiveName]; + if (seenDirectives === undefined) { + directiveDirectivesMap[directiveName] = seenDirectives = + Object.create(null); + } } else { seenDirectives = Object.create(null); }