From 26fb0fc6f646a831e9d5d30581cfa0ee2c8c792b Mon Sep 17 00:00:00 2001 From: Lee Byron Date: Tue, 27 Apr 2021 22:50:30 -0700 Subject: [PATCH] Significant additional changes - Rewrites default value circular reference checking as "detectDefaultValueCycle()" - Adds test suite for default value circular references - Moves default value validation to utility "validateInputValue()" - Adds "uncoerceDefaultValue()" to preserve behavior of existing programmatically provided default values - Rewrites "astFromValue()" to remove "uncoerce" and type checking behavior. It used to operate on "internal" coerced values, now it operates on assumed uncoerced values. (also re-writes a bunch of its test suite). - Extracts "validateInputValue()" from "coerceInputValue()" so it can be used separately --- src/index.d.ts | 2 + src/index.js | 2 + src/type/__tests__/validation-test.js | 98 +++++++ src/type/definition.js | 74 ++++- src/type/introspection.js | 5 +- src/type/validate.js | 276 +++++++------------ src/utilities/__tests__/astFromValue-test.js | 167 ++++++----- src/utilities/astFromValue.js | 158 +++++------ src/utilities/buildClientSchema.js | 4 +- src/utilities/coerceInputValue.d.ts | 11 +- src/utilities/coerceInputValue.js | 195 ++++--------- src/utilities/extendSchema.js | 12 +- src/utilities/findBreakingChanges.js | 2 - src/utilities/index.d.ts | 3 + src/utilities/index.js | 3 + src/utilities/printSchema.js | 5 +- src/utilities/validateInputValue.d.ts | 21 ++ src/utilities/validateInputValue.js | 166 +++++++++++ src/utilities/valueFromAST.js | 7 + src/utilities/valueFromASTUntyped.js | 6 +- 20 files changed, 701 insertions(+), 516 deletions(-) create mode 100644 src/utilities/validateInputValue.d.ts create mode 100644 src/utilities/validateInputValue.js diff --git a/src/index.d.ts b/src/index.d.ts index e408ae67cb2..fb50df1874f 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -407,6 +407,8 @@ export { visitWithTypeInfo, // Coerces a JavaScript value to a GraphQL type, or produces errors. coerceInputValue, + // Validate a JavaScript value with a GraphQL type, collecting all errors. + validateInputValue, // Concatenates multiple AST together. concatAST, // Separates an AST into an AST per Operation. diff --git a/src/index.js b/src/index.js index cd446b7fd52..07395335eb1 100644 --- a/src/index.js +++ b/src/index.js @@ -396,6 +396,8 @@ export { visitWithTypeInfo, // Coerces a JavaScript value to a GraphQL type, or produces errors. coerceInputValue, + // Validate a JavaScript value with a GraphQL type, collecting all errors. + validateInputValue, // Concatenates multiple AST together. concatAST, // Separates an AST into an AST per Operation. diff --git a/src/type/__tests__/validation-test.js b/src/type/__tests__/validation-test.js index 34939afe936..389e8d13962 100644 --- a/src/type/__tests__/validation-test.js +++ b/src/type/__tests__/validation-test.js @@ -867,6 +867,104 @@ describe('Type System: Input Objects must have fields', () => { ]); }); + it('rejects Input Objects with default value circular reference', () => { + const validSchema = buildSchema(` + type Query { + field(arg1: A, arg2: B): String + } + + input A { + x: A = null + y: A = { x: null, y: null } + z: [A] = [] + } + + input B { + x: B2 = {} + } + + input B2 { + x: B3 = {} + } + + input B3 { + x: B = { x: { x: null } } + } + `); + + expect(validateSchema(validSchema)).to.deep.equal([]); + + const invalidSchema = buildSchema(` + type Query { + field(arg1: A, arg2: B, arg3: C, arg4: D, arg5: E): String + } + + input A { + x: A = {} + } + + input B { + x: B2 = {} + } + + input B2 { + x: B3 = {} + } + + input B3 { + x: B = {} + } + + input C { + x: [C] = [{}] + } + + input D { + x: D = { x: { x: {} } } + } + + input E { + x: E = { x: null } + y: E = { y: null } + } + `); + + expect(validateSchema(invalidSchema)).to.deep.equal([ + { + message: + 'Cannot reference Input Object field A.x within itself through a series of default values: A.x.', + locations: [{ line: 7, column: 16 }], + }, + { + message: + 'Cannot reference Input Object field B.x within itself through a series of default values: B.x, B2.x, B3.x.', + locations: [ + { line: 11, column: 17 }, + { line: 15, column: 17 }, + { line: 19, column: 16 }, + ], + }, + { + message: + 'Cannot reference Input Object field C.x within itself through a series of default values: C.x.', + locations: [{ line: 23, column: 18 }], + }, + { + message: + 'Cannot reference Input Object field D.x within itself through a series of default values: D.x.', + locations: [{ line: 27, column: 16 }], + }, + { + message: + 'Cannot reference Input Object field E.x within itself through a series of default values: E.x, E.y.', + locations: [ + { line: 31, column: 16 }, + { line: 32, column: 16 }, + ], + }, + ]); + }); + it('rejects an Input Object type with incorrectly typed fields', () => { const schema = buildSchema(` type Query { diff --git a/src/type/definition.js b/src/type/definition.js index 9461e79fb8c..2b6747aeea3 100644 --- a/src/type/definition.js +++ b/src/type/definition.js @@ -13,6 +13,7 @@ import { devAssert } from '../jsutils/devAssert'; import { keyValMap } from '../jsutils/keyValMap'; import { instanceOf } from '../jsutils/instanceOf'; import { didYouMean } from '../jsutils/didYouMean'; +import { isIterableObject } from '../jsutils/isIterableObject'; import { isObjectLike } from '../jsutils/isObjectLike'; import { identityFunc } from '../jsutils/identityFunc'; import { suggestionList } from '../jsutils/suggestionList'; @@ -813,7 +814,10 @@ function defineFieldMap( name: argName, description: argConfig.description, type: argConfig.type, - defaultValue: argConfig.defaultValue, + defaultValue: uncoerceDefaultValue( + argConfig.defaultValue, + argConfig.type, + ), deprecationReason: argConfig.deprecationReason, extensions: argConfig.extensions && toObjMap(argConfig.extensions), astNode: argConfig.astNode, @@ -1479,7 +1483,10 @@ export class GraphQLInputObjectType { getFields(): GraphQLInputFieldMap { if (typeof this._fields === 'function') { - this._fields = this._fields(); + const _fields = this._fields; + // Assign before call to avoid potential infinite recursion. + this._fields = {}; + this._fields = _fields(); } return this._fields; } @@ -1535,7 +1542,10 @@ function defineInputFieldMap( name: fieldName, description: fieldConfig.description, type: fieldConfig.type, - defaultValue: fieldConfig.defaultValue, + defaultValue: uncoerceDefaultValue( + fieldConfig.defaultValue, + fieldConfig.type, + ), deprecationReason: fieldConfig.deprecationReason, extensions: fieldConfig.extensions && toObjMap(fieldConfig.extensions), astNode: fieldConfig.astNode, @@ -1587,3 +1597,61 @@ export function isRequiredInputField( } export type GraphQLInputFieldMap = ObjMap; + +/** + * Historically GraphQL.js allowed default values to be provided as + * assumed-coerced "internal" values, however default values should be provided + * as "external" pre-coerced values. `uncoerceDefaultValue()` will convert such + * "internal" values to "external" values to avoid breaking existing clients. + * + * This performs the opposite of `coerceInputValue()`. Given an "internal" + * coerced value, reverse the process to provide an "external" uncoerced value. + * + * This function should not throw Errors on incorrectly shaped input. Instead + * it will simply pass through such values directly. + * + */ +function uncoerceDefaultValue(value: mixed, type: GraphQLInputType): mixed { + // Explicitly return the value null. + if (value === null) { + return null; + } + + // Unwrap type + const namedType = getNamedType(type); + + if (isIterableObject(value)) { + return Array.from(value, (itemValue) => + uncoerceDefaultValue(itemValue, namedType), + ); + } + + if (isInputObjectType(namedType)) { + if (!isObjectLike(value)) { + return value; + } + + const fieldDefs = namedType.getFields(); + return mapValue(value, (fieldValue, fieldName) => + fieldName in fieldDefs + ? uncoerceDefaultValue(fieldValue, fieldDefs[fieldName].type) + : fieldValue, + ); + } + + if (isLeafType(namedType)) { + try { + // For leaf types (Scalars, Enums), serialize is the oppose of coercion + // (parseValue) and will produce an "external" value. + return namedType.serialize(value); + } catch (error) { + // Ingore any invalid data errors. + // istanbul ignore next - serialize should only throw GraphQLError + if (!(error instanceof GraphQLError)) { + throw error; + } + } + } + + return value; +} diff --git a/src/type/introspection.js b/src/type/introspection.js index f0bce5838a0..6eacc587a78 100644 --- a/src/type/introspection.js +++ b/src/type/introspection.js @@ -384,7 +384,10 @@ export const __InputValue: GraphQLObjectType = new GraphQLObjectType({ 'A GraphQL-formatted string representing the default value for this input value.', resolve(inputValue) { const { type, defaultValue } = inputValue; - const valueAST = astFromValue(defaultValue, type); + const valueAST = + defaultValue !== undefined + ? astFromValue(defaultValue, type) + : undefined; return valueAST ? print(valueAST) : null; }, }, diff --git a/src/type/validate.js b/src/type/validate.js index 1221d92dd20..ad8734c55e0 100644 --- a/src/type/validate.js +++ b/src/type/validate.js @@ -1,12 +1,6 @@ -import type { Path } from '../jsutils/Path'; -import { addPath, pathToArray } from '../jsutils/Path'; -import { didYouMean } from '../jsutils/didYouMean'; import { inspect } from '../jsutils/inspect'; -import { invariant } from '../jsutils/invariant'; -import { isIterableObject } from '../jsutils/isIterableObject'; import { isObjectLike } from '../jsutils/isObjectLike'; import { printPathArray } from '../jsutils/printPathArray'; -import { suggestionList } from '../jsutils/suggestionList'; import { GraphQLError } from '../error/GraphQLError'; import { locatedError } from '../error/locatedError'; @@ -20,6 +14,7 @@ import type { import { isValidNameError } from '../utilities/assertValidName'; import { isEqualType, isTypeSubTypeOf } from '../utilities/typeComparators'; +import { validateInputValue } from '../utilities/validateInputValue'; import type { GraphQLSchema } from './schema'; import type { @@ -28,7 +23,6 @@ import type { GraphQLUnionType, GraphQLEnumType, GraphQLInputObjectType, - GraphQLInputType, } from './definition'; import { assertSchema } from './schema'; import { isIntrospectionType } from './introspection'; @@ -41,9 +35,7 @@ import { isEnumType, isInputObjectType, isNamedType, - isListType, isNonNullType, - isLeafType, isInputType, isOutputType, isRequiredArgument, @@ -204,9 +196,12 @@ function validateDirectives(context: SchemaValidationContext): void { } if (arg.defaultValue !== undefined) { - validateDefaultValue(arg.defaultValue, arg.type).forEach((error) => { + validateInputValue(arg.defaultValue, arg.type, (error, value, path) => { + const coord = `@${directive.name}(${arg.name}:)`; context.reportError( - `Argument @${directive.name}(${arg.name}:) has invalid default value: ${error}`, + `Argument ${coord} has invalid default value ${inspect(value)}` + + (path.length > 0 ? ` at value${printPathArray(path)}` : '') + + `: ${error.message}`, arg.astNode?.defaultValue, ); }); @@ -268,7 +263,7 @@ function validateTypes(context: SchemaValidationContext): void { // Ensure Input Object fields are valid. validateInputFields(context, type); - // Ensure Input Objects do not contain non-nullable circular references + // Ensure Input Objects do not contain invalid circular references. validateInputObjectCircularRefs(type); } } @@ -329,9 +324,12 @@ function validateFields( } if (arg.defaultValue !== undefined) { - validateDefaultValue(arg.defaultValue, arg.type).forEach((error) => { + validateInputValue(arg.defaultValue, arg.type, (error, value, path) => { + const coord = `${type.name}.${field.name}(${argName}:)`; context.reportError( - `Argument ${type.name}.${field.name}(${argName}:) has invalid default value: ${error}`, + `Argument ${coord} has invalid default value ${inspect(value)}` + + (path.length > 0 ? ` at value${printPathArray(path)}` : '') + + `: ${error.message}`, arg.astNode?.defaultValue, ); }); @@ -584,12 +582,19 @@ function validateInputFields( } if (field.defaultValue !== undefined) { - validateDefaultValue(field.defaultValue, field.type).forEach((error) => { - context.reportError( - `Input field ${inputObj.name}.${field.name} has invalid default value: ${error}`, - field.astNode?.defaultValue, - ); - }); + validateInputValue( + field.defaultValue, + field.type, + (error, value, path) => { + const coord = `${inputObj.name}.${field.name}`; + context.reportError( + `Input field ${coord} has invalid default value ${inspect(value)}` + + (path.length > 0 ? ` at value${printPathArray(path)}` : '') + + `: ${error.message}`, + field.astNode?.defaultValue, + ); + }, + ); } } } @@ -606,60 +611,106 @@ function createInputObjectCircularRefsValidator( const fieldPath = []; // Position in the type path - const fieldPathIndexByTypeName = Object.create(null); + const fieldPathIndex = Object.create(null); return detectCycleRecursive; + function detectCycleRecursive(inputObj: GraphQLInputObjectType): void { + detectNonNullFieldCycle(inputObj); + detectDefaultValueCycle(inputObj, {}); + } + // This does a straight-forward DFS to find cycles. // It does not terminate when a cycle was found but continues to explore // the graph to find all possible cycles. - function detectCycleRecursive(inputObj: GraphQLInputObjectType): void { + function detectNonNullFieldCycle(inputObj: GraphQLInputObjectType): void { if (visitedTypes[inputObj.name]) { return; } visitedTypes[inputObj.name] = true; - fieldPathIndexByTypeName[inputObj.name] = fieldPath.length; + fieldPathIndex[inputObj.name] = fieldPath.length; const fields = Object.values(inputObj.getFields()); for (const field of fields) { + if (isNonNullType(field.type) && isInputObjectType(field.type.ofType)) { + const fieldType = field.type.ofType; + const cycleIndex = fieldPathIndex[fieldType.name]; + + fieldPath.push(field); + if (cycleIndex === undefined) { + detectNonNullFieldCycle(fieldType); + } else { + const cyclePath = fieldPath.slice(cycleIndex); + const pathStr = cyclePath.map((fieldObj) => fieldObj.name).join('.'); + context.reportError( + `Cannot reference Input Object "${fieldType.name}" within itself through a series of non-null fields: "${pathStr}".`, + cyclePath.map((fieldObj) => fieldObj.astNode), + ); + } + fieldPath.pop(); + } + } + + fieldPathIndex[inputObj.name] = undefined; + } + + // This does a straight-forward DFS to find cycles. + // It does not terminate when a cycle was found but continues to explore + // the graph to find all possible cycles. + function detectDefaultValueCycle( + inputObj: GraphQLInputObjectType, + value: mixed, + ): void { + // If the value is a List, recursively check each entry for a cycle. + if (Array.isArray(value)) { + value.forEach((entry) => detectDefaultValueCycle(inputObj, entry)); + return; + } + + // Only Input Object values can contain a cycle. + if (!isObjectLike(value)) { + return; + } + + for (const field of Object.values(inputObj.getFields())) { const fieldType = getNamedType(field.type); if (isInputObjectType(fieldType)) { - const isNonNullField = - isNonNullType(field.type) && field.type.ofType === fieldType; - if (isNonNullField || !isEmptyValue(field.defaultValue)) { - const cycleIndex = fieldPathIndexByTypeName[fieldType.name]; - - fieldPath.push(field); - if (cycleIndex === undefined) { - detectCycleRecursive(fieldType); - } else { - const cyclePath = fieldPath.slice(cycleIndex); - const pathStr = cyclePath - .map((fieldObj) => fieldObj.name) - .join('.'); - context.reportError( - `Cannot reference Input Object "${ - fieldType.name - }" within itself through a series of ${ - isNonNullField ? 'non-null fields' : 'non-empty default values' - }: "${pathStr}".`, - cyclePath.map((fieldObj) => fieldObj.astNode), - ); - } + // If the value has an entry for this field, recursively check it. + const entry = value[field.name]; + if (entry !== undefined) { + detectDefaultValueCycle(fieldType, entry); + continue; + } + + const fieldCoordinate = `${inputObj.name}.${field.name}`; + + // Check to see if there is cycle. + const cycleIndex = fieldPathIndex[fieldCoordinate]; + if (cycleIndex >= 0) { + const cyclePath = fieldPath.slice(cycleIndex); + const pathStr = cyclePath.map(([coord]) => coord).join(', '); + context.reportError( + `Cannot reference Input Object field ${fieldCoordinate} within itself through a series of default values: ${pathStr}.`, + cyclePath.map(([, astNode]) => astNode), + ); + continue; + } + + // Recurse into this field's default value once, tracking the path. + if (!visitedTypes[fieldCoordinate]) { + visitedTypes[fieldCoordinate] = true; + fieldPathIndex[fieldCoordinate] = fieldPath.length; + fieldPath.push([fieldCoordinate, field.astNode?.defaultValue]); + detectDefaultValueCycle(fieldType, field.defaultValue); fieldPath.pop(); + fieldPathIndex[fieldCoordinate] = undefined; } } } - - fieldPathIndexByTypeName[inputObj.name] = undefined; } } -function isEmptyValue(value: mixed) { - return value == null || (Array.isArray(value) && value.length === 0); -} - function getAllImplementsInterfaceNodes( type: GraphQLObjectType | GraphQLInterfaceType, iface: GraphQLInterfaceType, @@ -696,126 +747,3 @@ function getDeprecatedDirectiveNode( (node) => node.name.value === GraphQLDeprecatedDirective.name, ); } - -/** - * Coerce an internal JavaScript value given a GraphQL Input Type. - */ -function validateDefaultValue( - inputValue: mixed, - type: GraphQLInputType, - path?: Path, -): Array { - if (isNonNullType(type)) { - if (inputValue !== null) { - return validateDefaultValue(inputValue, type.ofType, path); - } - return invalidDefaultValue( - `Expected non-nullable type "${inspect(type)}" not to be null.`, - path, - ); - } - - if (inputValue === null) { - return []; - } - - if (isListType(type)) { - const itemType = type.ofType; - if (isIterableObject(inputValue)) { - const errors = []; - Array.from(inputValue).forEach((itemValue, index) => { - errors.push( - ...validateDefaultValue( - itemValue, - itemType, - addPath(path, index, undefined), - ), - ); - }); - return errors; - } - // Lists accept a non-list value as a list of one. - return validateDefaultValue(inputValue, itemType, path); - } - - if (isInputObjectType(type)) { - if (!isObjectLike(inputValue)) { - return invalidDefaultValue( - `Expected type "${type.name}" to be an object.`, - path, - ); - } - - const errors = []; - const fieldDefs = type.getFields(); - - for (const field of Object.values(fieldDefs)) { - const fieldPath = addPath(path, field.name, type.name); - const fieldValue = inputValue[field.name]; - - if (fieldValue === undefined) { - if (field.defaultValue === undefined && isNonNullType(field.type)) { - return invalidDefaultValue( - `Field "${field.name}" of required type "${inspect( - field.type, - )}" was not provided.`, - fieldPath, - ); - } - continue; - } - - errors.push(...validateDefaultValue(fieldValue, field.type, fieldPath)); - } - - // Ensure every provided field is defined. - for (const fieldName of Object.keys(inputValue)) { - if (!fieldDefs[fieldName]) { - const suggestions = suggestionList( - fieldName, - Object.keys(type.getFields()), - ); - errors.push( - ...invalidDefaultValue( - `Field "${fieldName}" is not defined by type "${type.name}".` + - didYouMean(suggestions), - path, - ), - ); - } - } - return errors; - } - - // istanbul ignore else (See: 'https://github.com/graphql/graphql-js/issues/2618') - if (isLeafType(type)) { - let parseResult; - let caughtError; - - // Scalars and Enums determine if a input value is valid via serialize(), - // which can throw to indicate failure. If it throws, maintain a reference - // to the original error. - try { - parseResult = type.serialize(inputValue); - } catch (error) { - caughtError = error; - } - if (parseResult === undefined) { - return invalidDefaultValue( - caughtError?.message ?? `Expected type "${type.name}".`, - path, - ); - } - return []; - } - - // istanbul ignore next (Not reachable. All possible input types have been considered) - invariant(false, 'Unexpected input type: ' + inspect((type: empty))); -} - -function invalidDefaultValue(message, path) { - return [ - (path ? `(at defaultValue${printPathArray(pathToArray(path))}) ` : '') + - message, - ]; -} diff --git a/src/utilities/__tests__/astFromValue-test.js b/src/utilities/__tests__/astFromValue-test.js index 3641f00227e..ef738807c4a 100644 --- a/src/utilities/__tests__/astFromValue-test.js +++ b/src/utilities/__tests__/astFromValue-test.js @@ -19,41 +19,46 @@ import { import { astFromValue } from '../astFromValue'; describe('astFromValue', () => { - it('converts boolean values to ASTs', () => { - expect(astFromValue(true, GraphQLBoolean)).to.deep.equal({ - kind: 'BooleanValue', - value: true, - }); - - expect(astFromValue(false, GraphQLBoolean)).to.deep.equal({ - kind: 'BooleanValue', - value: false, + it('converts null values to ASTs', () => { + expect(astFromValue(null, GraphQLBoolean)).to.deep.equal({ + kind: 'NullValue', }); - expect(astFromValue(undefined, GraphQLBoolean)).to.deep.equal(null); - - expect(astFromValue(null, GraphQLBoolean)).to.deep.equal({ + // Note: undefined values are represented as null. + expect(astFromValue(undefined, GraphQLBoolean)).to.deep.equal({ kind: 'NullValue', }); + }); - expect(astFromValue(0, GraphQLBoolean)).to.deep.equal({ + it('converts boolean values to ASTs', () => { + expect(astFromValue(true)).to.deep.equal({ kind: 'BooleanValue', - value: false, + value: true, }); - expect(astFromValue(1, GraphQLBoolean)).to.deep.equal({ + expect(astFromValue(true, GraphQLBoolean)).to.deep.equal({ kind: 'BooleanValue', value: true, }); - const NonNullBoolean = new GraphQLNonNull(GraphQLBoolean); - expect(astFromValue(0, NonNullBoolean)).to.deep.equal({ + expect(astFromValue(false, GraphQLBoolean)).to.deep.equal({ kind: 'BooleanValue', value: false, }); + + // Note: no type checking or coercion. + expect(astFromValue(0, GraphQLBoolean)).to.deep.equal({ + kind: 'IntValue', + value: '0', + }); }); it('converts Int values to Int ASTs', () => { + expect(astFromValue(-1)).to.deep.equal({ + kind: 'IntValue', + value: '-1', + }); + expect(astFromValue(-1, GraphQLInt)).to.deep.equal({ kind: 'IntValue', value: '-1', @@ -71,18 +76,24 @@ describe('astFromValue', () => { // GraphQL spec does not allow coercing non-integer values to Int to avoid // accidental data loss. - expect(() => astFromValue(123.5, GraphQLInt)).to.throw( - 'Int cannot represent non-integer value: 123.5', - ); + expect(astFromValue(123.5, GraphQLInt)).to.deep.equal({ + kind: 'FloatValue', + value: '123.5', + }); - // Note: outside the bounds of 32bit signed int. - expect(() => astFromValue(1e40, GraphQLInt)).to.throw( - 'Int cannot represent non 32-bit signed integer value: 1e+40', - ); + // Note: no type checking or coercion. + expect(astFromValue(1e40, GraphQLInt)).to.deep.equal({ + kind: 'FloatValue', + value: '1e+40', + }); - expect(() => astFromValue(NaN, GraphQLInt)).to.throw( - 'Int cannot represent non-integer value: NaN', - ); + expect(astFromValue(NaN, GraphQLInt)).to.deep.equal({ + kind: 'NullValue', + }); + + expect(astFromValue(Infinity, GraphQLInt)).to.deep.equal({ + kind: 'NullValue', + }); }); it('converts Float values to Int/Float ASTs', () => { @@ -128,21 +139,17 @@ describe('astFromValue', () => { value: 'VA\nLUE', }); + // Note: no type checking or coercion. expect(astFromValue(123, GraphQLString)).to.deep.equal({ - kind: 'StringValue', + kind: 'IntValue', value: '123', }); + // Note: no type checking or coercion. expect(astFromValue(false, GraphQLString)).to.deep.equal({ - kind: 'StringValue', - value: 'false', - }); - - expect(astFromValue(null, GraphQLString)).to.deep.equal({ - kind: 'NullValue', + kind: 'BooleanValue', + value: false, }); - - expect(astFromValue(undefined, GraphQLString)).to.deep.equal(null); }); it('converts ID values to Int/String ASTs', () => { @@ -182,62 +189,38 @@ describe('astFromValue', () => { kind: 'StringValue', value: '01', }); - - expect(() => astFromValue(false, GraphQLID)).to.throw( - 'ID cannot represent value: false', - ); - - expect(astFromValue(null, GraphQLID)).to.deep.equal({ kind: 'NullValue' }); - - expect(astFromValue(undefined, GraphQLID)).to.deep.equal(null); }); - it('converts using serialize from a custom scalar type', () => { - const passthroughScalar = new GraphQLScalarType({ - name: 'PassthroughScalar', + it('ignores custom scalar types, passing through values directly', () => { + const customScalar = new GraphQLScalarType({ + name: 'CustomScale', serialize(value) { return value; }, }); - expect(astFromValue('value', passthroughScalar)).to.deep.equal({ + expect(astFromValue('value', customScalar)).to.deep.equal({ kind: 'StringValue', value: 'value', }); - expect(() => astFromValue(NaN, passthroughScalar)).to.throw( - 'Cannot convert value to AST: NaN.', - ); - expect(() => astFromValue(Infinity, passthroughScalar)).to.throw( - 'Cannot convert value to AST: Infinity.', - ); - - const returnNullScalar = new GraphQLScalarType({ - name: 'ReturnNullScalar', - serialize() { - return null; - }, - }); - - expect(astFromValue('value', returnNullScalar)).to.equal(null); - - class SomeClass {} - - const returnCustomClassScalar = new GraphQLScalarType({ - name: 'ReturnCustomClassScalar', - serialize() { - return new SomeClass(); - }, + expect(astFromValue({ field: 'value' }, customScalar)).to.deep.equal({ + kind: 'ObjectValue', + fields: [ + { + kind: 'ObjectField', + name: { kind: 'Name', value: 'field' }, + value: { kind: 'StringValue', value: 'value' }, + }, + ], }); - - expect(() => astFromValue('value', returnCustomClassScalar)).to.throw( - 'Cannot convert value to AST: {}.', - ); }); - it('does not converts NonNull values to NullValue', () => { + it('ignores NonNull types when producing NullValue', () => { const NonNullBoolean = new GraphQLNonNull(GraphQLBoolean); - expect(astFromValue(null, NonNullBoolean)).to.deep.equal(null); + expect(astFromValue(null, NonNullBoolean)).to.deep.equal({ + kind: 'NullValue', + }); }); const complexValue = { someArbitrary: 'complexValue' }; @@ -257,20 +240,22 @@ describe('astFromValue', () => { value: 'HELLO', }); - expect(astFromValue(complexValue, myEnum)).to.deep.equal({ + expect(astFromValue('COMPLEX', myEnum)).to.deep.equal({ kind: 'EnumValue', value: 'COMPLEX', }); - // Note: case sensitive - expect(() => astFromValue('hello', myEnum)).to.throw( - 'Enum "MyEnum" cannot represent value: "hello"', - ); + // Note: no validation or coercion + expect(astFromValue('hello', myEnum)).to.deep.equal({ + kind: 'EnumValue', + value: 'hello', + }); - // Note: Not a valid enum value - expect(() => astFromValue('UNKNOWN_VALUE', myEnum)).to.throw( - 'Enum "MyEnum" cannot represent value: "UNKNOWN_VALUE"', - ); + // Non-names are string value + expect(astFromValue('hello friend', myEnum)).to.deep.equal({ + kind: 'StringValue', + value: 'hello friend', + }); }); it('converts array values to List ASTs', () => { @@ -319,7 +304,7 @@ describe('astFromValue', () => { }); }); - it('skip invalid list items', () => { + it('retains indicies in arrays, not performing type validation', () => { const ast = astFromValue( ['FOO', null, 'BAR'], new GraphQLList(new GraphQLNonNull(GraphQLString)), @@ -329,6 +314,7 @@ describe('astFromValue', () => { kind: 'ListValue', values: [ { kind: 'StringValue', value: 'FOO' }, + { kind: 'NullValue' }, { kind: 'StringValue', value: 'BAR' }, ], }); @@ -373,7 +359,10 @@ describe('astFromValue', () => { }); }); - it('does not converts non-object values as input objects', () => { - expect(astFromValue(5, inputObj)).to.equal(null); + it('does not perform type validation for mis-matched types', () => { + expect(astFromValue(5, inputObj)).to.deep.equal({ + kind: 'IntValue', + value: '5', + }); }); }); diff --git a/src/utilities/astFromValue.js b/src/utilities/astFromValue.js index 5621659f73a..ed9acc0442b 100644 --- a/src/utilities/astFromValue.js +++ b/src/utilities/astFromValue.js @@ -1,5 +1,4 @@ import { inspect } from '../jsutils/inspect'; -import { invariant } from '../jsutils/invariant'; import { isObjectLike } from '../jsutils/isObjectLike'; import { isIterableObject } from '../jsutils/isIterableObject'; @@ -9,134 +8,98 @@ import { Kind } from '../language/kinds'; import type { GraphQLInputType } from '../type/definition'; import { GraphQLID } from '../type/scalars'; import { - isLeafType, + getNamedType, isEnumType, isInputObjectType, - isListType, - isNonNullType, } from '../type/definition'; /** * Produces a GraphQL Value AST given a JavaScript object. - * Function will match JavaScript/JSON values to GraphQL AST schema format + * Function will match JavaScript values to GraphQL AST schema format * by using suggested GraphQLInputType. For example: * * astFromValue("value", GraphQLString) * - * A GraphQL type must be provided, which will be used to interpret different + * A GraphQL type may be provided, which will be used to interpret different * JavaScript values. * - * | JSON Value | GraphQL Value | - * | ------------- | -------------------- | - * | Object | Input Object | - * | Array | List | - * | Boolean | Boolean | - * | String | String / Enum Value | - * | Number | Int / Float | - * | Mixed | Enum Value | - * | null | NullValue | + * | JavaScript Value | GraphQL Value | + * | ----------------- | -------------------- | + * | Object | Input Object | + * | Array | List | + * | Boolean | Boolean | + * | String | String / Enum Value | + * | Number | Int / Float | + * | null / undefined | NullValue | * + * Note: This function does not perform any type validation or coercion. */ -export function astFromValue(value: mixed, type: GraphQLInputType): ?ValueNode { - if (isNonNullType(type)) { - const astValue = astFromValue(value, type.ofType); - if (astValue?.kind === Kind.NULL) { - return null; - } - return astValue; - } - - // only explicit null, not undefined, NaN - if (value === null) { +export function astFromValue(value: mixed, type?: GraphQLInputType): ValueNode { + // Like JSON, a null literal is produced for null and undefined. + if (value == null) { return { kind: Kind.NULL }; } - // undefined - if (value === undefined) { - return null; - } + const namedType = type && getNamedType(type); - // Convert JavaScript array to GraphQL list. If the GraphQLType is a list, but - // the value is not an array, convert the value using the list's item type. - if (isListType(type)) { - const itemType = type.ofType; - if (isIterableObject(value)) { - const valuesNodes = []; - for (const item of value) { - const itemNode = astFromValue(item, itemType); - if (itemNode != null) { - valuesNodes.push(itemNode); - } - } - return { kind: Kind.LIST, values: valuesNodes }; - } - return astFromValue(value, itemType); + // Convert JavaScript array to GraphQL list. + if (isIterableObject(value)) { + return { + kind: Kind.LIST, + values: Array.from(value, (item) => astFromValue(item, namedType)), + }; } // Populate the fields of the input object by creating ASTs from each value // in the JavaScript object according to the fields in the input type. - if (isInputObjectType(type)) { - if (!isObjectLike(value)) { - return null; - } - const fieldNodes = []; - for (const field of Object.values(type.getFields())) { - const fieldValue = astFromValue(value[field.name], field.type); - if (fieldValue) { - fieldNodes.push({ - kind: Kind.OBJECT_FIELD, - name: { kind: Kind.NAME, value: field.name }, - value: fieldValue, - }); - } - } - return { kind: Kind.OBJECT, fields: fieldNodes }; + if (isObjectLike(value)) { + const fieldDefs = + namedType && isInputObjectType(namedType) && namedType.getFields(); + return { + kind: Kind.OBJECT, + fields: Object.keys(value).map((fieldName) => ({ + kind: Kind.OBJECT_FIELD, + name: { kind: Kind.NAME, value: fieldName }, + value: astFromValue(value[fieldName], fieldDefs?.[fieldName]?.type), + })), + }; } - // istanbul ignore else (See: 'https://github.com/graphql/graphql-js/issues/2618') - if (isLeafType(type)) { - // Since value is an internally represented value, it must be serialized - // to an externally represented value before converting into an AST. - const serialized = type.serialize(value); - if (serialized == null) { - return null; - } + // Others serialize based on their corresponding JavaScript scalar types. + if (typeof value === 'boolean') { + return { kind: Kind.BOOLEAN, value }; + } - // Others serialize based on their corresponding JavaScript scalar types. - if (typeof serialized === 'boolean') { - return { kind: Kind.BOOLEAN, value: serialized }; + // JavaScript numbers can be Int or Float values. + if (typeof value === 'number') { + // Like JSON, a null literal is produced for non-finite values. + if (!Number.isFinite(value)) { + return { kind: Kind.NULL }; } + const stringNum = String(value); + return integerStringRegExp.test(stringNum) + ? { kind: Kind.INT, value: stringNum } + : { kind: Kind.FLOAT, value: stringNum }; + } - // JavaScript numbers can be Int or Float values. - if (typeof serialized === 'number' && Number.isFinite(serialized)) { - const stringNum = String(serialized); - return integerStringRegExp.test(stringNum) - ? { kind: Kind.INT, value: stringNum } - : { kind: Kind.FLOAT, value: stringNum }; + if (typeof value === 'string') { + // Enum types use Enum literals. + if (isEnumType(namedType) && nameRegExp.test(value)) { + return { kind: Kind.ENUM, value }; } - if (typeof serialized === 'string') { - // Enum types use Enum literals. - if (isEnumType(type)) { - return { kind: Kind.ENUM, value: serialized }; - } - - // ID types can use Int literals. - if (type === GraphQLID && integerStringRegExp.test(serialized)) { - return { kind: Kind.INT, value: serialized }; - } - - return { - kind: Kind.STRING, - value: serialized, - }; + // ID types can use Int literals. + if (namedType === GraphQLID && integerStringRegExp.test(value)) { + return { kind: Kind.INT, value }; } - throw new TypeError(`Cannot convert value to AST: ${inspect(serialized)}.`); + return { + kind: Kind.STRING, + value, + }; } - // istanbul ignore next (Not reachable. All possible input types have been considered) - invariant(false, 'Unexpected input type: ' + inspect((type: empty))); + throw new TypeError(`Cannot convert value to AST: ${inspect(value)}.`); } /** @@ -145,3 +108,6 @@ export function astFromValue(value: mixed, type: GraphQLInputType): ?ValueNode { * - NegativeSign? NonZeroDigit ( Digit+ )? */ const integerStringRegExp = /^-?(?:0|[1-9][0-9]*)$/; + +// https://spec.graphql.org/draft/#Name +const nameRegExp = /^[_a-zA-Z][_a-zA-Z0-9]*$/; diff --git a/src/utilities/buildClientSchema.js b/src/utilities/buildClientSchema.js index 487ee6d16e2..2e56170fd38 100644 --- a/src/utilities/buildClientSchema.js +++ b/src/utilities/buildClientSchema.js @@ -47,7 +47,7 @@ import type { IntrospectionTypeRef, IntrospectionNamedTypeRef, } from './getIntrospectionQuery'; -import { valueFromAST } from './valueFromAST'; +import { valueFromASTUntyped } from './valueFromASTUntyped'; /** * Build a GraphQLSchema for use by client tools. @@ -369,7 +369,7 @@ export function buildClientSchema( const defaultValue = inputValueIntrospection.defaultValue != null - ? valueFromAST(parseValue(inputValueIntrospection.defaultValue), type) + ? valueFromASTUntyped(parseValue(inputValueIntrospection.defaultValue)) : undefined; return { description: inputValueIntrospection.description, diff --git a/src/utilities/coerceInputValue.d.ts b/src/utilities/coerceInputValue.d.ts index 0f4d457cb37..16e7bf33542 100644 --- a/src/utilities/coerceInputValue.d.ts +++ b/src/utilities/coerceInputValue.d.ts @@ -19,10 +19,7 @@ export function coerceInputValue( /** * Coerces the default value of an input field or argument. */ -export function coerceDefaultValue( - withDefaultValue: { - readonly type: GraphQLInputType; - readonly defaultValue: unknown; - }, - onError?: OnErrorCB, -): unknown; +export function coerceDefaultValue(withDefaultValue: { + readonly type: GraphQLInputType; + readonly defaultValue: unknown; +}): unknown; diff --git a/src/utilities/coerceInputValue.js b/src/utilities/coerceInputValue.js index 711188e2796..0b966fcf0b8 100644 --- a/src/utilities/coerceInputValue.js +++ b/src/utilities/coerceInputValue.js @@ -1,16 +1,14 @@ import type { Path } from '../jsutils/Path'; import { inspect } from '../jsutils/inspect'; import { invariant } from '../jsutils/invariant'; -import { didYouMean } from '../jsutils/didYouMean'; import { isObjectLike } from '../jsutils/isObjectLike'; -import { suggestionList } from '../jsutils/suggestionList'; import { printPathArray } from '../jsutils/printPathArray'; -import { addPath, pathToArray } from '../jsutils/Path'; +import { addPath } from '../jsutils/Path'; import { isIterableObject } from '../jsutils/isIterableObject'; import { GraphQLError } from '../error/GraphQLError'; -import type { GraphQLInputType, GraphQLLeafType } from '../type/definition'; +import type { GraphQLInputType } from '../type/definition'; import { isLeafType, isInputObjectType, @@ -18,42 +16,27 @@ import { isNonNullType, } from '../type/definition'; +import { validateInputValue } from './validateInputValue'; + type OnErrorCB = ( path: $ReadOnlyArray, invalidValue: mixed, error: GraphQLError, ) => void; -type CoerceCB = (type: GraphQLLeafType, value: mixed) => mixed; - /** * Coerces a JavaScript value given a GraphQL Input Type. */ export function coerceInputValue( inputValue: mixed, type: GraphQLInputType, - onError: OnErrorCB = defaultOnError, -): mixed { - return coerceInputValueImpl(inputValue, type, inputCoercion, onError); -} - -export function coerceDefaultValue( - withDefaultValue: { +type: GraphQLInputType, +defaultValue: mixed, ... }, - onError: OnErrorCB = defaultOnError, + onError?: OnErrorCB = defaultOnError, ): mixed { - // Memoize the result of coercing the default value in a field hidden to the - // type system. - let coercedDefaultValue = (withDefaultValue: any)._coercedDefaultValue; - if (coercedDefaultValue === undefined) { - coercedDefaultValue = coerceInputValueImpl( - withDefaultValue.defaultValue, - withDefaultValue.type, - defaultValueCoercion, - onError, - ); - (withDefaultValue: any)._coercedDefaultValue = coercedDefaultValue; + const coercedValue = coerceInputValueImpl(inputValue, type, undefined); + if (coercedValue === undefined) { + validateInputValue(inputValue, type, onError); } - return coercedDefaultValue; + return coercedValue; } function defaultOnError( @@ -69,44 +52,35 @@ function defaultOnError( throw error; } -function inputCoercion(type: GraphQLLeafType, value: mixed): mixed { - // Scalars and Enums determine if a external input value is valid via - // parseValue(), - return type.parseValue(value); -} - -function defaultValueCoercion(type: GraphQLLeafType, value: mixed): mixed { - // Default values are initially represented as internal values, "serialize" - // converts the internal value to an external value, and "parseValue" converts - // back to an internal value. - return type.parseValue(type.serialize(value)); +export function coerceDefaultValue(withDefaultValue: { + +type: GraphQLInputType, + +defaultValue: mixed, + ... +}): mixed { + // Memoize the result of coercing the default value in a field hidden to the + // type system. + let coercedDefaultValue = (withDefaultValue: any)._coercedDefaultValue; + if (coercedDefaultValue === undefined) { + coercedDefaultValue = coerceInputValueImpl( + withDefaultValue.defaultValue, + withDefaultValue.type, + undefined, + ); + (withDefaultValue: any)._coercedDefaultValue = coercedDefaultValue; + } + return coercedDefaultValue; } function coerceInputValueImpl( inputValue: mixed, type: GraphQLInputType, - coerce: CoerceCB, - onError: OnErrorCB, path: Path | void, ): mixed { if (isNonNullType(type)) { - if (inputValue != null) { - return coerceInputValueImpl( - inputValue, - type.ofType, - coerce, - onError, - path, - ); + if (inputValue == null) { + return; // Invalid value } - onError( - pathToArray(path), - inputValue, - new GraphQLError( - `Expected non-nullable type "${inspect(type)}" not to be null.`, - ), - ); - return; + return coerceInputValueImpl(inputValue, type.ofType, path); } if (inputValue == null) { @@ -117,29 +91,29 @@ function coerceInputValueImpl( if (isListType(type)) { const itemType = type.ofType; if (isIterableObject(inputValue)) { - return Array.from(inputValue, (itemValue, index) => { - const itemPath = addPath(path, index, undefined); - return coerceInputValueImpl( + const coercedValue = []; + let index = 0; + for (const itemValue of inputValue) { + const coercedItem = coerceInputValueImpl( itemValue, itemType, - coerce, - onError, - itemPath, + addPath(path, index++, undefined), ); - }); + if (coercedItem === undefined) { + return; // Invalid value + } + coercedValue.push(coercedItem); + } + return coercedValue; } // Lists accept a non-list value as a list of one. - return [coerceInputValueImpl(inputValue, itemType, coerce, onError, path)]; + const coercedItem = coerceInputValueImpl(inputValue, itemType, path); + return coercedItem === undefined ? undefined : [coercedItem]; } if (isInputObjectType(type)) { if (!isObjectLike(inputValue)) { - onError( - pathToArray(path), - inputValue, - new GraphQLError(`Expected type "${type.name}" to be an object.`), - ); - return; + return; // Invalid value } const coercedValue = {}; @@ -147,47 +121,27 @@ function coerceInputValueImpl( for (const field of Object.values(fieldDefs)) { const fieldValue = inputValue[field.name]; - - if (fieldValue === undefined) { - if (field.defaultValue !== undefined) { - coercedValue[field.name] = coerceDefaultValue(field); - } else if (isNonNullType(field.type)) { - const typeStr = inspect(field.type); - onError( - pathToArray(path), - inputValue, - new GraphQLError( - `Field "${field.name}" of required type "${typeStr}" was not provided.`, - ), - ); + if (fieldValue !== undefined) { + const coercedFieldValue = coerceInputValueImpl( + fieldValue, + field.type, + addPath(path, field.name, type.name), + ); + if (coercedFieldValue === undefined) { + return; // Invalid value } - continue; + coercedValue[field.name] = coercedFieldValue; + } else if (field.defaultValue !== undefined) { + coercedValue[field.name] = coerceDefaultValue(field); + } else if (isNonNullType(field.type)) { + return; // Invalid value } - - coercedValue[field.name] = coerceInputValueImpl( - fieldValue, - field.type, - coerce, - onError, - addPath(path, field.name, type.name), - ); } // Ensure every provided field is defined. for (const fieldName of Object.keys(inputValue)) { if (!fieldDefs[fieldName]) { - const suggestions = suggestionList( - fieldName, - Object.keys(type.getFields()), - ); - onError( - pathToArray(path), - inputValue, - new GraphQLError( - `Field "${fieldName}" is not defined by type "${type.name}".` + - didYouMean(suggestions), - ), - ); + return; // Invalid value } } return coercedValue; @@ -195,39 +149,12 @@ function coerceInputValueImpl( // istanbul ignore else (See: 'https://github.com/graphql/graphql-js/issues/2618') if (isLeafType(type)) { - let coercedResult; - - // Coercion can throw to indicate failure. If it throws, maintain a - // reference to the original error. + // Coercion can throw to indicate failure. try { - coercedResult = coerce(type, inputValue); - } catch (error) { - if (error instanceof GraphQLError) { - onError(pathToArray(path), inputValue, error); - } else { - onError( - pathToArray(path), - inputValue, - new GraphQLError( - `Expected type "${type.name}". ` + error.message, - undefined, - undefined, - undefined, - undefined, - error, - ), - ); - } - return; - } - if (coercedResult === undefined) { - onError( - pathToArray(path), - inputValue, - new GraphQLError(`Expected type "${type.name}".`), - ); + return type.parseValue(inputValue); + } catch (_error) { + return; // Invalid valid } - return coercedResult; } // istanbul ignore next (Not reachable. All possible input types have been considered) diff --git a/src/utilities/extendSchema.js b/src/utilities/extendSchema.js index 71be143ca8e..3abfd297040 100644 --- a/src/utilities/extendSchema.js +++ b/src/utilities/extendSchema.js @@ -80,7 +80,7 @@ import { GraphQLInputObjectType, } from '../type/definition'; -import { valueFromAST } from './valueFromAST'; +import { valueFromASTUntyped } from './valueFromASTUntyped'; type Options = {| ...GraphQLSchemaValidationOptions, @@ -496,7 +496,10 @@ export function extendSchemaImpl( argConfigMap[arg.name.value] = { type, description: arg.description?.value, - defaultValue: valueFromAST(arg.defaultValue, type), + defaultValue: + arg.defaultValue != null + ? valueFromASTUntyped(arg.defaultValue) + : undefined, deprecationReason: getDeprecationReason(arg), astNode: arg, }; @@ -523,7 +526,10 @@ export function extendSchemaImpl( inputFieldMap[field.name.value] = { type, description: field.description?.value, - defaultValue: valueFromAST(field.defaultValue, type), + defaultValue: + field.defaultValue != null + ? valueFromASTUntyped(field.defaultValue) + : undefined, deprecationReason: getDeprecationReason(field), astNode: field, }; diff --git a/src/utilities/findBreakingChanges.js b/src/utilities/findBreakingChanges.js index 2a13d4655b6..89624133dfb 100644 --- a/src/utilities/findBreakingChanges.js +++ b/src/utilities/findBreakingChanges.js @@ -536,8 +536,6 @@ function typeKindName(type: GraphQLNamedType): string { function stringifyValue(value: mixed, type: GraphQLInputType): string { const ast = astFromValue(value, type); - invariant(ast != null); - const sortedAST = visit(ast, { ObjectValue(objectNode) { // Make a copy since sort mutates array diff --git a/src/utilities/index.d.ts b/src/utilities/index.d.ts index a690e640e95..2438cdaa5cf 100644 --- a/src/utilities/index.d.ts +++ b/src/utilities/index.d.ts @@ -77,6 +77,9 @@ export { TypeInfo, visitWithTypeInfo } from './TypeInfo'; // Coerces a JavaScript value to a GraphQL type, or produces errors. export { coerceInputValue } from './coerceInputValue'; +// Validate a JavaScript value with a GraphQL type, collecting all errors. +export { validateInputValue } from './validateInputValue'; + // Concatenates multiple AST together. export { concatAST } from './concatAST'; diff --git a/src/utilities/index.js b/src/utilities/index.js index b4c8372f04e..9566226e95a 100644 --- a/src/utilities/index.js +++ b/src/utilities/index.js @@ -75,6 +75,9 @@ export { TypeInfo, visitWithTypeInfo } from './TypeInfo'; // Coerces a JavaScript value to a GraphQL type, or produces errors. export { coerceInputValue } from './coerceInputValue'; +// Validate a JavaScript value with a GraphQL type, collecting all errors. +export { validateInputValue } from './validateInputValue'; + // Concatenates multiple AST together. export { concatAST } from './concatAST'; diff --git a/src/utilities/printSchema.js b/src/utilities/printSchema.js index b77923eb411..ffd82883e80 100644 --- a/src/utilities/printSchema.js +++ b/src/utilities/printSchema.js @@ -258,10 +258,9 @@ function printArgs( } function printInputValue(arg: GraphQLInputField): string { - const defaultAST = astFromValue(arg.defaultValue, arg.type); let argDecl = arg.name + ': ' + String(arg.type); - if (defaultAST) { - argDecl += ` = ${print(defaultAST)}`; + if (arg.defaultValue !== undefined) { + argDecl += ` = ${print(astFromValue(arg.defaultValue, arg.type))}`; } return argDecl + printDeprecated(arg.deprecationReason); } diff --git a/src/utilities/validateInputValue.d.ts b/src/utilities/validateInputValue.d.ts new file mode 100644 index 00000000000..5c2ffaf60cb --- /dev/null +++ b/src/utilities/validateInputValue.d.ts @@ -0,0 +1,21 @@ +import { GraphQLError } from '../error/GraphQLError'; +import { GraphQLInputType } from '../type/definition'; + +/** + * Validate a JavaScript value with a GraphQL type, collecting all errors via a + * callback function. + * + * Similar to coerceInputValue(), however instead of returning a coerced value, + * validates that the provided input value is allowed for this type. + */ +export function validateInputValue( + inputValue: unknown, + type: GraphQLInputType, + onError: OnErrorCallback, +): void; + +type OnErrorCallback = ( + path: ReadonlyArray, + invalidValue: unknown, + error: GraphQLError, +) => void; diff --git a/src/utilities/validateInputValue.js b/src/utilities/validateInputValue.js new file mode 100644 index 00000000000..972e5b7bf32 --- /dev/null +++ b/src/utilities/validateInputValue.js @@ -0,0 +1,166 @@ +import type { Path } from '../jsutils/Path'; +import { addPath, pathToArray } from '../jsutils/Path'; +import { didYouMean } from '../jsutils/didYouMean'; +import { inspect } from '../jsutils/inspect'; +import { invariant } from '../jsutils/invariant'; +import { isIterableObject } from '../jsutils/isIterableObject'; +import { isObjectLike } from '../jsutils/isObjectLike'; +import { suggestionList } from '../jsutils/suggestionList'; + +import { GraphQLError } from '../error/GraphQLError'; + +import { + isInputObjectType, + isListType, + isNonNullType, + isLeafType, + isRequiredInputField, +} from '../type/definition'; + +type OnErrorCB = ( + path: $ReadOnlyArray, + invalidValue: mixed, + error: GraphQLError, +) => void; + +/** + * Validate a JavaScript value with a GraphQL type, collecting all errors via a + * callback function. + * + * Similar to coerceInputValue(), however instead of returning a coerced value, + * validates that the provided input value is allowed for this type. + */ +export function validateInputValue( + inputValue: mixed, + type: GraphQLInputType, + onError: OnErrorCB, +): void { + validateInputValueImpl(inputValue, type, onError, undefined); +} + +function validateInputValueImpl( + inputValue: mixed, + type: GraphQLInputType, + onError: OnErrorCB, + path: Path | void, +): void { + if (isNonNullType(type)) { + if (inputValue == null) { + reportInvalidValue( + onError, + `Expected non-nullable type "${inspect(type)}" not to be null.`, + inputValue, + path, + ); + return; + } + return validateInputValueImpl(inputValue, type.ofType, onError, path); + } + + if (inputValue == null) { + return; + } + + if (isListType(type)) { + const itemType = type.ofType; + if (isIterableObject(inputValue)) { + let index = 0; + for (const itemValue of inputValue) { + validateInputValueImpl( + itemValue, + itemType, + onError, + addPath(path, index++, undefined), + ); + } + } else { + // Lists accept a non-list value as a list of one. + validateInputValueImpl(inputValue, itemType, onError, path); + } + } else if (isInputObjectType(type)) { + if (!isObjectLike(inputValue)) { + reportInvalidValue( + onError, + `Expected type "${type.name}" to be an object.`, + inputValue, + path, + ); + return; + } + + const fieldDefs = type.getFields(); + + for (const field of Object.values(fieldDefs)) { + const fieldValue = inputValue[field.name]; + if (fieldValue !== undefined) { + const fieldPath = addPath(path, field.name, type.name); + validateInputValueImpl(fieldValue, field.type, onError, fieldPath); + } else if (isRequiredInputField(field)) { + reportInvalidValue( + onError, + `Field "${field.name}" of required type "${inspect( + field.type, + )}" was not provided.`, + inputValue, + path, + ); + } + } + + // Ensure every provided field is defined. + for (const fieldName of Object.keys(inputValue)) { + if (!fieldDefs[fieldName]) { + const suggestions = suggestionList( + fieldName, + Object.keys(type.getFields()), + ); + reportInvalidValue( + onError, + `Field "${fieldName}" is not defined by type "${type.name}".` + + didYouMean(suggestions), + inputValue, + path, + ); + } + } + } else if (isLeafType(type)) { + let result; + let caughtError; + + try { + result = type.parseValue(inputValue); + } catch (error) { + caughtError = error; + } + + if (caughtError instanceof GraphQLError) { + onError(pathToArray(path), inputValue, caughtError); + } else if (result === undefined) { + reportInvalidValue( + onError, + `Expected type "${type.name}".` + + (caughtError ? ` ${caughtError.message}` : ''), + inputValue, + path, + caughtError, + ); + } + } else { + // istanbul ignore next (Not reachable. All possible input types have been considered) + invariant(false, 'Unexpected input type: ' + inspect((type: empty))); + } +} + +function reportInvalidValue( + onError: OnErrorCB, + message: string, + value: mixed, + path: Path | void, + originalError?: GraphQLError, +): void { + onError( + pathToArray(path), + value, + new GraphQLError(message, null, null, null, null, originalError), + ); +} diff --git a/src/utilities/valueFromAST.js b/src/utilities/valueFromAST.js index 0fe4bc7d570..a7ce924c664 100644 --- a/src/utilities/valueFromAST.js +++ b/src/utilities/valueFromAST.js @@ -15,6 +15,8 @@ import { } from '../type/definition'; import { coerceDefaultValue } from './coerceInputValue'; +// import { valueFromASTUntyped } from './valueFromASTUntyped'; +// import { coerceInputValue } from './coerceInputValue'; /** * Produces a JavaScript value given a GraphQL Value AST. @@ -41,6 +43,11 @@ export function valueFromAST( type: GraphQLInputType, variables?: ?ObjMap, ): mixed | void { + // let caughtError + // return coerceInputValue(valueFromASTUntyped(valueNode, variables), type, (_1,_2,error) => { + // caughtError = error; + // }) + if (!valueNode) { // When there is no node, then there is also no value. // Importantly, this is different from returning the value null. diff --git a/src/utilities/valueFromASTUntyped.js b/src/utilities/valueFromASTUntyped.js index 05f5db71b2e..79eea47da5e 100644 --- a/src/utilities/valueFromASTUntyped.js +++ b/src/utilities/valueFromASTUntyped.js @@ -38,8 +38,10 @@ export function valueFromASTUntyped( case Kind.BOOLEAN: return valueNode.value; case Kind.LIST: - return valueNode.values.map((node) => - valueFromASTUntyped(node, variables), + return valueNode.values.map( + (node) => + // Note: undefined variables are replaced with null within a List + valueFromASTUntyped(node, variables) ?? null, ); case Kind.OBJECT: return keyValMap(