diff --git a/src/type/__tests__/validation-test.js b/src/type/__tests__/validation-test.js index 7b914c5fa1..49eb6dafc7 100644 --- a/src/type/__tests__/validation-test.js +++ b/src/type/__tests__/validation-test.js @@ -20,6 +20,7 @@ import { GraphQLString, } from '../../'; import { parse } from '../../language/parser'; +import { Source } from '../../language/source'; import { validateSchema } from '../validate'; import { buildSchema } from '../../utilities/buildASTSchema'; import { extendSchema } from '../../utilities/extendSchema'; @@ -725,6 +726,10 @@ describe('Type System: Input Objects must have fields', () => { 'Input Object type SomeInputObject must define one or more fields.', locations: [{ line: 6, column: 7 }, { line: 4, column: 9 }], }, + { + message: 'Directive @test not allowed at INPUT_OBJECT location.', + locations: [{ line: 4, column: 38 }, { line: 2, column: 9 }], + }, ]); }); @@ -1827,3 +1832,288 @@ describe('Objects must adhere to Interface they implement', () => { ]); }); }); + +describe('Type System: Schema directives must validate', () => { + it('accepts a Schema with valid directives', () => { + const schema = buildSchema(` + schema @testA @testB { + query: Query + } + + type Query @testA @testB { + test: AnInterface @testC + } + + directive @testA on SCHEMA | OBJECT | INTERFACE | UNION | SCALAR | INPUT_OBJECT | ENUM + directive @testB on SCHEMA | OBJECT | INTERFACE | UNION | SCALAR | INPUT_OBJECT | ENUM + directive @testC on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION + directive @testD on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION + + interface AnInterface @testA { + field: String! @testC + } + + type TypeA implements AnInterface @testA { + field(arg: SomeInput @testC): String! @testC @testD + } + + type TypeB @testB @testA { + scalar_field: SomeScalar @testC + enum_field: SomeEnum @testC @testD + } + + union SomeUnion @testA = TypeA | TypeB + + scalar SomeScalar @testA @testB + + enum SomeEnum @testA @testB { + SOME_VALUE @testC + } + + input SomeInput @testA @testB { + some_input_field: String @testC + } + `); + expect(validateSchema(schema)).to.deep.equal([]); + }); + + it('rejects a Schema with directive defined multiple times', () => { + const schema = buildSchema(` + type Query { + test: String + } + + directive @testA on SCHEMA + directive @testA on SCHEMA + `); + expect(validateSchema(schema)).to.deep.equal([ + { + message: 'Directive @testA defined multiple times.', + locations: [{ line: 6, column: 7 }, { line: 7, column: 7 }], + }, + ]); + }); + + it('rejects a Schema with same directive used twice per location', () => { + const schema = buildSchema(` + directive @schema on SCHEMA + directive @object on OBJECT + directive @interface on INTERFACE + directive @union on UNION + directive @scalar on SCALAR + directive @input_object on INPUT_OBJECT + directive @enum on ENUM + directive @field_definition on FIELD_DEFINITION + directive @enum_value on ENUM_VALUE + directive @input_field_definition on INPUT_FIELD_DEFINITION + directive @argument_definition on ARGUMENT_DEFINITION + + schema @schema @schema { + query: Query + } + + type Query implements SomeInterface @object @object { + test(arg: SomeInput @argument_definition @argument_definition): String + } + + interface SomeInterface @interface @interface { + test: String @field_definition @field_definition + } + + union SomeUnion @union @union = Query + + scalar SomeScalar @scalar @scalar + + enum SomeEnum @enum @enum { + SOME_VALUE @enum_value @enum_value + } + + input SomeInput @input_object @input_object { + some_input_field: String @input_field_definition @input_field_definition + } + `); + expect(validateSchema(schema)).to.deep.equal([ + { + message: 'Directive @schema used twice at the same location.', + locations: [{ line: 14, column: 14 }, { line: 14, column: 22 }], + }, + { + message: + 'Directive @argument_definition used twice at the same location.', + locations: [{ line: 19, column: 29 }, { line: 19, column: 50 }], + }, + { + message: 'Directive @object used twice at the same location.', + locations: [{ line: 18, column: 43 }, { line: 18, column: 51 }], + }, + { + message: 'Directive @field_definition used twice at the same location.', + locations: [{ line: 23, column: 22 }, { line: 23, column: 40 }], + }, + { + message: 'Directive @interface used twice at the same location.', + locations: [{ line: 22, column: 31 }, { line: 22, column: 42 }], + }, + { + message: + 'Directive @input_field_definition not allowed at FIELD_DEFINITION location.', + locations: [{ line: 35, column: 34 }, { line: 11, column: 7 }], + }, + { + message: + 'Directive @input_field_definition not allowed at FIELD_DEFINITION location.', + locations: [{ line: 35, column: 58 }, { line: 11, column: 7 }], + }, + { + message: + 'Directive @input_field_definition used twice at the same location.', + locations: [{ line: 35, column: 34 }, { line: 35, column: 58 }], + }, + { + message: 'Directive @input_object used twice at the same location.', + locations: [{ line: 34, column: 23 }, { line: 34, column: 37 }], + }, + { + message: 'Directive @union used twice at the same location.', + locations: [{ line: 26, column: 23 }, { line: 26, column: 30 }], + }, + { + message: 'Directive @scalar used twice at the same location.', + locations: [{ line: 28, column: 25 }, { line: 28, column: 33 }], + }, + { + message: 'Directive @enum_value used twice at the same location.', + locations: [{ line: 31, column: 20 }, { line: 31, column: 32 }], + }, + { + message: 'Directive @enum used twice at the same location.', + locations: [{ line: 30, column: 21 }, { line: 30, column: 27 }], + }, + ]); + }); + + it('rejects a Schema with directive used again in extension', () => { + const schema = buildSchema( + new Source(` + directive @testA on OBJECT + + type Query @testA { + test: String + } + `), + 'schema.graphql', + ); + const extensions = parse( + new Source( + ` + extend type Query @testA + `, + 'extensions.graphql', + ), + ); + const extendedSchema = extendSchema(schema, extensions); + expect(validateSchema(extendedSchema)).to.deep.equal([ + { + message: 'Directive @testA used twice at the same location.', + locations: [{ line: 4, column: 20 }, { line: 2, column: 29 }], + }, + ]); + }); + + it('rejects a Schema with directives used in wrong location', () => { + const schema = buildSchema(` + directive @schema on SCHEMA + directive @object on OBJECT + directive @interface on INTERFACE + directive @union on UNION + directive @scalar on SCALAR + directive @input_object on INPUT_OBJECT + directive @enum on ENUM + directive @field_definition on FIELD_DEFINITION + directive @enum_value on ENUM_VALUE + directive @input_field_definition on INPUT_FIELD_DEFINITION + directive @argument_definition on ARGUMENT_DEFINITION + + schema @object { + query: Query + } + + type Query implements SomeInterface @schema { + test(arg: SomeInput @field_definition): String + } + + interface SomeInterface @interface { + test: String @argument_definition + } + + union SomeUnion @interface = Query + + scalar SomeScalar @enum_value + + enum SomeEnum @input_object { + SOME_VALUE @enum + } + + input SomeInput @object { + some_input_field: String @union + } + `); + const extensions = parse( + new Source( + ` + extend type Query @testA + `, + 'extensions.graphql', + ), + ); + const extendedSchema = extendSchema(schema, extensions); + expect(validateSchema(extendedSchema)).to.deep.equal([ + { + message: 'Directive @object not allowed at SCHEMA location.', + locations: [{ line: 14, column: 14 }, { line: 3, column: 7 }], + }, + { + message: + 'Directive @field_definition not allowed at ARGUMENT_DEFINITION location.', + locations: [{ line: 19, column: 29 }, { line: 9, column: 7 }], + }, + { + message: 'Directive @schema not allowed at OBJECT location.', + locations: [{ line: 18, column: 43 }, { line: 2, column: 7 }], + }, + { + message: 'No directive @testA defined.', + locations: [{ line: 2, column: 29 }], + }, + { + message: + 'Directive @argument_definition not allowed at FIELD_DEFINITION location.', + locations: [{ line: 23, column: 22 }, { line: 12, column: 7 }], + }, + { + message: 'Directive @union not allowed at FIELD_DEFINITION location.', + locations: [{ line: 35, column: 34 }, { line: 5, column: 7 }], + }, + { + message: 'Directive @object not allowed at INPUT_OBJECT location.', + locations: [{ line: 34, column: 23 }, { line: 3, column: 7 }], + }, + { + message: 'Directive @interface not allowed at UNION location.', + locations: [{ line: 26, column: 23 }, { line: 4, column: 7 }], + }, + { + message: 'Directive @enum_value not allowed at SCALAR location.', + locations: [{ line: 28, column: 25 }, { line: 10, column: 7 }], + }, + { + message: 'Directive @enum not allowed at ENUM_VALUE location.', + locations: [{ line: 31, column: 20 }, { line: 8, column: 7 }], + }, + { + message: 'Directive @input_object not allowed at ENUM location.', + locations: [{ line: 30, column: 21 }, { line: 7, column: 7 }], + }, + ]); + }); +}); diff --git a/src/type/validate.js b/src/type/validate.js index 1d8bdb13a1..5884e3cabc 100644 --- a/src/type/validate.js +++ b/src/type/validate.js @@ -7,44 +7,50 @@ * @flow strict */ -import { - isObjectType, - isInterfaceType, - isUnionType, - isEnumType, - isInputObjectType, - isNonNullType, - isNamedType, - isInputType, - isOutputType, -} from './definition'; import type { - GraphQLObjectType, - GraphQLInterfaceType, - GraphQLUnionType, + ASTNode, + DirectiveNode, + EnumValueDefinitionNode, + FieldDefinitionNode, + InputValueDefinitionNode, + NamedTypeNode, + TypeNode, +} from '../language/ast'; +import { DirectiveLocation } from '../language/directiveLocation'; +import type { DirectiveLocationEnum } from '../language/directiveLocation'; +import type { + GraphQLNamedType, GraphQLEnumType, GraphQLInputObjectType, + GraphQLInterfaceType, + GraphQLObjectType, + GraphQLUnionType, } from './definition'; -import { isDirective } from './directives'; import type { GraphQLDirective } from './directives'; -import { isIntrospectionType } from './introspection'; -import { isSchema } from './schema'; import type { GraphQLSchema } from './schema'; -import inspect from '../jsutils/inspect'; + +import { GraphQLError } from '../error/GraphQLError'; import find from '../jsutils/find'; +import inspect from '../jsutils/inspect'; import invariant from '../jsutils/invariant'; import objectValues from '../jsutils/objectValues'; -import { GraphQLError } from '../error/GraphQLError'; -import type { - ASTNode, - FieldDefinitionNode, - EnumValueDefinitionNode, - InputValueDefinitionNode, - NamedTypeNode, - TypeNode, -} from '../language/ast'; import { isValidNameError } from '../utilities/assertValidName'; import { isEqualType, isTypeSubTypeOf } from '../utilities/typeComparators'; +import { + isEnumType, + isInputObjectType, + isInputType, + isInterfaceType, + isNamedType, + isNonNullType, + isObjectType, + isOutputType, + isScalarType, + isUnionType, +} from './definition'; +import { isDirective } from './directives'; +import { isIntrospectionType } from './introspection'; +import { isSchema } from './schema'; /** * Implements the "Type Validation" sub-sections of the specification's @@ -70,7 +76,15 @@ export function validateSchema( // Validate the schema, producing a list of errors. const context = new SchemaValidationContext(schema); validateRootTypes(context); - validateDirectives(context); + validateDirectiveDefinitions(context); + + // Validate directives that are used on the schema + validateDirectivesAtLocation( + context, + getDirectives(schema), + DirectiveLocation.SCHEMA, + ); + validateTypes(context); // Persist the results of validation before returning to ensure validation @@ -165,7 +179,10 @@ function getOperationTypeNode( return type.astNode; } -function validateDirectives(context: SchemaValidationContext): void { +function validateDirectiveDefinitions(context: SchemaValidationContext): void { + // Ensure no directive is defined multiple times + const directiveDefinitions = new Map(); + for (const directive of context.schema.getDirectives()) { // Ensure all directives are in fact GraphQL directives. if (!isDirective(directive)) { @@ -175,6 +192,9 @@ function validateDirectives(context: SchemaValidationContext): void { ); continue; } + const existingDefinitions = directiveDefinitions.get(directive.name) || []; + existingDefinitions.push(directive); + directiveDefinitions.set(directive.name, existingDefinitions); // Ensure they are named correctly. validateName(context, directive); @@ -209,6 +229,15 @@ function validateDirectives(context: SchemaValidationContext): void { } } } + + for (const [directiveName, directiveList] of directiveDefinitions) { + if (directiveList.length > 1) { + context.reportError( + `Directive @${directiveName} defined multiple times.`, + directiveList.map(directive => directive.astNode).filter(Boolean), + ); + } + } } function validateName( @@ -250,18 +279,101 @@ function validateTypes(context: SchemaValidationContext): void { // Ensure objects implement the interfaces they claim to. validateObjectInterfaces(context, type); + + // Ensure directives are valid + validateDirectivesAtLocation( + context, + getDirectives(type), + DirectiveLocation.OBJECT, + ); } else if (isInterfaceType(type)) { // Ensure fields are valid. validateFields(context, type); + + // Ensure directives are valid + validateDirectivesAtLocation( + context, + getDirectives(type), + DirectiveLocation.INTERFACE, + ); } else if (isUnionType(type)) { // Ensure Unions include valid member types. validateUnionMembers(context, type); + + // Ensure directives are valid + validateDirectivesAtLocation( + context, + getDirectives(type), + DirectiveLocation.UNION, + ); } else if (isEnumType(type)) { // Ensure Enums have valid values. validateEnumValues(context, type); + + // Ensure directives are valid + validateDirectivesAtLocation( + context, + getDirectives(type), + DirectiveLocation.ENUM, + ); } else if (isInputObjectType(type)) { // Ensure Input Object fields are valid. validateInputFields(context, type); + + // Ensure directives are valid + validateDirectivesAtLocation( + context, + getDirectives(type), + DirectiveLocation.INPUT_OBJECT, + ); + } else if (isScalarType(type)) { + // Ensure directives are valid + validateDirectivesAtLocation( + context, + getDirectives(type), + DirectiveLocation.SCALAR, + ); + } + } +} + +function validateDirectivesAtLocation( + context: SchemaValidationContext, + directives: $ReadOnlyArray, + location: DirectiveLocationEnum, +): void { + const directivesNamed = new Map(); + const schema = context.schema; + for (const directive of directives) { + const directiveName = directive.name.value; + + // Ensure directive used is also defined + const schemaDirective = schema.getDirective(directiveName); + if (!schemaDirective) { + context.reportError(`No directive @${directiveName} defined.`, directive); + continue; + } + if (!schemaDirective.locations.includes(location)) { + const errorNodes = schemaDirective.astNode + ? [directive, schemaDirective.astNode] + : [directive]; + context.reportError( + `Directive @${directiveName} not allowed at ${location} location.`, + errorNodes, + ); + } + + const existingNodes = directivesNamed.get(directiveName) || []; + existingNodes.push(directive); + directivesNamed.set(directiveName, existingNodes); + } + + for (const [directiveName, directiveList] of directivesNamed) { + if (directiveList.length > 1) { + context.reportError( + `Directive @${directiveName} used twice at the same location.`, + directiveList, + ); } } } @@ -329,6 +441,24 @@ function validateFields( getFieldArgTypeNode(type, field.name, argName), ); } + + // Ensure argument definition directives are valid + if (arg.astNode && arg.astNode.directives) { + validateDirectivesAtLocation( + context, + arg.astNode.directives, + DirectiveLocation.ARGUMENT_DEFINITION, + ); + } + } + + // Ensure any directives are valid + if (field.astNode && field.astNode.directives) { + validateDirectivesAtLocation( + context, + field.astNode.directives, + DirectiveLocation.FIELD_DEFINITION, + ); } } } @@ -520,6 +650,15 @@ function validateEnumValues( enumValue.astNode, ); } + + // Ensure valid directives + if (enumValue.astNode && enumValue.astNode.directives) { + validateDirectivesAtLocation( + context, + enumValue.astNode.directives, + DirectiveLocation.ENUM_VALUE, + ); + } } } @@ -551,6 +690,15 @@ function validateInputFields( field.astNode && field.astNode.type, ); } + + // Ensure valid directives + if (field.astNode && field.astNode.directives) { + validateDirectivesAtLocation( + context, + field.astNode.directives, + DirectiveLocation.FIELD_DEFINITION, + ); + } } } @@ -586,6 +734,12 @@ function getAllSubNodes( return result; } +function getDirectives( + object: GraphQLSchema | GraphQLNamedType, +): $ReadOnlyArray { + return getAllSubNodes(object, node => node.directives); +} + function getImplementsInterfaceNode( type: GraphQLObjectType, iface: GraphQLInterfaceType,