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]
}
}