diff --git a/src/main/java/graphql/schema/validation/DefaultValuesAreValid.java b/src/main/java/graphql/schema/validation/DefaultValuesAreValid.java index 4527973461..fb373ba919 100644 --- a/src/main/java/graphql/schema/validation/DefaultValuesAreValid.java +++ b/src/main/java/graphql/schema/validation/DefaultValuesAreValid.java @@ -47,7 +47,7 @@ public TraversalControl visitGraphQLInputObjectField(GraphQLInputObjectField inp !validationUtil.isValidLiteralValue((Value) defaultValue.getValue(), inputObjectField.getType(), schema, graphQLContext, Locale.getDefault())) { invalid = true; } else if (defaultValue.isExternal() && - !isValidExternalValue(schema, defaultValue.getValue(), inputObjectField.getType(), graphQLContext)) { + !isValidExternalValue(schema, defaultValue.getValue(), inputObjectField.getType(), graphQLContext, errorCollector)) { invalid = true; } if (invalid) { @@ -70,7 +70,7 @@ public TraversalControl visitGraphQLArgument(GraphQLArgument argument, Traverser !validationUtil.isValidLiteralValue((Value) defaultValue.getValue(), argument.getType(), schema, graphQLContext, Locale.getDefault())) { invalid = true; } else if (defaultValue.isExternal() && - !isValidExternalValue(schema, defaultValue.getValue(), argument.getType(), graphQLContext)) { + !isValidExternalValue(schema, defaultValue.getValue(), argument.getType(), graphQLContext, errorCollector)) { invalid = true; } if (invalid) { @@ -80,7 +80,17 @@ public TraversalControl visitGraphQLArgument(GraphQLArgument argument, Traverser return TraversalControl.CONTINUE; } - private boolean isValidExternalValue(GraphQLSchema schema, Object externalValue, GraphQLInputType type, GraphQLContext graphQLContext) { + private boolean isValidExternalValue( + GraphQLSchema schema, + Object externalValue, + GraphQLInputType type, + GraphQLContext graphQLContext, + SchemaValidationErrorCollector errorCollector + ) { + // Coercion expands nested field defaults. Avoid recursing into a cycle that has already made the schema invalid. + if (errorCollector.containsValidationError(SchemaValidationErrorType.DefaultValueCircularRef)) { + return true; + } try { ValuesResolver.externalValueToInternalValue(schema.getCodeRegistry().getFieldVisibility(), externalValue, type, graphQLContext, Locale.getDefault()); return true; diff --git a/src/main/java/graphql/schema/validation/NoDefaultValueCircularRefs.java b/src/main/java/graphql/schema/validation/NoDefaultValueCircularRefs.java new file mode 100644 index 0000000000..4e39dfe6fc --- /dev/null +++ b/src/main/java/graphql/schema/validation/NoDefaultValueCircularRefs.java @@ -0,0 +1,190 @@ +package graphql.schema.validation; + +import graphql.Internal; +import graphql.language.ArrayValue; +import graphql.language.ObjectField; +import graphql.language.ObjectValue; +import graphql.language.Value; +import graphql.schema.GraphQLArgument; +import graphql.schema.GraphQLInputObjectField; +import graphql.schema.GraphQLInputObjectType; +import graphql.schema.GraphQLSchemaElement; +import graphql.schema.GraphQLType; +import graphql.schema.GraphQLTypeVisitorStub; +import graphql.schema.InputValueWithState; +import graphql.util.FpKit; +import graphql.util.TraversalControl; +import graphql.util.TraverserContext; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static graphql.schema.GraphQLTypeUtil.unwrapAll; + +/** + * Validates that {@code InputObjectDefaultValueHasCycle(inputObject)} is {@code false} + * for every input object type, as required by the Input Object type validation rules + * in the GraphQL specification. + *
+ * For example, consider this type configuration: + * + * input A { b:B = {} } + * input B { a:A = {} } + * + *
+ * The default values used in these types form a cycle that can create an infinitely large + * value. This validator rejects default values that can create these kinds of cycles. + * + * @see Input Objects Type Validation + */ +@Internal +public class NoDefaultValueCircularRefs extends GraphQLTypeVisitorStub { + + private final Set checkedFields = new LinkedHashSet<>(); + private final LinkedHashSet fieldPath = new LinkedHashSet<>(); + + @Override + public TraversalControl visitGraphQLInputObjectType(GraphQLInputObjectType type, TraverserContext context) { + checkType(type, getErrorCollector(context)); + return TraversalControl.CONTINUE; + } + + @Override + public TraversalControl visitGraphQLArgument(GraphQLArgument argument, TraverserContext context) { + GraphQLType namedType = unwrapAll(argument.getType()); + if (namedType instanceof GraphQLInputObjectType) { + checkType((GraphQLInputObjectType) namedType, getErrorCollector(context)); + } + return TraversalControl.CONTINUE; + } + + private void checkType(GraphQLInputObjectType type, SchemaValidationErrorCollector errorCollector) { + for (GraphQLInputObjectField field : type.getFieldDefinitions()) { + GraphQLInputObjectType fieldType = getInputObjectType(field); + if (fieldType == null) { + continue; + } + checkFieldDefaultValue(field, fieldType, type.getName(), errorCollector); + } + } + + private void checkValue( + GraphQLInputObjectType inputObject, + @Nullable Object value, + SchemaValidationErrorCollector errorCollector + ) { + if (value == null) { + return; + } + if (value instanceof ArrayValue) { + for (Value itemValue : ((ArrayValue) value).getValues()) { + checkValue(inputObject, itemValue, errorCollector); + } + return; + } + if (FpKit.isIterable(value)) { + for (Object itemValue : FpKit.toIterable(value)) { + checkValue(inputObject, itemValue, errorCollector); + } + return; + } + + Map valueMap = getValueMap(value); + if (valueMap == null) { + return; + } + checkObjectValue(inputObject, valueMap, errorCollector); + } + + private void checkObjectValue( + GraphQLInputObjectType inputObject, + Map valueMap, + SchemaValidationErrorCollector errorCollector + ) { + for (GraphQLInputObjectField field : inputObject.getFieldDefinitions()) { + boolean hasValue = valueMap.containsKey(field.getName()); + if (!hasValue && field.getInputFieldDefaultValue().isNotSet()) { + continue; + } + + GraphQLInputObjectType fieldType = getInputObjectType(field); + if (fieldType == null) { + continue; + } + if (hasValue) { + checkValue(fieldType, valueMap.get(field.getName()), errorCollector); + continue; + } + checkFieldDefaultValue(field, fieldType, inputObject.getName(), errorCollector); + } + } + + private void checkFieldDefaultValue( + GraphQLInputObjectField field, + GraphQLInputObjectType fieldType, + String parentTypeName, + SchemaValidationErrorCollector errorCollector + ) { + InputValueWithState defaultValue = field.getInputFieldDefaultValue(); + if (!defaultValue.isLiteral() && !defaultValue.isExternal()) { + return; + } + + String coordinate = parentTypeName + "." + field.getName(); + if (fieldPath.contains(coordinate)) { + addError(coordinate, errorCollector); + return; + } + if (!checkedFields.add(coordinate)) { + return; + } + + fieldPath.add(coordinate); + checkValue(fieldType, defaultValue.getValue(), errorCollector); + fieldPath.remove(coordinate); + } + + private void addError(String coordinate, SchemaValidationErrorCollector errorCollector) { + List path = new ArrayList<>(fieldPath); + List intermediaries = path.subList(path.indexOf(coordinate) + 1, path.size()); + String via = intermediaries.isEmpty() + ? "" + : " via the default values of: " + String.join(", ", intermediaries); + String message = "Invalid circular reference. The default value of Input Object field " + + coordinate + " references itself" + via + "."; + errorCollector.addError(new SchemaValidationError( + SchemaValidationErrorType.DefaultValueCircularRef, message)); + } + + private @Nullable GraphQLInputObjectType getInputObjectType(GraphQLInputObjectField field) { + GraphQLType type = unwrapAll(field.getType()); + if (type instanceof GraphQLInputObjectType) { + return (GraphQLInputObjectType) type; + } + return null; + } + + private @Nullable Map getValueMap(Object value) { + if (value instanceof Map) { + return (Map) value; + } + if (!(value instanceof ObjectValue)) { + return null; + } + + Map> valueMap = new LinkedHashMap<>(); + for (ObjectField field : ((ObjectValue) value).getObjectFields()) { + valueMap.put(field.getName(), field.getValue()); + } + return valueMap; + } + + private SchemaValidationErrorCollector getErrorCollector(TraverserContext context) { + return context.getVarFromParents(SchemaValidationErrorCollector.class); + } +} diff --git a/src/main/java/graphql/schema/validation/SchemaValidationErrorType.java b/src/main/java/graphql/schema/validation/SchemaValidationErrorType.java index b1caecc8e4..b7ce1cc631 100644 --- a/src/main/java/graphql/schema/validation/SchemaValidationErrorType.java +++ b/src/main/java/graphql/schema/validation/SchemaValidationErrorType.java @@ -25,5 +25,6 @@ public enum SchemaValidationErrorType implements SchemaValidationErrorClassifica OneOfNotInhabited, RequiredInputFieldCannotBeDeprecated, RequiredFieldArgumentCannotBeDeprecated, - RequiredDirectiveArgumentCannotBeDeprecated + RequiredDirectiveArgumentCannotBeDeprecated, + DefaultValueCircularRef } diff --git a/src/main/java/graphql/schema/validation/SchemaValidator.java b/src/main/java/graphql/schema/validation/SchemaValidator.java index 1f676fb77e..5e8432b0a9 100644 --- a/src/main/java/graphql/schema/validation/SchemaValidator.java +++ b/src/main/java/graphql/schema/validation/SchemaValidator.java @@ -14,11 +14,10 @@ @Internal public class SchemaValidator { - - private final List rules = new ArrayList<>(); - - public SchemaValidator() { + public List getRules() { + List rules = new ArrayList<>(); rules.add(new NoUnbrokenInputCycles()); + rules.add(new NoDefaultValueCircularRefs()); rules.add(new TypesImplementInterfaces()); rules.add(new TypeAndFieldRule()); rules.add(new DefaultValuesAreValid()); @@ -27,9 +26,6 @@ public SchemaValidator() { rules.add(new InputAndOutputTypesUsedAppropriately()); rules.add(new OneOfInputObjectRules()); rules.add(new DeprecatedInputObjectAndArgumentsAreValid()); - } - - public List getRules() { return rules; } @@ -38,7 +34,7 @@ public Set validateSchema(GraphQLSchema schema) { Map, Object> rootVars = new LinkedHashMap<>(); rootVars.put(GraphQLSchema.class, schema); rootVars.put(SchemaValidationErrorCollector.class, validationErrorCollector); - new SchemaTraverser().depthFirstFullSchema(rules, schema, rootVars); + new SchemaTraverser().depthFirstFullSchema(getRules(), schema, rootVars); return validationErrorCollector.getErrors(); } diff --git a/src/test/groovy/graphql/schema/diffing/SchemaDiffingTest.groovy b/src/test/groovy/graphql/schema/diffing/SchemaDiffingTest.groovy index 46481fa7a5..bd263631ab 100644 --- a/src/test/groovy/graphql/schema/diffing/SchemaDiffingTest.groovy +++ b/src/test/groovy/graphql/schema/diffing/SchemaDiffingTest.groovy @@ -1446,20 +1446,20 @@ class SchemaDiffingTest extends Specification { def schema1 = schema(''' input I { name: String - field: I = {name: "default name"} + field: I = {name: "default name", field: null} } type Query { foo(arg: I): String - } + } ''') def schema2 = schema(''' input I { name: String - field: [I] = [{name: "default name"}] + field: [I] = [{name: "default name", field: null}] } type Query { foo(arg: I): String - } + } ''') when: diff --git a/src/test/groovy/graphql/schema/validation/NoDefaultValueCircularRefsTest.groovy b/src/test/groovy/graphql/schema/validation/NoDefaultValueCircularRefsTest.groovy new file mode 100644 index 0000000000..29b376236c --- /dev/null +++ b/src/test/groovy/graphql/schema/validation/NoDefaultValueCircularRefsTest.groovy @@ -0,0 +1,167 @@ +package graphql.schema.validation + +import graphql.TestUtil +import graphql.schema.GraphQLSchema +import spock.lang.Specification + +import static graphql.Scalars.GraphQLString +import static graphql.schema.GraphQLArgument.newArgument +import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition +import static graphql.schema.GraphQLInputObjectField.newInputObjectField +import static graphql.schema.GraphQLInputObjectType.newInputObject +import static graphql.schema.GraphQLList.list +import static graphql.schema.GraphQLObjectType.newObject +import static graphql.schema.GraphQLSchema.newSchema +import static graphql.schema.GraphQLTypeReference.typeRef + +class NoDefaultValueCircularRefsTest extends Specification { + + def "circular SDL defaults are rejected: #description"() { + when: + TestUtil.schema(sdl) + + then: + def exception = thrown(InvalidSchemaException) + exception.message.contains(expectedMessage) + + where: + description | sdl | expectedMessage + "self reference" | ''' + type Query { test(arg: A): String } + input A { x: A = {} } + ''' | "Invalid circular reference. The default value of Input Object field A.x references itself." + "mutual types" | ''' + type Query { test(arg: A): String } + input A { b: B = {} } + input B { a: A = {} } + ''' | "Invalid circular reference" + "three types" | ''' + type Query { test(arg: B): String } + input B { x: B2 = {} } + input B2 { x: B3 = {} } + input B3 { x: B = {} } + ''' | "Invalid circular reference. The default value of Input Object field B.x references itself via the default values of: B2.x, B3.x." + "list wrapping" | ''' + type Query { test(arg: C): String } + input C { x: [C] = [{}] } + ''' | "Invalid circular reference. The default value of Input Object field C.x references itself." + "nested value" | ''' + type Query { test(arg: D): String } + input D { x: D = { x: { x: {} } } } + ''' | "Invalid circular reference. The default value of Input Object field D.x references itself." + "cross field" | ''' + type Query { test(arg: E): String } + input E { + x: E = { x: null } + y: E = { y: null } + } + ''' | "Invalid circular reference. The default value of Input Object field E.x references itself via the default values of: E.y." + "non-null type" | ''' + type Query { test(arg: F): String } + input F { x: F2! = {} } + input F2 { x: F = { x: {} } } + ''' | "Invalid circular reference. The default value of Input Object field F2.x references itself." + "omitted field" | ''' + type Query { test(arg: A): String } + input A { x: B = {name: "hi"} } + input B { + name: String + a: A = {} + } + ''' | "Invalid circular reference. The default value of Input Object field A.x references itself via the default values of: B.a." + } + + def "multiple independent cycles are reported"() { + when: + TestUtil.schema(''' + type Query { test(a: A, b: P): String } + input A { x: A = {} } + input P { x: P = {} } + ''') + + then: + def exception = thrown(InvalidSchemaException) + exception.message.contains("A.x references itself") + exception.message.contains("P.x references itself") + } + + def "non-circular SDL defaults are accepted: #description"() { + when: + def schema = TestUtil.schema(sdl) + + then: + schema.getType("A") != null + + where: + description | sdl + "explicit nested null" | ''' + type Query { test(arg: A): String } + input A { b: B = {a: null} } + input B { a: A = {} } + ''' + "recursive field unset" | ''' + type Query { test(arg: A): String } + input A { b: B = {} } + input B { a: A } + ''' + "scalar default" | ''' + type Query { test(arg: A): String } + input A { name: String = "hi" } + ''' + "null default" | ''' + type Query { test(arg: A): String } + input A { x: A = null } + ''' + "empty list" | ''' + type Query { test(arg: A): String } + input A { x: [A] = [] } + ''' + "explicit self field null" | ''' + type Query { test(arg: A): String } + input A { x: A = {x: null} } + ''' + } + + def "circular programmatic defaults are rejected: #description"() { + when: + buildProgrammaticSchema(defaultValue, listType) + + then: + def exception = thrown(InvalidSchemaException) + exception.message.contains("Invalid circular reference") + + where: + description | defaultValue | listType + "map" | [:] | false + "list" | [[:]] | true + "Java array" | ([[:]] as Object[]) | true + } + + def "explicit null breaks a programmatic default cycle"() { + expect: + buildProgrammaticSchema([x: null], false).getType("A") != null + } + + private static GraphQLSchema buildProgrammaticSchema(Object defaultValue, boolean listType) { + def recursiveType = typeRef("A") + def fieldType = listType ? list(recursiveType) : recursiveType + def inputType = newInputObject() + .name("A") + .field(newInputObjectField() + .name("x") + .type(fieldType) + .defaultValueProgrammatic(defaultValue)) + .build() + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("test") + .type(GraphQLString) + .argument(newArgument() + .name("arg") + .type(inputType) + .defaultValueProgrammatic([:]))) + .build() + return newSchema().query(queryType).build() + } +} diff --git a/src/test/groovy/graphql/schema/validation/SchemaValidatorTest.groovy b/src/test/groovy/graphql/schema/validation/SchemaValidatorTest.groovy index 706542df8d..7a9404f93a 100644 --- a/src/test/groovy/graphql/schema/validation/SchemaValidatorTest.groovy +++ b/src/test/groovy/graphql/schema/validation/SchemaValidatorTest.groovy @@ -10,16 +10,21 @@ class SchemaValidatorTest extends Specification { when: def validator = new SchemaValidator() def rules = validator.rules + def nextRules = validator.rules + then: - rules.size() == 9 - rules[0] instanceof NoUnbrokenInputCycles - rules[1] instanceof TypesImplementInterfaces - rules[2] instanceof TypeAndFieldRule - rules[3] instanceof DefaultValuesAreValid - rules[4] instanceof AppliedDirectivesAreValid - rules[5] instanceof AppliedDirectiveArgumentsAreValid - rules[6] instanceof InputAndOutputTypesUsedAppropriately - rules[7] instanceof OneOfInputObjectRules - rules[8] instanceof DeprecatedInputObjectAndArgumentsAreValid + rules*.class == [ + NoUnbrokenInputCycles, + NoDefaultValueCircularRefs, + TypesImplementInterfaces, + TypeAndFieldRule, + DefaultValuesAreValid, + AppliedDirectivesAreValid, + AppliedDirectiveArgumentsAreValid, + InputAndOutputTypesUsedAppropriately, + OneOfInputObjectRules, + DeprecatedInputObjectAndArgumentsAreValid, + ] + rules[1] !== nextRules[1] } }