diff --git a/src/execution/__tests__/nonnull-test.ts b/src/execution/__tests__/nonnull-test.ts index 9ad78a09cd..ff08aafd73 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 "cannotBeNull" of non-null type "String!" must not be null.', + 'Argument "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 "cannotBeNull" of required type "String!" was provided the variable "$testVar" which was not provided a runtime value.', + 'Argument "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 "cannotBeNull" of non-null type "String!" must not be null.', + 'Argument "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__/oneof-test.ts b/src/execution/__tests__/oneof-test.ts index f4a11f8997..eed65ae580 100644 --- a/src/execution/__tests__/oneof-test.ts +++ b/src/execution/__tests__/oneof-test.ts @@ -30,7 +30,12 @@ function executeQuery( rootValue: unknown, variableValues?: { [variable: string]: unknown }, ): ExecutionResult | Promise { - return execute({ schema, document: parse(query), rootValue, variableValues }); + return execute({ + schema, + document: parse(query, { experimentalFragmentArguments: true }), + rootValue, + variableValues, + }); } describe('Execute: Handles OneOf Input Objects', () => { @@ -83,7 +88,7 @@ describe('Execute: Handles OneOf Input Objects', () => { message: // This type of error would be caught at validation-time // hence the vague error message here. - 'Argument "input" of non-null type "TestInputObject!" must not be null.', + 'Argument "input" has invalid value: Expected variable "$input" provided to type "TestInputObject!" to provide a runtime value.', path: ['test'], }, ], @@ -134,6 +139,28 @@ describe('Execute: Handles OneOf Input Objects', () => { }); }); + it('rejects a variable with a nulled key', () => { + const query = ` + query ($input: TestInputObject!) { + test(input: $input) { + a + b + } + } + `; + const result = executeQuery(query, rootValue, { input: { a: null } }); + + expectJSON(result).toDeepEqual({ + errors: [ + { + message: + 'Variable "$input" has invalid value: Field "a" for OneOf type "TestInputObject" must be non-null.', + locations: [{ line: 2, column: 16 }], + }, + ], + }); + }); + it('rejects a variable with multiple non-null keys', () => { const query = ` query ($input: TestInputObject!) { @@ -152,7 +179,7 @@ describe('Execute: Handles OneOf Input Objects', () => { { locations: [{ column: 16, line: 2 }], message: - 'Variable "$input" got invalid value { a: "abc", b: 123 }; Exactly one key must be specified for OneOf type "TestInputObject".', + 'Variable "$input" has invalid value: Exactly one key must be specified for OneOf type "TestInputObject".', }, ], }); @@ -176,7 +203,125 @@ describe('Execute: Handles OneOf Input Objects', () => { { locations: [{ column: 16, line: 2 }], message: - 'Variable "$input" got invalid value { a: "abc", b: null }; Exactly one key must be specified for OneOf type "TestInputObject".', + 'Variable "$input" has invalid value: Exactly one key must be specified for OneOf type "TestInputObject".', + }, + ], + }); + }); + + it('errors with nulled variable for field', () => { + const query = ` + query ($a: String) { + test(input: { a: $a }) { + a + b + } + } + `; + const result = executeQuery(query, rootValue, { a: null }); + + expectJSON(result).toDeepEqual({ + data: { + test: null, + }, + errors: [ + { + // A nullable variable in a oneOf field position would be caught at validation-time + // hence the vague error message here. + message: + 'Argument "input" has invalid value: Expected variable "$a" provided to field "a" for OneOf Input Object type "TestInputObject" not to be null.', + locations: [{ line: 3, column: 23 }], + path: ['test'], + }, + ], + }); + }); + + it('errors with missing variable for field', () => { + const query = ` + query ($a: String) { + test(input: { a: $a }) { + a + b + } + } + `; + const result = executeQuery(query, rootValue); + + expectJSON(result).toDeepEqual({ + data: { + test: null, + }, + errors: [ + { + // A nullable variable in a oneOf field position would be caught at validation-time + // hence the vague error message here. + message: + 'Argument "input" has invalid value: Expected variable "$a" provided to field "a" for OneOf Input Object type "TestInputObject" to provide a runtime value.', + locations: [{ line: 3, column: 23 }], + path: ['test'], + }, + ], + }); + }); + + it('errors with nulled fragment variable for field', () => { + const query = ` + query { + ...TestFragment(a: null) + } + fragment TestFragment($a: String) on Query { + test(input: { a: $a }) { + a + b + } + } + `; + const result = executeQuery(query, rootValue, { a: null }); + + expectJSON(result).toDeepEqual({ + data: { + test: null, + }, + errors: [ + { + // A nullable variable in a oneOf field position would be caught at validation-time + // hence the vague error message here. + message: + 'Argument "input" has invalid value: Expected variable "$a" provided to field "a" for OneOf Input Object type "TestInputObject" not to be null.', + locations: [{ line: 6, column: 23 }], + path: ['test'], + }, + ], + }); + }); + + it('errors with missing fragment variable for field', () => { + const query = ` + query { + ...TestFragment + } + fragment TestFragment($a: String) on Query { + test(input: { a: $a }) { + a + b + } + } + `; + const result = executeQuery(query, rootValue); + + expectJSON(result).toDeepEqual({ + data: { + test: null, + }, + errors: [ + { + // A nullable variable in a oneOf field position would be caught at validation-time + // hence the vague error message here. + message: + 'Argument "input" has invalid value: Expected variable "$a" provided to field "a" for OneOf Input Object type "TestInputObject" to provide a runtime value.', + locations: [{ line: 6, column: 23 }], + path: ['test'], }, ], }); diff --git a/src/execution/__tests__/subscribe-test.ts b/src/execution/__tests__/subscribe-test.ts index e6faca31e5..019ebac7a2 100644 --- a/src/execution/__tests__/subscribe-test.ts +++ b/src/execution/__tests__/subscribe-test.ts @@ -524,7 +524,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 922a8b9e4f..e8c0a54d49 100644 --- a/src/execution/__tests__/variables-test.ts +++ b/src/execution/__tests__/variables-test.ts @@ -82,6 +82,15 @@ const TestInputObject = new GraphQLInputObjectType({ }, }); +const TestOneOfInputObject = new GraphQLInputObjectType({ + name: 'TestOneOfInputObject', + fields: { + a: { type: GraphQLString }, + b: { type: GraphQLString }, + }, + isOneOf: true, +}); + const TestNestedInputObject = new GraphQLInputObjectType({ name: 'TestNestedInputObject', fields: { @@ -124,6 +133,9 @@ const TestType = new GraphQLObjectType({ type: new GraphQLNonNull(TestEnum), }), fieldWithObjectInput: fieldWithInputArg({ type: TestInputObject }), + fieldWithOneOfObjectInput: fieldWithInputArg({ + type: TestOneOfInputObject, + }), fieldWithNullableStringInput: fieldWithInputArg({ type: GraphQLString }), fieldWithNonNullableStringInput: fieldWithInputArg({ type: new GraphQLNonNull(GraphQLString), @@ -273,7 +285,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Argument "input" of type "TestInputObject" has invalid value ["foo", "bar", "baz"].', + 'Argument "input" has invalid value: Expected value of type "TestInputObject" to be an object, found: ["foo", "bar", "baz"].', path: ['fieldWithObjectInput'], locations: [{ line: 3, column: 41 }], }, @@ -309,9 +321,10 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Argument "input" of type "TestInputObject" has invalid value { c: "foo", e: "bar" }.', + 'Argument "input" has invalid value at .e: FaultyScalarErrorMessage', path: ['fieldWithObjectInput'], - locations: [{ line: 3, column: 41 }], + locations: [{ line: 3, column: 13 }], + extensions: { code: 'FaultyScalarErrorExtensionCode' }, }, ], }); @@ -465,7 +478,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Variable "$input" got invalid value "SerializedValue" at "input.e"; FaultyScalarErrorMessage', + 'Variable "$input" has invalid value at .e: Argument "input" has invalid value at .e: FaultyScalarErrorMessage', locations: [{ line: 2, column: 16 }], extensions: { code: 'FaultyScalarErrorExtensionCode' }, }, @@ -481,7 +494,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 }], }, ], @@ -495,7 +508,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 }], }, ], @@ -509,7 +522,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Variable "$input" got invalid value { a: "foo", b: "bar" }; Field "TestInputObject.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 }], }, ], @@ -528,12 +541,12 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Variable "$input" got invalid value { a: "foo" } at "input.na"; Field "TestInputObject.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 "TestNestedInputObject.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 }], }, ], @@ -550,7 +563,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 }], }, ], @@ -725,7 +738,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 }], }, ], @@ -744,7 +757,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 }], }, ], @@ -810,7 +823,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 }], }, ], @@ -838,7 +851,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Argument "input" of required type "String!" was provided the variable "$foo" which was not provided a runtime value.', + 'Argument "input" has invalid value: Expected variable "$foo" provided to type "String!" to provide a runtime value.', locations: [{ line: 3, column: 50 }], path: ['fieldWithNonNullableStringInput'], }, @@ -893,7 +906,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 }], }, ], @@ -956,7 +969,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 }], }, ], @@ -975,7 +988,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 }], }, ], @@ -1005,7 +1018,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 }], }, ], @@ -1090,7 +1103,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Argument "input" of type "String" has invalid value WRONG_TYPE.', + 'Argument "input" has invalid value: String cannot represent a non string value: WRONG_TYPE', locations: [{ line: 3, column: 48 }], path: ['fieldWithDefaultArgumentValue'], }, @@ -1130,7 +1143,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 }], }; } @@ -1299,7 +1312,7 @@ describe('Execute: Handles inputs', () => { expect(result).to.have.property('errors'); expect(result.errors).to.have.length(1); expect(result.errors?.at(0)?.message).to.match( - /Argument "value" of non-null type "String!"/, + /Argument "value" has invalid value: Expected value of non-null type "String!" not to be null./, ); }); diff --git a/src/execution/values.ts b/src/execution/values.ts index b2e9818127..0a847da77f 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'; @@ -12,10 +12,9 @@ import type { VariableDefinitionNode, } from '../language/ast.js'; import { Kind } from '../language/kinds.js'; -import { print } from '../language/printer.js'; import type { GraphQLArgument, GraphQLField } from '../type/definition.js'; -import { isNonNullType } from '../type/definition.js'; +import { isNonNullType, isRequiredArgument } from '../type/definition.js'; import type { GraphQLDirective } from '../type/directives.js'; import type { GraphQLSchema } from '../type/schema.js'; @@ -24,6 +23,10 @@ import { coerceInputLiteral, coerceInputValue, } from '../utilities/coerceInputValue.js'; +import { + validateInputLiteral, + validateInputValue, +} from '../utilities/validateInputValue.js'; import type { GraphQLVariableSignature } from './getVariableSignature.js'; import { getVariableSignature } from './getVariableSignature.js'; @@ -101,61 +104,34 @@ function coerceVariableValues( } const { name: varName, type: varType } = varSignature; - if (!Object.hasOwn(inputs, varName)) { - const defaultValue = varSignature.defaultValue; - if (defaultValue) { - sources[varName] = { - signature: varSignature, - value: undefined, - }; - coerced[varName] = coerceDefaultValue(defaultValue, varType); - } else if (isNonNullType(varType)) { - const varTypeStr = inspect(varType); - onError( - new GraphQLError( - `Variable "$${varName}" of required type "${varTypeStr}" was not provided.`, - { nodes: varDefNode }, - ), - ); - } else { - sources[varName] = { - signature: varSignature, - value: undefined, - }; - } - continue; - } + const value = Object.hasOwn(inputs, varName) ? inputs[varName] : undefined; + sources[varName] = { signature: varSignature, value }; - const value = inputs[varName]; - if (value === null && isNonNullType(varType)) { - const varTypeStr = inspect(varType); - onError( - new GraphQLError( - `Variable "$${varName}" of non-null type "${varTypeStr}" 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] = { signature: varSignature, 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 }; @@ -223,59 +199,47 @@ export function experimentalGetArgumentValues( const argType = argDef.type; const argumentNode = argNodeMap.get(name); - if (argumentNode == null) { + if (!argumentNode) { + if (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.name}" of required type "${argType}" was not provided.`, + { nodes: node }, + ); + } if (argDef.defaultValue) { coercedValues[name] = coerceDefaultValue( argDef.defaultValue, argDef.type, ); - } else if (isNonNullType(argType)) { - throw new GraphQLError( - `Argument "${name}" of required type "${inspect(argType)}" ` + - 'was not provided.', - { nodes: node }, - ); } continue; } const valueNode = argumentNode.value; - let isNull = valueNode.kind === Kind.NULL; + // Variables without a value are treated as if no argument was provided if + // the argument is not required. if (valueNode.kind === Kind.VARIABLE) { const variableName = valueNode.name.value; const scopedVariableValues = fragmentVariables?.sources[variableName] ? fragmentVariables : variableValues; if ( - scopedVariableValues == null || - !Object.hasOwn(scopedVariableValues.coerced, variableName) + scopedVariableValues?.coerced[variableName] === undefined && + !isRequiredArgument(argDef) ) { if (argDef.defaultValue) { coercedValues[name] = coerceDefaultValue( argDef.defaultValue, argDef.type, ); - } else if (isNonNullType(argType)) { - throw new GraphQLError( - `Argument "${name}" of required type "${inspect(argType)}" ` + - `was provided the variable "$${variableName}" which was not provided a runtime value.`, - { nodes: valueNode }, - ); } continue; } - isNull = scopedVariableValues.coerced[variableName] == null; } - - if (isNull && isNonNullType(argType)) { - throw new GraphQLError( - `Argument "${name}" of non-null type "${inspect(argType)}" ` + - 'must not be null.', - { nodes: valueNode }, - ); - } - const coercedValue = coerceInputLiteral( valueNode, argType, @@ -286,12 +250,20 @@ export function experimentalGetArgumentValues( // 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 "${name}" of type "${inspect( - argType, - )}" has invalid value ${print(valueNode)}.`, - { nodes: valueNode }, + validateInputLiteral( + valueNode, + argType, + (error, path) => { + error.message = `Argument "${argDef.name}" has invalid value${printPathArray( + path, + )}: ${error.message}`; + throw error; + }, + variableValues, + fragmentVariables, ); + /* c8 ignore next */ + invariant(false, 'Invalid argument'); } coercedValues[name] = coercedValue; } diff --git a/src/index.ts b/src/index.ts index b05d3f57c8..b701188cfb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -454,10 +454,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 8cc43fabd8..3c4b3a25c2 100644 --- a/src/type/__tests__/enumType-test.ts +++ b/src/type/__tests__/enumType-test.ts @@ -283,7 +283,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/type/definition.ts b/src/type/definition.ts index 60771a9193..c4d94eca26 100644 --- a/src/type/definition.ts +++ b/src/type/definition.ts @@ -40,6 +40,8 @@ import type { import { Kind } from '../language/kinds.js'; import { print } from '../language/printer.js'; +import type { GraphQLVariableSignature } from '../execution/getVariableSignature.js'; + import { valueFromASTUntyped } from '../utilities/valueFromASTUntyped.js'; import { assertEnumValueName, assertName } from './assertName.js'; @@ -1046,7 +1048,9 @@ export interface GraphQLArgument { astNode: Maybe; } -export function isRequiredArgument(arg: GraphQLArgument): boolean { +export function isRequiredArgument( + arg: GraphQLArgument | GraphQLVariableSignature, +): boolean { return isNonNullType(arg.type) && arg.defaultValue === undefined; } diff --git a/src/utilities/__tests__/coerceInputValue-test.ts b/src/utilities/__tests__/coerceInputValue-test.ts index c6d10c3b9a..19ae0bf48a 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,85 +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 "TestInputObject.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); }); }); @@ -307,98 +165,24 @@ describe('coerceInputValue', () => { isOneOf: true, }); - it('returns no error for a valid input', () => { - const result = coerceValue({ foo: 123 }, TestInputObject); - expectValue(result).to.deep.equal({ foo: 123 }); - }); - - it('returns an error if more than one field is specified', () => { - const result = coerceValue({ foo: 123, bar: null }, TestInputObject); - expectErrors(result).to.deep.equal([ - { - error: - 'Exactly one key must be specified for OneOf type "TestInputObject".', - path: [], - value: { foo: 123, bar: null }, - }, - ]); + it('returns for valid input', () => { + test({ foo: 123 }, TestInputObject, { foo: 123 }); }); - it('returns an error the one field is null', () => { - const result = coerceValue({ bar: null }, TestInputObject); - expectErrors(result).to.deep.equal([ - { - error: 'Field "bar" must be non-null.', - path: ['bar'], - value: null, - }, - ]); + it('invalid if more than one field is specified', () => { + test({ foo: 123, bar: null }, 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('invalid if the one field is null', () => { + test({ bar: null }, TestInputObject, undefined); }); - 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', - }, - { - error: - 'Exactly one key must be specified for OneOf type "TestInputObject".', - path: [], - value: { foo: 'abc', bar: 'def' }, - }, - ]); - }); - - 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 an invalid field', () => { + test({ foo: NaN }, TestInputObject, undefined); }); - it('returns error for a misspelled field', () => { - const result = coerceValue({ bart: 123 }, TestInputObject); - expectErrors(result).to.deep.equal([ - { - error: - 'Field "bart" is not defined by type "TestInputObject". Did you mean "bar"?', - path: [], - value: { bart: 123 }, - }, - { - error: - 'Exactly one key must be specified for OneOf type "TestInputObject".', - path: [], - value: { bart: 123 }, - }, - ]); + it('invalid for an unknown field', () => { + test({ foo: 123, unknownField: 123 }, TestInputObject, undefined); }); }); @@ -415,23 +199,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); }); }); @@ -439,8 +221,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', () => { @@ -450,29 +231,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', () => { @@ -485,24 +252,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); }); }); @@ -510,49 +268,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..883de86a4e --- /dev/null +++ b/src/utilities/__tests__/validateInputValue-test.ts @@ -0,0 +1,828 @@ +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", but encountered error "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 no-throw-literal, @typescript-eslint/only-throw-error + throw 'Not an error object.'; + }, + }); + + test({}, TestThrowScalar, [ + { + error: + 'Expected value of type "TestScalar", but encountered error "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, + (error, path) => { + errors.push({ error: error.message, path }); + }, + variableValues, + ); + 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 != null); + test(inputValue, type, expected, variableValuesOrErrors.variableValues); + } + + it('ignores variables statically', () => { + const TestNonNull = new GraphQLNonNull(GraphQLInt); + test('$var', TestNonNull, []); + }); + + it('returns an error for null variables for non-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", but encountered error "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 no-throw-literal, @typescript-eslint/only-throw-error + throw 'Not an error object.'; + }, + }); + + test('{}', TestThrowScalar, [ + { + error: + 'Expected value of type "TestScalar", but encountered error "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 757121b3ae..f71b5242f4 100644 --- a/src/utilities/coerceInputValue.ts +++ b/src/utilities/coerceInputValue.ts @@ -1,15 +1,6 @@ -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, VariableNode } from '../language/ast.js'; import { Kind } from '../language/kinds.js'; @@ -21,7 +12,6 @@ import type { import { assertLeafType, isInputObjectType, - isLeafType, isListType, isNonNullType, isRequiredInputField, @@ -31,194 +21,104 @@ 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]; } - // Lists accept a non-list value as a list of one. - return [coerceInputValueImpl(inputValue, itemType, onError, path)]; + 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); + } + return coercedValue; } if (isInputObjectType(type)) { if (!isObjectLike(inputValue)) { - onError( - pathToArray(path), - inputValue, - new GraphQLError(`Expected type "${type}" 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 "${type}.${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}".` + - didYouMean(suggestions), - ), - ); + } else { + const coercedField = coerceInputValue(fieldValue, field.type); + if (coercedField === undefined) { + return; // Invalid: intentionally return no value. + } + coercedValue[field.name] = coercedField; } } if (type.isOneOf) { const keys = Object.keys(coercedValue); if (keys.length !== 1) { - onError( - pathToArray(path), - inputValue, - new GraphQLError( - `Exactly one key must be specified for OneOf type "${type}".`, - ), - ); + return; // Invalid: intentionally return no value. } const key = keys[0]; const value = coercedValue[key]; if (value === null) { - onError( - pathToArray(path).concat(key), - value, - new GraphQLError(`Field "${key}" must be non-null.`), - ); + return; // Invalid: intentionally return no value. } } 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}". ` + error.message, { - originalError: error, - }), - ); - } - return; - } - if (parseResult === undefined) { - onError( - pathToArray(path), - inputValue, - new GraphQLError(`Expected type "${type}".`), - ); - } - 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 12dba542dc..95e733acf7 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -78,12 +78,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..c97b5f5ca0 --- /dev/null +++ b/src/utilities/validateInputValue.ts @@ -0,0 +1,462 @@ +import { didYouMean } from '../jsutils/didYouMean.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, VariableNode } 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, +): void { + return validateInputValueImpl(inputValue, type, onError, undefined); +} + +function validateInputValueImpl( + inputValue: unknown, + type: GraphQLInputType, + onError: (error: GraphQLError, path: ReadonlyArray) => void, + path: Path | undefined, +): 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 validateInputValueImpl(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. + validateInputValueImpl(inputValue, type.ofType, onError, path); + } else { + let index = 0; + for (const itemValue of inputValue) { + validateInputValueImpl( + 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 { + validateInputValueImpl( + fieldValue, + field.type, + onError, + addPath(path, field.name, type.name), + ); + } + } + + const fields = Object.keys(inputValue); + // Ensure every provided field is defined. + for (const fieldName of fields) { + if (!Object.hasOwn(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, + ); + } + } + + if (type.isOneOf) { + if (fields.length !== 1) { + reportInvalidValue( + onError, + `Exactly one key must be specified for OneOf type "${type}".`, + path, + ); + } + + const field = fields[0]; + const value = inputValue[field]; + if (value === null) { + reportInvalidValue( + onError, + `Field "${field}" for OneOf type "${type}" must be non-null.`, + 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 != null + ? `, but encountered error "${caughtError.message != null && caughtError.message !== '' ? 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, + onError: (error: GraphQLError, path: ReadonlyArray) => void, + variables?: Maybe, + fragmentVariables?: Maybe, +): void { + const context: ValidationContext = { + static: !variables && !fragmentVariables, + onError, + variables, + fragmentVariables, + }; + return validateInputLiteralImpl(context, valueNode, type, undefined); +} + +interface ValidationContext { + static: boolean; + onError: (error: GraphQLError, path: ReadonlyArray) => void; + variables?: Maybe; + fragmentVariables?: Maybe; +} + +function validateInputLiteralImpl( + context: ValidationContext, + valueNode: ValueNode, + type: GraphQLInputType, + path: Path | undefined, +): void { + if (valueNode.kind === Kind.VARIABLE) { + if (context.static) { + // If no variable values are provided, this is being validated statically, + // and cannot yet produce any validation errors for variables. + return; + } + const scopedVariableValues = getScopedVariableValues(context, valueNode); + const value = scopedVariableValues?.coerced[valueNode.name.value]; + if (isNonNullType(type)) { + if (value === undefined) { + reportInvalidLiteral( + context.onError, + `Expected variable "$${valueNode.name.value}" provided to type "${type}" to provide a runtime value.`, + valueNode, + path, + ); + } else if (value === null) { + reportInvalidLiteral( + context.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( + context.onError, + `Expected value of non-null type "${type}" not to be null.`, + valueNode, + path, + ); + return; + } + return validateInputLiteralImpl(context, valueNode, type.ofType, 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. + validateInputLiteralImpl(context, valueNode, type.ofType, path); + } else { + let index = 0; + for (const itemNode of valueNode.values) { + validateInputLiteralImpl( + context, + itemNode, + type.ofType, + addPath(path, index++, undefined), + ); + } + } + } else if (isInputObjectType(type)) { + if (valueNode.kind !== Kind.OBJECT) { + reportInvalidLiteral( + context.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( + context.onError, + `Expected value of type "${type}" to include required field "${ + field.name + }", found: ${print(valueNode)}.`, + valueNode, + path, + ); + } + } else { + const fieldValueNode = fieldNode.value; + if (fieldValueNode.kind === Kind.VARIABLE && !context.static) { + const scopedVariableValues = getScopedVariableValues( + context, + fieldValueNode, + ); + const variableName = fieldValueNode.name.value; + const value = scopedVariableValues?.coerced[variableName]; + if (type.isOneOf) { + if (value === undefined) { + reportInvalidLiteral( + context.onError, + `Expected variable "$${variableName}" provided to field "${field.name}" for OneOf Input Object type "${type}" to provide a runtime value.`, + valueNode, + path, + ); + } else if (value === null) { + reportInvalidLiteral( + context.onError, + `Expected variable "$${variableName}" provided to field "${field.name}" for OneOf Input Object type "${type}" not to be null.`, + valueNode, + path, + ); + } + } else if (value === undefined && !isRequiredInputField(field)) { + continue; + } + } + + validateInputLiteralImpl( + context, + fieldValueNode, + field.type, + addPath(path, field.name, type.name), + ); + } + } + + const fields = valueNode.fields; + // Ensure every provided field is defined. + for (const fieldNode of fields) { + const fieldName = fieldNode.name.value; + if (!Object.hasOwn(fieldDefs, fieldName)) { + const suggestions = suggestionList(fieldName, Object.keys(fieldDefs)); + reportInvalidLiteral( + context.onError, + `Expected value of type "${type}" not to include unknown field "${fieldName}"${ + suggestions.length > 0 + ? `.${didYouMean(suggestions)} Found` + : ', found' + }: ${print(valueNode)}.`, + fieldNode, + path, + ); + } + } + + if (type.isOneOf) { + const isNotExactlyOneField = fields.length !== 1; + if (isNotExactlyOneField) { + reportInvalidLiteral( + context.onError, + `OneOf Input Object "${type}" must specify exactly one key.`, + valueNode, + path, + ); + return; + } + + const fieldValueNode = fields[0].value; + if (fieldValueNode.kind === Kind.NULL) { + const fieldName = fields[0].name.value; + reportInvalidLiteral( + context.onError, + `Field "${type}.${fieldName}" used for OneOf Input Object must be non-null.`, + valueNode, + addPath(path, fieldName, undefined), + ); + } + } + } else { + assertLeafType(type); + + const constValueNode = replaceVariables(valueNode); + + let result; + let caughtError; + try { + result = type.parseConstLiteral(constValueNode); + } catch (error) { + if (error instanceof GraphQLError) { + context.onError(error, pathToArray(path)); + return; + } + caughtError = error; + } + + if (result === undefined) { + reportInvalidLiteral( + context.onError, + `Expected value of type "${type}"${ + caughtError != null + ? `, but encountered error "${caughtError.message != null && caughtError.message !== '' ? caughtError.message : caughtError}"; found` + : ', found' + }: ${print(valueNode)}.`, + valueNode, + path, + caughtError, + ); + } + } +} + +function getScopedVariableValues( + context: ValidationContext, + valueNode: VariableNode, +): Maybe { + const variableName = valueNode.name.value; + const { fragmentVariables, variables } = context; + return fragmentVariables?.sources[variableName] + ? fragmentVariables + : variables; +} + +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/ValidationContext.ts b/src/validation/ValidationContext.ts index d45e7a46a4..fa94e004e8 100644 --- a/src/validation/ValidationContext.ts +++ b/src/validation/ValidationContext.ts @@ -35,6 +35,7 @@ type NodeWithSelectionSet = OperationDefinitionNode | FragmentDefinitionNode; interface VariableUsage { readonly node: VariableNode; readonly type: Maybe; + readonly parentType: Maybe; readonly defaultValue: GraphQLDefaultValueUsage | undefined; readonly fragmentVariableDefinition: Maybe; } @@ -226,6 +227,7 @@ export class ValidationContext extends ASTValidationContext { newUsages.push({ node: variable, type: typeInfo.getInputType(), + parentType: typeInfo.getParentInputType(), defaultValue: undefined, // fragment variables have a variable default but no location default, which is what this default value represents fragmentVariableDefinition, }); @@ -233,6 +235,7 @@ export class ValidationContext extends ASTValidationContext { newUsages.push({ node: variable, type: typeInfo.getInputType(), + parentType: typeInfo.getParentInputType(), defaultValue: typeInfo.getDefaultValue(), fragmentVariableDefinition: undefined, }); diff --git a/src/validation/__tests__/ValuesOfCorrectTypeRule-test.ts b/src/validation/__tests__/ValuesOfCorrectTypeRule-test.ts index 819d103e6a..75bf4ee9df 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 }], }, ]); @@ -907,7 +905,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 }], }, ]); @@ -943,7 +941,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 }], }, ]); @@ -962,7 +960,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 }], }, ]); @@ -971,10 +969,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.'); }, }); @@ -996,14 +992,14 @@ 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", but encountered error "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', + 'Invalid scalar is always invalid.', ); }); @@ -1029,7 +1025,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 }], }, ]); @@ -1086,25 +1082,10 @@ describe('Validate: Values of correct type', () => { oneOfArgField(oneOfArg: { stringField: null }) } } - `).toDeepEqual([ - { - message: 'Field "OneOfInput.stringField" must be non-null.', - locations: [{ line: 4, column: 37 }], - }, - ]); - }); - - it('Exactly one nullable variable', () => { - expectErrors(` - query ($string: String) { - complicatedArgs { - oneOfArgField(oneOfArg: { stringField: $string }) - } - } `).toDeepEqual([ { message: - 'Variable "$string" must be non-nullable to be used for OneOf Input Object "OneOfInput".', + 'Field "OneOfInput.stringField" used for OneOf Input Object must be non-null.', locations: [{ line: 4, column: 37 }], }, ]); @@ -1198,15 +1179,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 }], }, ]); @@ -1232,7 +1213,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 }], }, ]); @@ -1265,7 +1246,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/__tests__/VariablesInAllowedPositionRule-test.ts b/src/validation/__tests__/VariablesInAllowedPositionRule-test.ts index d0da8f5305..127f146230 100644 --- a/src/validation/__tests__/VariablesInAllowedPositionRule-test.ts +++ b/src/validation/__tests__/VariablesInAllowedPositionRule-test.ts @@ -365,6 +365,37 @@ describe('Validate: Variables are in allowed positions', () => { }); }); + describe('Validates OneOf Input Objects', () => { + it('Allows exactly one non-nullable variable', () => { + expectValid(` + query ($string: String!) { + complicatedArgs { + oneOfArgField(oneOfArg: { stringField: $string }) + } + } + `); + }); + + it('Forbids one nullable variable', () => { + expectErrors(` + query ($string: String) { + complicatedArgs { + oneOfArgField(oneOfArg: { stringField: $string }) + } + } + `).toDeepEqual([ + { + message: + 'Variable "$string" is of type "String" but must be non-nullable to be used for OneOf Input Object "OneOfInput".', + locations: [ + { line: 2, column: 16 }, + { line: 4, column: 52 }, + ], + }, + ]); + }); + }); + describe('Fragment arguments are validated', () => { it('Boolean => Boolean', () => { expectValid(` diff --git a/src/validation/rules/ValuesOfCorrectTypeRule.ts b/src/validation/rules/ValuesOfCorrectTypeRule.ts index d2b566823d..d22feca326 100644 --- a/src/validation/rules/ValuesOfCorrectTypeRule.ts +++ b/src/validation/rules/ValuesOfCorrectTypeRule.ts @@ -1,31 +1,11 @@ -import { didYouMean } from '../../jsutils/didYouMean.js'; -import { inspect } from '../../jsutils/inspect.js'; -import { suggestionList } from '../../jsutils/suggestionList.js'; +import type { Maybe } from '../../jsutils/Maybe.js'; -import { GraphQLError } from '../../error/GraphQLError.js'; - -import type { - ObjectFieldNode, - ObjectValueNode, - ValueNode, - VariableDefinitionNode, -} from '../../language/ast.js'; -import { Kind } from '../../language/kinds.js'; -import { print } from '../../language/printer.js'; +import type { ValueNode } from '../../language/ast.js'; import type { ASTVisitor } from '../../language/visitor.js'; -import type { GraphQLInputObjectType } from '../../type/definition.js'; -import { - getNamedType, - getNullableType, - isInputObjectType, - isLeafType, - isListType, - isNonNullType, - isRequiredInputField, -} from '../../type/definition.js'; +import type { GraphQLInputType } from '../../type/index.js'; -import { replaceVariables } from '../../utilities/replaceVariables.js'; +import { validateInputLiteral } from '../../utilities/validateInputValue.js'; import type { ValidationContext } from '../ValidationContext.js'; @@ -40,92 +20,24 @@ import type { ValidationContext } from '../ValidationContext.js'; export function ValuesOfCorrectTypeRule( context: ValidationContext, ): ASTVisitor { - let variableDefinitions: { [key: string]: VariableDefinitionNode } = {}; - return { - OperationDefinition: { - enter() { - variableDefinitions = {}; - }, - }, - VariableDefinition(definition) { - variableDefinitions[definition.variable.name.value] = definition; - }, - ListValue(node) { + NullValue: (node) => + isValidValueNode(context, node, context.getInputType()), + 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)) { - const typeStr = inspect(fieldDef.type); - context.reportError( - new GraphQLError( - `Field "${type}.${fieldDef.name}" of required type "${typeStr}" was not provided.`, - { nodes: node }, - ), - ); - } - } - - if (type.isOneOf) { - validateOneOfInputObject( - context, - node, - type, - fieldNodeMap, - variableDefinitions, - ); - } - }, - 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 "${inspect(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), + isValidValueNode(context, node, context.getParentInputType()), + ObjectValue: (node) => + isValidValueNode(context, node, context.getInputType()), + EnumValue: (node) => + isValidValueNode(context, node, context.getInputType()), + IntValue: (node) => isValidValueNode(context, node, context.getInputType()), + FloatValue: (node) => + isValidValueNode(context, node, context.getInputType()), + StringValue: (node) => + isValidValueNode(context, node, context.getInputType()), + BooleanValue: (node) => + isValidValueNode(context, node, context.getInputType()), }; } @@ -133,102 +45,15 @@ export function ValuesOfCorrectTypeRule( * 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)) { - const typeStr = inspect(locationType); - context.reportError( - new GraphQLError( - `Expected value of type "${typeStr}", found ${print(node)}.`, - { nodes: node }, - ), - ); - return; - } - - const constValueNode = replaceVariables(node); - - // Scalars and Enums determine if a literal value is valid via parseConstLiteral(), - // which may throw or return undefined to indicate an invalid value. - try { - const parseResult = type.parseConstLiteral(constValueNode); - if (parseResult === undefined) { - const typeStr = inspect(locationType); - context.reportError( - new GraphQLError( - `Expected value of type "${typeStr}", found ${print(node)}.`, - { nodes: node }, - ), - ); - } - } catch (error) { - const typeStr = inspect(locationType); - if (error instanceof GraphQLError) { - context.reportError(error); - } else { - context.reportError( - new GraphQLError( - `Expected value of type "${typeStr}", found ${print(node)}; ` + - error.message, - { nodes: node, originalError: error }, - ), - ); - } - } -} - -function validateOneOfInputObject( +function isValidValueNode( context: ValidationContext, - node: ObjectValueNode, - type: GraphQLInputObjectType, - fieldNodeMap: Map, - variableDefinitions: { [key: string]: VariableDefinitionNode }, -): void { - const keys = Array.from(fieldNodeMap.keys()); - const isNotExactlyOneField = keys.length !== 1; - - if (isNotExactlyOneField) { - context.reportError( - new GraphQLError( - `OneOf Input Object "${type}" must specify exactly one key.`, - { nodes: [node] }, - ), - ); - return; - } - - const value = fieldNodeMap.get(keys[0])?.value; - const isNullLiteral = !value || value.kind === Kind.NULL; - const isVariable = value?.kind === Kind.VARIABLE; - - if (isNullLiteral) { - context.reportError( - new GraphQLError(`Field "${type}.${keys[0]}" must be non-null.`, { - nodes: [node], - }), - ); - return; - } - - if (isVariable) { - const variableName = value.name.value; - const definition = variableDefinitions[variableName]; - const isNullableVariable = definition.type.kind !== Kind.NON_NULL_TYPE; - - if (isNullableVariable) { - context.reportError( - new GraphQLError( - `Variable "$${variableName}" must be non-nullable to be used for OneOf Input Object "${type}".`, - { nodes: [node] }, - ), - ); - } + node: ValueNode, + inputType: Maybe, +): false { + if (inputType) { + validateInputLiteral(node, inputType, (error) => { + context.reportError(error); + }); } + return false; } diff --git a/src/validation/rules/VariablesInAllowedPositionRule.ts b/src/validation/rules/VariablesInAllowedPositionRule.ts index 888e49d3dd..3259787669 100644 --- a/src/validation/rules/VariablesInAllowedPositionRule.ts +++ b/src/validation/rules/VariablesInAllowedPositionRule.ts @@ -11,7 +11,11 @@ import type { GraphQLDefaultValueUsage, GraphQLType, } from '../../type/definition.js'; -import { isNonNullType } from '../../type/definition.js'; +import { + isInputObjectType, + isNonNullType, + isNullableType, +} from '../../type/definition.js'; import type { GraphQLSchema } from '../../type/schema.js'; import { isTypeSubTypeOf } from '../../utilities/typeComparators.js'; @@ -42,6 +46,7 @@ export function VariablesInAllowedPositionRule( for (const { node, type, + parentType, defaultValue, fragmentVariableDefinition, } of usages) { @@ -59,24 +64,40 @@ export function VariablesInAllowedPositionRule( // than the expected item type (contravariant). const schema = context.getSchema(); const varType = typeFromAST(schema, varDef.type); - if ( - varType && - !allowedVariableUsage( - schema, - varType, - varDef.defaultValue, - type, - defaultValue, - ) - ) { - const varTypeStr = inspect(varType); - const typeStr = inspect(type); - context.reportError( - new GraphQLError( - `Variable "$${varName}" of type "${varTypeStr}" used in position expecting type "${typeStr}".`, - { nodes: [varDef, node] }, - ), - ); + if (varType) { + if ( + !allowedVariableUsage( + schema, + varType, + varDef.defaultValue, + type, + defaultValue, + ) + ) { + const varTypeStr = inspect(varType); + const typeStr = inspect(type); + context.reportError( + new GraphQLError( + `Variable "$${varName}" of type "${varTypeStr}" used in position expecting type "${typeStr}".`, + { nodes: [varDef, node] }, + ), + ); + } + + if ( + isInputObjectType(parentType) && + parentType.isOneOf && + isNullableType(varType) + ) { + const varTypeStr = inspect(varType); + const parentTypeStr = inspect(parentType); + context.reportError( + new GraphQLError( + `Variable "$${varName}" is of type "${varTypeStr}" but must be non-nullable to be used for OneOf Input Object "${parentTypeStr}".`, + { nodes: [varDef, node] }, + ), + ); + } } } }