diff --git a/Sources/GraphQL/Type/Definition.swift b/Sources/GraphQL/Type/Definition.swift index 2302670d..8c96b1c3 100644 --- a/Sources/GraphQL/Type/Definition.swift +++ b/Sources/GraphQL/Type/Definition.swift @@ -169,6 +169,7 @@ extension GraphQLNonNull: GraphQLWrapperType {} public final class GraphQLScalarType { public let name: String public let description: String? + public let specifiedByURL: String? public let kind: TypeKind = .scalar let serialize: (Any) throws -> Map @@ -178,6 +179,7 @@ public final class GraphQLScalarType { public init( name: String, description: String? = nil, + specifiedByURL: String? = nil, serialize: @escaping (Any) throws -> Map, parseValue: ((Map) throws -> Map)? = nil, parseLiteral: ((Value) throws -> Map)? = nil @@ -185,6 +187,7 @@ public final class GraphQLScalarType { try assertValid(name: name) self.name = name self.description = description + self.specifiedByURL = specifiedByURL self.serialize = serialize self.parseValue = parseValue ?? defaultParseValue self.parseLiteral = parseLiteral ?? defaultParseLiteral @@ -218,6 +221,7 @@ extension GraphQLScalarType: Encodable { private enum CodingKeys: String, CodingKey { case name case description + case specifiedByURL case kind } } @@ -229,6 +233,8 @@ extension GraphQLScalarType: KeySubscriptable { return name case CodingKeys.description.rawValue: return description + case CodingKeys.specifiedByURL.rawValue: + return specifiedByURL case CodingKeys.kind.rawValue: return kind default: @@ -1217,12 +1223,14 @@ public final class GraphQLInputObjectType { public let name: String public let description: String? public let fields: InputObjectFieldDefinitionMap + public let isOneOf: Bool public let kind: TypeKind = .inputObject public init( name: String, description: String? = nil, - fields: InputObjectFieldMap = [:] + fields: InputObjectFieldMap = [:], + isOneOf: Bool = false ) throws { try assertValid(name: name) self.name = name @@ -1231,6 +1239,7 @@ public final class GraphQLInputObjectType { name: name, fields: fields ) + self.isOneOf = isOneOf } func replaceTypeReferences(typeMap: TypeMap) throws { @@ -1245,6 +1254,7 @@ extension GraphQLInputObjectType: Encodable { case name case description case fields + case isOneOf case kind } } @@ -1258,6 +1268,8 @@ extension GraphQLInputObjectType: KeySubscriptable { return description case CodingKeys.fields.rawValue: return fields + case CodingKeys.isOneOf.rawValue: + return isOneOf case CodingKeys.kind.rawValue: return kind default: diff --git a/Sources/GraphQL/Type/Directives.swift b/Sources/GraphQL/Type/Directives.swift index 04cfb352..77bde6bd 100644 --- a/Sources/GraphQL/Type/Directives.swift +++ b/Sources/GraphQL/Type/Directives.swift @@ -123,6 +123,31 @@ public let GraphQLDeprecatedDirective = try! GraphQLDirective( ] ) +/** + * Used to provide a URL for specifying the behavior of custom scalar definitions. + */ +public let GraphQLSpecifiedByDirective = try! GraphQLDirective( + name: "specifiedBy", + description: "Exposes a URL that specifies the behavior of this scalar.", + locations: [.scalar], + args: [ + "url": GraphQLArgument( + type: GraphQLNonNull(GraphQLString), + description: "The URL that specifies the behavior of this scalar." + ), + ] +) + +/** + * Used to indicate an Input Object is a OneOf Input Object. + */ +public let GraphQLOneOfDirective = try! GraphQLDirective( + name: "oneOf", + description: "Indicates exactly one field must be supplied and this field must not be `null`.", + locations: [.inputObject], + args: [:] +) + /** * The full list of specified directives. */ @@ -130,4 +155,6 @@ let specifiedDirectives: [GraphQLDirective] = [ GraphQLIncludeDirective, GraphQLSkipDirective, GraphQLDeprecatedDirective, + GraphQLSpecifiedByDirective, + GraphQLOneOfDirective, ] diff --git a/Sources/GraphQL/Type/Introspection.swift b/Sources/GraphQL/Type/Introspection.swift index 23892996..013433d6 100644 --- a/Sources/GraphQL/Type/Introspection.swift +++ b/Sources/GraphQL/Type/Introspection.swift @@ -185,7 +185,7 @@ let __Type: GraphQLObjectType = try! GraphQLObjectType( "many kinds of types in GraphQL as represented by the `__TypeKind` enum." + "\n\nDepending on the kind of a type, certain fields describe " + "information about that type. Scalar types provide no information " + - "beyond a name and description, while Enum types provide their values. " + + "beyond a name and description and optional `specifiedByURL`, while Enum types provide their values. " + "Object and Interface types provide the fields they describe. Abstract " + "types, Union and Interface, provide the Object types possible " + "at runtime. List and NonNull types compose other types.", @@ -217,6 +217,7 @@ let __Type: GraphQLObjectType = try! GraphQLObjectType( ), "name": GraphQLField(type: GraphQLString), "description": GraphQLField(type: GraphQLString), + "specifiedByURL": GraphQLField(type: GraphQLString), "fields": GraphQLField( type: GraphQLList(GraphQLNonNull(__Field)), args: [ @@ -310,6 +311,15 @@ let __Type: GraphQLObjectType = try! GraphQLObjectType( } ), "ofType": GraphQLField(type: GraphQLTypeReference("__Type")), + "isOneOf": GraphQLField( + type: GraphQLBoolean, + resolve: { type, _, _, _ in + if let type = type as? GraphQLInputObjectType { + return type.isOneOf + } + return false + } + ), ] ) diff --git a/Sources/GraphQL/Utilities/IsValidValue.swift b/Sources/GraphQL/Utilities/IsValidValue.swift index 9e236eed..581c169d 100644 --- a/Sources/GraphQL/Utilities/IsValidValue.swift +++ b/Sources/GraphQL/Utilities/IsValidValue.swift @@ -63,6 +63,22 @@ func validate(value: Map, forType type: GraphQLInputType) throws -> [String] { } } + // Ensure only one field in oneOf input is defined + if objectType.isOneOf { + let keys = dictionary.filter { $1 != .undefined }.keys + if keys.count != 1 { + errors.append( + "Exactly one key must be specified for OneOf type \"\(objectType.name)\"." + ) + } + + let key = keys[0] + let value = dictionary[key] + if value == .null { + errors.append("Field \"\(key)\" must be non-null.") + } + } + // Ensure every defined field is valid. for (fieldName, field) in fields { let newErrors = try validate(value: value[fieldName], forType: field.type).map { diff --git a/Sources/GraphQL/Utilities/ValueFromAST.swift b/Sources/GraphQL/Utilities/ValueFromAST.swift index 098ee1f6..ae5baf42 100644 --- a/Sources/GraphQL/Utilities/ValueFromAST.swift +++ b/Sources/GraphQL/Utilities/ValueFromAST.swift @@ -99,6 +99,18 @@ func valueFromAST( } } } + + if objectType.isOneOf { + let keys = object.filter { $1 != .undefined }.keys + if keys.count != 1 { + return .undefined // Invalid: not exactly one key, intentionally return no value. + } + + if object[keys[0]] == .null { + return .undefined // Invalid: value not non-null, intentionally return no value. + } + } + return .dictionary(object) } diff --git a/Sources/GraphQL/Validation/Rules/ValuesOfCorrectTypeRule.swift b/Sources/GraphQL/Validation/Rules/ValuesOfCorrectTypeRule.swift index 9cfcb7bb..b90013dc 100644 --- a/Sources/GraphQL/Validation/Rules/ValuesOfCorrectTypeRule.swift +++ b/Sources/GraphQL/Validation/Rules/ValuesOfCorrectTypeRule.swift @@ -37,8 +37,9 @@ func ValuesOfCorrectTypeRule(context: ValidationContext) -> Visitor { return .break // Don't traverse further. } // Ensure every required field exists. - let fieldNodeMap = Dictionary(grouping: object.fields) { field in - field.name.value + var fieldNodeMap = [String: ObjectField]() + for field in object.fields { + fieldNodeMap[field.name.value] = field } for (fieldName, fieldDef) in type.fields { if fieldNodeMap[fieldName] == nil, isRequiredInputField(fieldDef) { @@ -52,7 +53,15 @@ func ValuesOfCorrectTypeRule(context: ValidationContext) -> Visitor { } } - // TODO: Add oneOf support + if type.isOneOf { + validateOneOfInputObject( + context: context, + node: object, + type: type, + fieldNodeMap: fieldNodeMap, + variableDefinitions: variableDefinitions + ) + } return .continue } if let field = node as? ObjectField { @@ -172,3 +181,55 @@ func isValidValueNode(_ context: ValidationContext, _ node: Value) { } } } + +func validateOneOfInputObject( + context: ValidationContext, + node: ObjectValue, + type: GraphQLInputObjectType, + fieldNodeMap: [String: ObjectField], + variableDefinitions: [String: VariableDefinition] +) { + let keys = Array(fieldNodeMap.keys) + let isNotExactlyOneField = keys.count != 1 + + if isNotExactlyOneField { + context.report( + error: GraphQLError( + message: "OneOf Input Object \"\(type.name)\" must specify exactly one key.", + nodes: [node] + ) + ) + return + } + + let value = fieldNodeMap[keys[0]]?.value + let isNullLiteral = value == nil || value?.kind == .nullValue + + if isNullLiteral { + context.report( + error: GraphQLError( + message: "Field \"\(type.name).\(keys[0])\" must be non-null.", + nodes: [node] + ) + ) + return + } + + if let value = value, value.kind == .variable { + let variable = value as! Variable // Force unwrap is safe because of variable definition + let variableName = variable.name.value + + if + let definition = variableDefinitions[variableName], + definition.type.kind != .nonNullType + { + context.report( + error: GraphQLError( + message: "Variable \"\(variableName)\" must be non-nullable to be used for OneOf Input Object \"\(type.name)\".", + nodes: [node] + ) + ) + return + } + } +} diff --git a/Tests/GraphQLTests/ExecutionTests/OneOfTests.swift b/Tests/GraphQLTests/ExecutionTests/OneOfTests.swift new file mode 100644 index 00000000..1b6a50d9 --- /dev/null +++ b/Tests/GraphQLTests/ExecutionTests/OneOfTests.swift @@ -0,0 +1,204 @@ +@testable import GraphQL +import NIO +import XCTest + +class OneOfTests: XCTestCase { + let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) + + // MARK: OneOf Input Objects + + func testAcceptsAGoodDefaultValue() throws { + let query = """ + query ($input: TestInputObject! = {a: "abc"}) { + test(input: $input) { + a + b + } + } + """ + let result = try graphql( + schema: getSchema(), + request: query, + eventLoopGroup: eventLoopGroup + ).wait() + XCTAssertEqual( + result, + GraphQLResult(data: [ + "test": [ + "a": "abc", + "b": .null, + ], + ]) + ) + } + + func testRejectsABadDefaultValue() throws { + let query = """ + query ($input: TestInputObject! = {a: "abc", b: 123}) { + test(input: $input) { + a + b + } + } + """ + let result = try graphql( + schema: getSchema(), + request: query, + eventLoopGroup: eventLoopGroup + ).wait() + XCTAssertEqual(result.errors.count, 1) + XCTAssertEqual( + result.errors[0].message, + "OneOf Input Object \"TestInputObject\" must specify exactly one key." + ) + } + + func testAcceptsAGoodVariable() throws { + let query = """ + query ($input: TestInputObject!) { + test(input: $input) { + a + b + } + } + """ + let result = try graphql( + schema: getSchema(), + request: query, + eventLoopGroup: eventLoopGroup, + variableValues: ["input": ["a": "abc"]] + ).wait() + XCTAssertEqual( + result, + GraphQLResult(data: [ + "test": [ + "a": "abc", + "b": .null, + ], + ]) + ) + } + + func testAcceptsAGoodVariableWithAnUndefinedKey() throws { + let query = """ + query ($input: TestInputObject!) { + test(input: $input) { + a + b + } + } + """ + let result = try graphql( + schema: getSchema(), + request: query, + eventLoopGroup: eventLoopGroup, + variableValues: ["input": ["a": "abc", "b": .undefined]] + ).wait() + XCTAssertEqual( + result, + GraphQLResult(data: [ + "test": [ + "a": "abc", + "b": .null, + ], + ]) + ) + } + + func testRejectsAVariableWithMultipleNonNullKeys() throws { + let query = """ + query ($input: TestInputObject!) { + test(input: $input) { + a + b + } + } + """ + let result = try graphql( + schema: getSchema(), + request: query, + eventLoopGroup: eventLoopGroup, + variableValues: ["input": ["a": "abc", "b": 123]] + ).wait() + XCTAssertEqual(result.errors.count, 1) + XCTAssertEqual( + result.errors[0].message, + """ + Variable "$input" got invalid value "{"a":"abc","b":123}". + Exactly one key must be specified for OneOf type "TestInputObject". + """ + ) + } + + func testRejectsAVariableWithMultipleNullableKeys() throws { + let query = """ + query ($input: TestInputObject!) { + test(input: $input) { + a + b + } + } + """ + let result = try graphql( + schema: getSchema(), + request: query, + eventLoopGroup: eventLoopGroup, + variableValues: ["input": ["a": "abc", "b": .null]] + ).wait() + XCTAssertEqual(result.errors.count, 1) + XCTAssertEqual( + result.errors[0].message, + """ + Variable "$input" got invalid value "{"a":"abc","b":null}". + Exactly one key must be specified for OneOf type "TestInputObject". + """ + ) + } +} + +func getSchema() throws -> GraphQLSchema { + let testObject = try GraphQLObjectType( + name: "TestObject", + fields: [ + "a": GraphQLField(type: GraphQLString), + "b": GraphQLField(type: GraphQLInt), + ], + isTypeOf: { source, _, _ in + source is TestObject + } + ) + let testInputObject = try GraphQLInputObjectType( + name: "TestInputObject", + fields: [ + "a": InputObjectField(type: GraphQLString), + "b": InputObjectField(type: GraphQLInt), + ], + isOneOf: true + ) + let schema = try GraphQLSchema( + query: GraphQLObjectType( + name: "Query", + fields: [ + "test": GraphQLField( + type: testObject, + args: [ + "input": GraphQLArgument(type: GraphQLNonNull(testInputObject)), + ], + resolve: { _, args, _, _ in + try MapDecoder().decode(TestObject.self, from: args["input"]) + } + ), + ] + ), + types: [ + testObject, + testInputObject, + ] + ) + return schema +} + +struct TestObject: Codable { + let a: String? + let b: Int? +} diff --git a/Tests/GraphQLTests/ValidationTests/ExampleSchema.swift b/Tests/GraphQLTests/ValidationTests/ExampleSchema.swift index fa7a0e08..476998bf 100644 --- a/Tests/GraphQLTests/ValidationTests/ExampleSchema.swift +++ b/Tests/GraphQLTests/ValidationTests/ExampleSchema.swift @@ -402,11 +402,11 @@ let ValidationExampleComplexInput = try! GraphQLInputObjectType( // } let ValidationExampleOneOfInput = try! GraphQLInputObjectType( name: "OneOfInput", - // TODO: Add @oneOf directive fields: [ - "stringField": InputObjectField(type: GraphQLBoolean), + "stringField": InputObjectField(type: GraphQLString), "intField": InputObjectField(type: GraphQLInt), - ] + ], + isOneOf: true ) // type ComplicatedArgs { diff --git a/Tests/GraphQLTests/ValidationTests/ValuesOfCorrectTypeRuleTests.swift b/Tests/GraphQLTests/ValidationTests/ValuesOfCorrectTypeRuleTests.swift index 5755223c..6f024b00 100644 --- a/Tests/GraphQLTests/ValidationTests/ValuesOfCorrectTypeRuleTests.swift +++ b/Tests/GraphQLTests/ValidationTests/ValuesOfCorrectTypeRuleTests.swift @@ -1024,17 +1024,29 @@ class ValuesOfCorrectTypeRuleTests: ValidationTestCase { // MARK: Valid oneOf Object Value -// func testExactlyOneField() throws { -// try assertValid( -// """ -// { -// complicatedArgs { -// oneOfArgField(oneOfArg: { stringField: "abc" }) -// } -// } -// """ -// ) -// } + func testExactlyOneField() throws { + try assertValid( + """ + { + complicatedArgs { + oneOfArgField(oneOfArg: { stringField: "abc" }) + } + } + """ + ) + } + + func testExactlyOneNonNullableVariable() throws { + try assertValid( + """ + query ($string: String!) { + complicatedArgs { + oneOfArgField(oneOfArg: { stringField: $string }) + } + } + """ + ) + } // MARK: Invalid input object value @@ -1240,7 +1252,87 @@ class ValuesOfCorrectTypeRuleTests: ValidationTestCase { XCTAssertEqual(errors, []) } - // MARK: Invalid oneOf input object value TODO + // MARK: Invalid oneOf input object value + + func testInvalidFieldType() throws { + let errors = try assertInvalid( + errorCount: 1, + query: + """ + { + complicatedArgs { + oneOfArgField(oneOfArg: { stringField: 2 }) + } + } + """ + ) + + try assertValidationError( + error: errors[0], + locations: [(line: 3, column: 44)], + message: #"String cannot represent a non-string value: 2"# + ) + } + + func testExactlyOneNullField() throws { + let errors = try assertInvalid( + errorCount: 1, + query: + """ + { + complicatedArgs { + oneOfArgField(oneOfArg: { stringField: null }) + } + } + """ + ) + + try assertValidationError( + error: errors[0], + locations: [(line: 3, column: 29)], + message: #"Field "OneOfInput.stringField" must be non-null."# + ) + } + + func testExactlyOneNullableVariable() throws { + let errors = try assertInvalid( + errorCount: 1, + query: + """ + query ($string: String) { + complicatedArgs { + oneOfArgField(oneOfArg: { stringField: $string }) + } + } + """ + ) + + try assertValidationError( + error: errors[0], + locations: [(line: 3, column: 29)], + message: #"Variable "string" must be non-nullable to be used for OneOf Input Object "OneOfInput"."# + ) + } + + func testMoreThanOneField() throws { + let errors = try assertInvalid( + errorCount: 1, + query: + """ + { + complicatedArgs { + oneOfArgField(oneOfArg: { stringField: "abc", intField: 123 }) + } + } + """ + ) + + try assertValidationError( + error: errors[0], + locations: [(line: 3, column: 29)], + message: #"OneOf Input Object "OneOfInput" must specify exactly one key."# + ) + } // MARK: Directive arguments