From 3d631c550ce60c7c8428938b17ee55056c08882a Mon Sep 17 00:00:00 2001 From: Andrii Furmanets Date: Wed, 29 Apr 2026 16:56:19 +0300 Subject: [PATCH] Validate SDL directive argument values --- .../__tests__/buildASTSchema-test.ts | 13 + .../__tests__/ValuesOfCorrectTypeRule-test.ts | 123 +++++- .../rules/ValuesOfCorrectTypeRule.ts | 352 +++++++++++++++++- src/validation/specifiedRules.ts | 6 +- 4 files changed, 489 insertions(+), 5 deletions(-) diff --git a/src/utilities/__tests__/buildASTSchema-test.ts b/src/utilities/__tests__/buildASTSchema-test.ts index 21de5434c2..7c80c79b80 100644 --- a/src/utilities/__tests__/buildASTSchema-test.ts +++ b/src/utilities/__tests__/buildASTSchema-test.ts @@ -1078,6 +1078,19 @@ describe('Schema Builder', () => { expect(() => buildSchema(sdl)).to.throw('Unknown directive "@unknown".'); }); + it('Rejects SDL with invalid directive argument values', () => { + const sdl = ` + type Query { + foo: String @test(arg: UNQUOTED_STRING) + } + + directive @test(arg: String!) on FIELD_DEFINITION + `; + expect(() => buildSchema(sdl)).to.throw( + 'String cannot represent a non string value: UNQUOTED_STRING', + ); + }); + it('Allows to disable SDL validation', () => { const sdl = ` type Query { diff --git a/src/validation/__tests__/ValuesOfCorrectTypeRule-test.ts b/src/validation/__tests__/ValuesOfCorrectTypeRule-test.ts index 6f7697a694..9eb671295e 100644 --- a/src/validation/__tests__/ValuesOfCorrectTypeRule-test.ts +++ b/src/validation/__tests__/ValuesOfCorrectTypeRule-test.ts @@ -11,10 +11,16 @@ import { GraphQLObjectType, GraphQLScalarType } from '../../type/definition'; import { GraphQLString } from '../../type/scalars'; import { GraphQLSchema } from '../../type/schema'; -import { ValuesOfCorrectTypeRule } from '../rules/ValuesOfCorrectTypeRule'; +import { buildSchema } from '../../utilities/buildASTSchema'; + +import { + ValuesOfCorrectTypeOnDirectivesRule, + ValuesOfCorrectTypeRule, +} from '../rules/ValuesOfCorrectTypeRule'; import { validate } from '../validate'; import { + expectSDLValidationErrors, expectValidationErrors, expectValidationErrorsWithSchema, } from './harness'; @@ -39,6 +45,18 @@ function expectValidWithSchema(schema: GraphQLSchema, queryStr: string) { expectErrorsWithSchema(schema, queryStr).toDeepEqual([]); } +function expectSDLErrors(sdlStr: string, schema?: GraphQLSchema) { + return expectSDLValidationErrors( + schema, + ValuesOfCorrectTypeOnDirectivesRule, + sdlStr, + ); +} + +function expectValidSDL(sdlStr: string, schema?: GraphQLSchema) { + expectSDLErrors(sdlStr, schema).toDeepEqual([]); +} + describe('Validate: Values of correct type', () => { describe('Valid values', () => { it('Good int value', () => { @@ -1143,6 +1161,109 @@ describe('Validate: Values of correct type', () => { }, ]); }); + + describe('within SDL', () => { + it('with directive arguments of valid types', () => { + expectValidSDL(` + enum DirectiveEnum { + VALUE + } + + input DirectiveInput { + enabled: Boolean! + } + + type Query { + field: String @test( + str: "value" + int: 1 + enum: VALUE + input: { enabled: true } + ) + } + + directive @test( + str: String + int: Int + enum: DirectiveEnum + input: DirectiveInput + ) on FIELD_DEFINITION + `); + }); + + it('with directive argument of invalid type', () => { + expectSDLErrors(` + type Query { + baz: Boolean @foo(bar: FOOBAR) + } + + directive @foo(bar: String!) on FIELD_DEFINITION + `).toDeepEqual([ + { + message: 'String cannot represent a non string value: FOOBAR', + locations: [{ line: 3, column: 36 }], + }, + ]); + }); + + it('with directive input object argument of invalid type', () => { + expectSDLErrors(` + input DirectiveInput { + count: Int! + } + + type Query { + field: String @test(input: { count: "one" }) + } + + directive @test(input: DirectiveInput) on FIELD_DEFINITION + `).toDeepEqual([ + { + message: 'Int cannot represent non-integer value: "one"', + locations: [{ line: 7, column: 49 }], + }, + ]); + }); + + it('with directive argument types extended inside SDL', () => { + const schema = buildSchema(` + enum DirectiveEnum { + OLD + } + + input DirectiveInput { + old: String + } + + type Query { + field: String + } + + directive @test( + enum: DirectiveEnum + input: DirectiveInput + ) on OBJECT + `); + + expectValidSDL( + ` + extend enum DirectiveEnum { + VALUE + } + + extend input DirectiveInput { + enabled: Boolean! + } + + extend type Query @test( + enum: VALUE + input: { enabled: true } + ) + `, + schema, + ); + }); + }); }); describe('Variable default values', () => { diff --git a/src/validation/rules/ValuesOfCorrectTypeRule.ts b/src/validation/rules/ValuesOfCorrectTypeRule.ts index 3a7f4f235a..2ed185cca2 100644 --- a/src/validation/rules/ValuesOfCorrectTypeRule.ts +++ b/src/validation/rules/ValuesOfCorrectTypeRule.ts @@ -7,26 +7,51 @@ import { suggestionList } from '../../jsutils/suggestionList'; import { GraphQLError } from '../../error/GraphQLError'; import type { + DirectiveDefinitionNode, + EnumTypeDefinitionNode, + EnumTypeExtensionNode, + InputObjectTypeDefinitionNode, + InputObjectTypeExtensionNode, ObjectFieldNode, ObjectValueNode, + TypeNode, ValueNode, } from '../../language/ast'; +import type { DirectiveLocation } from '../../language/directiveLocation'; import { Kind } from '../../language/kinds'; import { print } from '../../language/printer'; import type { ASTVisitor } from '../../language/visitor'; +import { visit } from '../../language/visitor'; -import type { GraphQLInputObjectType } from '../../type/definition'; +import type { + GraphQLFieldConfigArgumentMap, + GraphQLInputFieldConfigMap, + GraphQLInputType, +} from '../../type/definition'; import { getNamedType, getNullableType, + GraphQLEnumType, + GraphQLInputObjectType, + GraphQLList, + GraphQLNonNull, + GraphQLScalarType, + isEnumType, isInputObjectType, + isInputType, isLeafType, isListType, isNonNullType, isRequiredInputField, } from '../../type/definition'; +import { GraphQLDirective, specifiedDirectives } from '../../type/directives'; +import { specifiedScalarTypes } from '../../type/scalars'; +import { GraphQLSchema } from '../../type/schema'; + +import { TypeInfo, visitWithTypeInfo } from '../../utilities/TypeInfo'; -import type { ValidationContext } from '../ValidationContext'; +import type { SDLValidationContext } from '../ValidationContext'; +import { ValidationContext } from '../ValidationContext'; /** * Value literals of correct type @@ -115,6 +140,34 @@ export function ValuesOfCorrectTypeRule( }; } +/** + * @internal + */ +export function ValuesOfCorrectTypeOnDirectivesRule( + context: SDLValidationContext, +): ASTVisitor { + const schema = getDirectiveSchema(context); + + return { + Directive(directiveNode) { + const typeInfo = new TypeInfo(schema); + const validationContext = new ValidationContext( + schema, + context.getDocument(), + typeInfo, + (error) => context.reportError(error), + ); + + visit( + directiveNode, + // eslint-disable-next-line new-cap + visitWithTypeInfo(typeInfo, ValuesOfCorrectTypeRule(validationContext)), + ); + return false; + }, + }; +} + /** * Any value literal may be a valid representation of a Scalar, depending on * that scalar type. @@ -168,8 +221,301 @@ function isValidValueNode(context: ValidationContext, node: ValueNode): void { } } +function getDirectiveSchema(context: SDLValidationContext): GraphQLSchema { + const typeMap = getInputTypeMap(context); + const directiveMap: ObjMap = Object.create(null); + + const schema = context.getSchema(); + const definedDirectives = schema + ? schema.getDirectives() + : specifiedDirectives; + for (const directive of definedDirectives) { + directiveMap[directive.name] = replaceDirective(directive, typeMap); + } + + const astDefinitions = context.getDocument().definitions; + for (const def of astDefinitions) { + if (def.kind === Kind.DIRECTIVE_DEFINITION) { + directiveMap[def.name.value] = buildDirective(def, typeMap); + } + } + + return new GraphQLSchema({ + directives: Object.values(directiveMap), + }); +} + +function replaceDirective( + directive: GraphQLDirective, + typeMap: ObjMap, +): GraphQLDirective { + const config = directive.toConfig(); + return new GraphQLDirective({ + ...config, + args: replaceArgumentMap(config.args, typeMap), + }); +} + +function buildDirective( + directive: DirectiveDefinitionNode, + typeMap: ObjMap, +): GraphQLDirective { + return new GraphQLDirective({ + name: directive.name.value, + locations: directive.locations.map( + (location) => location.value as DirectiveLocation, + ), + args: buildArgumentMap(directive.arguments, typeMap), + isRepeatable: directive.repeatable, + astNode: directive, + }); +} + +function replaceArgumentMap( + args: GraphQLFieldConfigArgumentMap, + typeMap: ObjMap, +): GraphQLFieldConfigArgumentMap { + const argMap: GraphQLFieldConfigArgumentMap = Object.create(null); + for (const [argName, arg] of Object.entries(args)) { + argMap[argName] = { + ...arg, + type: replaceInputType(arg.type, typeMap), + }; + } + return argMap; +} + +function buildArgumentMap( + args: DirectiveDefinitionNode['arguments'], + typeMap: ObjMap, +): GraphQLFieldConfigArgumentMap { + const argMap: GraphQLFieldConfigArgumentMap = Object.create(null); + const argNodes = args ?? []; + for (const argNode of argNodes) { + const type = typeFromASTNode(argNode.type, typeMap); + if (type) { + argMap[argNode.name.value] = { + type, + defaultValue: argNode.defaultValue === undefined ? undefined : null, + astNode: argNode, + }; + } + } + return argMap; +} + +function getInputTypeMap( + context: SDLValidationContext, +): ObjMap { + const typeMap: ObjMap = Object.create(null); + const schema = context.getSchema(); + + const enumExtensions: ObjMap> = + Object.create(null); + const inputObjectExtensions: ObjMap> = + Object.create(null); + + for (const def of context.getDocument().definitions) { + if (def.kind === Kind.ENUM_TYPE_EXTENSION) { + const extensions = enumExtensions[def.name.value] ?? []; + extensions.push(def); + enumExtensions[def.name.value] = extensions; + } else if (def.kind === Kind.INPUT_OBJECT_TYPE_EXTENSION) { + const extensions = inputObjectExtensions[def.name.value] ?? []; + extensions.push(def); + inputObjectExtensions[def.name.value] = extensions; + } + } + + if (schema) { + for (const type of Object.values(schema.getTypeMap())) { + const enumExtensionsForType = enumExtensions[type.name]; + const inputObjectExtensionsForType = inputObjectExtensions[type.name]; + if (isEnumType(type) && enumExtensionsForType) { + typeMap[type.name] = extendEnumType(type, enumExtensionsForType); + } else if (isInputObjectType(type) && inputObjectExtensionsForType) { + typeMap[type.name] = extendInputObjectType( + type, + inputObjectExtensionsForType, + typeMap, + ); + } else if (isInputType(type)) { + typeMap[type.name] = type; + } + } + } else { + for (const type of specifiedScalarTypes) { + typeMap[type.name] = type; + } + } + + for (const def of context.getDocument().definitions) { + if (def.kind === Kind.SCALAR_TYPE_DEFINITION) { + typeMap[def.name.value] = new GraphQLScalarType({ + name: def.name.value, + astNode: def, + }); + } else if (def.kind === Kind.ENUM_TYPE_DEFINITION) { + const enumNodes = [def, ...(enumExtensions[def.name.value] ?? [])]; + typeMap[def.name.value] = buildEnumType(def, enumNodes); + } else if (def.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION) { + const inputNodes = [ + def, + ...(inputObjectExtensions[def.name.value] ?? []), + ]; + typeMap[def.name.value] = buildInputObjectType(def, inputNodes, typeMap); + } + } + + return typeMap; +} + +function replaceInputType( + type: T, + typeMap: ObjMap, +): T { + if (isListType(type)) { + return new GraphQLList(replaceInputType(type.ofType, typeMap)) as T; + } + + if (isNonNullType(type)) { + return new GraphQLNonNull(replaceInputType(type.ofType, typeMap)) as T; + } + + return (typeMap[type.name] ?? type) as T; +} + +function extendEnumType( + type: GraphQLEnumType, + extensions: ReadonlyArray, +): GraphQLEnumType { + const config = type.toConfig(); + return new GraphQLEnumType({ + ...config, + values: { + ...config.values, + ...buildEnumValueMap(extensions), + }, + extensionASTNodes: config.extensionASTNodes.concat(extensions), + }); +} + +function buildEnumType( + def: EnumTypeDefinitionNode, + nodes: ReadonlyArray, +): GraphQLEnumType { + return new GraphQLEnumType({ + name: def.name.value, + values: buildEnumValueMap(nodes), + astNode: def, + }); +} + +function buildEnumValueMap( + nodes: ReadonlyArray, +) { + const values = Object.create(null); + for (const node of nodes) { + const valueNodes = node.values ?? []; + for (const valueNode of valueNodes) { + values[valueNode.name.value] = {}; + } + } + return values; +} + +function extendInputObjectType( + type: GraphQLInputObjectType, + extensions: ReadonlyArray, + typeMap: ObjMap, +): GraphQLInputObjectType { + const config = type.toConfig(); + return new GraphQLInputObjectType({ + ...config, + fields: () => ({ + ...replaceInputFieldMap(config.fields, typeMap), + ...buildInputFieldMap(extensions, typeMap), + }), + extensionASTNodes: config.extensionASTNodes.concat(extensions), + }); +} + +function buildInputObjectType( + def: InputObjectTypeDefinitionNode, + nodes: ReadonlyArray< + InputObjectTypeDefinitionNode | InputObjectTypeExtensionNode + >, + typeMap: ObjMap, +): GraphQLInputObjectType { + return new GraphQLInputObjectType({ + name: def.name.value, + fields: () => buildInputFieldMap(nodes, typeMap), + astNode: def, + isOneOf: Boolean( + def.directives?.some((directive) => directive.name.value === 'oneOf'), + ), + }); +} + +function replaceInputFieldMap( + fields: GraphQLInputFieldConfigMap, + typeMap: ObjMap, +): GraphQLInputFieldConfigMap { + const fieldMap: GraphQLInputFieldConfigMap = Object.create(null); + for (const [fieldName, field] of Object.entries(fields)) { + fieldMap[fieldName] = { + ...field, + type: replaceInputType(field.type, typeMap), + }; + } + return fieldMap; +} + +function buildInputFieldMap( + nodes: ReadonlyArray< + InputObjectTypeDefinitionNode | InputObjectTypeExtensionNode + >, + typeMap: ObjMap, +): GraphQLInputFieldConfigMap { + const fields: GraphQLInputFieldConfigMap = Object.create(null); + for (const node of nodes) { + const fieldNodes = node.fields ?? []; + for (const fieldNode of fieldNodes) { + const type = typeFromASTNode(fieldNode.type, typeMap); + if (type) { + fields[fieldNode.name.value] = { + type, + defaultValue: fieldNode.defaultValue === undefined ? undefined : null, + astNode: fieldNode, + }; + } + } + } + return fields; +} + +function typeFromASTNode( + typeNode: TypeNode, + typeMap: ObjMap, +): GraphQLInputType | undefined { + if (typeNode.kind === Kind.LIST_TYPE) { + const innerType = typeFromASTNode(typeNode.type, typeMap); + return innerType && new GraphQLList(innerType); + } + + if (typeNode.kind === Kind.NON_NULL_TYPE) { + const innerType = typeFromASTNode(typeNode.type, typeMap); + if (!innerType || isNonNullType(innerType)) { + return; + } + return new GraphQLNonNull(innerType); + } + + return typeMap[typeNode.name.value]; +} + function validateOneOfInputObject( - context: ValidationContext, + context: ValidationContext | SDLValidationContext, node: ObjectValueNode, type: GraphQLInputObjectType, fieldNodeMap: ObjMap, diff --git a/src/validation/specifiedRules.ts b/src/validation/specifiedRules.ts index c312c9839c..41345ca3dd 100644 --- a/src/validation/specifiedRules.ts +++ b/src/validation/specifiedRules.ts @@ -62,7 +62,10 @@ import { UniqueTypeNamesRule } from './rules/UniqueTypeNamesRule'; // Spec Section: "Variable Uniqueness" import { UniqueVariableNamesRule } from './rules/UniqueVariableNamesRule'; // Spec Section: "Value Type Correctness" -import { ValuesOfCorrectTypeRule } from './rules/ValuesOfCorrectTypeRule'; +import { + ValuesOfCorrectTypeOnDirectivesRule, + ValuesOfCorrectTypeRule, +} from './rules/ValuesOfCorrectTypeRule'; // Spec Section: "Variables are Input Types" import { VariablesAreInputTypesRule } from './rules/VariablesAreInputTypesRule'; // Spec Section: "All Variable Usages Are Allowed" @@ -130,5 +133,6 @@ export const specifiedSDLRules: ReadonlyArray = KnownArgumentNamesOnDirectivesRule, UniqueArgumentNamesRule, UniqueInputFieldNamesRule, + ValuesOfCorrectTypeOnDirectivesRule, ProvidedRequiredArgumentsOnDirectivesRule, ]);