Skip to content

Commit 153e8b9

Browse files
Code-Hexleebyron
authored andcommitted
RFC: Default value validation & coercion
Implements graphql/graphql-spec#793 * Adds validation of default values during schema validation. * Adds coercion of default values anywhere a default value is used at runtime
1 parent 2d48fbb commit 153e8b9

File tree

9 files changed

+295
-57
lines changed

9 files changed

+295
-57
lines changed

src/execution/__tests__/variables-test.js

-1
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,6 @@ const TestType = new GraphQLObjectType({
9898
}),
9999
fieldWithNestedInputObject: fieldWithInputArg({
100100
type: TestNestedInputObject,
101-
defaultValue: 'Hello World',
102101
}),
103102
list: fieldWithInputArg({ type: new GraphQLList(GraphQLString) }),
104103
nnList: fieldWithInputArg({

src/execution/values.js

+6-3
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ import { isInputType, isNonNullType } from '../type/definition';
2020

2121
import { typeFromAST } from '../utilities/typeFromAST';
2222
import { valueFromAST } from '../utilities/valueFromAST';
23-
import { coerceInputValue } from '../utilities/coerceInputValue';
23+
import {
24+
coerceInputValue,
25+
coerceDefaultValue,
26+
} from '../utilities/coerceInputValue';
2427

2528
type CoercedVariableValues =
2629
| {| errors: $ReadOnlyArray<GraphQLError> |}
@@ -174,7 +177,7 @@ export function getArgumentValues(
174177

175178
if (!argumentNode) {
176179
if (argDef.defaultValue !== undefined) {
177-
coercedValues[name] = argDef.defaultValue;
180+
coercedValues[name] = coerceDefaultValue(argDef);
178181
} else if (isNonNullType(argType)) {
179182
throw new GraphQLError(
180183
`Argument "${name}" of required type "${inspect(argType)}" ` +
@@ -195,7 +198,7 @@ export function getArgumentValues(
195198
!hasOwnProperty(variableValues, variableName)
196199
) {
197200
if (argDef.defaultValue !== undefined) {
198-
coercedValues[name] = argDef.defaultValue;
201+
coercedValues[name] = coerceDefaultValue(argDef);
199202
} else if (isNonNullType(argType)) {
200203
throw new GraphQLError(
201204
`Argument "${name}" of required type "${inspect(argType)}" ` +

src/type/definition.js

+15-11
Original file line numberDiff line numberDiff line change
@@ -809,21 +809,11 @@ function defineFieldMap<TSource, TContext>(
809809
`${config.name}.${fieldName} args must be an object with argument names as keys.`,
810810
);
811811

812-
const args = Object.entries(argsConfig).map(([argName, argConfig]) => ({
813-
name: argName,
814-
description: argConfig.description,
815-
type: argConfig.type,
816-
defaultValue: argConfig.defaultValue,
817-
deprecationReason: argConfig.deprecationReason,
818-
extensions: argConfig.extensions && toObjMap(argConfig.extensions),
819-
astNode: argConfig.astNode,
820-
}));
821-
822812
return {
823813
name: fieldName,
824814
description: fieldConfig.description,
825815
type: fieldConfig.type,
826-
args,
816+
args: defineArguments(argsConfig),
827817
resolve: fieldConfig.resolve,
828818
subscribe: fieldConfig.subscribe,
829819
deprecationReason: fieldConfig.deprecationReason,
@@ -833,6 +823,20 @@ function defineFieldMap<TSource, TContext>(
833823
});
834824
}
835825

826+
export function defineArguments(
827+
config: GraphQLFieldConfigArgumentMap,
828+
): $ReadOnlyArray<GraphQLArgument> {
829+
return Object.entries(config).map(([argName, argConfig]) => ({
830+
name: argName,
831+
description: argConfig.description,
832+
type: argConfig.type,
833+
defaultValue: argConfig.defaultValue,
834+
deprecationReason: argConfig.deprecationReason,
835+
extensions: argConfig.extensions && toObjMap(argConfig.extensions),
836+
astNode: argConfig.astNode,
837+
}));
838+
}
839+
836840
function isPlainObj(obj: mixed): boolean {
837841
return isObjectLike(obj) && !Array.isArray(obj);
838842
}

src/type/directives.js

+7-11
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ import type {
1414
GraphQLFieldConfigArgumentMap,
1515
} from './definition';
1616
import { GraphQLString, GraphQLBoolean } from './scalars';
17-
import { argsToArgsConfig, GraphQLNonNull } from './definition';
17+
import {
18+
defineArguments,
19+
argsToArgsConfig,
20+
GraphQLNonNull,
21+
} from './definition';
1822

1923
/**
2024
* Test if the given value is a GraphQL directive.
@@ -44,7 +48,7 @@ export class GraphQLDirective {
4448
name: string;
4549
description: ?string;
4650
locations: Array<DirectiveLocationEnum>;
47-
args: Array<GraphQLArgument>;
51+
args: $ReadOnlyArray<GraphQLArgument>;
4852
isRepeatable: boolean;
4953
extensions: ?ReadOnlyObjMap<mixed>;
5054
astNode: ?DirectiveDefinitionNode;
@@ -69,15 +73,7 @@ export class GraphQLDirective {
6973
`@${config.name} args must be an object with argument names as keys.`,
7074
);
7175

72-
this.args = Object.entries(args).map(([argName, argConfig]) => ({
73-
name: argName,
74-
description: argConfig.description,
75-
type: argConfig.type,
76-
defaultValue: argConfig.defaultValue,
77-
deprecationReason: argConfig.deprecationReason,
78-
extensions: argConfig.extensions && toObjMap(argConfig.extensions),
79-
astNode: argConfig.astNode,
80-
}));
76+
this.args = defineArguments(args);
8177
}
8278

8379
toConfig(): GraphQLDirectiveNormalizedConfig {

src/type/validate.js

+192-16
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1+
import type { Path } from '../jsutils/Path';
2+
import { addPath, pathToArray } from '../jsutils/Path';
3+
import { didYouMean } from '../jsutils/didYouMean';
14
import { inspect } from '../jsutils/inspect';
5+
import { invariant } from '../jsutils/invariant';
6+
import { isIterableObject } from '../jsutils/isIterableObject';
7+
import { isObjectLike } from '../jsutils/isObjectLike';
8+
import { printPathArray } from '../jsutils/printPathArray';
9+
import { suggestionList } from '../jsutils/suggestionList';
210

311
import { GraphQLError } from '../error/GraphQLError';
412
import { locatedError } from '../error/locatedError';
@@ -20,18 +28,22 @@ import type {
2028
GraphQLUnionType,
2129
GraphQLEnumType,
2230
GraphQLInputObjectType,
31+
GraphQLInputType,
2332
} from './definition';
2433
import { assertSchema } from './schema';
2534
import { isIntrospectionType } from './introspection';
2635
import { isDirective, GraphQLDeprecatedDirective } from './directives';
2736
import {
37+
getNamedType,
2838
isObjectType,
2939
isInterfaceType,
3040
isUnionType,
3141
isEnumType,
3242
isInputObjectType,
3343
isNamedType,
44+
isListType,
3445
isNonNullType,
46+
isLeafType,
3547
isInputType,
3648
isOutputType,
3749
isRequiredArgument,
@@ -190,6 +202,15 @@ function validateDirectives(context: SchemaValidationContext): void {
190202
],
191203
);
192204
}
205+
206+
if (arg.defaultValue !== undefined) {
207+
validateDefaultValue(arg.defaultValue, arg.type).forEach((error) => {
208+
context.reportError(
209+
`Argument @${directive.name}(${arg.name}:) has invalid default value: ${error}`,
210+
arg.astNode?.defaultValue,
211+
);
212+
});
213+
}
193214
}
194215
}
195216
}
@@ -306,6 +327,15 @@ function validateFields(
306327
],
307328
);
308329
}
330+
331+
if (arg.defaultValue !== undefined) {
332+
validateDefaultValue(arg.defaultValue, arg.type).forEach((error) => {
333+
context.reportError(
334+
`Argument ${type.name}.${field.name}(${argName}:) has invalid default value: ${error}`,
335+
arg.astNode?.defaultValue,
336+
);
337+
});
338+
}
309339
}
310340
}
311341
}
@@ -528,7 +558,7 @@ function validateInputFields(
528558
);
529559
}
530560

531-
// Ensure the arguments are valid
561+
// Ensure the input fields are valid
532562
for (const field of fields) {
533563
// Ensure they are named correctly.
534564
validateName(context, field);
@@ -552,6 +582,15 @@ function validateInputFields(
552582
],
553583
);
554584
}
585+
586+
if (field.defaultValue !== undefined) {
587+
validateDefaultValue(field.defaultValue, field.type).forEach((error) => {
588+
context.reportError(
589+
`Input field ${inputObj.name}.${field.name} has invalid default value: ${error}`,
590+
field.astNode?.defaultValue,
591+
);
592+
});
593+
}
555594
}
556595
}
557596

@@ -584,29 +623,43 @@ function createInputObjectCircularRefsValidator(
584623

585624
const fields = Object.values(inputObj.getFields());
586625
for (const field of fields) {
587-
if (isNonNullType(field.type) && isInputObjectType(field.type.ofType)) {
588-
const fieldType = field.type.ofType;
589-
const cycleIndex = fieldPathIndexByTypeName[fieldType.name];
590-
591-
fieldPath.push(field);
592-
if (cycleIndex === undefined) {
593-
detectCycleRecursive(fieldType);
594-
} else {
595-
const cyclePath = fieldPath.slice(cycleIndex);
596-
const pathStr = cyclePath.map((fieldObj) => fieldObj.name).join('.');
597-
context.reportError(
598-
`Cannot reference Input Object "${fieldType.name}" within itself through a series of non-null fields: "${pathStr}".`,
599-
cyclePath.map((fieldObj) => fieldObj.astNode),
600-
);
626+
const fieldType = getNamedType(field.type);
627+
if (isInputObjectType(fieldType)) {
628+
const isNonNullField =
629+
isNonNullType(field.type) && field.type.ofType === fieldType;
630+
if (isNonNullField || !isEmptyValue(field.defaultValue)) {
631+
const cycleIndex = fieldPathIndexByTypeName[fieldType.name];
632+
633+
fieldPath.push(field);
634+
if (cycleIndex === undefined) {
635+
detectCycleRecursive(fieldType);
636+
} else {
637+
const cyclePath = fieldPath.slice(cycleIndex);
638+
const pathStr = cyclePath
639+
.map((fieldObj) => fieldObj.name)
640+
.join('.');
641+
context.reportError(
642+
`Cannot reference Input Object "${
643+
fieldType.name
644+
}" within itself through a series of ${
645+
isNonNullField ? 'non-null fields' : 'non-empty default values'
646+
}: "${pathStr}".`,
647+
cyclePath.map((fieldObj) => fieldObj.astNode),
648+
);
649+
}
650+
fieldPath.pop();
601651
}
602-
fieldPath.pop();
603652
}
604653
}
605654

606655
fieldPathIndexByTypeName[inputObj.name] = undefined;
607656
}
608657
}
609658

659+
function isEmptyValue(value: mixed) {
660+
return value == null || (Array.isArray(value) && value.length === 0);
661+
}
662+
610663
function getAllImplementsInterfaceNodes(
611664
type: GraphQLObjectType | GraphQLInterfaceType,
612665
iface: GraphQLInterfaceType,
@@ -643,3 +696,126 @@ function getDeprecatedDirectiveNode(
643696
(node) => node.name.value === GraphQLDeprecatedDirective.name,
644697
);
645698
}
699+
700+
/**
701+
* Coerce an internal JavaScript value given a GraphQL Input Type.
702+
*/
703+
function validateDefaultValue(
704+
inputValue: mixed,
705+
type: GraphQLInputType,
706+
path?: Path,
707+
): Array<string> {
708+
if (isNonNullType(type)) {
709+
if (inputValue !== null) {
710+
return validateDefaultValue(inputValue, type.ofType, path);
711+
}
712+
return invalidDefaultValue(
713+
`Expected non-nullable type "${inspect(type)}" not to be null.`,
714+
path,
715+
);
716+
}
717+
718+
if (inputValue === null) {
719+
return [];
720+
}
721+
722+
if (isListType(type)) {
723+
const itemType = type.ofType;
724+
if (isIterableObject(inputValue)) {
725+
const errors = [];
726+
Array.from(inputValue).forEach((itemValue, index) => {
727+
errors.push(
728+
...validateDefaultValue(
729+
itemValue,
730+
itemType,
731+
addPath(path, index, undefined),
732+
),
733+
);
734+
});
735+
return errors;
736+
}
737+
// Lists accept a non-list value as a list of one.
738+
return validateDefaultValue(inputValue, itemType, path);
739+
}
740+
741+
if (isInputObjectType(type)) {
742+
if (!isObjectLike(inputValue)) {
743+
return invalidDefaultValue(
744+
`Expected type "${type.name}" to be an object.`,
745+
path,
746+
);
747+
}
748+
749+
const errors = [];
750+
const fieldDefs = type.getFields();
751+
752+
for (const field of Object.values(fieldDefs)) {
753+
const fieldPath = addPath(path, field.name, type.name);
754+
const fieldValue = inputValue[field.name];
755+
756+
if (fieldValue === undefined) {
757+
if (field.defaultValue === undefined && isNonNullType(field.type)) {
758+
return invalidDefaultValue(
759+
`Field "${field.name}" of required type "${inspect(
760+
field.type,
761+
)}" was not provided.`,
762+
fieldPath,
763+
);
764+
}
765+
continue;
766+
}
767+
768+
errors.push(...validateDefaultValue(fieldValue, field.type, fieldPath));
769+
}
770+
771+
// Ensure every provided field is defined.
772+
for (const fieldName of Object.keys(inputValue)) {
773+
if (!fieldDefs[fieldName]) {
774+
const suggestions = suggestionList(
775+
fieldName,
776+
Object.keys(type.getFields()),
777+
);
778+
errors.push(
779+
...invalidDefaultValue(
780+
`Field "${fieldName}" is not defined by type "${type.name}".` +
781+
didYouMean(suggestions),
782+
path,
783+
),
784+
);
785+
}
786+
}
787+
return errors;
788+
}
789+
790+
// istanbul ignore else (See: 'https://github.com/graphql/graphql-js/issues/2618')
791+
if (isLeafType(type)) {
792+
let parseResult;
793+
let caughtError;
794+
795+
// Scalars and Enums determine if a input value is valid via serialize(),
796+
// which can throw to indicate failure. If it throws, maintain a reference
797+
// to the original error.
798+
try {
799+
parseResult = type.serialize(inputValue);
800+
} catch (error) {
801+
caughtError = error;
802+
}
803+
if (parseResult === undefined) {
804+
return invalidDefaultValue(
805+
caughtError?.message ?? `Expected type "${type.name}".`,
806+
path,
807+
);
808+
}
809+
return [];
810+
}
811+
812+
// istanbul ignore next (Not reachable. All possible input types have been considered)
813+
invariant(false, 'Unexpected input type: ' + inspect((type: empty)));
814+
}
815+
816+
function invalidDefaultValue(message, path) {
817+
return [
818+
(path ? `(at defaultValue${printPathArray(pathToArray(path))}) ` : '') +
819+
message,
820+
];
821+
}

0 commit comments

Comments
 (0)