diff --git a/execution/engine/engine_config_test.go b/execution/engine/engine_config_test.go index d824273f4d..db6427d70b 100644 --- a/execution/engine/engine_config_test.go +++ b/execution/engine/engine_config_test.go @@ -399,6 +399,13 @@ directive @specifiedBy( url: String! ) on SCALAR +""" +The @oneOf built-in directive marks an input object as a OneOf Input Object. +Exactly one field must be provided and its value must be non-null at runtime. +All fields defined within a @oneOf input must be nullable in the schema. +""" +directive @oneOf on INPUT_OBJECT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/execution/engine/testdata/full_introspection.json b/execution/engine/testdata/full_introspection.json index b61c53265c..8473834888 100644 --- a/execution/engine/testdata/full_introspection.json +++ b/execution/engine/testdata/full_introspection.json @@ -735,6 +735,14 @@ "defaultValue": null } ] + }, + { + "name": "oneOf", + "description": "The @oneOf built-in directive marks an input object as a OneOf Input Object.\nExactly one field must be provided and its value must be non-null at runtime.\nAll fields defined within a @oneOf input must be nullable in the schema.", + "locations": [ + "INPUT_OBJECT" + ], + "args": [] } ] } diff --git a/execution/engine/testdata/full_introspection_with_deprecated.json b/execution/engine/testdata/full_introspection_with_deprecated.json index ebfbecfe7b..74f8fa552f 100644 --- a/execution/engine/testdata/full_introspection_with_deprecated.json +++ b/execution/engine/testdata/full_introspection_with_deprecated.json @@ -769,6 +769,14 @@ "defaultValue": null } ] + }, + { + "name": "oneOf", + "description": "The @oneOf built-in directive marks an input object as a OneOf Input Object.\nExactly one field must be provided and its value must be non-null at runtime.\nAll fields defined within a @oneOf input must be nullable in the schema.", + "locations": [ + "INPUT_OBJECT" + ], + "args": [] } ] } diff --git a/execution/engine/testdata/full_introspection_with_typenames.json b/execution/engine/testdata/full_introspection_with_typenames.json index 6ed6f65361..2eaf5e37e9 100644 --- a/execution/engine/testdata/full_introspection_with_typenames.json +++ b/execution/engine/testdata/full_introspection_with_typenames.json @@ -831,6 +831,15 @@ "defaultValue": null } ] + }, + { + "__typename": "__Directive", + "name": "oneOf", + "description": "The @oneOf built-in directive marks an input object as a OneOf Input Object.\nExactly one field must be provided and its value must be non-null at runtime.\nAll fields defined within a @oneOf input must be nullable in the schema.", + "locations": [ + "INPUT_OBJECT" + ], + "args": [] } ] } diff --git a/v2/pkg/asttransform/baseschema.go b/v2/pkg/asttransform/baseschema.go index 45a6ebc94f..48a0a3dd9a 100644 --- a/v2/pkg/asttransform/baseschema.go +++ b/v2/pkg/asttransform/baseschema.go @@ -168,6 +168,13 @@ directive @deprecated( directive @specifiedBy(url: String!) on SCALAR +""" +The @oneOf built-in directive marks an input object as a OneOf Input Object. +Exactly one field must be provided and its value must be non-null at runtime. +All fields defined within a @oneOf input must be nullable in the schema. +""" +directive @oneOf on INPUT_OBJECT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/v2/pkg/asttransform/fixtures/complete.golden b/v2/pkg/asttransform/fixtures/complete.golden index 27488b6fd9..fa69f656e6 100644 --- a/v2/pkg/asttransform/fixtures/complete.golden +++ b/v2/pkg/asttransform/fixtures/complete.golden @@ -57,6 +57,13 @@ directive @specifiedBy( url: String! ) on SCALAR +""" +The @oneOf built-in directive marks an input object as a OneOf Input Object. +Exactly one field must be provided and its value must be non-null at runtime. +All fields defined within a @oneOf input must be nullable in the schema. +""" +directive @oneOf on INPUT_OBJECT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/v2/pkg/asttransform/fixtures/custom_query_name.golden b/v2/pkg/asttransform/fixtures/custom_query_name.golden index 3bcc2af17f..b1b8ff8c13 100644 --- a/v2/pkg/asttransform/fixtures/custom_query_name.golden +++ b/v2/pkg/asttransform/fixtures/custom_query_name.golden @@ -57,6 +57,13 @@ directive @specifiedBy( url: String! ) on SCALAR +""" +The @oneOf built-in directive marks an input object as a OneOf Input Object. +Exactly one field must be provided and its value must be non-null at runtime. +All fields defined within a @oneOf input must be nullable in the schema. +""" +directive @oneOf on INPUT_OBJECT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/v2/pkg/asttransform/fixtures/mutation_only.golden b/v2/pkg/asttransform/fixtures/mutation_only.golden index 66da6be74e..bfe7dab3d3 100644 --- a/v2/pkg/asttransform/fixtures/mutation_only.golden +++ b/v2/pkg/asttransform/fixtures/mutation_only.golden @@ -49,6 +49,13 @@ directive @specifiedBy( url: String! ) on SCALAR +""" +The @oneOf built-in directive marks an input object as a OneOf Input Object. +Exactly one field must be provided and its value must be non-null at runtime. +All fields defined within a @oneOf input must be nullable in the schema. +""" +directive @oneOf on INPUT_OBJECT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/v2/pkg/asttransform/fixtures/schema_missing.golden b/v2/pkg/asttransform/fixtures/schema_missing.golden index 27488b6fd9..fa69f656e6 100644 --- a/v2/pkg/asttransform/fixtures/schema_missing.golden +++ b/v2/pkg/asttransform/fixtures/schema_missing.golden @@ -57,6 +57,13 @@ directive @specifiedBy( url: String! ) on SCALAR +""" +The @oneOf built-in directive marks an input object as a OneOf Input Object. +Exactly one field must be provided and its value must be non-null at runtime. +All fields defined within a @oneOf input must be nullable in the schema. +""" +directive @oneOf on INPUT_OBJECT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/v2/pkg/asttransform/fixtures/simple.golden b/v2/pkg/asttransform/fixtures/simple.golden index 27488b6fd9..fa69f656e6 100644 --- a/v2/pkg/asttransform/fixtures/simple.golden +++ b/v2/pkg/asttransform/fixtures/simple.golden @@ -57,6 +57,13 @@ directive @specifiedBy( url: String! ) on SCALAR +""" +The @oneOf built-in directive marks an input object as a OneOf Input Object. +Exactly one field must be provided and its value must be non-null at runtime. +All fields defined within a @oneOf input must be nullable in the schema. +""" +directive @oneOf on INPUT_OBJECT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/v2/pkg/asttransform/fixtures/subscription_only.golden b/v2/pkg/asttransform/fixtures/subscription_only.golden index e8e857300f..923037d7ff 100644 --- a/v2/pkg/asttransform/fixtures/subscription_only.golden +++ b/v2/pkg/asttransform/fixtures/subscription_only.golden @@ -48,6 +48,13 @@ directive @specifiedBy( url: String! ) on SCALAR +""" +The @oneOf built-in directive marks an input object as a OneOf Input Object. +Exactly one field must be provided and its value must be non-null at runtime. +All fields defined within a @oneOf input must be nullable in the schema. +""" +directive @oneOf on INPUT_OBJECT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/v2/pkg/asttransform/fixtures/subscription_renamed.golden b/v2/pkg/asttransform/fixtures/subscription_renamed.golden index e78cb9d419..21a6637642 100644 --- a/v2/pkg/asttransform/fixtures/subscription_renamed.golden +++ b/v2/pkg/asttransform/fixtures/subscription_renamed.golden @@ -48,6 +48,13 @@ directive @specifiedBy( url: String! ) on SCALAR +""" +The @oneOf built-in directive marks an input object as a OneOf Input Object. +Exactly one field must be provided and its value must be non-null at runtime. +All fields defined within a @oneOf input must be nullable in the schema. +""" +directive @oneOf on INPUT_OBJECT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/v2/pkg/asttransform/fixtures/with_mutation_subscription.golden b/v2/pkg/asttransform/fixtures/with_mutation_subscription.golden index 229b2423c5..709ad78ac1 100644 --- a/v2/pkg/asttransform/fixtures/with_mutation_subscription.golden +++ b/v2/pkg/asttransform/fixtures/with_mutation_subscription.golden @@ -68,6 +68,13 @@ directive @specifiedBy( url: String! ) on SCALAR +""" +The @oneOf built-in directive marks an input object as a OneOf Input Object. +Exactly one field must be provided and its value must be non-null at runtime. +All fields defined within a @oneOf input must be nullable in the schema. +""" +directive @oneOf on INPUT_OBJECT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/v2/pkg/astvalidation/operation_rule_values.go b/v2/pkg/astvalidation/operation_rule_values.go index 5030117759..c21924432b 100644 --- a/v2/pkg/astvalidation/operation_rule_values.go +++ b/v2/pkg/astvalidation/operation_rule_values.go @@ -430,6 +430,11 @@ func (v *valuesVisitor) valueSatisfiesInputObjectTypeDefinition(value ast.Value, return false } + // Validate @oneOf constraint if present + if v.objectValueViolatesOneOf(value, inputObjectTypeDefinition) { + return false + } + return true } @@ -464,6 +469,58 @@ func (v *valuesVisitor) objectValueHasDuplicateFields(objectValue int) bool { return hasDuplicates } +// objectValueViolatesOneOf checks if an input object value violates the @oneOf directive constraint. +func (v *valuesVisitor) objectValueViolatesOneOf(objectValue ast.Value, defRef int) bool { + def := v.definition.InputObjectTypeDefinitions[defRef] + // Check if the input object type has @oneOf directive + if !def.HasDirectives { + return false + } + hasOneOfDirective := def.Directives.HasDirectiveByName(v.definition, "oneOf") + if !hasOneOfDirective { + return false + } + + fieldRefs := v.operation.ObjectValues[objectValue.Ref].Refs + if len(fieldRefs) != 1 { + objName := v.definition.InputObjectTypeDefinitionNameBytes(defRef) + v.Report.AddExternalError(operationreport.ErrOneOfInputObjectFieldCount(objName, len(fieldRefs), objectValue.Position)) + return true + } + + for _, fieldRef := range fieldRefs { + fieldValue := v.operation.ObjectFieldValue(fieldRef) + + if fieldValue.Kind == ast.ValueKindNull { + objName := v.definition.InputObjectTypeDefinitionNameBytes(defRef) + fieldName := v.operation.ObjectFieldNameBytes(fieldRef) + v.Report.AddExternalError(operationreport.ErrOneOfInputObjectNullValue(objName, fieldName, fieldValue.Position)) + return true + } + + if fieldValue.Kind == ast.ValueKindVariable { + // For variables, check if the variable type is nullable + variableDefinitionRef, variableTypeRef, _, ok := v.operationVariableType(fieldValue.Ref) + if !ok { + continue + } + + // Collect nullable variables + if v.operation.Types[variableTypeRef].TypeKind != ast.TypeKindNonNull { + objName := v.definition.InputObjectTypeDefinitionNameBytes(defRef) + fieldName := v.operation.ObjectFieldNameBytes(fieldRef) + variableName := v.operation.VariableValueNameBytes(fieldValue.Ref) + v.Report.AddExternalError(operationreport.ErrOneOfInputObjectNullableVariable( + objName, fieldName, variableName, fieldValue.Position, + v.operation.VariableDefinitions[variableDefinitionRef].VariableValue.Position)) + return true + } + } + } + + return false +} + func (v *valuesVisitor) objectFieldDefined(objectField, inputObjectTypeDefinition int) bool { name := v.operation.ObjectFieldNameBytes(objectField) for _, i := range v.definition.InputObjectTypeDefinitions[inputObjectTypeDefinition].InputFieldsDefinition.Refs { diff --git a/v2/pkg/astvalidation/operation_validation_test.go b/v2/pkg/astvalidation/operation_validation_test.go index 57e056f3e0..1f35d84c57 100644 --- a/v2/pkg/astvalidation/operation_validation_test.go +++ b/v2/pkg/astvalidation/operation_validation_test.go @@ -3104,6 +3104,79 @@ type Query { }`, Values(), Valid) }) + + t.Run("oneOf", func(t *testing.T) { + t.Run("oneOf with default value", func(t *testing.T) { + run(t, ` + mutation addPet($pet: PetInput! = { cat: { name: "black" } }) { + addPet(pet: $pet) { + name + } + }`, Values(), Valid) + }) + t.Run("list of oneOf with non-null variable", func(t *testing.T) { + run(t, ` + mutation addPet($dog: DogInput!) { + addPets(pets: [{ dog: $dog }]) { + name + } + }`, Values(), Valid) + }) + t.Run("oneOf with no fields", func(t *testing.T) { + run(t, ` + mutation { + addPet(pet: {}) { + name + } + }`, Values(), Invalid, + withValidationErrors(`OneOf input object "PetInput" must have exactly one field provided, but 0 fields were provided`)) + }) + t.Run("oneOf with null field", func(t *testing.T) { + run(t, ` + mutation { + addPet(pet: { cat: null }) { + name + } + }`, Values(), Invalid, + withValidationErrors(`OneOf input object "PetInput" field "cat" value must be non-null`)) + }) + t.Run("oneOf with two null fields", func(t *testing.T) { + run(t, ` + mutation { + addPet(pet: { cat: null, dog: null }) { + name + } + }`, Values(), Invalid, + withValidationErrors(`OneOf input object "PetInput" must have exactly one field provided, but 2 fields were provided`)) + }) + t.Run("oneOf with one null field", func(t *testing.T) { + run(t, ` + mutation { + addPet(pet: { cat: { name: "black" }, dog: null }) { + name + } + }`, Values(), Invalid, + withValidationErrors(`OneOf input object "PetInput" must have exactly one field provided, but 2 fields were provided`)) + }) + t.Run("oneOf with two fields", func(t *testing.T) { + run(t, ` + mutation oneOfWithTwoFields($dog: DogInput) { + addPet(pet: { cat: { name: "black" }, dog: $dog }) { + name + } + }`, Values(), Invalid, + withValidationErrors(`OneOf input object "PetInput" must have exactly one field provided, but 2 fields were provided`)) + }) + t.Run("list of oneOf with nullable variable", func(t *testing.T) { + run(t, ` + mutation listOfOneOfWithNullableVariable($dog: DogInput) { + addPets(pets: [{ dog: $dog }]) { + name + } + }`, Values(), Invalid, + withValidationErrors(`OneOf input object "PetInput" field "dog" cannot use nullable variable "$dog". Variables used in oneOf fields must be non-nullable`)) + }) + }) }) t.Run("5.6.2 Input Object Field Names", func(t *testing.T) { t.Run("147", func(t *testing.T) { @@ -4011,6 +4084,36 @@ type Query { )) }) + t.Run("variables for OneOf fields must be non-nullable", func(t *testing.T) { + t.Run("non-nullable var", func(t *testing.T) { + run(t, ` + mutation addCat($cat: CatInput!) { + addPet(pet: { cat: $cat }) { + name + } + } + `, Values(), Valid) + }) + t.Run("non-nullable var with default", func(t *testing.T) { + run(t, ` + mutation addCatWithDefault($cat: CatInput! = { name: "black" }) { + addPet(pet: { cat: $cat }) { + name + } + } + `, Values(), Valid) + }) + t.Run("nullable cat", func(t *testing.T) { + run(t, ` + mutation addNullableCat($cat: CatInput) { + addPet(pet: { cat: $cat }) { + name + } + } + `, Values(), Invalid, withValidationErrors(`OneOf input object "PetInput" field "cat" cannot use nullable variable "$cat". Variables used in oneOf fields must be non-nullable`)) + }) + }) + t.Run("nested variable with a list type validation", func(t *testing.T) { definition := ` scalar String @@ -4946,6 +5049,25 @@ type Subscription { type Mutation { mutateDog: Dog + addPet(pet: PetInput!): Pet + addPets(pets: [PetInput!]!): [Pet] +} + +input CatInput { + name: String! + nickname: String + meowVolume: Int +} + +input DogInput { + name: String! + nickname: String + barkVolume: Int +} + +input PetInput @oneOf { + cat: CatInput + dog: DogInput } input ComplexInput { name: String, owner: String, optionalListOfOptionalStrings: [String]} @@ -5138,6 +5260,13 @@ directive @deprecated( reason: String = "No longer supported" ) on FIELD_DEFINITION | ENUM_VALUE +""" +The @oneOf built-in directive marks an input object as a OneOf Input Object. +Exactly one field must be provided and its value must be non-null at runtime. +All fields defined within a @oneOf input must be nullable in the schema. +""" +directive @oneOf on INPUT_OBJECT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection.golden b/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection.golden index 0064f2d6bf..f6ab072284 100644 --- a/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection.golden +++ b/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection.golden @@ -350,6 +350,16 @@ ], "isRepeatable": false, "__typename": "__Directive" + }, + { + "name": "oneOf", + "description": "The @oneOf built-in directive marks an input object as a OneOf Input Object.\nExactly one field must be provided and its value must be non-null at runtime.\nAll fields defined within a @oneOf input must be nullable in the schema.", + "locations": [ + "INPUT_OBJECT" + ], + "args": [], + "isRepeatable": false, + "__typename": "__Directive" } ], "__typename": "__Schema" diff --git a/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection_with_custom_root_operation_types.golden b/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection_with_custom_root_operation_types.golden index 0e8d299c2c..6b73ac8dc5 100644 --- a/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection_with_custom_root_operation_types.golden +++ b/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection_with_custom_root_operation_types.golden @@ -498,6 +498,16 @@ ], "isRepeatable": false, "__typename": "__Directive" + }, + { + "name": "oneOf", + "description": "The @oneOf built-in directive marks an input object as a OneOf Input Object.\nExactly one field must be provided and its value must be non-null at runtime.\nAll fields defined within a @oneOf input must be nullable in the schema.", + "locations": [ + "INPUT_OBJECT" + ], + "args": [], + "isRepeatable": false, + "__typename": "__Directive" } ], "__typename": "__Schema" diff --git a/v2/pkg/federation/fixtures/federated_schema.golden b/v2/pkg/federation/fixtures/federated_schema.golden index 5a3119c899..40ac93d20d 100644 --- a/v2/pkg/federation/fixtures/federated_schema.golden +++ b/v2/pkg/federation/fixtures/federated_schema.golden @@ -97,6 +97,13 @@ directive @specifiedBy( url: String! ) on SCALAR +""" +The @oneOf built-in directive marks an input object as a OneOf Input Object. +Exactly one field must be provided and its value must be non-null at runtime. +All fields defined within a @oneOf input must be nullable in the schema. +""" +directive @oneOf on INPUT_OBJECT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/v2/pkg/operationreport/externalerror.go b/v2/pkg/operationreport/externalerror.go index 8de9805f5c..834290cfc3 100644 --- a/v2/pkg/operationreport/externalerror.go +++ b/v2/pkg/operationreport/externalerror.go @@ -25,6 +25,9 @@ const ( MissingRequiredFieldOfInputObjectErrMsg = `Field "%s.%s" of required type "%s" was not provided.` UnknownFieldOfInputObjectErrMsg = `Field "%s" is not defined by type "%s".` DuplicatedFieldInputObjectErrMsg = `There can be only one input field named "%s".` + OneOfInputObjectFieldCountErrMsg = `OneOf input object "%s" must have exactly one field provided, but %d fields were provided.` + OneOfInputObjectNullValueErrMsg = `OneOf input object "%s" field "%s" value must be non-null.` + OneOfInputObjectNullableVariableErrMsg = `OneOf input object "%s" field "%s" cannot use nullable variable "$%s". Variables used in oneOf fields must be non-nullable.` ValueIsNotAnInputObjectTypeErrMsg = `Expected value of type "%s", found %s.` ) @@ -215,6 +218,40 @@ func ErrDuplicatedFieldInputObject(fieldName ast.ByteSlice, first, duplicated po return err } +func ErrOneOfInputObjectFieldCount(objName ast.ByteSlice, fieldsProvided int, position position.Position) (err ExternalError) { + err.Message = fmt.Sprintf(OneOfInputObjectFieldCountErrMsg, objName, fieldsProvided) + err.Locations = LocationsFromPosition(position) + + return err +} + +func ErrOneOfInputObjectNullValue(objName, fieldName ast.ByteSlice, fieldPosition position.Position) (err ExternalError) { + err.Message = fmt.Sprintf(OneOfInputObjectNullValueErrMsg, objName, fieldName) + err.Locations = []Location{ + { + Line: fieldPosition.LineStart, + Column: fieldPosition.CharStart, + }, + } + + return err +} +func ErrOneOfInputObjectNullableVariable(objName, fieldName, variableName ast.ByteSlice, fieldPosition, variablePosition position.Position) (err ExternalError) { + err.Message = fmt.Sprintf(OneOfInputObjectNullableVariableErrMsg, objName, fieldName, variableName) + err.Locations = []Location{ + { + Line: fieldPosition.LineStart, + Column: fieldPosition.CharStart, + }, + { + Line: variablePosition.LineStart, + Column: variablePosition.CharStart, + }, + } + + return err +} + func ErrArgumentNotDefinedOnField(argName, typeName, fieldName ast.ByteSlice, position position.Position) (err ExternalError) { err.Message = fmt.Sprintf(UnknownArgumentOnFieldErrMsg, argName, typeName, fieldName) err.Locations = LocationsFromPosition(position) diff --git a/v2/pkg/variablesvalidation/variablesvalidation.go b/v2/pkg/variablesvalidation/variablesvalidation.go index 70bb6033ba..11c6b4e49d 100644 --- a/v2/pkg/variablesvalidation/variablesvalidation.go +++ b/v2/pkg/variablesvalidation/variablesvalidation.go @@ -372,6 +372,64 @@ func (v *variablesVisitor) traverseFieldDefinitionType(fieldTypeDefinitionNodeKi v.traverseNamedTypeNode(jsonValue, v.definition.ResolveTypeNameBytes(typeRef)) } +func (v *variablesVisitor) violatesOneOfConstraint(inputObjectDefRef int, jsonValue *astjson.Value, typeName []byte) bool { + def := v.definition.InputObjectTypeDefinitions[inputObjectDefRef] + + // Check if the input object type has @oneOf directive + if !def.HasDirectives { + return false + } + hasOneOfDirective := def.Directives.HasDirectiveByName(v.definition, "oneOf") + if !hasOneOfDirective { + return false + } + + obj := jsonValue.GetObject() + totalFieldCount := obj.Len() + + // Prioritize the count error + if totalFieldCount != 1 { + variableContent := string(jsonValue.MarshalTo(nil)) + var path string + if len(v.path) > 1 { + path = fmt.Sprintf(` at "%s"`, v.renderPath()) + } + v.err = v.newInvalidVariableError( + fmt.Sprintf(`%s%s; OneOf input object "%s" must have exactly one field provided, but %d fields were provided.`, + v.invalidValueMessage(string(v.currentVariableName), variableContent), + path, + string(typeName), + totalFieldCount)) + return true + } + + // Check if the single field has a null value + var nullFieldName []byte + obj.Visit(func(key []byte, val *astjson.Value) { + if val.Type() == astjson.TypeNull { + nullFieldName = key + } + }) + + if nullFieldName == nil { + // We have exactly one field, and it's non-null + return false + } + + variableContent := string(jsonValue.MarshalTo(nil)) + var path string + if len(v.path) > 1 { + path = fmt.Sprintf(` at "%s"`, v.renderPath()) + } + v.err = v.newInvalidVariableError( + fmt.Sprintf(`%s%s; OneOf input object "%s" field "%s" value must be non-null.`, + v.invalidValueMessage(string(v.currentVariableName), variableContent), + path, + string(typeName), + string(nullFieldName))) + return true +} + func (v *variablesVisitor) traverseNamedTypeNode(jsonValue *astjson.Value, typeName []byte) { if v.err != nil { return @@ -415,6 +473,9 @@ func (v *variablesVisitor) traverseNamedTypeNode(jsonValue *astjson.Value, typeN return } } + if v.violatesOneOfConstraint(fieldTypeDefinitionNode.Ref, jsonValue, typeName) { + return // Error already reported + } case ast.NodeKindScalarTypeDefinition: switch unsafebytes.BytesToString(typeName) { case "String": diff --git a/v2/pkg/variablesvalidation/variablesvalidation_test.go b/v2/pkg/variablesvalidation/variablesvalidation_test.go index 754a2475ad..2e707c3f51 100644 --- a/v2/pkg/variablesvalidation/variablesvalidation_test.go +++ b/v2/pkg/variablesvalidation/variablesvalidation_test.go @@ -1491,6 +1491,230 @@ func TestVariablesValidation(t *testing.T) { err := runTest(t, tc) require.NoError(t, err) }) + + t.Run("oneOf input objects", func(t *testing.T) { + catDogSchema := ` + input PetInput @oneOf { + cat: String, dog: String + } + type Query { + pet(input: PetInput!): String + }` + t.Run("valid cases", func(t *testing.T) { + t.Run("exactly one field provided", func(t *testing.T) { + tc := testCase{ + schema: catDogSchema, + operation: `query($input: PetInput!) { pet(input: $input) }`, + variables: `{"input": {"cat": "Fluffy"}}`, + } + err := runTest(t, tc) + require.NoError(t, err) + }) + t.Run("exactly one field provided - different field", func(t *testing.T) { + tc := testCase{ + schema: catDogSchema, + operation: `query($input: PetInput!) { pet(input: $input) }`, + variables: `{"input": {"dog": "Rex"}}`, + } + err := runTest(t, tc) + require.NoError(t, err) + }) + t.Run("exactly one field provided - via nested var", func(t *testing.T) { + tc := testCase{ + schema: catDogSchema, + operation: `query($s: String) { pet(input: {cat: $s} ) }`, + variables: `{"s": "Rex"}`, + } + err := runTest(t, tc) + require.NoError(t, err) + }) + t.Run("exactly one field with complex type", func(t *testing.T) { + tc := testCase{ + schema: `input CatInput { name: String! } input PetInput @oneOf { cat: CatInput, dog: String } type Query { pet(input: PetInput!): String }`, + operation: `query($input: PetInput!) { pet(input: $input) }`, + variables: `{"input": {"cat": {"name": "Fluffy"}}}`, + } + err := runTest(t, tc) + require.NoError(t, err) + }) + }) + + t.Run("invalid cases", func(t *testing.T) { + t.Run("no fields provided", func(t *testing.T) { + tc := testCase{ + schema: catDogSchema, + operation: `query($input: PetInput!) { pet(input: $input) }`, + variables: `{"input": {}}`, + } + err := runTest(t, tc) + require.Error(t, err) + assert.Contains(t, err.Error(), `OneOf input object "PetInput" must have exactly one field provided, but 0 fields were provided`) + }) + t.Run("multiple fields provided", func(t *testing.T) { + tc := testCase{ + schema: catDogSchema, + operation: `query($input: PetInput!) { pet(input: $input) }`, + variables: `{"input": {"cat": "Fluffy", "dog": "Rex"}}`, + } + err := runTest(t, tc) + require.Error(t, err) + assert.Contains(t, err.Error(), `OneOf input object "PetInput" must have exactly one field provided, but 2 fields were provided`) + }) + t.Run("multiple fields with one null", func(t *testing.T) { + tc := testCase{ + schema: catDogSchema, + operation: `query($input: PetInput!) { pet(input: $input) }`, + variables: `{"input": {"cat": "Fluffy", "dog": null}}`, + } + err := runTest(t, tc) + require.Error(t, err) + assert.Contains(t, err.Error(), `Variable "$input" got invalid value; OneOf input object "PetInput" must have exactly one field provided, but 2 fields were provided`) + }) + t.Run("one field provided but null", func(t *testing.T) { + tc := testCase{ + schema: catDogSchema, + operation: `query($input: PetInput!) { pet(input: $input) }`, + variables: `{"input": {"dog": null}}`, + } + err := runTest(t, tc) + require.Error(t, err) + assert.Contains(t, err.Error(), `OneOf input object "PetInput" field "dog" value must be non-null`) + }) + + t.Run("all fields null", func(t *testing.T) { + tc := testCase{ + schema: catDogSchema, + operation: `query($input: PetInput!) { pet(input: $input) }`, + variables: `{"input": {"cat": null, "dog": null}}`, + } + err := runTest(t, tc) + require.Error(t, err) + assert.Contains(t, err.Error(), `Variable "$input" got invalid value; OneOf input object "PetInput" must have exactly one field provided, but 2 fields were provided`) + }) + }) + + t.Run("nested oneOf input objects", func(t *testing.T) { + t.Run("valid nested oneOf", func(t *testing.T) { + tc := testCase{ + schema: ` + input PetInput @oneOf { cat: String, dog: String } + input SearchInput @oneOf { byName: String, byPet: PetInput } + type Query { search(input: SearchInput!): String } + `, + operation: `query($input: SearchInput!) { search(input: $input) }`, + variables: `{"input": {"byPet": {"cat": "Fluffy"}}}`, + } + err := runTest(t, tc) + require.NoError(t, err) + }) + + t.Run("invalid nested oneOf - inner violation", func(t *testing.T) { + tc := testCase{ + schema: ` + input PetInput @oneOf { cat: String, dog: String } + input SearchInput @oneOf { byName: String, byPet: PetInput } + type Query { search(input: SearchInput!): String } + `, + operation: `query($input: SearchInput!) { search(input: $input) }`, + variables: `{"input": {"byPet": {"cat": "Fluffy", "dog": "Rex"}}}`, + } + err := runTest(t, tc) + require.Error(t, err) + assert.Contains(t, err.Error(), `Variable "$input" got invalid value at "input.byPet"; OneOf input object "PetInput" must have exactly one field provided, but 2 fields were provided`) + }) + + t.Run("invalid nested oneOf - outer violation", func(t *testing.T) { + tc := testCase{ + schema: ` + input PetInput @oneOf { cat: String, dog: String } + input SearchInput @oneOf { byName: String, byPet: PetInput } + type Query { search(input: SearchInput!): String } + `, + operation: `query($input: SearchInput!) { search(input: $input) }`, + variables: `{"input": {"byName": "Fluffy", "byPet": {"cat": "Fluffy"}}}`, + } + err := runTest(t, tc) + require.Error(t, err) + assert.Contains(t, err.Error(), `OneOf input object "SearchInput" must have exactly one field provided, but 2 fields were provided`) + }) + }) + + t.Run("oneOf with lists", func(t *testing.T) { + t.Run("valid list of oneOf", func(t *testing.T) { + tc := testCase{ + schema: ` + input PetInput @oneOf { cat: String, dog: String } + type Query { pets(input: [PetInput!]!): String }`, + operation: `query($input: [PetInput!]!) { pets(input: $input) }`, + variables: `{"input": [{"cat": "Fluffy"}, {"dog": "Rex"}]}`, + } + err := runTest(t, tc) + require.NoError(t, err) + }) + t.Run("invalid list of oneOf - one element violates", func(t *testing.T) { + tc := testCase{ + schema: ` + input PetInput @oneOf { cat: String, dog: String } + type Query { pets(input: [PetInput!]!): String }`, + operation: `query($input: [PetInput!]!) { pets(input: $input) }`, + variables: `{"input": [{"cat": "Fluffy"}, {"cat": "Whiskers", "dog": "Rex"}]}`, + } + err := runTest(t, tc) + require.Error(t, err) + assert.Contains(t, err.Error(), `OneOf input object "PetInput" must have exactly one field provided, but 2 fields were provided`) + }) + t.Run("oneOf field is list", func(t *testing.T) { + tc := testCase{ + schema: ` + input SearchInput @oneOf { names: [String!], ids: [ID!] } + type Query { search(input: SearchInput!): String }`, + operation: `query($input: SearchInput!) { search(input: $input) }`, + variables: `{"input": {"names": ["Fluffy", "Rex"]}}`, + } + err := runTest(t, tc) + require.NoError(t, err) + }) + t.Run("oneOf field is an empty list", func(t *testing.T) { + tc := testCase{ + schema: ` + input SearchInput @oneOf { names: [String!], ids: [ID!] } + type Query { search(input: SearchInput!): String }`, + operation: `query($input: SearchInput!) { search(input: $input) }`, + variables: `{"input": {"names": []}}`, + } + err := runTest(t, tc) + require.NoError(t, err) + }) + }) + + t.Run("oneOf with variables content enabled", func(t *testing.T) { + t.Run("multiple fields error with content", func(t *testing.T) { + tc := testCase{ + schema: `input PetInput @oneOf { cat: String, dog: String } type Query { pet(input: PetInput!): String }`, + operation: `query($input: PetInput!) { pet(input: $input) }`, + variables: `{"input": {"cat": "Fluffy", "dog": "Rex"}}`, + } + err := runTestWithVariablesContentEnabled(t, tc) + require.Error(t, err) + assert.Contains(t, err.Error(), `Variable "$input" got invalid value {"cat":"Fluffy","dog":"Rex"}`) + assert.Contains(t, err.Error(), `OneOf input object "PetInput" must have exactly one field provided, but 2 fields were provided`) + }) + t.Run("nested oneOf error shows the value", func(t *testing.T) { + tc := testCase{ + schema: ` + input PetInput @oneOf { cat: String, dog: String } + input SearchInput { pets: [PetInput!] } + type Query { search(input: SearchInput!): String } + `, + operation: `query($input: SearchInput!) { search(input: $input) }`, + variables: `{"input": {"pets": [{"cat": "Fluffy"}, {"cat": null}]}}`, + } + err := runTestWithVariablesContentEnabled(t, tc) + require.Error(t, err) + assert.Contains(t, err.Error(), `Variable "$input" got invalid value {"cat":null} at "input.pets.[1]"; OneOf input object "PetInput" field "cat" value must be non-null`) + }) + }) + }) } type testCase struct {