From c7f677b18d5659448fa31cf757eb82eebca3a3b1 Mon Sep 17 00:00:00 2001 From: Lee Byron Date: Thu, 13 May 2021 10:30:16 -0700 Subject: [PATCH] Input Value Validation Factors out input validation to reusable functions: * Introduces `validateInputLiteral` by extracting this behavior from `ValuesOfCorrectTypeRule`. * Introduces `validateInputValue` by extracting this behavior from `coerceInputValue` * Simplifies `coerceInputValue` to return early on validation error * Unifies error reporting between `validateInputValue` and `validateInputLiteral`, causing some error message strings to change, but error data (eg locations) are preserved. These two parallel functions will be used to validate default values in #3049 Potentially breaking if you rely on the existing behavior of `coerceInputValue` to call a callback function, as the call signature has changed. GraphQL behavior should not change, though error messages are now slightly different. --- src/execution/__tests__/nonnull-test.ts | 6 +- src/execution/__tests__/subscribe-test.ts | 2 +- src/execution/__tests__/variables-test.ts | 39 +- src/execution/values.ts | 160 ++-- src/index.ts | 6 +- src/jsutils/printPathArray.ts | 11 +- src/type/__tests__/enumType-test.ts | 2 +- .../__tests__/coerceInputValue-test.ts | 321 ++----- .../__tests__/validateInputValue-test.ts | 838 ++++++++++++++++++ src/utilities/coerceInputValue.ts | 177 +--- src/utilities/index.ts | 9 +- src/utilities/validateInputValue.ts | 382 ++++++++ .../__tests__/ValuesOfCorrectTypeRule-test.ts | 35 +- .../rules/ValuesOfCorrectTypeRule.ts | 145 +-- 14 files changed, 1472 insertions(+), 661 deletions(-) create mode 100644 src/utilities/__tests__/validateInputValue-test.ts create mode 100644 src/utilities/validateInputValue.ts diff --git a/src/execution/__tests__/nonnull-test.ts b/src/execution/__tests__/nonnull-test.ts index 54f8b23d61..3478c5be76 100644 --- a/src/execution/__tests__/nonnull-test.ts +++ b/src/execution/__tests__/nonnull-test.ts @@ -647,7 +647,7 @@ describe('Execute: handles non-nullable types', () => { errors: [ { message: - 'Argument Query.withNonNullArg(cannotBeNull:) of non-null type String! must not be null.', + 'Argument Query.withNonNullArg(cannotBeNull:) has invalid value: Expected value of non-null type String! not to be null.', locations: [{ line: 3, column: 42 }], path: ['withNonNullArg'], }, @@ -677,7 +677,7 @@ describe('Execute: handles non-nullable types', () => { errors: [ { message: - 'Argument Query.withNonNullArg(cannotBeNull:) of required type String! was provided the variable "$testVar" which was not provided a runtime value.', + 'Argument Query.withNonNullArg(cannotBeNull:) has invalid value: Expected variable "$testVar" provided to type String! to provide a runtime value.', locations: [{ line: 3, column: 42 }], path: ['withNonNullArg'], }, @@ -705,7 +705,7 @@ describe('Execute: handles non-nullable types', () => { errors: [ { message: - 'Argument Query.withNonNullArg(cannotBeNull:) of non-null type String! must not be null.', + 'Argument Query.withNonNullArg(cannotBeNull:) has invalid value: Expected variable "$testVar" provided to non-null type String! not to be null.', locations: [{ line: 3, column: 43 }], path: ['withNonNullArg'], }, diff --git a/src/execution/__tests__/subscribe-test.ts b/src/execution/__tests__/subscribe-test.ts index 6a903450d9..43ea4294da 100644 --- a/src/execution/__tests__/subscribe-test.ts +++ b/src/execution/__tests__/subscribe-test.ts @@ -523,7 +523,7 @@ describe('Subscription Initialization Phase', () => { errors: [ { message: - 'Variable "$arg" got invalid value "meow"; Int cannot represent non-integer value: "meow"', + 'Variable "$arg" has invalid value: Int cannot represent non-integer value: "meow"', locations: [{ line: 2, column: 21 }], }, ], diff --git a/src/execution/__tests__/variables-test.ts b/src/execution/__tests__/variables-test.ts index 9cea44a1d2..21aa0636b8 100644 --- a/src/execution/__tests__/variables-test.ts +++ b/src/execution/__tests__/variables-test.ts @@ -226,7 +226,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Argument TestType.fieldWithObjectInput(input:) of type TestInputObject has invalid value ["foo", "bar", "baz"].', + 'Argument TestType.fieldWithObjectInput(input:) has invalid value: Expected value of type TestInputObject to be an object, found: ["foo", "bar", "baz"].', path: ['fieldWithObjectInput'], locations: [{ line: 3, column: 41 }], }, @@ -262,9 +262,10 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Argument TestType.fieldWithObjectInput(input:) of type TestInputObject has invalid value { c: "foo", e: "bar" }.', + 'Argument TestType.fieldWithObjectInput(input:) has invalid value at .e: FaultyScalarErrorMessage', path: ['fieldWithObjectInput'], locations: [{ line: 3, column: 41 }], + extensions: { code: 'FaultyScalarErrorExtensionCode' }, }, ], }); @@ -418,7 +419,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Variable "$input" got invalid value "SerializedValue" at "input.e"; FaultyScalarErrorMessage', + 'Variable "$input" has invalid value at .e: FaultyScalarErrorMessage', locations: [{ line: 2, column: 16 }], extensions: { code: 'FaultyScalarErrorExtensionCode' }, }, @@ -434,7 +435,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Variable "$input" got invalid value null at "input.c"; Expected non-nullable type "String!" not to be null.', + 'Variable "$input" has invalid value at .c: Expected value of non-null type String! not to be null.', locations: [{ line: 2, column: 16 }], }, ], @@ -448,7 +449,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Variable "$input" got invalid value "foo bar"; Expected type "TestInputObject" to be an object.', + 'Variable "$input" has invalid value: Expected value of type TestInputObject to be an object, found: "foo bar".', locations: [{ line: 2, column: 16 }], }, ], @@ -462,7 +463,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Variable "$input" got invalid value { a: "foo", b: "bar" }; Field "c" of required type "String!" was not provided.', + 'Variable "$input" has invalid value: Expected value of type TestInputObject to include required field "c", found: { a: "foo", b: "bar" }.', locations: [{ line: 2, column: 16 }], }, ], @@ -481,12 +482,12 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Variable "$input" got invalid value { a: "foo" } at "input.na"; Field "c" of required type "String!" was not provided.', + 'Variable "$input" has invalid value at .na: Expected value of type TestInputObject to include required field "c", found: { a: "foo" }.', locations: [{ line: 2, column: 18 }], }, { message: - 'Variable "$input" got invalid value { na: { a: "foo" } }; Field "nb" of required type "String!" was not provided.', + 'Variable "$input" has invalid value: Expected value of type TestNestedInputObject to include required field "nb", found: { na: { a: "foo" } }.', locations: [{ line: 2, column: 18 }], }, ], @@ -503,7 +504,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Variable "$input" got invalid value { a: "foo", b: "bar", c: "baz", extra: "dog" }; Field "extra" is not defined by type "TestInputObject".', + 'Variable "$input" has invalid value: Expected value of type TestInputObject not to include unknown field "extra", found: { a: "foo", b: "bar", c: "baz", extra: "dog" }.', locations: [{ line: 2, column: 16 }], }, ], @@ -678,7 +679,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Variable "$value" of required type String! was not provided.', + 'Variable "$value" has invalid value: Expected a value of non-null type String! to be provided.', locations: [{ line: 2, column: 16 }], }, ], @@ -697,7 +698,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Variable "$value" of non-null type String! must not be null.', + 'Variable "$value" has invalid value: Expected value of non-null type String! not to be null.', locations: [{ line: 2, column: 16 }], }, ], @@ -763,7 +764,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Variable "$value" got invalid value [1, 2, 3]; String cannot represent a non string value: [1, 2, 3]', + 'Variable "$value" has invalid value: String cannot represent a non string value: [1, 2, 3]', locations: [{ line: 2, column: 16 }], }, ], @@ -791,7 +792,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Argument TestType.fieldWithNonNullableStringInput(input:) of required type String! was provided the variable "$foo" which was not provided a runtime value.', + 'Argument TestType.fieldWithNonNullableStringInput(input:) has invalid value: Expected variable "$foo" provided to type String! to provide a runtime value.', locations: [{ line: 3, column: 50 }], path: ['fieldWithNonNullableStringInput'], }, @@ -846,7 +847,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Variable "$input" of non-null type [String]! must not be null.', + 'Variable "$input" has invalid value: Expected value of non-null type [String]! not to be null.', locations: [{ line: 2, column: 16 }], }, ], @@ -909,7 +910,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Variable "$input" got invalid value null at "input[1]"; Expected non-nullable type "String!" not to be null.', + 'Variable "$input" has invalid value at [1]: Expected value of non-null type String! not to be null.', locations: [{ line: 2, column: 16 }], }, ], @@ -928,7 +929,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Variable "$input" of non-null type [String!]! must not be null.', + 'Variable "$input" has invalid value: Expected value of non-null type [String!]! not to be null.', locations: [{ line: 2, column: 16 }], }, ], @@ -958,7 +959,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Variable "$input" got invalid value null at "input[1]"; Expected non-nullable type "String!" not to be null.', + 'Variable "$input" has invalid value at [1]: Expected value of non-null type String! not to be null.', locations: [{ line: 2, column: 16 }], }, ], @@ -1043,7 +1044,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Argument TestType.fieldWithDefaultArgumentValue(input:) of type String has invalid value WRONG_TYPE.', + 'Argument TestType.fieldWithDefaultArgumentValue(input:) has invalid value: String cannot represent a non string value: WRONG_TYPE', locations: [{ line: 3, column: 48 }], path: ['fieldWithDefaultArgumentValue'], }, @@ -1083,7 +1084,7 @@ describe('Execute: Handles inputs', () => { function invalidValueError(value: number, index: number) { return { - message: `Variable "$input" got invalid value ${value} at "input[${index}]"; String cannot represent a non string value: ${value}`, + message: `Variable "$input" has invalid value at [${index}]: String cannot represent a non string value: ${value}`, locations: [{ line: 2, column: 14 }], }; } diff --git a/src/execution/values.ts b/src/execution/values.ts index 0c194c41ef..163b9c5313 100644 --- a/src/execution/values.ts +++ b/src/execution/values.ts @@ -1,4 +1,4 @@ -import { inspect } from '../jsutils/inspect.js'; +import { invariant } from '../jsutils/invariant.js'; import type { Maybe } from '../jsutils/Maybe.js'; import type { ObjMap, ReadOnlyObjMap } from '../jsutils/ObjMap.js'; import { printPathArray } from '../jsutils/printPathArray.js'; @@ -14,7 +14,11 @@ import { Kind } from '../language/kinds.js'; import { print } from '../language/printer.js'; import type { GraphQLField, GraphQLInputType } from '../type/definition.js'; -import { isInputType, isNonNullType } from '../type/definition.js'; +import { + isInputType, + isNonNullType, + isRequiredArgument, +} from '../type/definition.js'; import type { GraphQLDirective } from '../type/directives.js'; import type { GraphQLSchema } from '../type/schema.js'; @@ -24,6 +28,10 @@ import { coerceInputValue, } from '../utilities/coerceInputValue.js'; import { typeFromAST } from '../utilities/typeFromAST.js'; +import { + validateInputLiteral, + validateInputValue, +} from '../utilities/validateInputValue.js'; export interface VariableValues { readonly sources: ReadOnlyObjMap; @@ -106,55 +114,37 @@ function coerceVariableValues( continue; } - if (!Object.hasOwn(inputs, varName)) { - const defaultValue = varDefNode.defaultValue; - if (defaultValue) { - sources[varName] = { - variable: varDefNode, - type: varType, - value: undefined, - }; - coerced[varName] = coerceInputLiteral(defaultValue, varType); - } else if (isNonNullType(varType)) { - onError( - new GraphQLError( - `Variable "$${varName}" of required type ${varType} was not provided.`, - { nodes: varDefNode }, - ), - ); - } - continue; - } + const value = Object.hasOwn(inputs, varName) ? inputs[varName] : undefined; + sources[varName] = { variable: varDefNode, type: varType, value }; - const value = inputs[varName]; - if (value === null && isNonNullType(varType)) { - onError( - new GraphQLError( - `Variable "$${varName}" of non-null type ${varType} must not be null.`, - { nodes: varDefNode }, - ), - ); - continue; + if (value === undefined) { + if (varDefNode.defaultValue) { + coerced[varName] = coerceInputLiteral(varDefNode.defaultValue, varType); + continue; + } else if (!isNonNullType(varType)) { + // Non-provided values for nullable variables are omitted. + continue; + } } - sources[varName] = { variable: varDefNode, type: varType, value }; - coerced[varName] = coerceInputValue( - value, - varType, - (path, invalidValue, error) => { - let prefix = - `Variable "$${varName}" got invalid value ` + inspect(invalidValue); - if (path.length > 0) { - prefix += ` at "${varName}${printPathArray(path)}"`; - } + const coercedValue = coerceInputValue(value, varType); + if (coercedValue !== undefined) { + coerced[varName] = coercedValue; + } else { + validateInputValue(value, varType, (error, path) => { onError( - new GraphQLError(prefix + '; ' + error.message, { - nodes: varDefNode, - originalError: error, - }), + new GraphQLError( + `Variable "$${varName}" has invalid value${printPathArray(path)}: ${ + error.message + }`, + { + nodes: varDefNode, + originalError: error, + }, + ), ); - }, - ); + }); + } } return { sources, coerced }; @@ -185,65 +175,57 @@ export function getArgumentValues( const argType = argDef.type; const argumentNode = argNodeMap.get(name); - if (argumentNode == null) { + if (!argumentNode && isRequiredArgument(argDef)) { + // Note: ProvidedRequiredArgumentsRule validation should catch this before + // execution. This is a runtime check to ensure execution does not + // continue with an invalid argument value. + throw new GraphQLError( + `Argument ${argDef} of required type ${argType} was not provided.`, + { nodes: node }, + ); + } + + // Variables without a value are treated as if no argument was provided if + // the argument is not required. + if ( + !argumentNode || + (argumentNode.value.kind === Kind.VARIABLE && + variableValues?.coerced[argumentNode.value.name.value] === undefined && + !isRequiredArgument(argDef)) + ) { if (argDef.defaultValue) { coercedValues[name] = coerceDefaultValue( argDef.defaultValue, argDef.type, ); - } else if (isNonNullType(argType)) { - throw new GraphQLError( - `Argument ${argDef} of required type ${argType} was not provided.`, - { nodes: node }, - ); } continue; } const valueNode = argumentNode.value; - let isNull = valueNode.kind === Kind.NULL; - - if (valueNode.kind === Kind.VARIABLE) { - const variableName = valueNode.name.value; - if ( - variableValues == null || - !Object.hasOwn(variableValues.coerced, variableName) - ) { - if (argDef.defaultValue) { - coercedValues[name] = coerceDefaultValue( - argDef.defaultValue, - argDef.type, - ); - } else if (isNonNullType(argType)) { - throw new GraphQLError( - `Argument ${argDef} of required type ${argType} ` + - `was provided the variable "$${variableName}" which was not provided a runtime value.`, - { nodes: valueNode }, - ); - } - continue; - } - isNull = variableValues.coerced[variableName] == null; - } - - if (isNull && isNonNullType(argType)) { - throw new GraphQLError( - `Argument ${argDef} of non-null type ${argType} must not be null.`, - { nodes: valueNode }, - ); - } - const coercedValue = coerceInputLiteral(valueNode, argType, variableValues); if (coercedValue === undefined) { // Note: ValuesOfCorrectTypeRule validation should catch this before // execution. This is a runtime check to ensure execution does not // continue with an invalid argument value. - throw new GraphQLError( - `Argument ${argDef} of type ${argType} has invalid value ${print( - valueNode, - )}.`, - { nodes: valueNode }, + validateInputLiteral( + valueNode, + argType, + variableValues, + (error, path) => { + throw new GraphQLError( + `Argument ${argDef} has invalid value${printPathArray(path)}: ${ + error.message + }`, + { + nodes: valueNode, + originalError: error, + }, + ); + }, ); + // istanbul ignore next (validateInputLiteral should throw) + invariant(false, 'Invalid argument'); } coercedValues[name] = coercedValue; } diff --git a/src/index.ts b/src/index.ts index 322b2d0f64..8c13807b01 100644 --- a/src/index.ts +++ b/src/index.ts @@ -451,10 +451,14 @@ export { replaceVariables, // Create a GraphQL literal (AST) from a JavaScript input value. valueToLiteral, - // Coerces a JavaScript value to a GraphQL type, or produces errors. + // Coerces a JavaScript value to a GraphQL type, or returns undefined. coerceInputValue, // Coerces a GraphQL literal (AST) to a GraphQL type, or returns undefined. coerceInputLiteral, + // Validate a JavaScript value with a GraphQL type, collecting all errors. + validateInputValue, + // Validate a GraphQL literal (AST) with a GraphQL type, collecting all errors. + validateInputLiteral, // Concatenates multiple AST together. concatAST, // Separates an AST into an AST per Operation. diff --git a/src/jsutils/printPathArray.ts b/src/jsutils/printPathArray.ts index 0d9fcc2b19..e51abf67dc 100644 --- a/src/jsutils/printPathArray.ts +++ b/src/jsutils/printPathArray.ts @@ -2,9 +2,10 @@ * Build a string describing the path. */ export function printPathArray(path: ReadonlyArray): string { - return path - .map((key) => - typeof key === 'number' ? '[' + key.toString() + ']' : '.' + key, - ) - .join(''); + if (path.length === 0) { + return ''; + } + return ` at ${path + .map((key) => (typeof key === 'number' ? `[${key}]` : `.${key}`)) + .join('')}`; } diff --git a/src/type/__tests__/enumType-test.ts b/src/type/__tests__/enumType-test.ts index 49f937356b..c9c583b136 100644 --- a/src/type/__tests__/enumType-test.ts +++ b/src/type/__tests__/enumType-test.ts @@ -266,7 +266,7 @@ describe('Type System: Enum Values', () => { errors: [ { message: - 'Variable "$color" got invalid value 2; Enum "Color" cannot represent non-string value: 2.', + 'Variable "$color" has invalid value: Enum "Color" cannot represent non-string value: 2.', locations: [{ line: 1, column: 8 }], }, ], diff --git a/src/utilities/__tests__/coerceInputValue-test.ts b/src/utilities/__tests__/coerceInputValue-test.ts index d203c83b8c..eb304b28ea 100644 --- a/src/utilities/__tests__/coerceInputValue-test.ts +++ b/src/utilities/__tests__/coerceInputValue-test.ts @@ -36,71 +36,28 @@ import { coerceInputValue, } from '../coerceInputValue.js'; -interface CoerceResult { - value: unknown; - errors: ReadonlyArray; -} - -interface CoerceError { - path: ReadonlyArray; - value: unknown; - error: string; -} - -function coerceValue( - inputValue: unknown, - type: GraphQLInputType, -): CoerceResult { - const errors: Array = []; - const value = coerceInputValue( - inputValue, - type, - (path, invalidValue, error) => { - errors.push({ path, value: invalidValue, error: error.message }); - }, - ); - - return { errors, value }; -} - -function expectValue(result: CoerceResult) { - expect(result.errors).to.deep.equal([]); - return expect(result.value); -} - -function expectErrors(result: CoerceResult) { - return expect(result.errors); -} - describe('coerceInputValue', () => { + function test( + inputValue: unknown, + type: GraphQLInputType, + expected: unknown, + ) { + expect(coerceInputValue(inputValue, type)).to.deep.equal(expected); + } + describe('for GraphQLNonNull', () => { const TestNonNull = new GraphQLNonNull(GraphQLInt); - it('returns no error for non-null value', () => { - const result = coerceValue(1, TestNonNull); - expectValue(result).to.equal(1); + it('returns for a non-null value', () => { + test(1, TestNonNull, 1); }); - it('returns an error for undefined value', () => { - const result = coerceValue(undefined, TestNonNull); - expectErrors(result).to.deep.equal([ - { - error: 'Expected non-nullable type "Int!" not to be null.', - path: [], - value: undefined, - }, - ]); + it('invalid for undefined value', () => { + test(undefined, TestNonNull, undefined); }); - it('returns an error for null value', () => { - const result = coerceValue(null, TestNonNull); - expectErrors(result).to.deep.equal([ - { - error: 'Expected non-nullable type "Int!" not to be null.', - path: [], - value: null, - }, - ]); + it('invalid for null value', () => { + test(null, TestNonNull, undefined); }); }); @@ -115,42 +72,27 @@ describe('coerceInputValue', () => { }, }); - it('returns no error for valid input', () => { - const result = coerceValue({ value: 1 }, TestScalar); - expectValue(result).to.equal(1); + it('returns for valid input', () => { + test({ value: 1 }, TestScalar, 1); }); - it('returns no error for null result', () => { - const result = coerceValue({ value: null }, TestScalar); - expectValue(result).to.equal(null); + it('returns for null result', () => { + test({ value: null }, TestScalar, null); }); - it('returns no error for NaN result', () => { - const result = coerceValue({ value: NaN }, TestScalar); - expectValue(result).to.satisfy(Number.isNaN); + it('returns for NaN result', () => { + expect(coerceInputValue({ value: NaN }, TestScalar)).to.satisfy( + Number.isNaN, + ); }); - it('returns an error for undefined result', () => { - const result = coerceValue({ value: undefined }, TestScalar); - expectErrors(result).to.deep.equal([ - { - error: 'Expected type "TestScalar".', - path: [], - value: { value: undefined }, - }, - ]); + it('invalid for undefined result', () => { + test({ value: undefined }, TestScalar, undefined); }); - it('returns a thrown error', () => { + it('invalid for undefined result', () => { const inputValue = { error: 'Some error message' }; - const result = coerceValue(inputValue, TestScalar); - expectErrors(result).to.deep.equal([ - { - error: 'Expected type "TestScalar". Some error message', - path: [], - value: { error: 'Some error message' }, - }, - ]); + test(inputValue, TestScalar, undefined); }); }); @@ -164,44 +106,18 @@ describe('coerceInputValue', () => { }); it('returns no error for a known enum name', () => { - const fooResult = coerceValue('FOO', TestEnum); - expectValue(fooResult).to.equal('InternalFoo'); + test('FOO', TestEnum, 'InternalFoo'); - const barResult = coerceValue('BAR', TestEnum); - expectValue(barResult).to.equal(123456789); + test('BAR', TestEnum, 123456789); }); - it('returns an error for misspelled enum value', () => { - const result = coerceValue('foo', TestEnum); - expectErrors(result).to.deep.equal([ - { - error: - 'Value "foo" does not exist in "TestEnum" enum. Did you mean the enum value "FOO"?', - path: [], - value: 'foo', - }, - ]); + it('invalid for misspelled enum value', () => { + test('foo', TestEnum, undefined); }); - it('returns an error for incorrect value type', () => { - const result1 = coerceValue(123, TestEnum); - expectErrors(result1).to.deep.equal([ - { - error: 'Enum "TestEnum" cannot represent non-string value: 123.', - path: [], - value: 123, - }, - ]); - - const result2 = coerceValue({ field: 'value' }, TestEnum); - expectErrors(result2).to.deep.equal([ - { - error: - 'Enum "TestEnum" cannot represent non-string value: { field: "value" }.', - path: [], - value: { field: 'value' }, - }, - ]); + it('invalid for incorrect value type', () => { + test(123, TestEnum, undefined); + test({ field: 'value' }, TestEnum, undefined); }); }); @@ -215,84 +131,27 @@ describe('coerceInputValue', () => { }); it('returns no error for a valid input', () => { - const result = coerceValue({ foo: 123 }, TestInputObject); - expectValue(result).to.deep.equal({ foo: 123 }); + test({ foo: 123 }, TestInputObject, { foo: 123 }); }); - it('returns an error for a non-object type', () => { - const result = coerceValue(123, TestInputObject); - expectErrors(result).to.deep.equal([ - { - error: 'Expected type "TestInputObject" to be an object.', - path: [], - value: 123, - }, - ]); + it('invalid for a non-object type', () => { + test(123, TestInputObject, undefined); }); - it('returns an error for an invalid field', () => { - const result = coerceValue({ foo: NaN }, TestInputObject); - expectErrors(result).to.deep.equal([ - { - error: 'Int cannot represent non-integer value: NaN', - path: ['foo'], - value: NaN, - }, - ]); - }); - - it('returns multiple errors for multiple invalid fields', () => { - const result = coerceValue({ foo: 'abc', bar: 'def' }, TestInputObject); - expectErrors(result).to.deep.equal([ - { - error: 'Int cannot represent non-integer value: "abc"', - path: ['foo'], - value: 'abc', - }, - { - error: 'Int cannot represent non-integer value: "def"', - path: ['bar'], - value: 'def', - }, - ]); + it('invalid for an invalid field', () => { + test({ foo: NaN }, TestInputObject, undefined); }); - it('returns error for a missing required field', () => { - const result = coerceValue({ bar: 123 }, TestInputObject); - expectErrors(result).to.deep.equal([ - { - error: 'Field "foo" of required type "Int!" was not provided.', - path: [], - value: { bar: 123 }, - }, - ]); + it('invalid for multiple invalid fields', () => { + test({ foo: 'abc', bar: 'def' }, TestInputObject, undefined); }); - it('returns error for an unknown field', () => { - const result = coerceValue( - { foo: 123, unknownField: 123 }, - TestInputObject, - ); - expectErrors(result).to.deep.equal([ - { - error: - 'Field "unknownField" is not defined by type "TestInputObject".', - path: [], - value: { foo: 123, unknownField: 123 }, - }, - ]); + it('invalid for a missing required field', () => { + test({ bar: 123 }, TestInputObject, undefined); }); - it('returns error for a misspelled field', () => { - const result = coerceValue({ foo: 123, bart: 123 }, TestInputObject); - expectErrors(result).to.deep.equal([ - { - error: - 'Field "bart" is not defined by type "TestInputObject". Did you mean "bar"?', - path: [], - value: { foo: 123, bart: 123 }, - }, - ]); + it('invalid for an unknown field', () => { + test({ foo: 123, unknownField: 123 }, TestInputObject, undefined); }); }); @@ -309,23 +168,21 @@ describe('coerceInputValue', () => { }); it('returns no errors for valid input value', () => { - const result = coerceValue({ foo: 5 }, makeTestInputObject(7)); - expectValue(result).to.deep.equal({ foo: 5 }); + test({ foo: 5 }, makeTestInputObject(7), { foo: 5 }); }); it('returns object with default value', () => { - const result = coerceValue({}, makeTestInputObject(7)); - expectValue(result).to.deep.equal({ foo: 7 }); + test({}, makeTestInputObject(7), { foo: 7 }); }); it('returns null as value', () => { - const result = coerceValue({}, makeTestInputObject(null)); - expectValue(result).to.deep.equal({ foo: null }); + test({}, makeTestInputObject(null), { foo: null }); }); it('returns NaN as value', () => { - const result = coerceValue({}, makeTestInputObject(NaN)); - expectValue(result).to.have.property('foo').that.satisfy(Number.isNaN); + expect(coerceInputValue({}, makeTestInputObject(NaN))) + .to.have.property('foo') + .that.satisfy(Number.isNaN); }); }); @@ -333,8 +190,7 @@ describe('coerceInputValue', () => { const TestList = new GraphQLList(GraphQLInt); it('returns no error for a valid input', () => { - const result = coerceValue([1, 2, 3], TestList); - expectValue(result).to.deep.equal([1, 2, 3]); + test([1, 2, 3], TestList, [1, 2, 3]); }); it('returns no error for a valid iterable input', () => { @@ -344,29 +200,15 @@ describe('coerceInputValue', () => { yield 3; } - const result = coerceValue(listGenerator(), TestList); - expectValue(result).to.deep.equal([1, 2, 3]); + test(listGenerator(), TestList, [1, 2, 3]); }); - it('returns an error for an invalid input', () => { - const result = coerceValue([1, 'b', true, 4], TestList); - expectErrors(result).to.deep.equal([ - { - error: 'Int cannot represent non-integer value: "b"', - path: [1], - value: 'b', - }, - { - error: 'Int cannot represent non-integer value: true', - path: [2], - value: true, - }, - ]); + it('invalid for an invalid input', () => { + test([1, 'b', true, 4], TestList, undefined); }); it('returns a list for a non-list value', () => { - const result = coerceValue(42, TestList); - expectValue(result).to.deep.equal([42]); + test(42, TestList, [42]); }); it('returns a list for a non-list object value', () => { @@ -379,24 +221,15 @@ describe('coerceInputValue', () => { }), ); - const result = coerceValue({ length: 100500 }, TestListOfObjects); - expectValue(result).to.deep.equal([{ length: 100500 }]); + test({ length: 100500 }, TestListOfObjects, [{ length: 100500 }]); }); - it('returns an error for a non-list invalid value', () => { - const result = coerceValue('INVALID', TestList); - expectErrors(result).to.deep.equal([ - { - error: 'Int cannot represent non-integer value: "INVALID"', - path: [], - value: 'INVALID', - }, - ]); + it('invalid for a non-list invalid value', () => { + test('INVALID', TestList, undefined); }); it('returns null for a null value', () => { - const result = coerceValue(null, TestList); - expectValue(result).to.deep.equal(null); + test(null, TestList, null); }); }); @@ -404,49 +237,23 @@ describe('coerceInputValue', () => { const TestNestedList = new GraphQLList(new GraphQLList(GraphQLInt)); it('returns no error for a valid input', () => { - const result = coerceValue([[1], [2, 3]], TestNestedList); - expectValue(result).to.deep.equal([[1], [2, 3]]); + test([[1], [2, 3]], TestNestedList, [[1], [2, 3]]); }); it('returns a list for a non-list value', () => { - const result = coerceValue(42, TestNestedList); - expectValue(result).to.deep.equal([[42]]); + test(42, TestNestedList, [[42]]); }); it('returns null for a null value', () => { - const result = coerceValue(null, TestNestedList); - expectValue(result).to.deep.equal(null); + test(null, TestNestedList, null); }); it('returns nested lists for nested non-list values', () => { - const result = coerceValue([1, 2, 3], TestNestedList); - expectValue(result).to.deep.equal([[1], [2], [3]]); + test([1, 2, 3], TestNestedList, [[1], [2], [3]]); }); it('returns nested null for nested null values', () => { - const result = coerceValue([42, [null], null], TestNestedList); - expectValue(result).to.deep.equal([[42], [null], null]); - }); - }); - - describe('with default onError', () => { - it('throw error without path', () => { - expect(() => - coerceInputValue(null, new GraphQLNonNull(GraphQLInt)), - ).to.throw( - 'Invalid value null: Expected non-nullable type "Int!" not to be null.', - ); - }); - - it('throw error with path', () => { - expect(() => - coerceInputValue( - [null], - new GraphQLList(new GraphQLNonNull(GraphQLInt)), - ), - ).to.throw( - 'Invalid value null at "value[0]": Expected non-nullable type "Int!" not to be null.', - ); + test([42, [null], null], TestNestedList, [[42], [null], null]); }); }); }); diff --git a/src/utilities/__tests__/validateInputValue-test.ts b/src/utilities/__tests__/validateInputValue-test.ts new file mode 100644 index 0000000000..dc6b5f191e --- /dev/null +++ b/src/utilities/__tests__/validateInputValue-test.ts @@ -0,0 +1,838 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { invariant } from '../../jsutils/invariant.js'; +import type { ReadOnlyObjMap } from '../../jsutils/ObjMap.js'; + +import { Parser, parseValue } from '../../language/parser.js'; +import { TokenKind } from '../../language/tokenKind.js'; + +import type { GraphQLInputType } from '../../type/definition.js'; +import { + GraphQLEnumType, + GraphQLInputObjectType, + GraphQLList, + GraphQLNonNull, + GraphQLScalarType, +} from '../../type/definition.js'; +import { GraphQLInt } from '../../type/scalars.js'; +import { GraphQLSchema } from '../../type/schema.js'; + +import type { VariableValues } from '../../execution/values.js'; +import { getVariableValues } from '../../execution/values.js'; + +import { + validateInputLiteral, + validateInputValue, +} from '../validateInputValue.js'; + +describe('validateInputValue', () => { + function test( + inputValue: unknown, + type: GraphQLInputType, + expected: unknown, + ) { + const errors: any = []; + validateInputValue(inputValue, type, (error, path) => { + errors.push({ error: error.message, path }); + }); + expect(errors).to.deep.equal(expected); + } + + describe('for GraphQLNonNull', () => { + const TestNonNull = new GraphQLNonNull(GraphQLInt); + + it('returns no error for non-null value', () => { + test(1, TestNonNull, []); + }); + + it('returns an error for undefined value', () => { + test(undefined, TestNonNull, [ + { + error: 'Expected a value of non-null type Int! to be provided.', + path: [], + }, + ]); + }); + + it('returns an error for null value', () => { + test(null, TestNonNull, [ + { + error: 'Expected value of non-null type Int! not to be null.', + path: [], + }, + ]); + }); + }); + + describe('for GraphQLScalar', () => { + const TestScalar = new GraphQLScalarType({ + name: 'TestScalar', + parseValue(input: any) { + invariant(typeof input === 'object' && input !== null); + if (input.error != null) { + throw new Error(input.error); + } + return input.value; + }, + }); + + it('returns no error for valid input', () => { + test({ value: 1 }, TestScalar, []); + }); + + it('returns no error for null result', () => { + test({ value: null }, TestScalar, []); + }); + + it('returns no error for NaN result', () => { + test({ value: NaN }, TestScalar, []); + }); + + it('returns an error for undefined result', () => { + test({ value: undefined }, TestScalar, [ + { + error: + 'Expected value of type TestScalar, found: { value: undefined }.', + path: [], + }, + ]); + }); + + it('returns an error for undefined result', () => { + const inputValue = { error: 'Some error message.' }; + test(inputValue, TestScalar, [ + { + error: + 'Expected value of type TestScalar; Some error message. Found: { error: "Some error message." }.', + path: [], + }, + ]); + }); + }); + + describe('for GraphQLEnum', () => { + const TestEnum = new GraphQLEnumType({ + name: 'TestEnum', + values: { + FOO: { value: 'InternalFoo' }, + BAR: { value: 123456789 }, + }, + }); + + it('returns no error for a known enum name', () => { + test('FOO', TestEnum, []); + + test('BAR', TestEnum, []); + }); + + it('returns an error for unknown enum value', () => { + test('UNKNOWN', TestEnum, [ + { + error: 'Value "UNKNOWN" does not exist in "TestEnum" enum.', + path: [], + }, + ]); + }); + + it('returns an error for misspelled enum value', () => { + test('foo', TestEnum, [ + { + error: + 'Value "foo" does not exist in "TestEnum" enum. Did you mean the enum value "FOO"?', + path: [], + }, + ]); + }); + + it('returns an error for incorrect value type', () => { + test(123, TestEnum, [ + { + error: 'Enum "TestEnum" cannot represent non-string value: 123.', + path: [], + }, + ]); + + test({ field: 'value' }, TestEnum, [ + { + error: + 'Enum "TestEnum" cannot represent non-string value: { field: "value" }.', + path: [], + }, + ]); + }); + + it('reports thrown non-error', () => { + const TestThrowScalar = new GraphQLScalarType({ + name: 'TestScalar', + parseValue() { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw 'Not an error object.'; + }, + }); + + test({}, TestThrowScalar, [ + { + error: + 'Expected value of type TestScalar; Not an error object. Found: {}.', + path: [], + }, + ]); + }); + }); + + describe('for GraphQLInputObject', () => { + const TestInputObject = new GraphQLInputObjectType({ + name: 'TestInputObject', + fields: { + foo: { type: new GraphQLNonNull(GraphQLInt) }, + bar: { type: GraphQLInt }, + }, + }); + + it('returns no error for a valid input', () => { + test({ foo: 123 }, TestInputObject, []); + }); + + it('returns an error for a non-object type', () => { + test(123, TestInputObject, [ + { + error: + 'Expected value of type TestInputObject to be an object, found: 123.', + path: [], + }, + ]); + }); + + it('returns an error for an invalid field', () => { + test({ foo: NaN }, TestInputObject, [ + { + error: 'Int cannot represent non-integer value: NaN', + path: ['foo'], + }, + ]); + }); + + it('returns multiple errors for multiple invalid fields', () => { + test({ foo: 'abc', bar: 'def' }, TestInputObject, [ + { + error: 'Int cannot represent non-integer value: "abc"', + path: ['foo'], + }, + { + error: 'Int cannot represent non-integer value: "def"', + path: ['bar'], + }, + ]); + }); + + it('returns error for a missing required field', () => { + test({ bar: 123 }, TestInputObject, [ + { + error: + 'Expected value of type TestInputObject to include required field "foo", found: { bar: 123 }.', + path: [], + }, + ]); + }); + + it('returns error for an unknown field', () => { + test({ foo: 123, unknownField: 123 }, TestInputObject, [ + { + error: + 'Expected value of type TestInputObject not to include unknown field "unknownField", found: { foo: 123, unknownField: 123 }.', + path: [], + }, + ]); + }); + + it('returns error for a misspelled field', () => { + test({ foo: 123, bart: 123 }, TestInputObject, [ + { + error: + 'Expected value of type TestInputObject not to include unknown field "bart". Did you mean "bar"? Found: { foo: 123, bart: 123 }.', + path: [], + }, + ]); + }); + }); + + describe('for GraphQLInputObject with default value', () => { + function makeTestInputObject(defaultValue: unknown) { + return new GraphQLInputObjectType({ + name: 'TestInputObject', + fields: { + foo: { + type: new GraphQLScalarType({ name: 'TestScalar' }), + defaultValue, + }, + }, + }); + } + + it('no error for no errors for valid input value', () => { + test({ foo: 5 }, makeTestInputObject(7), []); + }); + + it('no error for object with default value', () => { + test({}, makeTestInputObject(7), []); + }); + + it('no error for null as value', () => { + test({}, makeTestInputObject(null), []); + }); + + it('no error for NaN as value', () => { + test({}, makeTestInputObject(NaN), []); + }); + }); + + describe('for GraphQLList', () => { + const TestList = new GraphQLList(GraphQLInt); + + it('returns no error for a valid input', () => { + test([1, 2, 3], TestList, []); + }); + + it('returns no error for a valid iterable input', () => { + // TODO: put an error in this list and show it appears + function* listGenerator() { + yield 1; + yield 2; + yield 3; + } + + test(listGenerator(), TestList, []); + }); + + it('returns an error for an invalid input', () => { + test([1, 'b', true, 4], TestList, [ + { + error: 'Int cannot represent non-integer value: "b"', + path: [1], + }, + { + error: 'Int cannot represent non-integer value: true', + path: [2], + }, + ]); + }); + + it('no error for a list for a non-list value', () => { + test(42, TestList, []); + }); + + it('returns an error for a non-list invalid value', () => { + test('INVALID', TestList, [ + { + error: 'Int cannot represent non-integer value: "INVALID"', + path: [], + }, + ]); + }); + + it('no error for null for a null value', () => { + test(null, TestList, []); + }); + }); + + describe('for nested GraphQLList', () => { + const TestNestedList = new GraphQLList(new GraphQLList(GraphQLInt)); + + it('no error for a valid input', () => { + test([[1], [2, 3]], TestNestedList, []); + }); + + it('no error for a list for a non-list value', () => { + test(42, TestNestedList, []); + }); + + it('no error for null for a null value', () => { + test(null, TestNestedList, []); + }); + + it('no error for nested lists for nested non-list values', () => { + test([1, 2, 3], TestNestedList, []); + }); + + it('no error for nested null for nested null values', () => { + test([42, [null], null], TestNestedList, []); + }); + }); +}); + +describe('validateInputLiteral', () => { + function test( + inputValue: string, + type: GraphQLInputType, + expected: unknown, + variableValues?: VariableValues, + ) { + const errors: any = []; + validateInputLiteral( + parseValue(inputValue), + type, + variableValues, + (error, path) => { + errors.push({ error: error.message, path }); + }, + ); + expect(errors).to.deep.equal(expected); + } + + function testWithVariables( + variableDefs: string, + values: ReadOnlyObjMap, + inputValue: string, + type: GraphQLInputType, + expected: unknown, + ) { + const parser = new Parser(variableDefs); + parser.expectToken(TokenKind.SOF); + const variableValuesOrErrors = getVariableValues( + new GraphQLSchema({ types: [GraphQLInt] }), + parser.parseVariableDefinitions(), + values, + ); + invariant(variableValuesOrErrors.variableValues !== undefined); + test(inputValue, type, expected, variableValuesOrErrors.variableValues); + } + + it('ignores variables statically', () => { + const TestNonNull = new GraphQLNonNull(GraphQLInt); + test('$var', TestNonNull, []); + }); + + it('returns an error for missing variables', () => { + testWithVariables('($var: Int)', {}, '$var', GraphQLInt, [ + { + error: + 'Expected variable "$var" provided to type Int to provide a runtime value.', + path: [], + }, + ]); + }); + + it('returns an error for null variables for nullable types', () => { + const TestNonNull = new GraphQLNonNull(GraphQLInt); + testWithVariables('($var: Int)', { var: null }, '$var', TestNonNull, [ + { + error: + 'Expected variable "$var" provided to non-null type Int! not to be null.', + path: [], + }, + ]); + }); + + describe('for GraphQLNonNull', () => { + const TestNonNull = new GraphQLNonNull(GraphQLInt); + + it('returns no error for non-null value', () => { + test('1', TestNonNull, []); + }); + + it('returns an error for null value', () => { + test('null', TestNonNull, [ + { + error: 'Expected value of non-null type Int! not to be null.', + path: [], + }, + ]); + }); + }); + + describe('for GraphQLScalar', () => { + const TestScalar = new GraphQLScalarType({ + name: 'TestScalar', + parseValue(input: any) { + invariant(typeof input === 'object' && input !== null); + if (input.error != null) { + throw new Error(input.error); + } + return input.value; + }, + }); + + it('returns no error for valid input', () => { + test('{ value: 1 }', TestScalar, []); + }); + + it('returns no error for null result', () => { + test('{ value: null }', TestScalar, []); + }); + + it('returns no error for NaN result', () => { + test('{ value: NaN }', TestScalar, []); + }); + + it('returns an error for undefined result', () => { + test('{}', TestScalar, [ + { + error: 'Expected value of type TestScalar, found: { }.', + path: [], + }, + ]); + }); + + it('returns an error for undefined result', () => { + const inputValue = '{ error: "Some error message." }'; + test(inputValue, TestScalar, [ + { + error: + 'Expected value of type TestScalar; Some error message. Found: { error: "Some error message." }.', + path: [], + }, + ]); + }); + + it('reports thrown non-error', () => { + const TestThrowScalar = new GraphQLScalarType({ + name: 'TestScalar', + parseValue() { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw 'Not an error object.'; + }, + }); + + test('{}', TestThrowScalar, [ + { + error: + 'Expected value of type TestScalar; Not an error object. Found: { }.', + path: [], + }, + ]); + }); + }); + + describe('for GraphQLEnum', () => { + const TestEnum = new GraphQLEnumType({ + name: 'TestEnum', + values: { + FOO: { value: 'InternalFoo' }, + BAR: { value: 123456789 }, + }, + }); + + it('returns no error for a known enum name', () => { + test('FOO', TestEnum, []); + + test('BAR', TestEnum, []); + }); + + it('returns an error for unknown enum value', () => { + test('UNKNOWN', TestEnum, [ + { + error: 'Value "UNKNOWN" does not exist in "TestEnum" enum.', + path: [], + }, + ]); + }); + + it('returns an error for misspelled enum value', () => { + test('foo', TestEnum, [ + { + error: + 'Value "foo" does not exist in "TestEnum" enum. Did you mean the enum value "FOO"?', + path: [], + }, + ]); + }); + + it('returns an error for incorrect value type', () => { + test('"FOO"', TestEnum, [ + { + error: + 'Enum "TestEnum" cannot represent non-enum value: "FOO". Did you mean the enum value "FOO"?', + path: [], + }, + ]); + + test('"UNKNOWN"', TestEnum, [ + { + error: 'Enum "TestEnum" cannot represent non-enum value: "UNKNOWN".', + path: [], + }, + ]); + + test('123', TestEnum, [ + { + error: 'Enum "TestEnum" cannot represent non-enum value: 123.', + path: [], + }, + ]); + + test('{ field: "value" }', TestEnum, [ + { + error: + 'Enum "TestEnum" cannot represent non-enum value: { field: "value" }.', + path: [], + }, + ]); + }); + }); + + describe('for GraphQLInputObject', () => { + const TestInputObject = new GraphQLInputObjectType({ + name: 'TestInputObject', + fields: { + foo: { type: new GraphQLNonNull(GraphQLInt) }, + bar: { type: GraphQLInt }, + optional: { type: new GraphQLNonNull(GraphQLInt), defaultValue: 42 }, + }, + }); + + it('returns no error for a valid input', () => { + test('{ foo: 123 }', TestInputObject, []); + }); + + it('returns an error for a non-object type', () => { + test('123', TestInputObject, [ + { + error: + 'Expected value of type TestInputObject to be an object, found: 123.', + path: [], + }, + ]); + }); + + it('returns an error for an invalid field', () => { + test('{ foo: 1.5 }', TestInputObject, [ + { + error: 'Int cannot represent non-integer value: 1.5', + path: ['foo'], + }, + ]); + }); + + it('returns multiple errors for multiple invalid fields', () => { + test('{ foo: "abc", bar: "def" }', TestInputObject, [ + { + error: 'Int cannot represent non-integer value: "abc"', + path: ['foo'], + }, + { + error: 'Int cannot represent non-integer value: "def"', + path: ['bar'], + }, + ]); + }); + + it('returns error for a missing required field', () => { + test('{ bar: 123 }', TestInputObject, [ + { + error: + 'Expected value of type TestInputObject to include required field "foo", found: { bar: 123 }.', + path: [], + }, + ]); + }); + + it('returns error for an unknown field', () => { + test('{ foo: 123, unknownField: 123 }', TestInputObject, [ + { + error: + 'Expected value of type TestInputObject not to include unknown field "unknownField", found: { foo: 123, unknownField: 123 }.', + path: [], + }, + ]); + }); + + it('returns error for a misspelled field', () => { + test('{ foo: 123, bart: 123 }', TestInputObject, [ + { + error: + 'Expected value of type TestInputObject not to include unknown field "bart". Did you mean "bar"? Found: { foo: 123, bart: 123 }.', + path: [], + }, + ]); + }); + + it('allows variables in an object, statically', () => { + test('{ foo: $var }', TestInputObject, []); + }); + + it('allows correct use of variables', () => { + testWithVariables( + '($var: Int)', + { var: 123 }, + '{ foo: $var }', + TestInputObject, + [], + ); + }); + + it('allows missing variables in an nullable field', () => { + testWithVariables( + '($var: Int)', + {}, + '{ foo: 123, bar: $var }', + TestInputObject, + [], + ); + }); + + it('allows missing variables in an optional field', () => { + testWithVariables( + '($var: Int)', + {}, + '{ foo: 123, optional: $var }', + TestInputObject, + [], + ); + }); + + it('errors on missing variable in an required field', () => { + testWithVariables('($var: Int)', {}, '{ foo: $var }', TestInputObject, [ + { + error: + 'Expected variable "$var" provided to type Int! to provide a runtime value.', + path: ['foo'], + }, + ]); + }); + + it('errors on null variable in an non-null field', () => { + testWithVariables( + '($var: Int)', + { var: null }, + '{ foo: 123, optional: $var }', + TestInputObject, + [ + { + error: + 'Expected variable "$var" provided to non-null type Int! not to be null.', + path: ['optional'], + }, + ], + ); + }); + }); + + describe('for GraphQLInputObject with default value', () => { + function makeTestInputObject(defaultValue: unknown) { + return new GraphQLInputObjectType({ + name: 'TestInputObject', + fields: { + foo: { + type: new GraphQLScalarType({ name: 'TestScalar' }), + defaultValue, + }, + }, + }); + } + + it('no error for no errors for valid input value', () => { + test('{ foo: 5 }', makeTestInputObject(7), []); + }); + + it('no error for object with default value', () => { + test('{}', makeTestInputObject(7), []); + }); + + it('no error for null as value', () => { + test('{}', makeTestInputObject(null), []); + }); + + it('no error for NaN as value', () => { + test('{}', makeTestInputObject(NaN), []); + }); + }); + + describe('for GraphQLList', () => { + const TestList = new GraphQLList(GraphQLInt); + + it('returns no error for a valid input', () => { + test('[1, 2, 3]', TestList, []); + }); + + it('returns an error for an invalid input', () => { + test('[1, "b", true, 4]', TestList, [ + { + error: 'Int cannot represent non-integer value: "b"', + path: [1], + }, + { + error: 'Int cannot represent non-integer value: true', + path: [2], + }, + ]); + }); + + it('no error for a list for a non-list value', () => { + test('42', TestList, []); + }); + + it('returns an error for a non-list invalid value', () => { + test('"INVALID"', TestList, [ + { + error: 'Int cannot represent non-integer value: "INVALID"', + path: [], + }, + ]); + }); + + it('no error for null for a null value', () => { + test('null', TestList, []); + }); + + it('allows variables in a list, statically', () => { + test('[1, $var, 3]', TestList, []); + }); + + it('allows missing variables in a list (which coerce to null)', () => { + testWithVariables('($var: Int)', {}, '[1, $var, 3]', TestList, []); + }); + + it('errors on missing variables in a list of non-null', () => { + const TestListNonNull = new GraphQLList(new GraphQLNonNull(GraphQLInt)); + testWithVariables('($var: Int)', {}, '[1, $var, 3]', TestListNonNull, [ + { + error: + 'Expected variable "$var" provided to type Int! to provide a runtime value.', + path: [1], + }, + ]); + }); + + it('errors on null variables in a list of non-null', () => { + const TestListNonNull = new GraphQLList(new GraphQLNonNull(GraphQLInt)); + testWithVariables( + '($var: Int)', + { var: null }, + '[1, $var, 3]', + TestListNonNull, + [ + { + error: + 'Expected variable "$var" provided to non-null type Int! not to be null.', + path: [1], + }, + ], + ); + }); + }); + + describe('for nested GraphQLList', () => { + const TestNestedList = new GraphQLList(new GraphQLList(GraphQLInt)); + + it('no error for a valid input', () => { + test('[[1], [2, 3]]', TestNestedList, []); + }); + + it('no error for a list for a non-list value', () => { + test('42', TestNestedList, []); + }); + + it('no error for null for a null value', () => { + test('null', TestNestedList, []); + }); + + it('no error for nested lists for nested non-list values', () => { + test('[1, 2, 3]', TestNestedList, []); + }); + + it('no error for nested null for nested null values', () => { + test('[42, [null], null]', TestNestedList, []); + }); + }); +}); diff --git a/src/utilities/coerceInputValue.ts b/src/utilities/coerceInputValue.ts index d918cf9a9f..0e1793b26a 100644 --- a/src/utilities/coerceInputValue.ts +++ b/src/utilities/coerceInputValue.ts @@ -1,15 +1,7 @@ -import { didYouMean } from '../jsutils/didYouMean.js'; -import { inspect } from '../jsutils/inspect.js'; import { invariant } from '../jsutils/invariant.js'; import { isIterableObject } from '../jsutils/isIterableObject.js'; import { isObjectLike } from '../jsutils/isObjectLike.js'; import type { Maybe } from '../jsutils/Maybe.js'; -import type { Path } from '../jsutils/Path.js'; -import { addPath, pathToArray } from '../jsutils/Path.js'; -import { printPathArray } from '../jsutils/printPathArray.js'; -import { suggestionList } from '../jsutils/suggestionList.js'; - -import { GraphQLError } from '../error/GraphQLError.js'; import type { ValueNode } from '../language/ast.js'; import { Kind } from '../language/kinds.js'; @@ -21,7 +13,6 @@ import type { import { assertLeafType, isInputObjectType, - isLeafType, isListType, isNonNullType, isRequiredInputField, @@ -31,170 +22,90 @@ import type { VariableValues } from '../execution/values.js'; import { replaceVariables } from './replaceVariables.js'; -type OnErrorCB = ( - path: ReadonlyArray, - invalidValue: unknown, - error: GraphQLError, -) => void; - /** * Coerces a JavaScript value given a GraphQL Input Type. + * + * Returns `undefined` when the value could not be validly coerced according to + * the provided type. */ export function coerceInputValue( inputValue: unknown, type: GraphQLInputType, - onError: OnErrorCB = defaultOnError, -): unknown { - return coerceInputValueImpl(inputValue, type, onError, undefined); -} - -function defaultOnError( - path: ReadonlyArray, - invalidValue: unknown, - error: GraphQLError, -): void { - let errorPrefix = 'Invalid value ' + inspect(invalidValue); - if (path.length > 0) { - errorPrefix += ` at "value${printPathArray(path)}"`; - } - error.message = errorPrefix + ': ' + error.message; - throw error; -} - -function coerceInputValueImpl( - inputValue: unknown, - type: GraphQLInputType, - onError: OnErrorCB, - path: Path | undefined, ): unknown { if (isNonNullType(type)) { - if (inputValue != null) { - return coerceInputValueImpl(inputValue, type.ofType, onError, path); + if (inputValue == null) { + return; // Invalid: intentionally return no value. } - onError( - pathToArray(path), - inputValue, - new GraphQLError( - `Expected non-nullable type "${inspect(type)}" not to be null.`, - ), - ); - return; + return coerceInputValue(inputValue, type.ofType); } if (inputValue == null) { - // Explicitly return the value null. - return null; + return null; // Explicitly return the value null. } if (isListType(type)) { - const itemType = type.ofType; - if (isIterableObject(inputValue)) { - return Array.from(inputValue, (itemValue, index) => { - const itemPath = addPath(path, index, undefined); - return coerceInputValueImpl(itemValue, itemType, onError, itemPath); - }); + if (!isIterableObject(inputValue)) { + // Lists accept a non-list value as a list of one. + const coercedItem = coerceInputValue(inputValue, type.ofType); + if (coercedItem === undefined) { + return; // Invalid: intentionally return no value. + } + return [coercedItem]; + } + const coercedValue = []; + for (const itemValue of inputValue) { + const coercedItem = coerceInputValue(itemValue, type.ofType); + if (coercedItem === undefined) { + return; // Invalid: intentionally return no value. + } + coercedValue.push(coercedItem); } - // Lists accept a non-list value as a list of one. - return [coerceInputValueImpl(inputValue, itemType, onError, path)]; + return coercedValue; } if (isInputObjectType(type)) { if (!isObjectLike(inputValue)) { - onError( - pathToArray(path), - inputValue, - new GraphQLError(`Expected type "${type.name}" to be an object.`), - ); - return; + return; // Invalid: intentionally return no value. } const coercedValue: any = {}; const fieldDefs = type.getFields(); - + const hasUndefinedField = Object.keys(inputValue).some( + (name) => !Object.hasOwn(fieldDefs, name), + ); + if (hasUndefinedField) { + return; // Invalid: intentionally return no value. + } for (const field of Object.values(fieldDefs)) { const fieldValue = inputValue[field.name]; - if (fieldValue === undefined) { + if (isRequiredInputField(field)) { + return; // Invalid: intentionally return no value. + } if (field.defaultValue) { coercedValue[field.name] = coerceDefaultValue( field.defaultValue, field.type, ); - } else if (isNonNullType(field.type)) { - const typeStr = inspect(field.type); - onError( - pathToArray(path), - inputValue, - new GraphQLError( - `Field "${field.name}" of required type "${typeStr}" was not provided.`, - ), - ); } - continue; - } - - coercedValue[field.name] = coerceInputValueImpl( - fieldValue, - field.type, - onError, - addPath(path, field.name, type.name), - ); - } - - // Ensure every provided field is defined. - for (const fieldName of Object.keys(inputValue)) { - if (fieldDefs[fieldName] == null) { - const suggestions = suggestionList( - fieldName, - Object.keys(type.getFields()), - ); - onError( - pathToArray(path), - inputValue, - new GraphQLError( - `Field "${fieldName}" is not defined by type "${type.name}".` + - didYouMean(suggestions), - ), - ); + } else { + const coercedField = coerceInputValue(fieldValue, field.type); + if (coercedField === undefined) { + return; // Invalid: intentionally return no value. + } + coercedValue[field.name] = coercedField; } } return coercedValue; } - if (isLeafType(type)) { - let parseResult; + const leafType = assertLeafType(type); - // Scalars and Enums determine if an input value is valid via parseValue(), - // which can throw to indicate failure. If it throws, maintain a reference - // to the original error. - try { - parseResult = type.parseValue(inputValue); - } catch (error) { - if (error instanceof GraphQLError) { - onError(pathToArray(path), inputValue, error); - } else { - onError( - pathToArray(path), - inputValue, - new GraphQLError(`Expected type "${type.name}". ` + error.message, { - originalError: error, - }), - ); - } - return; - } - if (parseResult === undefined) { - onError( - pathToArray(path), - inputValue, - new GraphQLError(`Expected type "${type.name}".`), - ); - } - return parseResult; + try { + return leafType.parseValue(inputValue); + } catch (_error) { + // Invalid: ignore error and intentionally return no value. } - /* c8 ignore next 3 */ - // Not reachable, all possible types have been considered. - invariant(false, 'Unexpected input type: ' + inspect(type)); } /** diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 88e93b46e2..bff737e315 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -72,12 +72,19 @@ export { replaceVariables } from './replaceVariables.js'; export { valueToLiteral } from './valueToLiteral.js'; export { - // Coerces a JavaScript value to a GraphQL type, or produces errors. + // Coerces a JavaScript value to a GraphQL type, or returns undefined. coerceInputValue, // Coerces a GraphQL literal (AST) to a GraphQL type, or returns undefined. coerceInputLiteral, } from './coerceInputValue.js'; +export { + // Validate a JavaScript value with a GraphQL type, collecting all errors. + validateInputValue, + // Validate a GraphQL literal (AST) with a GraphQL type, collecting all errors. + validateInputLiteral, +} from './validateInputValue.js'; + // Concatenates multiple AST together. export { concatAST } from './concatAST.js'; diff --git a/src/utilities/validateInputValue.ts b/src/utilities/validateInputValue.ts new file mode 100644 index 0000000000..b40c74933e --- /dev/null +++ b/src/utilities/validateInputValue.ts @@ -0,0 +1,382 @@ +import { didYouMean } from '../jsutils/didYouMean.js'; +import { hasOwnProperty } from '../jsutils/hasOwnProperty.js'; +import { inspect } from '../jsutils/inspect.js'; +import { isIterableObject } from '../jsutils/isIterableObject.js'; +import { isObjectLike } from '../jsutils/isObjectLike.js'; +import { keyMap } from '../jsutils/keyMap.js'; +import type { Maybe } from '../jsutils/Maybe.js'; +import type { Path } from '../jsutils/Path.js'; +import { addPath, pathToArray } from '../jsutils/Path.js'; +import { suggestionList } from '../jsutils/suggestionList.js'; + +import { GraphQLError } from '../error/GraphQLError.js'; + +import type { ASTNode, ValueNode } from '../language/ast.js'; +import { Kind } from '../language/kinds.js'; +import { print } from '../language/printer.js'; + +import type { GraphQLInputType } from '../type/definition.js'; +import { + assertLeafType, + isInputObjectType, + isListType, + isNonNullType, + isRequiredInputField, +} from '../type/definition.js'; + +import type { VariableValues } from '../execution/values.js'; + +import { replaceVariables } from './replaceVariables.js'; + +/** + * Validate that the provided input value is allowed for this type, collecting + * all errors via a callback function. + */ +export function validateInputValue( + inputValue: unknown, + type: GraphQLInputType, + onError: (error: GraphQLError, path: ReadonlyArray) => void, + path?: Path, +): void { + if (isNonNullType(type)) { + if (inputValue === undefined) { + reportInvalidValue( + onError, + `Expected a value of non-null type ${type} to be provided.`, + path, + ); + return; + } + if (inputValue === null) { + reportInvalidValue( + onError, + `Expected value of non-null type ${type} not to be null.`, + path, + ); + return; + } + return validateInputValue(inputValue, type.ofType, onError, path); + } + + if (inputValue == null) { + return; + } + + if (isListType(type)) { + if (!isIterableObject(inputValue)) { + // Lists accept a non-list value as a list of one. + validateInputValue(inputValue, type.ofType, onError, path); + } else { + let index = 0; + for (const itemValue of inputValue) { + validateInputValue( + itemValue, + type.ofType, + onError, + addPath(path, index++, undefined), + ); + } + } + } else if (isInputObjectType(type)) { + if (!isObjectLike(inputValue)) { + reportInvalidValue( + onError, + `Expected value of type ${type} to be an object, found: ${inspect( + inputValue, + )}.`, + path, + ); + return; + } + + const fieldDefs = type.getFields(); + + for (const field of Object.values(fieldDefs)) { + const fieldValue = inputValue[field.name]; + if (fieldValue === undefined) { + if (isRequiredInputField(field)) { + reportInvalidValue( + onError, + `Expected value of type ${type} to include required field "${ + field.name + }", found: ${inspect(inputValue)}.`, + path, + ); + } + } else { + validateInputValue( + fieldValue, + field.type, + onError, + addPath(path, field.name, type.name), + ); + } + } + + // Ensure every provided field is defined. + for (const fieldName of Object.keys(inputValue)) { + if (!hasOwnProperty(fieldDefs, fieldName)) { + const suggestions = suggestionList(fieldName, Object.keys(fieldDefs)); + reportInvalidValue( + onError, + `Expected value of type ${type} not to include unknown field "${fieldName}"${ + suggestions.length > 0 + ? `.${didYouMean(suggestions)} Found` + : ', found' + }: ${inspect(inputValue)}.`, + path, + ); + } + } + } else { + assertLeafType(type); + + let result; + let caughtError; + + try { + result = type.parseValue(inputValue); + } catch (error) { + if (error instanceof GraphQLError) { + onError(error, pathToArray(path)); + return; + } + caughtError = error; + } + + if (result === undefined) { + reportInvalidValue( + onError, + `Expected value of type ${type}${ + caughtError + ? `; ${caughtError.message || caughtError} Found` + : ', found' + }: ${inspect(inputValue)}.`, + path, + caughtError, + ); + } + } +} + +function reportInvalidValue( + onError: (error: GraphQLError, path: ReadonlyArray) => void, + message: string, + path: Path | undefined, + originalError?: GraphQLError | undefined, +): void { + onError(new GraphQLError(message, { originalError }), pathToArray(path)); +} + +/** + * Validate that the provided input literal is allowed for this type, collecting + * all errors via a callback function. + * + * If variable values are not provided, the literal is validated statically + * (not assuming that those variables are missing runtime values). + */ +export function validateInputLiteral( + valueNode: ValueNode, + type: GraphQLInputType, + variables: Maybe, + onError: (error: GraphQLError, path: ReadonlyArray) => void, + path?: Path, +): void { + if (valueNode.kind === Kind.VARIABLE) { + if (!variables) { + // If no variable values are provided, this is being validated statically, + // and cannot yet produce any validation errors for variables. + return; + } + if (isMissingVariable(valueNode, variables)) { + reportInvalidLiteral( + onError, + `Expected variable "$${valueNode.name.value}" provided to type ${type} to provide a runtime value.`, + valueNode, + path, + ); + } else if ( + isNonNullType(type) && + variables.coerced[valueNode.name.value] === null + ) { + reportInvalidLiteral( + onError, + `Expected variable "$${valueNode.name.value}" provided to non-null type ${type} not to be null.`, + valueNode, + path, + ); + } + // Note: This does no further checking that this variable is correct. + // This assumes this variable usage has already been validated. + return; + } + + if (isNonNullType(type)) { + if (valueNode.kind === Kind.NULL) { + reportInvalidLiteral( + onError, + `Expected value of non-null type ${type} not to be null.`, + valueNode, + path, + ); + return; + } + return validateInputLiteral( + valueNode, + type.ofType, + variables, + onError, + path, + ); + } + + if (valueNode.kind === Kind.NULL) { + return; + } + + if (isListType(type)) { + if (valueNode.kind !== Kind.LIST) { + // Lists accept a non-list value as a list of one. + validateInputLiteral(valueNode, type.ofType, variables, onError, path); + } else { + let index = 0; + for (const itemNode of valueNode.values) { + // A variable may be missing if the item type is nullable. + if ( + variables && + isMissingVariable(itemNode, variables) && + !isNonNullType(type.ofType) + ) { + continue; + } + validateInputLiteral( + itemNode, + type.ofType, + variables, + onError, + addPath(path, index++, undefined), + ); + } + } + } else if (isInputObjectType(type)) { + if (valueNode.kind !== Kind.OBJECT) { + reportInvalidLiteral( + onError, + `Expected value of type ${type} to be an object, found: ${print( + valueNode, + )}.`, + valueNode, + path, + ); + return; + } + + const fieldDefs = type.getFields(); + const fieldNodes = keyMap(valueNode.fields, (field) => field.name.value); + + for (const field of Object.values(fieldDefs)) { + const fieldNode = fieldNodes[field.name]; + if (fieldNode === undefined) { + if (isRequiredInputField(field)) { + reportInvalidLiteral( + onError, + `Expected value of type ${type} to include required field "${ + field.name + }", found: ${print(valueNode)}.`, + valueNode, + path, + ); + } + } else { + // A variable may be missing if the input field is not required. + if ( + variables && + isMissingVariable(fieldNode.value, variables) && + !isRequiredInputField(field) + ) { + continue; + } + validateInputLiteral( + fieldNode.value, + field.type, + variables, + onError, + addPath(path, field.name, type.name), + ); + } + } + + // Ensure every provided field is defined. + for (const fieldNode of valueNode.fields) { + const fieldName = fieldNode.name.value; + if (!hasOwnProperty(fieldDefs, fieldName)) { + const suggestions = suggestionList(fieldName, Object.keys(fieldDefs)); + reportInvalidLiteral( + onError, + `Expected value of type ${type} not to include unknown field "${fieldName}"${ + suggestions.length > 0 + ? `.${didYouMean(suggestions)} Found` + : ', found' + }: ${print(valueNode)}.`, + fieldNode, + path, + ); + } + } + } else { + assertLeafType(type); + + const constValueNode = replaceVariables(valueNode); + + let result; + let caughtError; + try { + result = type.parseLiteral(constValueNode); + } catch (error) { + if (error instanceof GraphQLError) { + onError(error, pathToArray(path)); + return; + } + caughtError = error; + } + + if (result === undefined) { + reportInvalidLiteral( + onError, + `Expected value of type ${type}${ + caughtError + ? `; ${caughtError.message || caughtError} Found` + : ', found' + }: ${print(valueNode)}.`, + valueNode, + path, + caughtError, + ); + } + } +} + +// Returns true if the provided valueNode is a variable which is not defined +// in the set of variables. +function isMissingVariable( + valueNode: ValueNode, + variables: VariableValues, +): boolean { + return ( + valueNode.kind === Kind.VARIABLE && + variables.coerced[valueNode.name.value] === undefined + ); +} + +function reportInvalidLiteral( + onError: (error: GraphQLError, path: ReadonlyArray) => void, + message: string, + valueNode: ASTNode, + path: Path | undefined, + originalError?: GraphQLError | undefined, +): void { + onError( + new GraphQLError(message, { nodes: valueNode, originalError }), + pathToArray(path), + ); +} diff --git a/src/validation/__tests__/ValuesOfCorrectTypeRule-test.ts b/src/validation/__tests__/ValuesOfCorrectTypeRule-test.ts index f0b7dfa57e..27678c9897 100644 --- a/src/validation/__tests__/ValuesOfCorrectTypeRule-test.ts +++ b/src/validation/__tests__/ValuesOfCorrectTypeRule-test.ts @@ -3,8 +3,6 @@ import { describe, it } from 'mocha'; import { expectJSON } from '../../__testUtils__/expectJSON.js'; -import { inspect } from '../../jsutils/inspect.js'; - import { parse } from '../../language/parser.js'; import { GraphQLObjectType, GraphQLScalarType } from '../../type/definition.js'; @@ -793,7 +791,7 @@ describe('Validate: Values of correct type', () => { } `).toDeepEqual([ { - message: 'Expected value of type "Int!", found null.', + message: 'Expected value of non-null type Int! not to be null.', locations: [{ line: 4, column: 32 }], }, ]); @@ -885,7 +883,7 @@ describe('Validate: Values of correct type', () => { `).toDeepEqual([ { message: - 'Field "ComplexInput.requiredField" of required type "Boolean!" was not provided.', + 'Expected value of type ComplexInput to include required field "requiredField", found: { intField: 4 }.', locations: [{ line: 4, column: 41 }], }, ]); @@ -921,7 +919,7 @@ describe('Validate: Values of correct type', () => { } `).toDeepEqual([ { - message: 'Expected value of type "Boolean!", found null.', + message: 'Expected value of non-null type Boolean! not to be null.', locations: [{ line: 6, column: 29 }], }, ]); @@ -940,7 +938,7 @@ describe('Validate: Values of correct type', () => { `).toDeepEqual([ { message: - 'Field "invalidField" is not defined by type "ComplexInput". Did you mean "intField"?', + 'Expected value of type ComplexInput not to include unknown field "invalidField". Did you mean "intField"? Found: { requiredField: true, invalidField: "value" }.', locations: [{ line: 6, column: 15 }], }, ]); @@ -949,10 +947,8 @@ describe('Validate: Values of correct type', () => { it('reports original error for custom scalar which throws', () => { const customScalar = new GraphQLScalarType({ name: 'Invalid', - parseValue(value) { - throw new Error( - `Invalid scalar is always invalid: ${inspect(value)}`, - ); + parseValue() { + throw new Error('Invalid scalar is always invalid.'); }, }); @@ -974,14 +970,13 @@ describe('Validate: Values of correct type', () => { expectJSON(errors).toDeepEqual([ { message: - 'Expected value of type "Invalid", found 123; Invalid scalar is always invalid: 123', + 'Expected value of type Invalid; Invalid scalar is always invalid. Found: 123.', locations: [{ line: 1, column: 19 }], }, ]); - expect(errors[0]).to.have.nested.property( - 'originalError.message', - 'Invalid scalar is always invalid: 123', + expect(errors[0].originalError?.message).to.equal( + 'Invalid scalar is always invalid.', ); }); @@ -1007,7 +1002,7 @@ describe('Validate: Values of correct type', () => { expectErrorsWithSchema(schema, '{ invalidArg(arg: 123) }').toDeepEqual([ { - message: 'Expected value of type "CustomScalar", found 123.', + message: 'Expected value of type CustomScalar, found: 123.', locations: [{ line: 1, column: 19 }], }, ]); @@ -1112,15 +1107,15 @@ describe('Validate: Values of correct type', () => { } `).toDeepEqual([ { - message: 'Expected value of type "Int!", found null.', + message: 'Expected value of non-null type Int! not to be null.', locations: [{ line: 3, column: 22 }], }, { - message: 'Expected value of type "String!", found null.', + message: 'Expected value of non-null type String! not to be null.', locations: [{ line: 4, column: 25 }], }, { - message: 'Expected value of type "Boolean!", found null.', + message: 'Expected value of non-null type Boolean! not to be null.', locations: [{ line: 5, column: 47 }], }, ]); @@ -1146,7 +1141,7 @@ describe('Validate: Values of correct type', () => { }, { message: - 'Expected value of type "ComplexInput", found "NotVeryComplex".', + 'Expected value of type ComplexInput to be an object, found: "NotVeryComplex".', locations: [{ line: 5, column: 30 }], }, ]); @@ -1179,7 +1174,7 @@ describe('Validate: Values of correct type', () => { `).toDeepEqual([ { message: - 'Field "ComplexInput.requiredField" of required type "Boolean!" was not provided.', + 'Expected value of type ComplexInput to include required field "requiredField", found: { intField: 3 }.', locations: [{ line: 2, column: 55 }], }, ]); diff --git a/src/validation/rules/ValuesOfCorrectTypeRule.ts b/src/validation/rules/ValuesOfCorrectTypeRule.ts index 1bacd16691..8966750115 100644 --- a/src/validation/rules/ValuesOfCorrectTypeRule.ts +++ b/src/validation/rules/ValuesOfCorrectTypeRule.ts @@ -1,23 +1,8 @@ -import { didYouMean } from '../../jsutils/didYouMean.js'; -import { suggestionList } from '../../jsutils/suggestionList.js'; - -import { GraphQLError } from '../../error/GraphQLError.js'; - -import type { ValueNode } from '../../language/ast.js'; -import { print } from '../../language/printer.js'; +import { Kind } from '../../language/kinds.js'; +import { isValueNode } from '../../language/predicates.js'; import type { ASTVisitor } from '../../language/visitor.js'; -import { - getNamedType, - getNullableType, - isInputObjectType, - isLeafType, - isListType, - isNonNullType, - isRequiredInputField, -} from '../../type/definition.js'; - -import { replaceVariables } from '../../utilities/replaceVariables.js'; +import { validateInputLiteral } from '../../utilities/validateInputValue.js'; import type { ValidationContext } from '../ValidationContext.js'; @@ -33,121 +18,19 @@ export function ValuesOfCorrectTypeRule( context: ValidationContext, ): ASTVisitor { return { - ListValue(node) { - // Note: TypeInfo will traverse into a list's item type, so look to the - // parent input type to check if it is a list. - const type = getNullableType(context.getParentInputType()); - if (!isListType(type)) { - isValidValueNode(context, node); - return false; // Don't traverse further. - } - }, - ObjectValue(node) { - const type = getNamedType(context.getInputType()); - if (!isInputObjectType(type)) { - isValidValueNode(context, node); - return false; // Don't traverse further. - } - // Ensure every required field exists. - const fieldNodeMap = new Map( - node.fields.map((field) => [field.name.value, field]), - ); - for (const fieldDef of Object.values(type.getFields())) { - const fieldNode = fieldNodeMap.get(fieldDef.name); - if (!fieldNode && isRequiredInputField(fieldDef)) { - context.reportError( - new GraphQLError( - `Field "${fieldDef}" of required type "${fieldDef.type}" was not provided.`, - { nodes: node }, - ), - ); + enter(node) { + if (isValueNode(node)) { + const inputType = + node.kind === Kind.LIST + ? context.getParentInputType() + : context.getInputType(); + if (inputType) { + validateInputLiteral(node, inputType, undefined, (error) => { + context.reportError(error); + }); } + return false; } }, - ObjectField(node) { - const parentType = getNamedType(context.getParentInputType()); - const fieldType = context.getInputType(); - if (!fieldType && isInputObjectType(parentType)) { - const suggestions = suggestionList( - node.name.value, - Object.keys(parentType.getFields()), - ); - context.reportError( - new GraphQLError( - `Field "${node.name.value}" is not defined by type "${parentType}".` + - didYouMean(suggestions), - { nodes: node }, - ), - ); - } - }, - NullValue(node) { - const type = context.getInputType(); - if (isNonNullType(type)) { - context.reportError( - new GraphQLError( - `Expected value of type "${type}", found ${print(node)}.`, - { nodes: node }, - ), - ); - } - }, - EnumValue: (node) => isValidValueNode(context, node), - IntValue: (node) => isValidValueNode(context, node), - FloatValue: (node) => isValidValueNode(context, node), - StringValue: (node) => isValidValueNode(context, node), - BooleanValue: (node) => isValidValueNode(context, node), }; } - -/** - * Any value literal may be a valid representation of a Scalar, depending on - * that scalar type. - */ -function isValidValueNode(context: ValidationContext, node: ValueNode): void { - // Report any error at the full type expected by the location. - const locationType = context.getInputType(); - if (!locationType) { - return; - } - - const type = getNamedType(locationType); - - if (!isLeafType(type)) { - context.reportError( - new GraphQLError( - `Expected value of type "${locationType}", found ${print(node)}.`, - { nodes: node }, - ), - ); - return; - } - - const constValueNode = replaceVariables(node); - - // Scalars and Enums determine if a literal value is valid via parseLiteral(), - // which may throw or return undefined to indicate an invalid value. - try { - const parseResult = type.parseLiteral(constValueNode); - if (parseResult === undefined) { - context.reportError( - new GraphQLError( - `Expected value of type "${locationType}", found ${print(node)}.`, - { nodes: node }, - ), - ); - } - } catch (error) { - if (error instanceof GraphQLError) { - context.reportError(error); - } else { - context.reportError( - new GraphQLError( - `Expected value of type "${locationType}", found ${print(node)}; ` + - error.message, - { nodes: node, originalError: error }, - ), - ); - } - } -}