diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 00000000..b5c3d3ce --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,76 @@ +# Migration + +## 2.0 to 3.0 + +### TypeReference removal + +The `GraphQLTypeReference` type was removed in v3.0.0, since it was made unnecessary by introducing closure-based `field` API that allows the package to better control the order of type resolution. + +To remove `GraphQLTypeReference`, you can typically just replace it with a reference to the `GraphQLObjectType` instance: + +```swift +// Before +let object1 = try GraphQLObjectType( + name: "Object1" +) +let object2 = try GraphQLObjectType( + name: "Object2" + fields: ["object1": GraphQLField(type: GraphQLTypeReference("Object1"))] +) + +// After +let object1 = try GraphQLObjectType( + name: "Object1" +) +let object2 = try GraphQLObjectType( + name: "Object2" + fields: ["object1": GraphQLField(type: object1)] +) +``` + +For more complex cyclic or recursive types, simply create the types first and assign the `fields` property afterward. Here's an example: + +```swift +// Before +let object1 = try GraphQLObjectType( + name: "Object1" + fields: ["object2": GraphQLField(type: GraphQLTypeReference("Object2"))] +) +let object2 = try GraphQLObjectType( + name: "Object2" + fields: ["object1": GraphQLField(type: GraphQLTypeReference("Object1"))] +) + +// After +let object1 = try GraphQLObjectType(name: "Object1") +let object2 = try GraphQLObjectType(name: "Object2") +object1.fields = { [weak object2] in + guard let object2 = object2 else { return [:] } + return ["object2": GraphQLField(type: object2)] +} +object2.fields = { [weak object1] in + guard let object1 = object1 else { return [:] } + return ["object1": GraphQLField(type: object1)] +} +``` + +Note that this also gives you the chance to explicitly handle the memory cycle that cyclic types cause as well. + +### Type Definition Arrays + +The following type properties were changed from arrays to closures. To get the array version, in most cases you can just call the `get`-style function (i.e. for `GraphQLObject.fields`, use `GraphQLObject.getFields()`): + +- `GraphQLObjectType.fields` +- `GraphQLObjectType.interfaces` +- `GraphQLInterfaceType.fields` +- `GraphQLInterfaceType.interfaces` +- `GraphQLUnionType.types` +- `GraphQLInputObjectType.fields` + +### Directive description is optional + +`GraphQLDirective` has changed from a struct to a class, and its `description` property is now optional. + +### GraphQL type codability + +With GraphQL type definitions now including closures, many of the objects in [Definition](https://github.com/GraphQLSwift/GraphQL/blob/main/Sources/GraphQL/Type/Definition.swift) are no longer codable. If you are depending on codability, you can conform the type appropriately in your downstream package. \ No newline at end of file diff --git a/Package.resolved b/Package.resolved index 838f977e..48cc3bd8 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "cd142fd2f64be2100422d658e7411e39489da985", + "version" : "1.2.0" + } + }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", @@ -17,6 +26,15 @@ "revision" : "4c4453b489cf76e6b3b0f300aba663eb78182fad", "version" : "2.70.0" } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "c8a44d836fe7913603e246acab7c528c2e780168", + "version" : "1.4.0" + } } ], "version" : 2 diff --git a/README.md b/README.md index e4698313..87aeef25 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # GraphQL [![Swift][swift-badge]][swift-url] +[![SSWG][sswg-badge]][sswg-url] [![License][mit-badge]][mit-url] -[![GitHub Actions][gh-actions-badge]][gh-actions-url] [![Codebeat][codebeat-badge]][codebeat-url] + The Swift implementation for GraphQL, a query language for APIs created by Facebook. Looking for help? Find resources [from the community](http://graphql.org/community/). @@ -123,6 +124,8 @@ should be encoded using the `GraphQLJSONEncoder` provided by this package. This package supports Swift versions in [alignment with Swift NIO](https://github.com/apple/swift-nio?tab=readme-ov-file#swift-versions). +For details on upgrading to new major versions, see [MIGRATION](MIGRATION.md). + ## Contributing If you think you have found a security vulnerability, please follow the @@ -156,9 +159,12 @@ missing, looking at the original code and "translating" it to Swift works, most This project is released under the MIT license. See [LICENSE](LICENSE) for details. -[swift-badge]: https://img.shields.io/badge/Swift-5.5-orange.svg?style=flat +[swift-badge]: https://img.shields.io/badge/Swift-5.10-orange.svg?style=flat [swift-url]: https://swift.org +[sswg-badge]: https://img.shields.io/badge/sswg-incubating-blue.svg?style=flat +[sswg-url]: https://swift.org/sswg/incubation-process.html#incubating-level + [mit-badge]: https://img.shields.io/badge/License-MIT-blue.svg?style=flat [mit-url]: https://tldrlegal.com/license/mit-license diff --git a/Sources/GraphQL/Execution/Execute.swift b/Sources/GraphQL/Execution/Execute.swift index 4abeffb6..7794f1c7 100644 --- a/Sources/GraphQL/Execution/Execute.swift +++ b/Sources/GraphQL/Execution/Execute.swift @@ -466,7 +466,14 @@ func getOperationRootType( ) throws -> GraphQLObjectType { switch operation.operation { case .query: - return schema.queryType + guard let queryType = schema.queryType else { + throw GraphQLError( + message: "Schema is not configured for queries", + nodes: [operation] + ) + } + + return queryType case .mutation: guard let mutationType = schema.mutationType else { throw GraphQLError( @@ -1191,6 +1198,14 @@ func defaultResolve( let value = subscriptable[info.fieldName] return eventLoopGroup.next().makeSucceededFuture(value) } + if let subscriptable = source as? [String: Any] { + let value = subscriptable[info.fieldName] + return eventLoopGroup.next().makeSucceededFuture(value) + } + if let subscriptable = source as? OrderedDictionary { + let value = subscriptable[info.fieldName] + return eventLoopGroup.next().makeSucceededFuture(value) + } let mirror = Mirror(reflecting: source) guard let value = mirror.getValue(named: info.fieldName) else { @@ -1213,16 +1228,16 @@ func getFieldDef( parentType: GraphQLObjectType, fieldName: String ) throws -> GraphQLFieldDefinition { - if fieldName == SchemaMetaFieldDef.name, schema.queryType.name == parentType.name { + if fieldName == SchemaMetaFieldDef.name, schema.queryType?.name == parentType.name { return SchemaMetaFieldDef - } else if fieldName == TypeMetaFieldDef.name, schema.queryType.name == parentType.name { + } else if fieldName == TypeMetaFieldDef.name, schema.queryType?.name == parentType.name { return TypeMetaFieldDef } else if fieldName == TypeNameMetaFieldDef.name { return TypeNameMetaFieldDef } // This field should exist because we passed validation before execution - guard let fieldDefinition = parentType.fields[fieldName] else { + guard let fieldDefinition = try parentType.getFields()[fieldName] else { throw GraphQLError( message: "Expected field definition not found: '\(fieldName)' on '\(parentType.name)'" ) diff --git a/Sources/GraphQL/Execution/Values.swift b/Sources/GraphQL/Execution/Values.swift index 4ca05b90..4de6e2c9 100644 --- a/Sources/GraphQL/Execution/Values.swift +++ b/Sources/GraphQL/Execution/Values.swift @@ -88,7 +88,7 @@ func getVariableValue( definitionAST: VariableDefinition, input: Map ) throws -> Map { - let type = typeFromAST(schema: schema, inputTypeAST: definitionAST.type) + var type = typeFromAST(schema: schema, inputTypeAST: definitionAST.type) let variable = definitionAST.variable guard let inputType = type as? GraphQLInputType else { @@ -157,7 +157,7 @@ func coerceValue(value: Map, type: GraphQLInputType) throws -> Map { throw GraphQLError(message: "Must be dictionary to extract to an input type") } - let fields = objectType.fields + let fields = try objectType.getFields() var object = OrderedDictionary() for (fieldName, field) in fields { @@ -184,3 +184,34 @@ func coerceValue(value: Map, type: GraphQLInputType) throws -> Map { throw GraphQLError(message: "Provided type is not an input type") } + +/** + * Prepares an object map of argument values given a directive definition + * and a AST node which may contain directives. Optionally also accepts a map + * of variable values. + * + * If the directive does not exist on the node, returns undefined. + * + * Note: The returned value is a plain Object with a prototype, since it is + * exposed to user code. Care should be taken to not pull values from the + * Object prototype. + */ +func getDirectiveValues( + directiveDef: GraphQLDirective, + directives: [Directive], + variableValues: [String: Map] = [:] +) throws -> Map? { + let directiveNode = directives.find { directive in + directive.name.value == directiveDef.name + } + + if let directiveNode = directiveNode { + return try getArgumentValues( + argDefs: directiveDef.args, + argASTs: directiveNode.arguments, + variables: variableValues + ) + } + + return nil +} diff --git a/Sources/GraphQL/Language/AST.swift b/Sources/GraphQL/Language/AST.swift index 698ac207..97274af3 100644 --- a/Sources/GraphQL/Language/AST.swift +++ b/Sources/GraphQL/Language/AST.swift @@ -360,7 +360,7 @@ public func == (lhs: Definition, rhs: Definition) -> Bool { return false } -public enum OperationType: String { +public enum OperationType: String, CaseIterable { case query case mutation case subscription @@ -1739,6 +1739,19 @@ public final class SchemaDefinition { self.directives = directives self.operationTypes = operationTypes } + + public func get(key: String) -> NodeResult? { + switch key { + case "description": + return description.map { .node($0) } + case "directives": + return .array(directives) + case "operationTypes": + return .array(operationTypes) + default: + return nil + } + } } extension SchemaDefinition: Equatable { @@ -1760,6 +1773,15 @@ public final class OperationTypeDefinition { self.operation = operation self.type = type } + + public func get(key: String) -> NodeResult? { + switch key { + case "type": + return .node(type) + default: + return nil + } + } } extension OperationTypeDefinition: Equatable { @@ -1831,6 +1853,19 @@ public final class ScalarTypeDefinition { self.name = name self.directives = directives } + + public func get(key: String) -> NodeResult? { + switch key { + case "description": + return description.map { .node($0) } + case "name": + return .node(name) + case "directives": + return .array(directives) + default: + return nil + } + } } extension ScalarTypeDefinition: Equatable { @@ -1865,6 +1900,23 @@ public final class ObjectTypeDefinition { self.directives = directives self.fields = fields } + + public func get(key: String) -> NodeResult? { + switch key { + case "description": + return description.map { .node($0) } + case "name": + return .node(name) + case "interfaces": + return .array(interfaces) + case "directives": + return .array(directives) + case "fields": + return .array(fields) + default: + return nil + } + } } extension ObjectTypeDefinition: Equatable { @@ -1901,6 +1953,23 @@ public final class FieldDefinition { self.type = type self.directives = directives } + + public func get(key: String) -> NodeResult? { + switch key { + case "description": + return description.map { .node($0) } + case "name": + return .node(name) + case "arguments": + return .array(arguments) + case "type": + return .node(type) + case "directives": + return .array(directives) + default: + return nil + } + } } extension FieldDefinition: Equatable { @@ -1937,6 +2006,23 @@ public final class InputValueDefinition { self.defaultValue = defaultValue self.directives = directives } + + public func get(key: String) -> NodeResult? { + switch key { + case "description": + return description.map { .node($0) } + case "name": + return .node(name) + case "type": + return .node(type) + case "defaultValue": + return defaultValue.map { .node($0) } + case "directives": + return .array(directives) + default: + return nil + } + } } extension InputValueDefinition: Equatable { @@ -1989,6 +2075,23 @@ public final class InterfaceTypeDefinition { self.directives = directives self.fields = fields } + + public func get(key: String) -> NodeResult? { + switch key { + case "description": + return description.map { .node($0) } + case "name": + return .node(name) + case "interfaces": + return .array(interfaces) + case "directives": + return .array(directives) + case "fields": + return .array(fields) + default: + return nil + } + } } extension InterfaceTypeDefinition: Equatable { @@ -2021,6 +2124,21 @@ public final class UnionTypeDefinition { self.directives = directives self.types = types } + + public func get(key: String) -> NodeResult? { + switch key { + case "description": + return description.map { .node($0) } + case "name": + return .node(name) + case "directives": + return .array(directives) + case "types": + return .array(types) + default: + return nil + } + } } extension UnionTypeDefinition: Equatable { @@ -2053,6 +2171,21 @@ public final class EnumTypeDefinition { self.directives = directives self.values = values } + + public func get(key: String) -> NodeResult? { + switch key { + case "description": + return description.map { .node($0) } + case "name": + return .node(name) + case "directives": + return .array(directives) + case "values": + return .array(values) + default: + return nil + } + } } extension EnumTypeDefinition: Equatable { @@ -2082,6 +2215,19 @@ public final class EnumValueDefinition { self.name = name self.directives = directives } + + public func get(key: String) -> NodeResult? { + switch key { + case "description": + return description.map { .node($0) } + case "name": + return .node(name) + case "directives": + return .array(directives) + default: + return nil + } + } } extension EnumValueDefinition: Equatable { @@ -2113,6 +2259,21 @@ public final class InputObjectTypeDefinition { self.directives = directives self.fields = fields } + + public func get(key: String) -> NodeResult? { + switch key { + case "description": + return description.map { .node($0) } + case "name": + return .node(name) + case "directives": + return .array(directives) + case "fields": + return .array(fields) + default: + return nil + } + } } extension InputObjectTypeDefinition: Equatable { @@ -2124,15 +2285,34 @@ extension InputObjectTypeDefinition: Equatable { } } +protocol TypeExtension: TypeSystemDefinition { + var name: Name { get } +} + +extension ScalarExtensionDefinition: TypeExtension {} +extension TypeExtensionDefinition: TypeExtension {} +extension InterfaceExtensionDefinition: TypeExtension {} +extension UnionExtensionDefinition: TypeExtension {} +extension EnumExtensionDefinition: TypeExtension {} +extension InputObjectExtensionDefinition: TypeExtension {} + public final class TypeExtensionDefinition { public let kind: Kind = .typeExtensionDefinition public let loc: Location? public let definition: ObjectTypeDefinition + var name: Name { + return definition.name + } + init(loc: Location? = nil, definition: ObjectTypeDefinition) { self.loc = loc self.definition = definition } + + public func get(key: String) -> NodeResult? { + definition.get(key: key) + } } extension TypeExtensionDefinition: Equatable { @@ -2150,6 +2330,10 @@ public final class SchemaExtensionDefinition { self.loc = loc self.definition = definition } + + public func get(key: String) -> NodeResult? { + definition.get(key: key) + } } extension SchemaExtensionDefinition: Equatable { @@ -2163,10 +2347,18 @@ public final class InterfaceExtensionDefinition { public let loc: Location? public let definition: InterfaceTypeDefinition + var name: Name { + return definition.name + } + init(loc: Location? = nil, definition: InterfaceTypeDefinition) { self.loc = loc self.definition = definition } + + public func get(key: String) -> NodeResult? { + definition.get(key: key) + } } extension InterfaceExtensionDefinition: Equatable { @@ -2182,10 +2374,20 @@ public final class ScalarExtensionDefinition { public let kind: Kind = .scalarExtensionDefinition public let loc: Location? public let definition: ScalarTypeDefinition + public let directives: [Directive] + + var name: Name { + return definition.name + } - init(loc: Location? = nil, definition: ScalarTypeDefinition) { + init(loc: Location? = nil, definition: ScalarTypeDefinition, directives: [Directive] = []) { self.loc = loc self.definition = definition + self.directives = directives + } + + public func get(key: String) -> NodeResult? { + definition.get(key: key) } } @@ -2200,10 +2402,18 @@ public final class UnionExtensionDefinition { public let loc: Location? public let definition: UnionTypeDefinition + var name: Name { + return definition.name + } + init(loc: Location? = nil, definition: UnionTypeDefinition) { self.loc = loc self.definition = definition } + + public func get(key: String) -> NodeResult? { + definition.get(key: key) + } } extension UnionExtensionDefinition: Equatable { @@ -2217,10 +2427,18 @@ public final class EnumExtensionDefinition { public let loc: Location? public let definition: EnumTypeDefinition + var name: Name { + return definition.name + } + init(loc: Location? = nil, definition: EnumTypeDefinition) { self.loc = loc self.definition = definition } + + public func get(key: String) -> NodeResult? { + definition.get(key: key) + } } extension EnumExtensionDefinition: Equatable { @@ -2234,10 +2452,18 @@ public final class InputObjectExtensionDefinition { public let loc: Location? public let definition: InputObjectTypeDefinition + var name: Name { + return definition.name + } + init(loc: Location? = nil, definition: InputObjectTypeDefinition) { self.loc = loc self.definition = definition } + + public func get(key: String) -> NodeResult? { + definition.get(key: key) + } } extension InputObjectExtensionDefinition: Equatable { @@ -2273,6 +2499,21 @@ public final class DirectiveDefinition { self.locations = locations self.repeatable = repeatable } + + public func get(key: String) -> NodeResult? { + switch key { + case "description": + return description.map { .node($0) } + case "name": + return .node(name) + case "arguments": + return .array(arguments) + case "locations": + return .array(locations) + default: + return nil + } + } } extension DirectiveDefinition: Equatable { diff --git a/Sources/GraphQL/Language/BlockString.swift b/Sources/GraphQL/Language/BlockString.swift index 1a819b40..8b10af6f 100644 --- a/Sources/GraphQL/Language/BlockString.swift +++ b/Sources/GraphQL/Language/BlockString.swift @@ -1,5 +1,70 @@ import Foundation +func isPrintableAsBlockString(_ value: String) -> Bool { + if value == "" { + return true // empty string is printable + } + + var isEmptyLine = true + var hasIndent = false + var hasCommonIndent = true + var seenNonEmptyLine = false + + let scalars = Array(value.unicodeScalars) + for i in 0 ..< scalars.count { + switch scalars[i].value { + case 0x0000, + 0x0001, + 0x0002, + 0x0003, + 0x0004, + 0x0005, + 0x0006, + 0x0007, + 0x0008, + 0x000B, + 0x000C, + 0x000E, + 0x000F: + return false // Has non-printable characters + + case 0x000D: // \r + return false // Has \r or \r\n which will be replaced as \n + + case 10: // \n + if isEmptyLine && !seenNonEmptyLine { + return false // Has leading new line + } + seenNonEmptyLine = true + + isEmptyLine = true + hasIndent = false + + case 9, // \t + 32: // + if !hasIndent { + hasIndent = isEmptyLine + } + + default: + if hasCommonIndent { + hasCommonIndent = hasIndent + } + isEmptyLine = false + } + } + + if isEmptyLine { + return false // Has trailing empty lines + } + + if hasCommonIndent && seenNonEmptyLine { + return false // Has internal indent + } + + return true +} + /** * Print a block string in the indented block form by adding a leading and * trailing blank line. However, if a block string starts with whitespace and is diff --git a/Sources/GraphQL/Language/Parser.swift b/Sources/GraphQL/Language/Parser.swift index 57159646..87f9432e 100644 --- a/Sources/GraphQL/Language/Parser.swift +++ b/Sources/GraphQL/Language/Parser.swift @@ -1112,7 +1112,8 @@ func parseScalarExtensionDefinition(lexer: Lexer) throws -> ScalarExtensionDefin definition: ScalarTypeDefinition( name: name, directives: directives - ) + ), + directives: directives ) } diff --git a/Sources/GraphQL/Language/Predicates.swift b/Sources/GraphQL/Language/Predicates.swift new file mode 100644 index 00000000..8c7c9f8f --- /dev/null +++ b/Sources/GraphQL/Language/Predicates.swift @@ -0,0 +1,41 @@ + +func isTypeSystemDefinitionNode( + _ node: Node +) -> Bool { + return + node.kind == Kind.schemaDefinition || + isTypeDefinitionNode(node) || + node.kind == Kind.directiveDefinition +} + +func isTypeDefinitionNode( + _ node: Node +) -> Bool { + return + node.kind == Kind.scalarTypeDefinition || + node.kind == Kind.objectTypeDefinition || + node.kind == Kind.interfaceTypeDefinition || + node.kind == Kind.unionTypeDefinition || + node.kind == Kind.enumTypeDefinition || + node.kind == Kind.inputObjectTypeDefinition +} + +func isTypeSystemExtensionNode( + _ node: Node +) -> Bool { + return + node.kind == Kind.schemaExtensionDefinition || + isTypeExtensionNode(node) +} + +func isTypeExtensionNode( + _ node: Node +) -> Bool { + return + node.kind == Kind.scalarExtensionDefinition || + node.kind == Kind.typeExtensionDefinition || + node.kind == Kind.interfaceExtensionDefinition || + node.kind == Kind.unionExtensionDefinition || + node.kind == Kind.enumExtensionDefinition || + node.kind == Kind.inputObjectExtensionDefinition +} diff --git a/Sources/GraphQL/Language/Visitor.swift b/Sources/GraphQL/Language/Visitor.swift index d5dd4a53..7f428c2a 100644 --- a/Sources/GraphQL/Language/Visitor.swift +++ b/Sources/GraphQL/Language/Visitor.swift @@ -17,6 +17,7 @@ let QueryDocumentKeys: [Kind: [String]] = [ .floatValue: [], .stringValue: [], .booleanValue: [], + .nullValue: [], .enumValue: [], .listValue: ["values"], .objectValue: ["fields"], @@ -28,22 +29,29 @@ let QueryDocumentKeys: [Kind: [String]] = [ .listType: ["type"], .nonNullType: ["type"], - .schemaDefinition: ["directives", "operationTypes"], + .schemaDefinition: ["description", "directives", "operationTypes"], .operationTypeDefinition: ["type"], - .scalarTypeDefinition: ["name", "directives"], - .objectTypeDefinition: ["name", "interfaces", "directives", "fields"], - .fieldDefinition: ["name", "arguments", "type", "directives"], - .inputValueDefinition: ["name", "type", "defaultValue", "directives"], - .interfaceTypeDefinition: ["name", "interfaces", "directives", "fields"], - .unionTypeDefinition: ["name", "directives", "types"], - .enumTypeDefinition: ["name", "directives", "values"], - .enumValueDefinition: ["name", "directives"], - .inputObjectTypeDefinition: ["name", "directives", "fields"], - - .typeExtensionDefinition: ["definition"], - - .directiveDefinition: ["name", "arguments", "locations"], + .scalarTypeDefinition: ["description", "name", "directives"], + .objectTypeDefinition: ["description", "name", "interfaces", "directives", "fields"], + .fieldDefinition: ["description", "name", "arguments", "type", "directives"], + .inputValueDefinition: ["description", "name", "type", "defaultValue", "directives"], + .interfaceTypeDefinition: ["description", "name", "interfaces", "directives", "fields"], + .unionTypeDefinition: ["description", "name", "directives", "types"], + .enumTypeDefinition: ["description", "name", "directives", "values"], + .enumValueDefinition: ["description", "name", "directives"], + .inputObjectTypeDefinition: ["description", "name", "directives", "fields"], + + .directiveDefinition: ["description", "name", "arguments", "locations"], + + .schemaExtensionDefinition: ["directives", "operationTypes"], + + .scalarExtensionDefinition: ["name", "directives"], + .typeExtensionDefinition: ["name", "interfaces", "directives", "fields"], + .interfaceExtensionDefinition: ["name", "interfaces", "directives", "fields"], + .unionExtensionDefinition: ["name", "directives", "types"], + .enumExtensionDefinition: ["name", "directives", "values"], + .inputObjectExtensionDefinition: ["name", "directives", "fields"], ] /** diff --git a/Sources/GraphQL/Type/Definition.swift b/Sources/GraphQL/Type/Definition.swift index 8c96b1c3..06375abb 100644 --- a/Sources/GraphQL/Type/Definition.swift +++ b/Sources/GraphQL/Type/Definition.swift @@ -5,7 +5,7 @@ import OrderedCollections /** * These are all of the possible kinds of types. */ -public protocol GraphQLType: CustomDebugStringConvertible, Encodable, KeySubscriptable {} +public protocol GraphQLType: CustomDebugStringConvertible {} extension GraphQLScalarType: GraphQLType {} extension GraphQLObjectType: GraphQLType {} extension GraphQLInterfaceType: GraphQLType {} @@ -77,14 +77,6 @@ extension GraphQLObjectType: GraphQLCompositeType {} extension GraphQLInterfaceType: GraphQLCompositeType {} extension GraphQLUnionType: GraphQLCompositeType {} -protocol GraphQLTypeReferenceContainer: GraphQLNamedType { - func replaceTypeReferences(typeMap: TypeMap) throws -} - -extension GraphQLObjectType: GraphQLTypeReferenceContainer {} -extension GraphQLInterfaceType: GraphQLTypeReferenceContainer {} -extension GraphQLInputObjectType: GraphQLTypeReferenceContainer {} - /** * These types may describe the parent context of a selection set. */ @@ -170,6 +162,8 @@ public final class GraphQLScalarType { public let name: String public let description: String? public let specifiedByURL: String? + public let astNode: ScalarTypeDefinition? + public let extensionASTNodes: [ScalarExtensionDefinition] public let kind: TypeKind = .scalar let serialize: (Any) throws -> Map @@ -180,14 +174,18 @@ public final class GraphQLScalarType { name: String, description: String? = nil, specifiedByURL: String? = nil, - serialize: @escaping (Any) throws -> Map, + serialize: @escaping (Any) throws -> Map = { try map(from: $0) }, parseValue: ((Map) throws -> Map)? = nil, - parseLiteral: ((Value) throws -> Map)? = nil + parseLiteral: ((Value) throws -> Map)? = nil, + astNode: ScalarTypeDefinition? = nil, + extensionASTNodes: [ScalarExtensionDefinition] = [] ) throws { try assertValid(name: name) self.name = name self.description = description self.specifiedByURL = specifiedByURL + self.astNode = astNode + self.extensionASTNodes = extensionASTNodes self.serialize = serialize self.parseValue = parseValue ?? defaultParseValue self.parseLiteral = parseLiteral ?? defaultParseLiteral @@ -217,32 +215,6 @@ let defaultParseLiteral: ((Value) throws -> Map) = { value in try valueFromASTUntyped(valueAST: value) } -extension GraphQLScalarType: Encodable { - private enum CodingKeys: String, CodingKey { - case name - case description - case specifiedByURL - case kind - } -} - -extension GraphQLScalarType: KeySubscriptable { - public subscript(key: String) -> Any? { - switch key { - case CodingKeys.name.rawValue: - return name - case CodingKeys.description.rawValue: - return description - case CodingKeys.specifiedByURL.rawValue: - return specifiedByURL - case CodingKeys.kind.rawValue: - return kind - default: - return nil - } - } -} - extension GraphQLScalarType: CustomDebugStringConvertible { public var debugDescription: String { return name @@ -286,82 +258,78 @@ extension GraphQLScalarType: Hashable { * ) * * When two types need to refer to each other, or a type needs to refer to - * itself in a field, you can wrap it in a GraphQLTypeReference to supply the fields lazily. + * itself in a field, you can use a closure to supply the fields lazily. * * Example: * * let PersonType = GraphQLObjectType( * name: "Person", - * fields: [ + * fields: { + * [ * "name": GraphQLField(type: GraphQLString), - * "bestFriend": GraphQLField(type: GraphQLTypeReference("PersonType")), + * "bestFriend": GraphQLField(type: PersonType), * ] + * } * ) * */ public final class GraphQLObjectType { public let name: String public let description: String? - public let fields: GraphQLFieldDefinitionMap - public let interfaces: [GraphQLInterfaceType] + public var fields: () throws -> GraphQLFieldMap + public var interfaces: () throws -> [GraphQLInterfaceType] public let isTypeOf: GraphQLIsTypeOf? + public let astNode: ObjectTypeDefinition? + public let extensionASTNodes: [TypeExtensionDefinition] public let kind: TypeKind = .object public init( name: String, description: String? = nil, - fields: GraphQLFieldMap, + fields: GraphQLFieldMap = [:], interfaces: [GraphQLInterfaceType] = [], - isTypeOf: GraphQLIsTypeOf? = nil + isTypeOf: GraphQLIsTypeOf? = nil, + astNode: ObjectTypeDefinition? = nil, + extensionASTNodes: [TypeExtensionDefinition] = [] ) throws { try assertValid(name: name) self.name = name self.description = description - self.fields = try defineFieldMap( - name: name, - fields: fields - ) - self.interfaces = try defineInterfaces( - name: name, - hasTypeOf: isTypeOf != nil, - interfaces: interfaces - ) + self.fields = { fields } + self.interfaces = { interfaces } self.isTypeOf = isTypeOf + self.astNode = astNode + self.extensionASTNodes = extensionASTNodes } - func replaceTypeReferences(typeMap: TypeMap) throws { - for field in fields { - try field.value.replaceTypeReferences(typeMap: typeMap) - } + public init( + name: String, + description: String? = nil, + fields: @escaping () throws -> GraphQLFieldMap, + interfaces: @escaping () throws -> [GraphQLInterfaceType] = { [] }, + isTypeOf: GraphQLIsTypeOf? = nil, + astNode: ObjectTypeDefinition? = nil, + extensionASTNodes: [TypeExtensionDefinition] = [] + ) throws { + try assertValid(name: name) + self.name = name + self.description = description + self.fields = fields + self.interfaces = interfaces + self.isTypeOf = isTypeOf + self.astNode = astNode + self.extensionASTNodes = extensionASTNodes } -} -extension GraphQLObjectType: Encodable { - private enum CodingKeys: String, CodingKey { - case name - case description - case fields - case interfaces - case kind + func getFields() throws -> GraphQLFieldDefinitionMap { + try defineFieldMap( + name: name, + fields: fields() + ) } -} -extension GraphQLObjectType: KeySubscriptable { - public subscript(key: String) -> Any? { - switch key { - case CodingKeys.name.rawValue: - return name - case CodingKeys.description.rawValue: - return description - case CodingKeys.fields.rawValue: - return fields - case CodingKeys.interfaces.rawValue: - return interfaces - case CodingKeys.kind.rawValue: - return kind.rawValue - default: - return nil - } + func getInterfaces() throws -> [GraphQLInterfaceType] { + return try interfaces() } } @@ -382,14 +350,6 @@ extension GraphQLObjectType: Hashable { } func defineFieldMap(name: String, fields: GraphQLFieldMap) throws -> GraphQLFieldDefinitionMap { - guard !fields.isEmpty else { - throw GraphQLError( - message: - "\(name) fields must be an object with field names as " + - "keys or a function which returns such an object." - ) - } - var fieldMap = GraphQLFieldDefinitionMap() for (name, config) in fields { @@ -402,7 +362,8 @@ func defineFieldMap(name: String, fields: GraphQLFieldMap) throws -> GraphQLFiel deprecationReason: config.deprecationReason, args: defineArgumentMap(args: config.args), resolve: config.resolve, - subscribe: config.subscribe + subscribe: config.subscribe, + astNode: config.astNode ) fieldMap[name] = field @@ -421,7 +382,8 @@ func defineArgumentMap(args: GraphQLArgumentConfigMap) throws -> [GraphQLArgumen type: config.type, defaultValue: config.defaultValue, description: config.description, - deprecationReason: config.deprecationReason + deprecationReason: config.deprecationReason, + astNode: config.astNode ) arguments.append(argument) } @@ -429,32 +391,6 @@ func defineArgumentMap(args: GraphQLArgumentConfigMap) throws -> [GraphQLArgumen return arguments } -func defineInterfaces( - name: String, - hasTypeOf: Bool, - interfaces: [GraphQLInterfaceType] -) throws -> [GraphQLInterfaceType] { - guard !interfaces.isEmpty else { - return [] - } - - if !hasTypeOf { - for interface in interfaces { - guard interface.resolveType != nil else { - throw GraphQLError( - message: - "Interface Type \(interface.name) does not provide a \"resolveType\" " + - "function and implementing Type \(name) does not provide a " + - "\"isTypeOf\" function. There is no way to resolve this implementing " + - "type during execution." - ) - } - } - } - - return interfaces -} - public protocol TypeResolveResultRepresentable { var typeResolveResult: TypeResolveResult { get } } @@ -525,17 +461,20 @@ public struct GraphQLField { public let description: String? public let resolve: GraphQLFieldResolve? public let subscribe: GraphQLFieldResolve? + public let astNode: FieldDefinition? public init( type: GraphQLOutputType, description: String? = nil, deprecationReason: String? = nil, - args: GraphQLArgumentConfigMap = [:] + args: GraphQLArgumentConfigMap = [:], + astNode: FieldDefinition? = nil ) { self.type = type self.args = args self.deprecationReason = deprecationReason self.description = description + self.astNode = astNode resolve = nil subscribe = nil } @@ -546,12 +485,14 @@ public struct GraphQLField { deprecationReason: String? = nil, args: GraphQLArgumentConfigMap = [:], resolve: GraphQLFieldResolve?, - subscribe: GraphQLFieldResolve? = nil + subscribe: GraphQLFieldResolve? = nil, + astNode: FieldDefinition? = nil ) { self.type = type self.args = args self.deprecationReason = deprecationReason self.description = description + self.astNode = astNode self.resolve = resolve self.subscribe = subscribe } @@ -561,12 +502,14 @@ public struct GraphQLField { description: String? = nil, deprecationReason: String? = nil, args: GraphQLArgumentConfigMap = [:], + astNode: FieldDefinition? = nil, resolve: @escaping GraphQLFieldResolveInput ) { self.type = type self.args = args self.deprecationReason = deprecationReason self.description = description + self.astNode = astNode self.resolve = { source, args, context, eventLoopGroup, info in let result = try resolve(source, args, context, info) @@ -587,6 +530,7 @@ public final class GraphQLFieldDefinition { public let subscribe: GraphQLFieldResolve? public let deprecationReason: String? public let isDeprecated: Bool + public let astNode: FieldDefinition? init( name: String, @@ -595,7 +539,8 @@ public final class GraphQLFieldDefinition { deprecationReason: String? = nil, args: [GraphQLArgumentDefinition] = [], resolve: GraphQLFieldResolve?, - subscribe: GraphQLFieldResolve? = nil + subscribe: GraphQLFieldResolve? = nil, + astNode: FieldDefinition? = nil ) { self.name = name self.description = description @@ -605,60 +550,27 @@ public final class GraphQLFieldDefinition { self.subscribe = subscribe self.deprecationReason = deprecationReason isDeprecated = deprecationReason != nil + self.astNode = astNode + } + + func toField() -> GraphQLField { + return .init( + type: type, + description: description, + deprecationReason: deprecationReason, + args: argConfigMap(), + resolve: resolve, + subscribe: subscribe, + astNode: astNode + ) } - func replaceTypeReferences(typeMap: TypeMap) throws { - let resolvedType = try resolveTypeReference(type: type, typeMap: typeMap) - - guard let outputType = resolvedType as? GraphQLOutputType else { - throw GraphQLError( - message: "Resolved type \"\(resolvedType)\" is not a valid output type." - ) - } - - type = outputType - } -} - -extension GraphQLFieldDefinition: Encodable { - private enum CodingKeys: String, CodingKey { - case name - case description - case type - case args - case deprecationReason - case isDeprecated - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(name, forKey: .name) - try container.encode(description, forKey: .description) - try container.encode(AnyEncodable(type), forKey: .type) - try container.encode(args, forKey: .args) - try container.encode(deprecationReason, forKey: .deprecationReason) - try container.encode(isDeprecated, forKey: .isDeprecated) - } -} - -extension GraphQLFieldDefinition: KeySubscriptable { - public subscript(key: String) -> Any? { - switch key { - case CodingKeys.name.rawValue: - return name - case CodingKeys.description.rawValue: - return description - case CodingKeys.type.rawValue: - return type - case CodingKeys.args.rawValue: - return args - case CodingKeys.deprecationReason.rawValue: - return deprecationReason - case CodingKeys.isDeprecated.rawValue: - return isDeprecated - default: - return nil + func argConfigMap() -> GraphQLArgumentConfigMap { + var argConfigs: GraphQLArgumentConfigMap = [:] + for argDef in args { + argConfigs[argDef.name] = argDef.toArg() } + return argConfigs } } @@ -669,17 +581,20 @@ public struct GraphQLArgument { public let description: String? public let defaultValue: Map? public let deprecationReason: String? + public let astNode: InputValueDefinition? public init( type: GraphQLInputType, description: String? = nil, defaultValue: Map? = nil, - deprecationReason: String? = nil + deprecationReason: String? = nil, + astNode: InputValueDefinition? = nil ) { self.type = type self.description = description self.defaultValue = defaultValue self.deprecationReason = deprecationReason + self.astNode = astNode } } @@ -689,62 +604,37 @@ public struct GraphQLArgumentDefinition { public let defaultValue: Map? public let description: String? public let deprecationReason: String? + public let astNode: InputValueDefinition? init( name: String, type: GraphQLInputType, defaultValue: Map? = nil, description: String? = nil, - deprecationReason: String? = nil + deprecationReason: String? = nil, + astNode: InputValueDefinition? = nil ) { self.name = name self.type = type self.defaultValue = defaultValue self.description = description self.deprecationReason = deprecationReason - } -} - -public func isRequiredArgument(_ arg: GraphQLArgumentDefinition) -> Bool { - return arg.type is GraphQLNonNull && arg.defaultValue == nil -} - -extension GraphQLArgumentDefinition: Encodable { - private enum CodingKeys: String, CodingKey { - case name - case description - case type - case defaultValue - case deprecationReason + self.astNode = astNode } - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(name, forKey: .name) - try container.encode(description, forKey: .description) - try container.encode(AnyEncodable(type), forKey: .type) - try container.encode(defaultValue, forKey: .defaultValue) - try container.encode(deprecationReason, forKey: .deprecationReason) + func toArg() -> GraphQLArgument { + return .init( + type: type, + description: description, + defaultValue: defaultValue, + deprecationReason: deprecationReason, + astNode: astNode + ) } } -extension GraphQLArgumentDefinition: KeySubscriptable { - public subscript(key: String) -> Any? { - switch key { - case CodingKeys.name.rawValue: - return name - case CodingKeys.description.rawValue: - return description - case CodingKeys.type.rawValue: - return type - case CodingKeys.defaultValue.rawValue: - return defaultValue - case CodingKeys.deprecationReason.rawValue: - return deprecationReason - default: - return nil - } - } +public func isRequiredArgument(_ arg: GraphQLArgumentDefinition) -> Bool { + return arg.type is GraphQLNonNull && arg.defaultValue == nil } /** @@ -769,60 +659,59 @@ public final class GraphQLInterfaceType { public let name: String public let description: String? public let resolveType: GraphQLTypeResolve? - public let fields: GraphQLFieldDefinitionMap - public let interfaces: [GraphQLInterfaceType] + public var fields: () throws -> GraphQLFieldMap + public var interfaces: () throws -> [GraphQLInterfaceType] + public let astNode: InterfaceTypeDefinition? + public let extensionASTNodes: [InterfaceExtensionDefinition] public let kind: TypeKind = .interface public init( name: String, description: String? = nil, interfaces: [GraphQLInterfaceType] = [], - fields: GraphQLFieldMap, - resolveType: GraphQLTypeResolve? = nil + fields: GraphQLFieldMap = [:], + resolveType: GraphQLTypeResolve? = nil, + astNode: InterfaceTypeDefinition? = nil, + extensionASTNodes: [InterfaceExtensionDefinition] = [] ) throws { try assertValid(name: name) self.name = name self.description = description - - self.fields = try defineFieldMap( - name: name, - fields: fields - ) - - self.interfaces = interfaces + self.fields = { fields } + self.interfaces = { interfaces } self.resolveType = resolveType + self.astNode = astNode + self.extensionASTNodes = extensionASTNodes } - func replaceTypeReferences(typeMap: TypeMap) throws { - for field in fields { - try field.value.replaceTypeReferences(typeMap: typeMap) - } + public init( + name: String, + description: String? = nil, + fields: @escaping () throws -> GraphQLFieldMap, + interfaces: @escaping () throws -> [GraphQLInterfaceType] = { [] }, + resolveType: GraphQLTypeResolve? = nil, + astNode: InterfaceTypeDefinition? = nil, + extensionASTNodes: [InterfaceExtensionDefinition] = [] + ) throws { + try assertValid(name: name) + self.name = name + self.description = description + self.fields = fields + self.interfaces = interfaces + self.resolveType = resolveType + self.astNode = astNode + self.extensionASTNodes = extensionASTNodes } -} -extension GraphQLInterfaceType: Encodable { - private enum CodingKeys: String, CodingKey { - case name - case description - case fields - case kind + func getFields() throws -> GraphQLFieldDefinitionMap { + try defineFieldMap( + name: name, + fields: fields() + ) } -} -extension GraphQLInterfaceType: KeySubscriptable { - public subscript(key: String) -> Any? { - switch key { - case CodingKeys.name.rawValue: - return name - case CodingKeys.description.rawValue: - return description - case CodingKeys.fields.rawValue: - return fields - case CodingKeys.kind.rawValue: - return kind - default: - return nil - } + func getInterfaces() throws -> [GraphQLInterfaceType] { + return try interfaces() } } @@ -842,6 +731,8 @@ extension GraphQLInterfaceType: Hashable { } } +public typealias GraphQLUnionTypeExtensions = [String: String]? + /** * Union Type Definition * @@ -868,57 +759,64 @@ extension GraphQLInterfaceType: Hashable { * */ public final class GraphQLUnionType { + public let kind: TypeKind = .union public let name: String public let description: String? public let resolveType: GraphQLTypeResolve? - public let types: [GraphQLObjectType] + public let types: () throws -> [GraphQLObjectType] public let possibleTypeNames: [String: Bool] - public let kind: TypeKind = .union + let extensions: [GraphQLUnionTypeExtensions] + let astNode: UnionTypeDefinition? + let extensionASTNodes: [UnionExtensionDefinition] public init( name: String, description: String? = nil, resolveType: GraphQLTypeResolve? = nil, - types: [GraphQLObjectType] + types: [GraphQLObjectType], + extensions: [GraphQLUnionTypeExtensions] = [], + astNode: UnionTypeDefinition? = nil, + extensionASTNodes: [UnionExtensionDefinition] = [] ) throws { try assertValid(name: name) self.name = name self.description = description self.resolveType = resolveType - self.types = try defineTypes( - name: name, - hasResolve: resolveType != nil, - types: types - ) + self.types = { types } + + self.extensions = extensions + self.astNode = astNode + self.extensionASTNodes = extensionASTNodes possibleTypeNames = [:] } -} -extension GraphQLUnionType: Encodable { - private enum CodingKeys: String, CodingKey { - case name - case description - case types - case kind + public init( + name: String, + description: String? = nil, + resolveType: GraphQLTypeResolve? = nil, + types: @escaping () throws -> [GraphQLObjectType], + extensions: [GraphQLUnionTypeExtensions] = [], + astNode: UnionTypeDefinition? = nil, + extensionASTNodes: [UnionExtensionDefinition] = [] + ) throws { + try assertValid(name: name) + self.name = name + self.description = description + self.resolveType = resolveType + + self.types = types + + self.extensions = extensions + self.astNode = astNode + self.extensionASTNodes = extensionASTNodes + + possibleTypeNames = [:] } -} -extension GraphQLUnionType: KeySubscriptable { - public subscript(key: String) -> Any? { - switch key { - case CodingKeys.name.rawValue: - return name - case CodingKeys.description.rawValue: - return description - case CodingKeys.types.rawValue: - return types - case CodingKeys.kind.rawValue: - return kind - default: - return nil - } + func getTypes() throws -> [GraphQLObjectType] { + try types() } } @@ -938,36 +836,6 @@ extension GraphQLUnionType: Hashable { } } -func defineTypes( - name: String, - hasResolve: Bool, - types: [GraphQLObjectType] -) throws -> [GraphQLObjectType] { - guard !types.isEmpty else { - throw GraphQLError( - message: - "Must provide Array of types or a function which returns " + - "such an array for Union \(name)." - ) - } - - if !hasResolve { - for type in types { - guard type.isTypeOf != nil else { - throw GraphQLError( - message: - "Union type \"\(name)\" does not provide a \"resolveType\" " + - "function and possible type \"\(type.name)\" does not provide an " + - "\"isTypeOf\" function. There is no way to resolve this possible type " + - "during execution." - ) - } - } - } - - return types -} - /** * Enum Type Definition * @@ -993,6 +861,8 @@ public final class GraphQLEnumType { public let name: String public let description: String? public let values: [GraphQLEnumValueDefinition] + public let astNode: EnumTypeDefinition? + public let extensionASTNodes: [EnumExtensionDefinition] public let valueLookup: [Map: GraphQLEnumValueDefinition] public let nameLookup: [String: GraphQLEnumValueDefinition] public let kind: TypeKind = .enum @@ -1000,7 +870,9 @@ public final class GraphQLEnumType { public init( name: String, description: String? = nil, - values: GraphQLEnumValueMap + values: GraphQLEnumValueMap, + astNode: EnumTypeDefinition? = nil, + extensionASTNodes: [EnumExtensionDefinition] = [] ) throws { try assertValid(name: name) self.name = name @@ -1009,6 +881,8 @@ public final class GraphQLEnumType { name: name, valueMap: values ) + self.astNode = astNode + self.extensionASTNodes = extensionASTNodes var valueLookup: [Map: GraphQLEnumValueDefinition] = [:] @@ -1078,32 +952,6 @@ public final class GraphQLEnumType { } } -extension GraphQLEnumType: Encodable { - private enum CodingKeys: String, CodingKey { - case name - case description - case values - case kind - } -} - -extension GraphQLEnumType: KeySubscriptable { - public subscript(key: String) -> Any? { - switch key { - case CodingKeys.name.rawValue: - return name - case CodingKeys.description.rawValue: - return description - case CodingKeys.values.rawValue: - return values - case CodingKeys.kind.rawValue: - return kind - default: - return nil - } - } -} - extension GraphQLEnumType: CustomDebugStringConvertible { public var debugDescription: String { return name @@ -1124,12 +972,6 @@ func defineEnumValues( name: String, valueMap: GraphQLEnumValueMap ) throws -> [GraphQLEnumValueDefinition] { - guard !valueMap.isEmpty else { - throw GraphQLError( - message: "\(name) values must be an object with value names as keys." - ) - } - var definitions: [GraphQLEnumValueDefinition] = [] for (valueName, value) in valueMap { @@ -1140,7 +982,8 @@ func defineEnumValues( description: value.description, deprecationReason: value.deprecationReason, isDeprecated: value.deprecationReason != nil, - value: value.value + value: value.value, + astNode: value.astNode ) definitions.append(definition) @@ -1155,47 +998,43 @@ public struct GraphQLEnumValue { public let value: Map public let description: String? public let deprecationReason: String? + public let astNode: EnumValueDefinition? public init( value: Map, description: String? = nil, - deprecationReason: String? = nil + deprecationReason: String? = nil, + astNode: EnumValueDefinition? = nil ) { self.value = value self.description = description self.deprecationReason = deprecationReason + self.astNode = astNode } } -public struct GraphQLEnumValueDefinition: Encodable { - private enum CodingKeys: String, CodingKey { - case name - case description - case deprecationReason - case isDeprecated - } - +public struct GraphQLEnumValueDefinition { public let name: String public let description: String? public let deprecationReason: String? public let isDeprecated: Bool public let value: Map -} + public let astNode: EnumValueDefinition? -extension GraphQLEnumValueDefinition: KeySubscriptable { - public subscript(key: String) -> Any? { - switch key { - case CodingKeys.name.rawValue: - return name - case CodingKeys.description.rawValue: - return description - case CodingKeys.deprecationReason.rawValue: - return deprecationReason - case CodingKeys.isDeprecated.rawValue: - return isDeprecated - default: - return nil - } + public init( + name: String, + description: String?, + deprecationReason: String?, + isDeprecated: Bool, + value: Map, + astNode: EnumValueDefinition? = nil + ) { + self.name = name + self.description = description + self.deprecationReason = deprecationReason + self.isDeprecated = isDeprecated + self.value = value + self.astNode = astNode } } @@ -1222,59 +1061,53 @@ extension GraphQLEnumValueDefinition: KeySubscriptable { public final class GraphQLInputObjectType { public let name: String public let description: String? - public let fields: InputObjectFieldDefinitionMap + public var fields: () throws -> InputObjectFieldMap + public let astNode: InputObjectTypeDefinition? + public let extensionASTNodes: [InputObjectExtensionDefinition] public let isOneOf: Bool public let kind: TypeKind = .inputObject public init( name: String, description: String? = nil, - fields: InputObjectFieldMap = [:], + fields: @escaping () throws -> InputObjectFieldMap, + astNode: InputObjectTypeDefinition? = nil, + extensionASTNodes: [InputObjectExtensionDefinition] = [], isOneOf: Bool = false ) throws { try assertValid(name: name) self.name = name self.description = description - self.fields = try defineInputObjectFieldMap( - name: name, - fields: fields - ) + self.fields = fields + self.astNode = astNode + self.extensionASTNodes = extensionASTNodes self.isOneOf = isOneOf } - func replaceTypeReferences(typeMap: TypeMap) throws { - for field in fields { - try field.value.replaceTypeReferences(typeMap: typeMap) + public init( + name: String, + description: String? = nil, + fields: InputObjectFieldMap = [:], + astNode: InputObjectTypeDefinition? = nil, + extensionASTNodes: [InputObjectExtensionDefinition] = [], + isOneOf: Bool = false + ) throws { + try assertValid(name: name) + self.name = name + self.description = description + self.fields = { + fields } + self.astNode = astNode + self.extensionASTNodes = extensionASTNodes + self.isOneOf = isOneOf } -} - -extension GraphQLInputObjectType: Encodable { - private enum CodingKeys: String, CodingKey { - case name - case description - case fields - case isOneOf - case kind - } -} -extension GraphQLInputObjectType: KeySubscriptable { - public subscript(key: String) -> Any? { - switch key { - case CodingKeys.name.rawValue: - return name - case CodingKeys.description.rawValue: - return description - case CodingKeys.fields.rawValue: - return fields - case CodingKeys.isOneOf.rawValue: - return isOneOf - case CodingKeys.kind.rawValue: - return kind - default: - return nil - } + func getFields() throws -> InputObjectFieldDefinitionMap { + try defineInputObjectFieldMap( + name: name, + fields: fields() + ) } } @@ -1298,14 +1131,6 @@ func defineInputObjectFieldMap( name: String, fields: InputObjectFieldMap ) throws -> InputObjectFieldDefinitionMap { - guard !fields.isEmpty else { - throw GraphQLError( - message: - "\(name) fields must be an object with field names as " + - "keys or a function which returns such an object." - ) - } - var definitionMap = InputObjectFieldDefinitionMap() for (name, field) in fields { @@ -1316,7 +1141,8 @@ func defineInputObjectFieldMap( type: field.type, description: field.description, defaultValue: field.defaultValue, - deprecationReason: field.deprecationReason + deprecationReason: field.deprecationReason, + astNode: field.astNode ) definitionMap[name] = definition @@ -1330,17 +1156,20 @@ public struct InputObjectField { public let defaultValue: Map? public let description: String? public let deprecationReason: String? + public let astNode: InputValueDefinition? public init( type: GraphQLInputType, defaultValue: Map? = nil, description: String? = nil, - deprecationReason: String? = nil + deprecationReason: String? = nil, + astNode: InputValueDefinition? = nil ) { self.type = type self.defaultValue = defaultValue self.description = description self.deprecationReason = deprecationReason + self.astNode = astNode } } @@ -1352,69 +1181,22 @@ public final class InputObjectFieldDefinition { public let description: String? public let defaultValue: Map? public let deprecationReason: String? + public let astNode: InputValueDefinition? init( name: String, type: GraphQLInputType, description: String? = nil, defaultValue: Map? = nil, - deprecationReason: String? = nil + deprecationReason: String? = nil, + astNode: InputValueDefinition? = nil ) { self.name = name self.type = type self.description = description self.defaultValue = defaultValue self.deprecationReason = deprecationReason - } - - func replaceTypeReferences(typeMap: TypeMap) throws { - let resolvedType = try resolveTypeReference(type: type, typeMap: typeMap) - - guard let inputType = resolvedType as? GraphQLInputType else { - throw GraphQLError( - message: "Resolved type \"\(resolvedType)\" is not a valid input type." - ) - } - - type = inputType - } -} - -extension InputObjectFieldDefinition: Encodable { - private enum CodingKeys: String, CodingKey { - case name - case description - case type - case defaultValue - case deprecationReason - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(name, forKey: .name) - try container.encode(description, forKey: .description) - try container.encode(AnyEncodable(type), forKey: .type) - try container.encode(defaultValue, forKey: .defaultValue) - try container.encode(deprecationReason, forKey: .deprecationReason) - } -} - -extension InputObjectFieldDefinition: KeySubscriptable { - public subscript(key: String) -> Any? { - switch key { - case CodingKeys.name.rawValue: - return name - case CodingKeys.description.rawValue: - return description - case CodingKeys.type.rawValue: - return type - case CodingKeys.defaultValue.rawValue: - return defaultValue - case CodingKeys.deprecationReason.rawValue: - return deprecationReason - default: - return nil - } + self.astNode = astNode } } @@ -1453,44 +1235,9 @@ public final class GraphQLList { ofType = type } - public init(_ name: String) { - ofType = GraphQLTypeReference(name) - } - var wrappedType: GraphQLType { return ofType } - - func replaceTypeReferences(typeMap: TypeMap) throws -> GraphQLList { - let resolvedType = try resolveTypeReference(type: ofType, typeMap: typeMap) - return GraphQLList(resolvedType) - } -} - -extension GraphQLList: Encodable { - private enum CodingKeys: String, CodingKey { - case ofType - case kind - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(AnyEncodable(ofType), forKey: .ofType) - try container.encode(kind, forKey: .kind) - } -} - -extension GraphQLList: KeySubscriptable { - public subscript(key: String) -> Any? { - switch key { - case CodingKeys.ofType.rawValue: - return ofType - case CodingKeys.kind.rawValue: - return kind - default: - return nil - } - } } extension GraphQLList: CustomDebugStringConvertible { @@ -1533,55 +1280,20 @@ public final class GraphQLNonNull { public let ofType: GraphQLNullableType public let kind: TypeKind = .nonNull - public init(_ type: GraphQLNullableType) { + public init(_ type: GraphQLType) throws { + guard let type = type as? GraphQLNullableType else { + throw GraphQLError(message: "type is already non null: \(type.debugDescription)") + } ofType = type } - public init(_ name: String) { - ofType = GraphQLTypeReference(name) + public init(_ type: GraphQLNullableType) { + ofType = type } var wrappedType: GraphQLType { return ofType } - - func replaceTypeReferences(typeMap: TypeMap) throws -> GraphQLNonNull { - let resolvedType = try resolveTypeReference(type: ofType, typeMap: typeMap) - - guard let nullableType = resolvedType as? GraphQLNullableType else { - throw GraphQLError( - message: "Resolved type \"\(resolvedType)\" is not a valid nullable type." - ) - } - - return GraphQLNonNull(nullableType) - } -} - -extension GraphQLNonNull: Encodable { - private enum CodingKeys: String, CodingKey { - case ofType - case kind - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(AnyEncodable(ofType), forKey: .ofType) - try container.encode(kind, forKey: .kind) - } -} - -extension GraphQLNonNull: KeySubscriptable { - public subscript(key: String) -> Any? { - switch key { - case CodingKeys.ofType.rawValue: - return ofType - case CodingKeys.kind.rawValue: - return kind - default: - return nil - } - } } extension GraphQLNonNull: CustomDebugStringConvertible { @@ -1599,41 +1311,3 @@ extension GraphQLNonNull: Hashable { return lhs.hashValue == rhs.hashValue } } - -/** - * A special type to allow object/interface/input types to reference itself. It's replaced with the real type - * object when the schema is built. - */ -public final class GraphQLTypeReference: GraphQLType, GraphQLOutputType, GraphQLInputType, - GraphQLNullableType, GraphQLNamedType -{ - public let name: String - public let kind: TypeKind = .typeReference - - public init(_ name: String) { - self.name = name - } -} - -extension GraphQLTypeReference: Encodable { - private enum CodingKeys: String, CodingKey { - case name - } -} - -extension GraphQLTypeReference: KeySubscriptable { - public subscript(_: String) -> Any? { - switch name { - case CodingKeys.name.rawValue: - return name - default: - return nil - } - } -} - -extension GraphQLTypeReference: CustomDebugStringConvertible { - public var debugDescription: String { - return name - } -} diff --git a/Sources/GraphQL/Type/Directives.swift b/Sources/GraphQL/Type/Directives.swift index 77bde6bd..ffc0b92e 100644 --- a/Sources/GraphQL/Type/Directives.swift +++ b/Sources/GraphQL/Type/Directives.swift @@ -8,6 +8,7 @@ public enum DirectiveLocation: String, Encodable { case field = "FIELD" case fragmentDefinition = "FRAGMENT_DEFINITION" case fragmentSpread = "FRAGMENT_SPREAD" + case fragmentVariableDefinition = "FRAGMENT_VARIABLE_DEFINITION" case inlineFragment = "INLINE_FRAGMENT" case variableDefinition = "VARIABLE_DEFINITION" // Schema Definitions @@ -28,19 +29,21 @@ public enum DirectiveLocation: String, Encodable { * Directives are used by the GraphQL runtime as a way of modifying execution * behavior. Type system creators will usually not create these directly. */ -public struct GraphQLDirective: Encodable { +public final class GraphQLDirective { public let name: String - public let description: String + public let description: String? public let locations: [DirectiveLocation] public let args: [GraphQLArgumentDefinition] public let isRepeatable: Bool + public let astNode: DirectiveDefinition? public init( name: String, - description: String = "", + description: String? = nil, locations: [DirectiveLocation], args: GraphQLArgumentConfigMap = [:], - isRepeatable: Bool = false + isRepeatable: Bool = false, + astNode: DirectiveDefinition? = nil ) throws { try assertValid(name: name) self.name = name @@ -48,6 +51,15 @@ public struct GraphQLDirective: Encodable { self.locations = locations self.args = try defineArgumentMap(args: args) self.isRepeatable = isRepeatable + self.astNode = astNode + } + + func argConfigMap() -> GraphQLArgumentConfigMap { + var argConfigs: GraphQLArgumentConfigMap = [:] + for argDef in args { + argConfigs[argDef.name] = argDef.toArg() + } + return argConfigs } } @@ -58,7 +70,7 @@ public let GraphQLIncludeDirective = try! GraphQLDirective( name: "include", description: "Directs the executor to include this field or fragment only when " + - "the `if` argument is true.", + "the \\`if\\` argument is true.", locations: [ .field, .fragmentSpread, @@ -78,7 +90,7 @@ public let GraphQLIncludeDirective = try! GraphQLDirective( public let GraphQLSkipDirective = try! GraphQLDirective( name: "skip", description: - "Directs the executor to skip this field or fragment when the `if` " + + "Directs the executor to skip this field or fragment when the \\`if\\` " + "argument is true.", locations: [ .field, @@ -96,7 +108,7 @@ public let GraphQLSkipDirective = try! GraphQLDirective( /** * Constant string used for default reason for a deprecation. */ -let defaulDeprecationReason: Map = "No longer supported" +let defaultDeprecationReason = "No longer supported" /** * Used to declare element of a GraphQL schema as deprecated. @@ -117,8 +129,9 @@ public let GraphQLDeprecatedDirective = try! GraphQLDirective( description: "Explains why this element was deprecated, usually also including a " + "suggestion for how to access supported similar data. Formatted " + - "in [Markdown](https://daringfireball.net/projects/markdown/).", - defaultValue: defaulDeprecationReason + "using the Markdown syntax, as specified by [CommonMark]" + + "(https://commonmark.org/).", + defaultValue: Map.string(defaultDeprecationReason) ), ] ) @@ -143,7 +156,7 @@ public let GraphQLSpecifiedByDirective = try! GraphQLDirective( */ public let GraphQLOneOfDirective = try! GraphQLDirective( name: "oneOf", - description: "Indicates exactly one field must be supplied and this field must not be `null`.", + description: "Indicates exactly one field must be supplied and this field must not be \\`null\\`.", locations: [.inputObject], args: [:] ) @@ -158,3 +171,9 @@ let specifiedDirectives: [GraphQLDirective] = [ GraphQLSpecifiedByDirective, GraphQLOneOfDirective, ] + +func isSpecifiedDirective(_ directive: GraphQLDirective) -> Bool { + return specifiedDirectives.contains { specifiedDirective in + specifiedDirective.name == directive.name + } +} diff --git a/Sources/GraphQL/Type/Introspection.swift b/Sources/GraphQL/Type/Introspection.swift index c8970109..9841851d 100644 --- a/Sources/GraphQL/Type/Introspection.swift +++ b/Sources/GraphQL/Type/Introspection.swift @@ -7,6 +7,7 @@ let __Schema = try! GraphQLObjectType( "exposes all available types and directives on the server, as well as " + "the entry points for query, mutation, and subscription operations.", fields: [ + "description": GraphQLField(type: GraphQLString), "types": GraphQLField( type: GraphQLNonNull(GraphQLList(GraphQLNonNull(__Type))), description: "A list of all types supported by this server.", @@ -75,7 +76,7 @@ let __Directive = try! GraphQLObjectType( description: "A Directive provides a way to describe alternate runtime execution and " + "type validation behavior in a GraphQL document." + - "\n\nIn some cases, you need to provide options to alter GraphQL\"s " + + "\n\nIn some cases, you need to provide options to alter GraphQL's " + "execution behavior in ways field arguments will not suffice, such as " + "conditionally including or skipping a field. Directives provide this by " + "describing additional information to the executor.", @@ -86,6 +87,9 @@ let __Directive = try! GraphQLObjectType( "locations": GraphQLField(type: GraphQLNonNull(GraphQLList(GraphQLNonNull(__DirectiveLocation)))), "args": GraphQLField( type: GraphQLNonNull(GraphQLList(GraphQLNonNull(__InputValue))), + args: [ + "includeDeprecated": GraphQLArgument(type: GraphQLBoolean, defaultValue: false), + ], resolve: { directive, _, _, _ -> [GraphQLArgumentDefinition]? in guard let directive = directive as? GraphQLDirective else { return nil @@ -131,6 +135,14 @@ let __DirectiveLocation = try! GraphQLEnumType( value: Map(DirectiveLocation.inlineFragment.rawValue), description: "Location adjacent to an inline fragment." ), + "VARIABLE_DEFINITION": GraphQLEnumValue( + value: Map(DirectiveLocation.variableDefinition.rawValue), + description: "Location adjacent to an operation variable definition." + ), + "FRAGMENT_VARIABLE_DEFINITION": GraphQLEnumValue( + value: Map(DirectiveLocation.fragmentVariableDefinition.rawValue), + description: "Location adjacent to a fragment variable definition." + ), "SCHEMA": GraphQLEnumValue( value: Map(DirectiveLocation.schema.rawValue), description: "Location adjacent to a schema definition." @@ -178,18 +190,21 @@ let __DirectiveLocation = try! GraphQLEnumType( ] ) -let __Type: GraphQLObjectType = try! GraphQLObjectType( - name: "__Type", - description: - "The fundamental unit of any GraphQL Schema is the type. There are " + - "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 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.", - fields: [ +let __Type: GraphQLObjectType = { + let __Type = try! GraphQLObjectType( + name: "__Type", + description: + "The fundamental unit of any GraphQL Schema is the type. There are " + + "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, 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.", + fields: [:] + ) + __Type.fields = { [ "kind": GraphQLField( type: GraphQLNonNull(__TypeKind), resolve: { type, _, _, _ -> TypeKind? in @@ -228,7 +243,7 @@ let __Type: GraphQLObjectType = try! GraphQLObjectType( ], resolve: { type, arguments, _, _ -> [GraphQLFieldDefinition]? in if let type = type as? GraphQLObjectType { - let fieldMap = type.fields + let fieldMap = try type.getFields() var fields = Array(fieldMap.values).sorted(by: { $0.name < $1.name }) if !(arguments["includeDeprecated"].bool ?? false) { @@ -239,7 +254,7 @@ let __Type: GraphQLObjectType = try! GraphQLObjectType( } if let type = type as? GraphQLInterfaceType { - let fieldMap = type.fields + let fieldMap = try type.getFields() var fields = Array(fieldMap.values).sorted(by: { $0.name < $1.name }) if !(arguments["includeDeprecated"].bool ?? false) { @@ -253,21 +268,21 @@ let __Type: GraphQLObjectType = try! GraphQLObjectType( } ), "interfaces": GraphQLField( - type: GraphQLList(GraphQLNonNull(GraphQLTypeReference("__Type"))), + type: GraphQLList(GraphQLNonNull(__Type)), resolve: { type, _, _, _ -> [GraphQLInterfaceType]? in if let type = type as? GraphQLObjectType { - return type.interfaces + return try type.getInterfaces() } if let type = type as? GraphQLInterfaceType { - return type.interfaces + return try type.getInterfaces() } return nil } ), "possibleTypes": GraphQLField( - type: GraphQLList(GraphQLNonNull(GraphQLTypeReference("__Type"))), + type: GraphQLList(GraphQLNonNull(__Type)), resolve: { type, _, _, info -> [GraphQLObjectType]? in guard let type = type as? GraphQLAbstractType else { return nil @@ -300,17 +315,23 @@ let __Type: GraphQLObjectType = try! GraphQLObjectType( ), "inputFields": GraphQLField( type: GraphQLList(GraphQLNonNull(__InputValue)), + args: [ + "includeDeprecated": GraphQLArgument( + type: GraphQLBoolean, + defaultValue: false + ), + ], resolve: { type, _, _, _ -> [InputObjectFieldDefinition]? in guard let type = type as? GraphQLInputObjectType else { return nil } - let fieldMap = type.fields + let fieldMap = try type.getFields() let fields = Array(fieldMap.values).sorted(by: { $0.name < $1.name }) return fields } ), - "ofType": GraphQLField(type: GraphQLTypeReference("__Type")), + "ofType": GraphQLField(type: __Type), "isOneOf": GraphQLField( type: GraphQLBoolean, resolve: { type, _, _, _ in @@ -320,8 +341,9 @@ let __Type: GraphQLObjectType = try! GraphQLObjectType( return false } ), - ] -) + ] } + return __Type +}() let __Field = try! GraphQLObjectType( name: "__Field", @@ -333,6 +355,9 @@ let __Field = try! GraphQLObjectType( "description": GraphQLField(type: GraphQLString), "args": GraphQLField( type: GraphQLNonNull(GraphQLList(GraphQLNonNull(__InputValue))), + args: [ + "includeDeprecated": GraphQLArgument(type: GraphQLBoolean, defaultValue: false), + ], resolve: { field, _, _, _ -> [GraphQLArgumentDefinition]? in guard let field = field as? GraphQLFieldDefinition else { return nil @@ -341,7 +366,7 @@ let __Field = try! GraphQLObjectType( return field.args } ), - "type": GraphQLField(type: GraphQLNonNull(GraphQLTypeReference("__Type"))), + "type": GraphQLField(type: GraphQLNonNull(__Type)), "isDeprecated": GraphQLField(type: GraphQLNonNull(GraphQLBoolean)), "deprecationReason": GraphQLField(type: GraphQLString), ] @@ -356,7 +381,7 @@ let __InputValue = try! GraphQLObjectType( fields: [ "name": GraphQLField(type: GraphQLNonNull(GraphQLString)), "description": GraphQLField(type: GraphQLString), - "type": GraphQLField(type: GraphQLNonNull(GraphQLTypeReference("__Type"))), + "type": GraphQLField(type: GraphQLNonNull(__Type)), "defaultValue": GraphQLField( type: GraphQLString, description: @@ -382,6 +407,8 @@ let __InputValue = try! GraphQLObjectType( return .string(print(ast: literal)) } ), + "isDeprecated": GraphQLField(type: GraphQLNonNull(GraphQLBoolean)), + "deprecationReason": GraphQLField(type: GraphQLString), ] ) @@ -408,12 +435,11 @@ public enum TypeKind: String, Encodable { case inputObject = "INPUT_OBJECT" case list = "LIST" case nonNull = "NON_NULL" - case typeReference = "TYPE_REFERENCE" } let __TypeKind = try! GraphQLEnumType( name: "__TypeKind", - description: "An enum describing what kind of type a given `__Type` is.", + description: "An enum describing what kind of type a given \\`__Type\\` is.", values: [ "SCALAR": GraphQLEnumValue( value: Map(TypeKind.scalar.rawValue), @@ -422,37 +448,37 @@ let __TypeKind = try! GraphQLEnumType( "OBJECT": GraphQLEnumValue( value: Map(TypeKind.object.rawValue), description: "Indicates this type is an object. " + - "`fields` and `interfaces` are valid fields." + "\\`fields\\` and \\`interfaces\\` are valid fields." ), "INTERFACE": GraphQLEnumValue( value: Map(TypeKind.interface.rawValue), description: "Indicates this type is an interface. " + - "`fields`, `interfaces`, and `possibleTypes` are valid fields." + "\\`fields\\`, \\`interfaces\\`, and \\`possibleTypes\\` are valid fields." ), "UNION": GraphQLEnumValue( value: Map(TypeKind.union.rawValue), description: "Indicates this type is a union. " + - "`possibleTypes` is a valid field." + "\\`possibleTypes\\` is a valid field." ), "ENUM": GraphQLEnumValue( value: Map(TypeKind.enum.rawValue), description: "Indicates this type is an enum. " + - "`enumValues` is a valid field." + "\\`enumValues\\` is a valid field." ), "INPUT_OBJECT": GraphQLEnumValue( value: Map(TypeKind.inputObject.rawValue), description: "Indicates this type is an input object. " + - "`inputFields` is a valid field." + "\\`inputFields\\` is a valid field." ), "LIST": GraphQLEnumValue( value: Map(TypeKind.list.rawValue), description: "Indicates this type is a list. " + - "`ofType` is a valid field." + "\\`ofType\\` is a valid field." ), "NON_NULL": GraphQLEnumValue( value: Map(TypeKind.nonNull.rawValue), description: "Indicates this type is a non-null. " + - "`ofType` is a valid field." + "\\`ofType\\` is a valid field." ), ] ) @@ -496,17 +522,17 @@ let TypeNameMetaFieldDef = GraphQLFieldDefinition( } ) -let introspectionTypeNames = [ - __Schema.name, - __Directive.name, - __DirectiveLocation.name, - __Type.name, - __Field.name, - __InputValue.name, - __EnumValue.name, - __TypeKind.name, +let introspectionTypes: [GraphQLNamedType] = [ + __Schema, + __Directive, + __DirectiveLocation, + __Type, + __Field, + __InputValue, + __EnumValue, + __TypeKind, ] func isIntrospectionType(type: GraphQLNamedType) -> Bool { - return introspectionTypeNames.contains(type.name) + return introspectionTypes.map { $0.name }.contains(type.name) } diff --git a/Sources/GraphQL/Type/Scalars.swift b/Sources/GraphQL/Type/Scalars.swift index e5f9a0d2..80225c67 100644 --- a/Sources/GraphQL/Type/Scalars.swift +++ b/Sources/GraphQL/Type/Scalars.swift @@ -274,3 +274,15 @@ public let GraphQLID = try! GraphQLScalarType( ) } ) + +let specifiedScalarTypes = [ + GraphQLString, + GraphQLInt, + GraphQLFloat, + GraphQLBoolean, + GraphQLID, +] + +func isSpecifiedScalarType(_ type: GraphQLNamedType) -> Bool { + return specifiedScalarTypes.contains { $0.name == type.name } +} diff --git a/Sources/GraphQL/Type/Schema.swift b/Sources/GraphQL/Type/Schema.swift index 7b4ad94b..3b06b455 100644 --- a/Sources/GraphQL/Type/Schema.swift +++ b/Sources/GraphQL/Type/Schema.swift @@ -1,3 +1,5 @@ +import OrderedCollections + /** * Schema Definition * @@ -26,21 +28,41 @@ * */ public final class GraphQLSchema { - public let queryType: GraphQLObjectType + let description: String? + let extensions: [GraphQLSchemaExtensions] + let astNode: SchemaDefinition? + let extensionASTNodes: [SchemaExtensionDefinition] + + // Used as a cache for validateSchema(). + var validationErrors: [GraphQLError]? + + public let queryType: GraphQLObjectType? public let mutationType: GraphQLObjectType? public let subscriptionType: GraphQLObjectType? public let directives: [GraphQLDirective] public let typeMap: TypeMap - public let implementations: [String: InterfaceImplementations] + public internal(set) var implementations: [String: InterfaceImplementations] private var subTypeMap: [String: [String: Bool]] = [:] public init( - query: GraphQLObjectType, + description: String? = nil, + query: GraphQLObjectType? = nil, mutation: GraphQLObjectType? = nil, subscription: GraphQLObjectType? = nil, types: [GraphQLNamedType] = [], - directives: [GraphQLDirective] = [] + directives: [GraphQLDirective] = [], + extensions: [GraphQLSchemaExtensions] = [], + astNode: SchemaDefinition? = nil, + extensionASTNodes: [SchemaExtensionDefinition] = [], + assumeValid: Bool = false ) throws { + validationErrors = assumeValid ? [] : nil + + self.description = description + self.extensions = extensions + self.astNode = astNode + self.extensionASTNodes = extensionASTNodes + queryType = query mutationType = mutation subscriptionType = subscription @@ -48,45 +70,106 @@ public final class GraphQLSchema { // Provide specified directives (e.g. @include and @skip) by default. self.directives = directives.isEmpty ? specifiedDirectives : directives - // Build type map now to detect any errors within this schema. - var initialTypes: [GraphQLNamedType] = [ - queryType, - ] + // To preserve order of user-provided types, we add first to add them to + // the set of "collected" types, so `collectReferencedTypes` ignore them. + var allReferencedTypes = TypeMap() + for type in types { + allReferencedTypes[type.name] = type + } + if !types.isEmpty { + for type in types { + // When we ready to process this type, we remove it from "collected" types + // and then add it together with all dependent types in the correct position. + allReferencedTypes[type.name] = nil + allReferencedTypes = try typeMapReducer(typeMap: allReferencedTypes, type: type) + } + } + + if let query = queryType { + allReferencedTypes = try typeMapReducer(typeMap: allReferencedTypes, type: query) + } if let mutation = mutationType { - initialTypes.append(mutation) + allReferencedTypes = try typeMapReducer(typeMap: allReferencedTypes, type: mutation) } if let subscription = subscriptionType { - initialTypes.append(subscription) + allReferencedTypes = try typeMapReducer(typeMap: allReferencedTypes, type: subscription) } - initialTypes.append(__Schema) - - if !types.isEmpty { - initialTypes.append(contentsOf: types) + for directive in self.directives { + for arg in directive.args { + allReferencedTypes = try typeMapReducer(typeMap: allReferencedTypes, type: arg.type) + } } - var typeMap = TypeMap() - - for type in initialTypes { - typeMap = try typeMapReducer(typeMap: typeMap, type: type) - } + allReferencedTypes = try typeMapReducer(typeMap: allReferencedTypes, type: __Schema) - self.typeMap = typeMap - try replaceTypeReferences(typeMap: typeMap) + // Storing the resulting map for reference by the schema. + var typeMap = TypeMap() // Keep track of all implementations by interface name. - implementations = collectImplementations(types: Array(typeMap.values)) + implementations = try collectImplementations(types: Array(typeMap.values)) - // Enforce correct interface implementations. - for (_, type) in typeMap { - if let object = type as? GraphQLObjectType { - for interface in object.interfaces { - try assert(object: object, implementsInterface: interface, schema: self) + for namedType in allReferencedTypes.values { + let typeName = namedType.name + if typeMap[typeName] != nil { + throw GraphQLError( + message: + "Schema must contain uniquely named types but contains multiple types named \"\(typeName)\"." + ) + } + typeMap[typeName] = namedType + + if let namedType = namedType as? GraphQLInterfaceType { + // Store implementations by interface. + for iface in try namedType.getInterfaces() { + let implementations = self.implementations[iface.name] ?? .init( + objects: [], + interfaces: [] + ) + + var interfaces = implementations.interfaces + interfaces.append(namedType) + self.implementations[iface.name] = .init( + objects: implementations.objects, + interfaces: interfaces + ) + } + } else if let namedType = namedType as? GraphQLObjectType { + // Store implementations by objects. + for iface in try namedType.getInterfaces() { + let implementations = self.implementations[iface.name] ?? .init( + objects: [], + interfaces: [] + ) + + var objects = implementations.objects + objects.append(namedType) + self.implementations[iface.name] = .init( + objects: objects, + interfaces: implementations.interfaces + ) } } } + + self.typeMap = typeMap + } + + convenience init(config: GraphQLSchemaNormalizedConfig) throws { + try self.init( + description: config.description, + query: config.query, + mutation: config.mutation, + subscription: config.subscription, + types: config.types, + directives: config.directives, + extensions: config.extensions, + astNode: config.astNode, + extensionASTNodes: config.extensionASTNodes, + assumeValid: config.assumeValid + ) } public func getType(name: String) -> GraphQLNamedType? { @@ -95,7 +178,7 @@ public final class GraphQLSchema { public func getPossibleTypes(abstractType: GraphQLAbstractType) -> [GraphQLObjectType] { if let unionType = abstractType as? GraphQLUnionType { - return unionType.types + return (try? unionType.getTypes()) ?? [] } if let interfaceType = abstractType as? GraphQLInterfaceType { @@ -135,7 +218,7 @@ public final class GraphQLSchema { map = [:] if let unionType = abstractType as? GraphQLUnionType { - for type in unionType.types { + for type in (try? unionType.getTypes()) ?? [] { map?[type.name] = true } } @@ -166,18 +249,24 @@ public final class GraphQLSchema { return nil } -} -extension GraphQLSchema: Encodable { - private enum CodingKeys: String, CodingKey { - case queryType - case mutationType - case subscriptionType - case directives + func toConfig() -> GraphQLSchemaNormalizedConfig { + return GraphQLSchemaNormalizedConfig( + description: description, + query: queryType, + mutation: mutationType, + subscription: subscriptionType, + types: Array(typeMap.values), + directives: directives, + extensions: extensions, + astNode: astNode, + extensionASTNodes: extensionASTNodes, + assumeValid: validationErrors != nil + ) } } -public typealias TypeMap = [String: GraphQLNamedType] +public typealias TypeMap = OrderedDictionary public struct InterfaceImplementations { public let objects: [GraphQLObjectType] @@ -194,7 +283,7 @@ public struct InterfaceImplementations { func collectImplementations( types: [GraphQLNamedType] -) -> [String: InterfaceImplementations] { +) throws -> [String: InterfaceImplementations] { var implementations: [String: InterfaceImplementations] = [:] for type in types { @@ -204,7 +293,7 @@ func collectImplementations( } // Store implementations by interface. - for iface in type.interfaces { + for iface in try type.getInterfaces() { implementations[iface.name] = InterfaceImplementations( interfaces: (implementations[iface.name]?.interfaces ?? []) + [type] ) @@ -213,7 +302,7 @@ func collectImplementations( if let type = type as? GraphQLObjectType { // Store implementations by objects. - for iface in type.interfaces { + for iface in try type.getInterfaces() { implementations[iface.name] = InterfaceImplementations( objects: (implementations[iface.name]?.objects ?? []) + [type] ) @@ -236,39 +325,28 @@ func typeMapReducer(typeMap: TypeMap, type: GraphQLType) throws -> TypeMap { } if let existingType = typeMap[type.name] { - if existingType is GraphQLTypeReference { - if type is GraphQLTypeReference { - // Just short circuit because they're both type references - return typeMap - } - // Otherwise, fall through and override the type reference + if !(existingType == type) { + throw GraphQLError( + message: + "Schema must contain unique named types but contains multiple " + + "types named \"\(type.name)\"." + ) } else { - if type is GraphQLTypeReference { - // Just ignore the reference and keep the concrete one - return typeMap - } else if !(existingType == type) { - throw GraphQLError( - message: - "Schema must contain unique named types but contains multiple " + - "types named \"\(type.name)\"." - ) - } else { - // Otherwise, it's already been defined so short circuit - return typeMap - } + // Otherwise, it's already been defined so short circuit + return typeMap } } typeMap[type.name] = type if let type = type as? GraphQLUnionType { - typeMap = try type.types.reduce(typeMap, typeMapReducer) + typeMap = try type.getTypes().reduce(typeMap, typeMapReducer) } if let type = type as? GraphQLObjectType { - typeMap = try type.interfaces.reduce(typeMap, typeMapReducer) + typeMap = try type.getInterfaces().reduce(typeMap, typeMapReducer) - for (_, field) in type.fields { + for (_, field) in try type.getFields() { if !field.args.isEmpty { let fieldArgTypes = field.args.map { $0.type } typeMap = try fieldArgTypes.reduce(typeMap, typeMapReducer) @@ -279,9 +357,9 @@ func typeMapReducer(typeMap: TypeMap, type: GraphQLType) throws -> TypeMap { } if let type = type as? GraphQLInterfaceType { - typeMap = try type.interfaces.reduce(typeMap, typeMapReducer) + typeMap = try type.getInterfaces().reduce(typeMap, typeMapReducer) - for (_, field) in type.fields { + for (_, field) in try type.getFields() { if !field.args.isEmpty { let fieldArgTypes = field.args.map { $0.type } typeMap = try fieldArgTypes.reduce(typeMap, typeMapReducer) @@ -292,7 +370,7 @@ func typeMapReducer(typeMap: TypeMap, type: GraphQLType) throws -> TypeMap { } if let type = type as? GraphQLInputObjectType { - for (_, field) in type.fields { + for (_, field) in try type.getFields() { typeMap = try typeMapReducer(typeMap: typeMap, type: field.type) } } @@ -300,121 +378,50 @@ func typeMapReducer(typeMap: TypeMap, type: GraphQLType) throws -> TypeMap { return typeMap } -func assert( - object: GraphQLObjectType, - implementsInterface interface: GraphQLInterfaceType, - schema: GraphQLSchema -) throws { - let objectFieldMap = object.fields - let interfaceFieldMap = interface.fields - - for (fieldName, interfaceField) in interfaceFieldMap { - guard let objectField = objectFieldMap[fieldName] else { - throw GraphQLError( - message: - "\(interface.name) expects field \(fieldName) " + - "but \(object.name) does not provide it." - ) - } - - // Assert interface field type is satisfied by object field type, by being - // a valid subtype. (covariant) - guard try isTypeSubTypeOf(schema, objectField.type, interfaceField.type) else { - throw GraphQLError( - message: - "\(interface.name).\(fieldName) expects type \"\(interfaceField.type)\" " + - "but " + - "\(object.name).\(fieldName) provides type \"\(objectField.type)\"." - ) - } - - // Assert each interface field arg is implemented. - for interfaceArg in interfaceField.args { - let argName = interfaceArg.name - guard let objectArg = objectField.args.find({ $0.name == argName }) else { - throw GraphQLError( - message: - "\(interface.name).\(fieldName) expects argument \"\(argName)\" but " + - "\(object.name).\(fieldName) does not provide it." - ) - } - - // Assert interface field arg type matches object field arg type. - // (invariant) - guard isEqualType(interfaceArg.type, objectArg.type) else { - throw GraphQLError( - message: - "\(interface.name).\(fieldName)(\(argName):) expects type " + - "\"\(interfaceArg.type)\" but " + - "\(object.name).\(fieldName)(\(argName):) provides type " + - "\"\(objectArg.type)\"." - ) - } - } - - // Assert additional arguments must not be required. - for objectArg in objectField.args { - let argName = objectArg.name - if - interfaceField.args.find({ $0.name == argName }) == nil, - isRequiredArgument(objectArg) - { - throw GraphQLError( - message: - "\(object.name).\(fieldName) includes required argument (\(argName):) that is missing " + - "from the Interface field \(interface.name).\(fieldName)." - ) - } - } - } -} - -func replaceTypeReferences(typeMap: TypeMap) throws { - for type in typeMap { - if let typeReferenceContainer = type.value as? GraphQLTypeReferenceContainer { - try typeReferenceContainer.replaceTypeReferences(typeMap: typeMap) - } - } - - // Check that no type names map to TypeReferences. That is, they have all been resolved to - // actual types. - for (typeName, graphQLNamedType) in typeMap { - if graphQLNamedType is GraphQLTypeReference { - throw GraphQLError( - message: "Type \"\(typeName)\" was referenced but not defined." - ) - } - } -} - -func resolveTypeReference(type: GraphQLType, typeMap: TypeMap) throws -> GraphQLType { - if let type = type as? GraphQLTypeReference { - guard let resolvedType = typeMap[type.name] else { - throw GraphQLError( - message: "Type \"\(type.name)\" not found in schema." - ) - } - - return resolvedType - } - - if let type = type as? GraphQLList { - return try type.replaceTypeReferences(typeMap: typeMap) - } - - if let type = type as? GraphQLNonNull { - return try type.replaceTypeReferences(typeMap: typeMap) +class GraphQLSchemaNormalizedConfig { + var description: String? + var query: GraphQLObjectType? + var mutation: GraphQLObjectType? + var subscription: GraphQLObjectType? + var types: [GraphQLNamedType] + var directives: [GraphQLDirective] + var extensions: [GraphQLSchemaExtensions] + var astNode: SchemaDefinition? + var extensionASTNodes: [SchemaExtensionDefinition] + var assumeValid: Bool + + init( + description: String? = nil, + query: GraphQLObjectType? = nil, + mutation: GraphQLObjectType? = nil, + subscription: GraphQLObjectType? = nil, + types: [GraphQLNamedType] = [], + directives: [GraphQLDirective] = [], + extensions: [GraphQLSchemaExtensions] = [], + astNode: SchemaDefinition? = nil, + extensionASTNodes: [SchemaExtensionDefinition] = [], + assumeValid: Bool = false + ) { + self.description = description + self.query = query + self.mutation = mutation + self.subscription = subscription + self.types = types + self.directives = directives + self.extensions = extensions + self.astNode = astNode + self.extensionASTNodes = extensionASTNodes + self.assumeValid = assumeValid } - - return type } -func resolveTypeReferences(types: [GraphQLType], typeMap: TypeMap) throws -> [GraphQLType] { - var resolvedTypes: [GraphQLType] = [] - - for type in types { - try resolvedTypes.append(resolveTypeReference(type: type, typeMap: typeMap)) - } - - return resolvedTypes -} +/** + * Custom extensions + * + * @remarks + * Use a unique identifier name for your extension, for example the name of + * your library or project. Do not use a shortened identifier as this increases + * the risk of conflicts. We recommend you add at most one extension field, + * an object which can contain all the values you need. + */ +public typealias GraphQLSchemaExtensions = [String: String]? diff --git a/Sources/GraphQL/Type/Validation.swift b/Sources/GraphQL/Type/Validation.swift new file mode 100644 index 00000000..86589d9c --- /dev/null +++ b/Sources/GraphQL/Type/Validation.swift @@ -0,0 +1,779 @@ +/** + * Implements the "Type Validation" sub-sections of the specification's + * "Type System" section. + * + * Validation runs synchronously, returning an array of encountered errors, or + * an empty array if no errors were encountered and the Schema is valid. + */ +func validateSchema( + schema: GraphQLSchema +) throws -> [GraphQLError] { + // If this Schema has already been validated, return the previous results. + if let validationErrors = schema.validationErrors { + return validationErrors + } + + // Validate the schema, producing a list of errors. + let context = SchemaValidationContext(schema: schema) + validateRootTypes(context: context) + validateDirectives(context: context) + try validateTypes(context: context) + + // Persist the results of validation before returning to ensure validation + // does not run multiple times for this schema. + let errors = context.getErrors() + schema.validationErrors = errors + return errors +} + +/** + * Utility function which asserts a schema is valid by throwing an error if + * it is invalid. + */ +func assertValidSchema(schema: GraphQLSchema) throws { + let errors = try validateSchema(schema: schema) + if !errors.isEmpty { + throw GraphQLError(message: errors.map { error in error.message }.joined(separator: "\n\n")) + } +} + +class SchemaValidationContext { + var _errors: [GraphQLError] + let schema: GraphQLSchema + + init(schema: GraphQLSchema) { + _errors = [] + self.schema = schema + } + + func reportError( + message: String, + nodes: [Node?] + ) { + let _nodes = nodes.compactMap { $0 } + _errors.append(GraphQLError(message: message, nodes: _nodes)) + } + + func reportError( + message: String, + node: Node? + ) { + let _nodes = [node].compactMap { $0 } + _errors.append(GraphQLError(message: message, nodes: _nodes)) + } + + func getErrors() -> [GraphQLError] { + return _errors + } +} + +func validateRootTypes(context: SchemaValidationContext) { + let schema = context.schema + + if schema.queryType == nil { + context.reportError(message: "Query root type must be provided.", node: schema.astNode) + } + + var rootTypesMap = [GraphQLObjectType: [OperationType]]() + for operationType in OperationType.allCases { + switch operationType { + case .query: + if let queryType = schema.queryType { + var operationTypes = rootTypesMap[queryType] ?? [] + operationTypes.append(operationType) + rootTypesMap[queryType] = operationTypes + } + case .mutation: + if let mutationType = schema.mutationType { + var operationTypes = rootTypesMap[mutationType] ?? [] + operationTypes.append(operationType) + rootTypesMap[mutationType] = operationTypes + } + case .subscription: + if let subscriptionType = schema.subscriptionType { + var operationTypes = rootTypesMap[subscriptionType] ?? [] + operationTypes.append(operationType) + rootTypesMap[subscriptionType] = operationTypes + } + } + } + + for (rootType, operationTypes) in rootTypesMap { + if operationTypes.count > 1 { + let operationList = operationTypes.map { $0.rawValue }.andList() + context.reportError( + message: "All root types must be different, \"\(rootType)\" type is used as \(operationList) root types.", + nodes: operationTypes.map { operationType in + getOperationTypeNode(schema: schema, operation: operationType) + } + ) + } + } +} + +func getOperationTypeNode( + schema: GraphQLSchema, + operation: OperationType +) -> Node? { + let nodes: [SchemaDefinition?] = [schema.astNode] + // TODO: Add schema operation extension support +// nodes.append(contentsOf: schema.extensionASTNodes) + return nodes.flatMap { schemaNode in + schemaNode?.operationTypes ?? [] + }.find { operationNode in operationNode.operation == operation }?.type +} + +func validateDirectives(context: SchemaValidationContext) { + for directive in context.schema.directives { + // Ensure they are named correctly. + validateName(context: context, name: directive.name, astNode: directive.astNode) + + if directive.locations.count == 0 { + context.reportError( + message: "Directive @\(directive.name) must include 1 or more locations.", + node: directive.astNode + ) + } + + // Ensure the arguments are valid. + for arg in directive.args { + // Ensure they are named correctly. + validateName(context: context, name: arg.name, astNode: arg.astNode) + + if isRequiredArgument(arg), arg.deprecationReason != nil { + context.reportError( + message: "Required argument @\(directive.name)(\(arg.name):) cannot be deprecated.", + nodes: [ + getDeprecatedDirectiveNode(directives: arg.astNode?.directives), + arg.astNode?.type, + ] + ) + } + } + } +} + +func validateName( + context: SchemaValidationContext, + name: String, + astNode: Node? +) { + // Ensure names are valid, however introspection types opt out. + if name.hasPrefix("__") { + context.reportError( + message: "Name \"\(name)\" must not begin with \"__\", which is reserved by GraphQL introspection.", + node: astNode + ) + } +} + +func validateTypes(context: SchemaValidationContext) throws { + let validateInputObjectCircularRefs = + try createInputObjectCircularRefsValidator(context: context) + let typeMap = context.schema.typeMap + for type in typeMap.values { + var astNode: Node? + + if let type = type as? GraphQLObjectType { + astNode = type.astNode + + // Ensure fields are valid + try validateFields(context: context, type: type) + + // Ensure objects implement the interfaces they claim to. + try validateInterfaces(context: context, type: type) + } else if let type = type as? GraphQLInterfaceType { + astNode = type.astNode + + // Ensure fields are valid. + try validateFields(context: context, type: type) + + // Ensure interfaces implement the interfaces they claim to. + try validateInterfaces(context: context, type: type) + } else if let type = type as? GraphQLUnionType { + astNode = type.astNode + + // Ensure Unions include valid member types. + try validateUnionMembers(context: context, union: type) + } else if let type = type as? GraphQLEnumType { + astNode = type.astNode + + // Ensure Enums have valid values. + validateEnumValues(context: context, enumType: type) + } else if let type = type as? GraphQLInputObjectType { + astNode = type.astNode + + // Ensure Input Object fields are valid. + try validateInputFields(context: context, inputObj: type) + + // Ensure Input Objects do not contain non-nullable circular references + try validateInputObjectCircularRefs(type) + } else if let type = type as? GraphQLScalarType { + astNode = type.astNode + } + + // Ensure it is named correctly (excluding introspection types). + if let astNode = astNode, !isIntrospectionType(type: type) { + validateName(context: context, name: type.name, astNode: astNode) + } + } +} + +func validateFields( + context: SchemaValidationContext, + type: GraphQLObjectType +) throws { + let fields = try type.getFields() + + // Objects and Interfaces both must define one or more fields. + if fields.count == 0 { + var nodes: [Node?] = [type.astNode] + nodes.append(contentsOf: type.extensionASTNodes) + context.reportError(message: "Type \(type) must define one or more fields.", nodes: nodes) + } + + for field in fields.values { + // Ensure they are named correctly. + validateName(context: context, name: field.name, astNode: field.astNode) + + // Ensure the arguments are valid + for arg in field.args { + let argName = arg.name + + // Ensure they are named correctly. + validateName(context: context, name: arg.name, astNode: arg.astNode) + + // Ensure the type is an input type + if !isInputType(type: arg.type) { + context.reportError( + message: "The type of \(type).\(field.name)(\(argName):) must be Input " + + "Type but got: \(arg.type).", + node: arg.astNode?.type + ) + } + + if isRequiredArgument(arg), arg.deprecationReason != nil { + context.reportError( + message: "Required argument \(type).\(field.name)(\(argName):) cannot be deprecated.", + nodes: [ + getDeprecatedDirectiveNode(directives: arg.astNode?.directives), + arg.astNode?.type, + ] + ) + } + } + } +} + +func validateFields( + context: SchemaValidationContext, + type: GraphQLInterfaceType +) throws { + let fields = try type.getFields() + + // Objects and Interfaces both must define one or more fields. + if fields.count == 0 { + var nodes: [Node?] = [type.astNode] + nodes.append(contentsOf: type.extensionASTNodes) + context.reportError(message: "Type \(type) must define one or more fields.", nodes: nodes) + } + + for field in fields.values { + // Ensure they are named correctly. + validateName(context: context, name: field.name, astNode: field.astNode) + + // Ensure the arguments are valid + for arg in field.args { + let argName = arg.name + + // Ensure they are named correctly. + validateName(context: context, name: arg.name, astNode: arg.astNode) + + // Ensure the type is an input type + if !isInputType(type: arg.type) { + context.reportError( + message: "The type of \(type).\(field.name)(\(argName):) must be Input " + + "Type but got: \(arg.type).", + node: arg.astNode?.type + ) + } + + if isRequiredArgument(arg), arg.deprecationReason != nil { + context.reportError( + message: "Required argument \(type).\(field.name)(\(argName):) cannot be deprecated.", + nodes: [ + getDeprecatedDirectiveNode(directives: arg.astNode?.directives), + arg.astNode?.type, + ] + ) + } + } + } +} + +func validateInterfaces( + context: SchemaValidationContext, + type: GraphQLObjectType +) throws { + var ifaceTypeNames = Set() + for iface in try type.getInterfaces() { + if type == iface { + context.reportError( + message: "Type \(type) cannot implement itself because it would create a circular reference.", + nodes: getAllImplementsInterfaceNodes(type: type, iface: iface) + ) + continue + } + + if ifaceTypeNames.contains(iface.name) { + context.reportError( + message: "Type \(type) can only implement \(iface.name) once.", + nodes: getAllImplementsInterfaceNodes(type: type, iface: iface) + ) + continue + } + + ifaceTypeNames.insert(iface.name) + + try validateTypeImplementsAncestors(context: context, type: type, iface: iface) + try validateTypeImplementsInterface(context: context, type: type, iface: iface) + } +} + +func validateInterfaces( + context: SchemaValidationContext, + type: GraphQLInterfaceType +) throws { + var ifaceTypeNames = Set() + for iface in try type.getInterfaces() { + if type == iface { + context.reportError( + message: "Type \(type) cannot implement itself because it would create a circular reference.", + nodes: getAllImplementsInterfaceNodes(type: type, iface: iface) + ) + continue + } + + if ifaceTypeNames.contains(iface.name) { + context.reportError( + message: "Type \(type) can only implement \(iface.name) once.", + nodes: getAllImplementsInterfaceNodes(type: type, iface: iface) + ) + continue + } + + ifaceTypeNames.insert(iface.name) + + try validateTypeImplementsAncestors(context: context, type: type, iface: iface) + try validateTypeImplementsInterface(context: context, type: type, iface: iface) + } +} + +func validateTypeImplementsInterface( + context: SchemaValidationContext, + type: GraphQLObjectType, + iface: GraphQLInterfaceType +) throws { + let typeFieldMap = try type.getFields() + + // Assert each interface field is implemented. + for ifaceField in try iface.getFields().values { + let fieldName = ifaceField.name + let typeField = typeFieldMap[fieldName] + + // Assert interface field exists on type. + guard let typeField = typeField else { + var nodes: [Node?] = [ifaceField.astNode, type.astNode] + nodes.append(contentsOf: type.extensionASTNodes) + context.reportError( + message: "Interface field \(iface.name).\(fieldName) expected but \(type) does not provide it.", + nodes: nodes + ) + continue + } + + // Assert interface field type is satisfied by type field type, by being + // a valid subtype. (covariant) + if try !isTypeSubTypeOf(context.schema, typeField.type, ifaceField.type) { + context.reportError( + message: "Interface field \(iface.name).\(fieldName) expects type " + + "\(ifaceField.type) but \(type).\(fieldName) " + + "is type \(typeField.type).", + nodes: [ifaceField.astNode?.type, typeField.astNode?.type] + ) + } + + // Assert each interface field arg is implemented. + for ifaceArg in ifaceField.args { + let argName = ifaceArg.name + let typeArg = typeField.args.find { arg in arg.name == argName } + + // Assert interface field arg exists on object field. + guard let typeArg = typeArg else { + context.reportError( + message: "Interface field argument \(iface.name).\(fieldName)(\(argName):) expected but \(type).\(fieldName) does not provide it.", + nodes: [ifaceArg.astNode, typeField.astNode] + ) + continue + } + + // Assert interface field arg type matches object field arg type. + // (invariant) + // TODO: change to contravariant? + if !isEqualType(ifaceArg.type, typeArg.type) { + context.reportError( + message: "Interface field argument \(iface.name).\(fieldName)(\(argName):) " + + "expects type \(ifaceArg.type) but " + + "\(type).\(fieldName)(\(argName):) is type " + + "\(typeArg.type).", + nodes: [ifaceArg.astNode?.type, typeArg.astNode?.type] + ) + } + + // TODO: validate default values? + } + + // Assert additional arguments must not be required. + for typeArg in typeField.args { + let argName = typeArg.name + let ifaceArg = ifaceField.args.find { arg in arg.name == argName } + if ifaceArg == nil, isRequiredArgument(typeArg) { + context.reportError( + message: "Argument \"\(type).\(fieldName)(\(argName):)\" must not be required type \"\(typeArg.type)\" if not provided by the Interface field \"\(iface.name).\(fieldName)\".", + nodes: [typeArg.astNode, ifaceField.astNode] + ) + } + } + } +} + +func validateTypeImplementsInterface( + context: SchemaValidationContext, + type: GraphQLInterfaceType, + iface: GraphQLInterfaceType +) throws { + let typeFieldMap = try type.getFields() + + // Assert each interface field is implemented. + for ifaceField in try iface.getFields().values { + let fieldName = ifaceField.name + let typeField = typeFieldMap[fieldName] + + // Assert interface field exists on type. + guard let typeField = typeField else { + var nodes: [Node?] = [ifaceField.astNode, type.astNode] + nodes.append(contentsOf: type.extensionASTNodes) + context.reportError( + message: "Interface field \(iface.name).\(fieldName) expected but \(type) does not provide it.", + nodes: nodes + ) + continue + } + + // Assert interface field type is satisfied by type field type, by being + // a valid subtype. (covariant) + if try !isTypeSubTypeOf(context.schema, typeField.type, ifaceField.type) { + context.reportError( + message: "Interface field \(iface.name).\(fieldName) expects type " + + "\(ifaceField.type) but \(type).\(fieldName) " + + "is type \(typeField.type).", + nodes: [ifaceField.astNode?.type, typeField.astNode?.type] + ) + } + + // Assert each interface field arg is implemented. + for ifaceArg in ifaceField.args { + let argName = ifaceArg.name + let typeArg = typeField.args.find { arg in arg.name == argName } + + // Assert interface field arg exists on object field. + guard let typeArg = typeArg else { + context.reportError( + message: "Interface field argument \(iface.name).\(fieldName)(\(argName):) expected but \(type).\(fieldName) does not provide it.", + nodes: [ifaceArg.astNode, typeField.astNode] + ) + continue + } + + // Assert interface field arg type matches object field arg type. + // (invariant) + // TODO: change to contravariant? + if !isEqualType(ifaceArg.type, typeArg.type) { + context.reportError( + message: "Interface field argument \(iface.name).\(fieldName)(\(argName):) " + + "expects type \(ifaceArg.type) but " + + "\(type).\(fieldName)(\(argName):) is type " + + "\(typeArg.type).", + nodes: [ifaceArg.astNode?.type, typeArg.astNode?.type] + ) + } + + // TODO: validate default values? + } + + // Assert additional arguments must not be required. + for typeArg in typeField.args { + let argName = typeArg.name + let ifaceArg = ifaceField.args.find { arg in arg.name == argName } + if ifaceArg == nil, isRequiredArgument(typeArg) { + context.reportError( + message: "Argument \"\(type).\(fieldName)(\(argName):)\" must not be required type \"\(typeArg.type)\" if not provided by the Interface field \"\(iface.name).\(fieldName)\".", + nodes: [typeArg.astNode, ifaceField.astNode] + ) + } + } + } +} + +func validateTypeImplementsAncestors( + context: SchemaValidationContext, + type: GraphQLObjectType, + iface: GraphQLInterfaceType +) throws { + let ifaceInterfaces = try type.getInterfaces() + for transitive in try iface.getInterfaces() { + if !ifaceInterfaces.contains(transitive) { + var nodes: [Node?] = getAllImplementsInterfaceNodes(type: iface, iface: transitive) + nodes.append(contentsOf: getAllImplementsInterfaceNodes(type: type, iface: iface)) + context.reportError( + message: transitive == type + ? + "Type \(type) cannot implement \(iface.name) because it would create a circular reference." + : + "Type \(type) must implement \(transitive.name) because it is implemented by \(iface.name).", + nodes: nodes + ) + } + } +} + +func validateTypeImplementsAncestors( + context: SchemaValidationContext, + type: GraphQLInterfaceType, + iface: GraphQLInterfaceType +) throws { + let ifaceInterfaces = try type.getInterfaces() + for transitive in try iface.getInterfaces() { + if !ifaceInterfaces.contains(transitive) { + var nodes: [Node?] = getAllImplementsInterfaceNodes(type: iface, iface: transitive) + nodes.append(contentsOf: getAllImplementsInterfaceNodes(type: type, iface: iface)) + context.reportError( + message: transitive == type + ? + "Type \(type) cannot implement \(iface.name) because it would create a circular reference." + : + "Type \(type) must implement \(transitive.name) because it is implemented by \(iface.name).", + nodes: nodes + ) + } + } +} + +func validateUnionMembers( + context: SchemaValidationContext, + union: GraphQLUnionType +) throws { + let memberTypes = try union.getTypes() + + if memberTypes.count == 0 { + var nodes: [Node?] = [union.astNode] + nodes.append(contentsOf: union.extensionASTNodes) + context.reportError( + message: "Union type \(union.name) must define one or more member types.", + nodes: nodes + ) + } + + var includedTypeNames = Set() + for memberType in memberTypes { + if includedTypeNames.contains(memberType.name) { + context.reportError( + message: "Union type \(union.name) can only include type \(memberType) once.", + nodes: getUnionMemberTypeNodes(union: union, typeName: memberType.name) + ) + continue + } + includedTypeNames.insert(memberType.name) + } +} + +func validateEnumValues( + context: SchemaValidationContext, + enumType: GraphQLEnumType +) { + let enumValues = enumType.values + + if enumValues.count == 0 { + var nodes: [Node?] = [enumType.astNode] + nodes.append(contentsOf: enumType.extensionASTNodes) + context.reportError( + message: "Enum type \(enumType) must define one or more values.", + nodes: nodes + ) + } + + for enumValue in enumValues { + // Ensure valid name. + validateName(context: context, name: enumValue.name, astNode: enumValue.astNode) + } +} + +func validateInputFields( + context: SchemaValidationContext, + inputObj: GraphQLInputObjectType +) throws { + let fields = try inputObj.getFields().values + + if fields.count == 0 { + var nodes: [Node?] = [inputObj.astNode] + nodes.append(contentsOf: inputObj.extensionASTNodes) + context.reportError( + message: "Input Object type \(inputObj.name) must define one or more fields.", + nodes: nodes + ) + } + + // Ensure the arguments are valid + for field in fields { + // Ensure they are named correctly. + validateName(context: context, name: field.name, astNode: field.astNode) + + // Ensure the type is an input type + if !isInputType(type: field.type) { + context.reportError( + message: "The type of \(inputObj.name).\(field.name) must be Input Type " + + "but got: \(field.type).", + node: field.astNode?.type + ) + } + + if isRequiredInputField(field), field.deprecationReason != nil { + context.reportError( + message: "Required input field \(inputObj.name).\(field.name) cannot be deprecated.", + nodes: [ + getDeprecatedDirectiveNode(directives: field.astNode?.directives), + field.astNode?.type, + ] + ) + } + + if inputObj.isOneOf { + validateOneOfInputObjectField(type: inputObj, field: field, context: context) + } + } +} + +func validateOneOfInputObjectField( + type: GraphQLInputObjectType, + field: InputObjectFieldDefinition, + context: SchemaValidationContext +) { + if field.type is GraphQLNonNull { + context.reportError( + message: "OneOf input field \(type).\(field.name) must be nullable.", + node: field.astNode?.type + ) + } + + if field.defaultValue != nil { + context.reportError( + message: "OneOf input field \(type).\(field.name) cannot have a default value.", + node: field.astNode + ) + } +} + +func createInputObjectCircularRefsValidator( + context: SchemaValidationContext +) throws -> (GraphQLInputObjectType) throws -> Void { + // Modified copy of algorithm from 'src/validation/rules/NoFragmentCycles.js'. + // Tracks already visited types to maintain O(N) and to ensure that cycles + // are not redundantly reported. + var visitedTypes = Set() + + // Array of types nodes used to produce meaningful errors + var fieldPath: [InputObjectFieldDefinition] = [] + + // Position in the type path + var fieldPathIndexByTypeName: [String: Int] = [:] + + return detectCycleRecursive + + // This does a straight-forward DFS to find cycles. + // It does not terminate when a cycle is found but continues to explore + // the graph to find all possible cycles. + func detectCycleRecursive(inputObj: GraphQLInputObjectType) throws { + if visitedTypes.contains(inputObj) { + return + } + + visitedTypes.insert(inputObj) + fieldPathIndexByTypeName[inputObj.name] = fieldPath.count + + let fields = try inputObj.getFields().values + for field in fields { + if + let nonNullType = field.type as? GraphQLNonNull, + let fieldType = nonNullType.ofType as? GraphQLInputObjectType + { + let cycleIndex = fieldPathIndexByTypeName[fieldType.name] + + fieldPath.append(field) + if let cycleIndex = cycleIndex { + let cyclePath = fieldPath[cycleIndex ..< fieldPath.count] + let pathStr = cyclePath.map { fieldObj in fieldObj.name }.joined(separator: ".") + context.reportError( + message: "Cannot reference Input Object \"\(fieldType)\" within itself through a series of non-null fields: \"\(pathStr)\".", + nodes: cyclePath.map { fieldObj in fieldObj.astNode } + ) + } else { + try detectCycleRecursive(inputObj: fieldType) + } + fieldPath.removeLast() + } + } + + fieldPathIndexByTypeName[inputObj.name] = nil + } +} + +func getAllImplementsInterfaceNodes( + type: GraphQLObjectType, + iface: GraphQLInterfaceType +) -> [NamedType] { + var nodes: [NamedType] = [] + nodes.append(contentsOf: type.astNode?.interfaces ?? []) + // TODO: Add extension support for interface conformance +// nodes.append(contentsOf: type.extensionASTNodes.flatMap { $0.interfaces }) + return nodes.filter { ifaceNode in ifaceNode.name.value == iface.name } +} + +func getAllImplementsInterfaceNodes( + type: GraphQLInterfaceType, + iface: GraphQLInterfaceType +) -> [NamedType] { + var nodes: [NamedType] = [] + nodes.append(contentsOf: type.astNode?.interfaces ?? []) + // TODO: Add extension support for interface conformance +// nodes.append(contentsOf: type.extensionASTNodes.flatMap { $0.interfaces }) + return nodes.filter { ifaceNode in ifaceNode.name.value == iface.name } +} + +func getUnionMemberTypeNodes( + union: GraphQLUnionType, + typeName: String +) -> [NamedType] { + var nodes: [NamedType] = [] + nodes.append(contentsOf: union.astNode?.types ?? []) + // TODO: Add extension support for union membership +// nodes.append(contentsOf: union.extensionASTNodes.flatMap { $0.types }) + return nodes.filter { typeNode in typeNode.name.value == typeName } +} + +func getDeprecatedDirectiveNode( + directives: [Directive]? +) -> Directive? { + return directives?.find { node in + node.name.value == GraphQLDeprecatedDirective.name + } +} diff --git a/Sources/GraphQL/Utilities/ASTFromValue.swift b/Sources/GraphQL/Utilities/ASTFromValue.swift index f5c721a7..e7c579f1 100644 --- a/Sources/GraphQL/Utilities/ASTFromValue.swift +++ b/Sources/GraphQL/Utilities/ASTFromValue.swift @@ -62,7 +62,7 @@ func astFromValue( return nil } - let fields = type.fields + let fields = try type.getFields() var fieldASTs: [ObjectField] = [] for (fieldName, field) in fields { @@ -96,6 +96,11 @@ func astFromValue( return nil } + // Others serialize based on their corresponding JavaScript scalar types. + if case let .bool(bool) = serialized { + return BooleanValue(value: bool) + } + // Others serialize based on their corresponding scalar types. if case let .bool(bool) = serialized { return BooleanValue(value: bool) diff --git a/Sources/GraphQL/Utilities/BuildASTSchema.swift b/Sources/GraphQL/Utilities/BuildASTSchema.swift new file mode 100644 index 00000000..e5a3805a --- /dev/null +++ b/Sources/GraphQL/Utilities/BuildASTSchema.swift @@ -0,0 +1,85 @@ +/** + * This takes the ast of a schema document produced by the parse function in + * src/language/parser.js. + * + * If no schema definition is provided, then it will look for types named Query, + * Mutation and Subscription. + * + * Given that AST it constructs a GraphQLSchema. The resulting schema + * has no resolve methods, so execution will use default resolvers. + */ +public func buildASTSchema( + documentAST: Document, + assumeValid: Bool = false, + assumeValidSDL: Bool = false +) throws -> GraphQLSchema { + if assumeValid != true, !assumeValidSDL { + try assertValidSDL(documentAST: documentAST) + } + let emptySchemaConfig = GraphQLSchemaNormalizedConfig() + let config = try extendSchemaImpl(emptySchemaConfig, documentAST) + + if config.astNode == nil { + try config.types.forEach { type in + switch type.name { + case "Query": config.query = try checkOperationType(operationType: .query, type: type) + case "Mutation": config + .mutation = try checkOperationType(operationType: .mutation, type: type) + case "Subscription": config + .subscription = try checkOperationType(operationType: .subscription, type: type) + default: break + } + } + } + + var directives = config.directives + directives.append(contentsOf: specifiedDirectives.filter { stdDirective in + config.directives.allSatisfy { directive in + directive.name != stdDirective.name + } + }) + + config.directives = directives + + return try GraphQLSchema(config: config) +} + +/** + * A helper function to build a GraphQLSchema directly from a source + * document. + */ +public func buildSchema( + source: Source, + assumeValid: Bool = false, + assumeValidSDL: Bool = false +) throws -> GraphQLSchema { + let document = try parse( + source: source + ) + + return try buildASTSchema( + documentAST: document, + assumeValid: assumeValid, + assumeValidSDL: assumeValidSDL + ) +} + +/** + * A helper function to build a GraphQLSchema directly from a source + * document. + */ +public func buildSchema( + source: String, + assumeValid: Bool = false, + assumeValidSDL: Bool = false +) throws -> GraphQLSchema { + let document = try parse( + source: source + ) + + return try buildASTSchema( + documentAST: document, + assumeValid: assumeValid, + assumeValidSDL: assumeValidSDL + ) +} diff --git a/Sources/GraphQL/Utilities/ConcatAST.swift b/Sources/GraphQL/Utilities/ConcatAST.swift new file mode 100644 index 00000000..198b1b3f --- /dev/null +++ b/Sources/GraphQL/Utilities/ConcatAST.swift @@ -0,0 +1,14 @@ +/** + * Provided a collection of ASTs, presumably each from different files, + * concatenate the ASTs together into batched AST, useful for validating many + * GraphQL source files which together represent one conceptual application. + */ +func concatAST( + documents: [Document] +) -> Document { + var definitions: [Definition] = [] + for doc in documents { + definitions.append(contentsOf: doc.definitions) + } + return Document(definitions: definitions) +} diff --git a/Sources/GraphQL/Utilities/ExtendSchema.swift b/Sources/GraphQL/Utilities/ExtendSchema.swift new file mode 100644 index 00000000..35f64e09 --- /dev/null +++ b/Sources/GraphQL/Utilities/ExtendSchema.swift @@ -0,0 +1,1030 @@ +import OrderedCollections + +/** + * Produces a new schema given an existing schema and a document which may + * contain GraphQL type extensions and definitions. The original schema will + * remain unaltered. + * + * Because a schema represents a graph of references, a schema cannot be + * extended without effectively making an entire copy. We do not know until it's + * too late if subgraphs remain unchanged. + * + * This algorithm copies the provided schema, applying extensions while + * producing the copy. The original schema remains unaltered. + */ +public func extendSchema( + schema: GraphQLSchema, + documentAST: Document, + assumeValid: Bool = false, + assumeValidSDL: Bool = false +) throws -> GraphQLSchema { + if !assumeValid, !assumeValidSDL { + try assertValidSDLExtension(documentAST: documentAST, schema: schema) + } + + let schemaConfig = schema.toConfig() + let extendedConfig = try extendSchemaImpl(schemaConfig, documentAST, assumeValid) + + return try ObjectIdentifier(schemaConfig) == ObjectIdentifier(extendedConfig) + ? schema + : GraphQLSchema(config: extendedConfig) +} + +func extendSchemaImpl( + _ schemaConfig: GraphQLSchemaNormalizedConfig, + _ documentAST: Document, + _ assumeValid: Bool = false +) throws -> GraphQLSchemaNormalizedConfig { + // Collect the type definitions and extensions found in the document. + var typeDefs = [TypeDefinition]() + + var scalarExtensions = [String: [ScalarExtensionDefinition]]() + var objectExtensions = [String: [TypeExtensionDefinition]]() + var interfaceExtensions = [String: [InterfaceExtensionDefinition]]() + var unionExtensions = [String: [UnionExtensionDefinition]]() + var enumExtensions = [String: [EnumExtensionDefinition]]() + var inputObjectExtensions = [String: [InputObjectExtensionDefinition]]() + + // New directives and types are separate because a directives and types can + // have the same name. For example, a type named "skip". + var directiveDefs = [DirectiveDefinition]() + + var schemaDef: SchemaDefinition? = nil + // Schema extensions are collected which may add additional operation types. + var schemaExtensions = [SchemaExtensionDefinition]() + + var isSchemaChanged = false + for def in documentAST.definitions { + switch def.kind { + case .schemaDefinition: + schemaDef = (def as! SchemaDefinition) + case .schemaExtensionDefinition: + schemaExtensions.append(def as! SchemaExtensionDefinition) + case .directiveDefinition: + directiveDefs.append(def as! DirectiveDefinition) + // Type Definitions + case + .scalarTypeDefinition, + .objectTypeDefinition, + .interfaceTypeDefinition, + .unionTypeDefinition, + .enumTypeDefinition, + .inputObjectTypeDefinition + : + typeDefs.append(def as! TypeDefinition) + // Type System Extensions + case .scalarExtensionDefinition: + let def = def as! ScalarExtensionDefinition + var extensions = scalarExtensions[def.definition.name.value] ?? [] + extensions.append(def) + scalarExtensions[def.definition.name.value] = extensions + case .typeExtensionDefinition: + let def = def as! TypeExtensionDefinition + var extensions = objectExtensions[def.definition.name.value] ?? [] + extensions.append(def) + objectExtensions[def.definition.name.value] = extensions + case .interfaceExtensionDefinition: + let def = def as! InterfaceExtensionDefinition + var extensions = interfaceExtensions[def.definition.name.value] ?? [] + extensions.append(def) + interfaceExtensions[def.definition.name.value] = extensions + case .unionExtensionDefinition: + let def = def as! UnionExtensionDefinition + var extensions = unionExtensions[def.definition.name.value] ?? [] + extensions.append(def) + unionExtensions[def.definition.name.value] = extensions + case .enumExtensionDefinition: + let def = def as! EnumExtensionDefinition + var extensions = enumExtensions[def.definition.name.value] ?? [] + extensions.append(def) + enumExtensions[def.definition.name.value] = extensions + case .inputObjectExtensionDefinition: + let def = def as! InputObjectExtensionDefinition + var extensions = inputObjectExtensions[def.definition.name.value] ?? [] + extensions.append(def) + inputObjectExtensions[def.definition.name.value] = extensions + default: + continue + } + isSchemaChanged = true + } + + // If this document contains no new types, extensions, or directives then + // return the same unmodified GraphQLSchema instance. + if !isSchemaChanged { + return schemaConfig + } + + var typeMap = OrderedDictionary() + for type in schemaConfig.types { + typeMap[type.name] = try extendNamedType(type) + } + + for typeNode in typeDefs { + let name = typeNode.name.value + typeMap[name] = try stdTypeMap[name] ?? buildType(astNode: typeNode) + } + + // Get the extended root operation types. + var query = schemaConfig.query.map { replaceNamedType($0) } + var mutation = schemaConfig.mutation.map { replaceNamedType($0) } + var subscription = schemaConfig.subscription.map { replaceNamedType($0) } + // Then, incorporate schema definition and all schema extensions. + if let schemaDef = schemaDef { + let schemaOperations = try getOperationTypes(nodes: [schemaDef]) + query = schemaOperations.query ?? query + mutation = schemaOperations.mutation ?? mutation + subscription = schemaOperations.subscription ?? subscription + } + let extensionOperations = try getOperationTypes(nodes: schemaExtensions) + query = extensionOperations.query ?? query + mutation = extensionOperations.mutation ?? mutation + subscription = extensionOperations.subscription ?? subscription + + var extensionASTNodes = schemaConfig.extensionASTNodes + extensionASTNodes.append(contentsOf: schemaExtensions) + + var directives = [GraphQLDirective]() + for directive in schemaConfig.directives { + try directives.append(replaceDirective(directive)) + } + for directive in directiveDefs { + try directives.append(buildDirective(node: directive)) + } + // Then, incorporate schema definition and all schema extensions. + return GraphQLSchemaNormalizedConfig( + description: schemaDef?.description?.value ?? schemaConfig.description, + query: query, + mutation: mutation, + subscription: subscription, + types: Array(typeMap.values), + directives: directives, + extensions: schemaConfig.extensions, + astNode: schemaDef ?? schemaConfig.astNode, + extensionASTNodes: extensionASTNodes, + assumeValid: assumeValid + ) + + // Below are functions used for producing this schema that have closed over + // this scope and have access to the schema, cache, and newly defined types. + + func replaceType(_ type: T) -> T { + if let type = type as? GraphQLList { + return GraphQLList(replaceType(type.ofType)) as! T + } + if let type = type as? GraphQLNonNull { + return GraphQLNonNull(replaceType(type.ofType)) as! T + } + if let type = type as? GraphQLNamedType { + return replaceNamedType(type) as! T + } + return type + } + + func replaceNamedType(_ type: T) -> T { + // Note: While this could make early assertions to get the correctly + // typed values, that would throw immediately while type system + // validation with validateSchema() will produce more actionable results. + return typeMap[type.name] as! T + } + + func replaceDirective(_ directive: GraphQLDirective) throws -> GraphQLDirective { + if isSpecifiedDirective(directive) { + // Builtin directives are not extended. + return directive + } + + return try GraphQLDirective( + name: directive.name, + description: directive.description, + locations: directive.locations, + args: directive.argConfigMap().mapValues { arg in extendArg(arg) }, + isRepeatable: directive.isRepeatable, + astNode: directive.astNode + ) + } + + func extendNamedType(_ type: GraphQLNamedType) throws -> GraphQLNamedType { + if isIntrospectionType(type: type) || isSpecifiedScalarType(type) { + // Builtin types are not extended. + return type + } + if let type = type as? GraphQLScalarType { + return try extendScalarType(type) + } + if let type = type as? GraphQLObjectType { + return try extendObjectType(type) + } + if let type = type as? GraphQLInterfaceType { + return try extendInterfaceType(type) + } + if let type = type as? GraphQLUnionType { + return try extendUnionType(type) + } + if let type = type as? GraphQLEnumType { + return try extendEnumType(type) + } + if let type = type as? GraphQLInputObjectType { + return try extendInputObjectType(type) + } + + // Not reachable, all possible type definition nodes have been considered. + throw GraphQLError(message: "Unexpected type: \(type.name)") + } + + func extendInputObjectType( + _ type: GraphQLInputObjectType + ) throws -> GraphQLInputObjectType { + let extensions = inputObjectExtensions[type.name] ?? [] + var extensionASTNodes = type.extensionASTNodes + extensionASTNodes.append(contentsOf: extensions) + + return try GraphQLInputObjectType( + name: type.name, + description: type.description, + fields: { + let fields = try type.getFields().mapValues { field in + InputObjectField( + type: replaceType(field.type), + defaultValue: field.defaultValue, + description: field.description, + deprecationReason: field.deprecationReason, + astNode: field.astNode + ) + }.merging(buildInputFieldMap(nodes: extensions)) { $1 } + return fields + }, + astNode: type.astNode, + extensionASTNodes: extensionASTNodes + ) + } + + func extendEnumType(_ type: GraphQLEnumType) throws -> GraphQLEnumType { + let extensions = enumExtensions[type.name] ?? [] + var extensionASTNodes = type.extensionASTNodes + extensionASTNodes.append(contentsOf: extensions) + + var values = GraphQLEnumValueMap() + for value in type.values { + values[value.name] = GraphQLEnumValue( + value: value.value, + description: value.description, + deprecationReason: value.deprecationReason, + astNode: value.astNode + ) + } + for (name, value) in try buildEnumValueMap(nodes: extensions) { + values[name] = value + } + + return try GraphQLEnumType( + name: type.name, + description: type.description, + values: values, + astNode: type.astNode, + extensionASTNodes: extensionASTNodes + ) + } + + func extendScalarType(_ type: GraphQLScalarType) throws -> GraphQLScalarType { + let extensions = scalarExtensions[type.name] ?? [] + var specifiedByURL = type.specifiedByURL + for extensionNode in extensions { + specifiedByURL = try getSpecifiedByURL(node: extensionNode) ?? specifiedByURL + } + + var extensionASTNodes = type.extensionASTNodes + extensionASTNodes.append(contentsOf: extensions) + return try GraphQLScalarType( + name: type.name, + description: type.description, + specifiedByURL: specifiedByURL, + serialize: type.serialize, + parseValue: type.parseValue, + parseLiteral: type.parseLiteral, + astNode: type.astNode, + extensionASTNodes: extensionASTNodes + ) + } + + func extendObjectType(_ type: GraphQLObjectType) throws -> GraphQLObjectType { + let extensions = objectExtensions[type.name] ?? [] + var extensionASTNodes = type.extensionASTNodes + extensionASTNodes.append(contentsOf: extensions) + + return try GraphQLObjectType( + name: type.name, + description: type.description, + fields: { + try type.getFields().mapValues { field in + extendField(field.toField()) + }.merging(buildFieldMap(nodes: extensions)) { $1 } + }, + interfaces: { + var interfaces = try type.getInterfaces().map { interface in + replaceNamedType(interface) + } + try interfaces.append(contentsOf: buildInterfaces(nodes: extensions)) + return interfaces + }, + isTypeOf: type.isTypeOf, + astNode: type.astNode, + extensionASTNodes: extensionASTNodes + ) + } + + func extendInterfaceType(_ type: GraphQLInterfaceType) throws -> GraphQLInterfaceType { + let extensions = interfaceExtensions[type.name] ?? [] + var extensionASTNodes = type.extensionASTNodes + extensionASTNodes.append(contentsOf: extensions) + + return try GraphQLInterfaceType( + name: type.name, + description: type.description, + fields: { + try type.getFields().mapValues { field in + extendField(field.toField()) + }.merging(buildFieldMap(nodes: extensions)) { $1 } + }, + interfaces: { + var interfaces = try type.getInterfaces().map { interface in + replaceNamedType(interface) + } + try interfaces.append(contentsOf: buildInterfaces(nodes: extensions)) + return interfaces + }, + resolveType: type.resolveType, + astNode: type.astNode, + extensionASTNodes: extensionASTNodes + ) + } + + func extendUnionType(_ type: GraphQLUnionType) throws -> GraphQLUnionType { + let extensions = unionExtensions[type.name] ?? [] + var extensionASTNodes = type.extensionASTNodes + extensionASTNodes.append(contentsOf: extensions) + + return try GraphQLUnionType( + name: type.name, + description: type.description, + resolveType: type.resolveType, + types: { + var types = try type.getTypes().map { type in + replaceNamedType(type) + } + try types.append(contentsOf: buildUnionTypes(nodes: extensions)) + return types + }, + astNode: type.astNode, + extensionASTNodes: extensionASTNodes + ) + } + + func extendField(_ field: GraphQLField) -> GraphQLField { + let args = field.args.merging(field.args.mapValues { extendArg($0) }) { $1 } + return GraphQLField( + type: replaceType(field.type), + description: field.description, + deprecationReason: field.deprecationReason, + args: args, + resolve: field.resolve, + subscribe: field.subscribe, + astNode: field.astNode + ) + } + + func extendArg(_ arg: GraphQLArgument) -> GraphQLArgument { + return GraphQLArgument( + type: replaceType(arg.type), + description: arg.description, + defaultValue: arg.defaultValue, + deprecationReason: arg.deprecationReason, + astNode: arg.astNode + ) + } + + struct OperationTypes { + let query: GraphQLObjectType? + let mutation: GraphQLObjectType? + let subscription: GraphQLObjectType? + } + + func getOperationTypes( + nodes: [SchemaDefinition] + ) throws -> OperationTypes { + var query: GraphQLObjectType? = nil + var mutation: GraphQLObjectType? = nil + var subscription: GraphQLObjectType? = nil + for node in nodes { + let operationTypesNodes = node.operationTypes + + for operationType in operationTypesNodes { + let namedType = try getNamedType(operationType.type) + + switch operationType.operation { + case .query: + query = try checkOperationType( + operationType: operationType.operation, + type: namedType + ) + case .mutation: + mutation = try checkOperationType( + operationType: operationType.operation, + type: namedType + ) + case .subscription: + subscription = try checkOperationType( + operationType: operationType.operation, + type: namedType + ) + } + } + } + + return OperationTypes(query: query, mutation: mutation, subscription: subscription) + } + + func getOperationTypes( + nodes: [SchemaExtensionDefinition] + ) throws -> OperationTypes { + var query: GraphQLObjectType? = nil + var mutation: GraphQLObjectType? = nil + var subscription: GraphQLObjectType? = nil + for node in nodes { + let operationTypesNodes = node.definition.operationTypes + + for operationType in operationTypesNodes { + let namedType = try getNamedType(operationType.type) + switch operationType.operation { + case .query: + query = try checkOperationType( + operationType: operationType.operation, + type: namedType + ) + case .mutation: + mutation = try checkOperationType( + operationType: operationType.operation, + type: namedType + ) + case .subscription: + subscription = try checkOperationType( + operationType: operationType.operation, + type: namedType + ) + } + } + } + + return OperationTypes(query: query, mutation: mutation, subscription: subscription) + } + + func getNamedType(_ node: NamedType) throws -> GraphQLNamedType { + let name = node.name.value + let type = stdTypeMap[name] ?? typeMap[name] + + guard let type = type else { + throw GraphQLError(message: "Unknown type: \"\(name)\".") + } + return type + } + + func getWrappedType(_ node: Type) throws -> GraphQLType { + if let node = node as? ListType { + return try GraphQLList(getWrappedType(node.type)) + } + if let node = node as? NonNullType { + return try GraphQLNonNull(getWrappedType(node.type)) + } + if let node = node as? NamedType { + return try getNamedType(node) + } + throw GraphQLError( + message: "No type wrapped" + ) + } + + func buildDirective(node: DirectiveDefinition) throws -> GraphQLDirective { + return try GraphQLDirective( + name: node.name.value, + description: node.description?.value, + locations: node.locations.compactMap { DirectiveLocation(rawValue: $0.value) }, + args: buildArgumentMap(node.arguments, methodFormat: "@\(node.name.printed)"), + isRepeatable: node.repeatable, + astNode: node + ) + } + + func buildFieldMap( + nodes: [InterfaceTypeDefinition] + ) throws -> GraphQLFieldMap { + var fieldConfigMap = GraphQLFieldMap() + for node in nodes { + for field in node.fields { + fieldConfigMap[field.name.value] = try .init( + type: checkedFieldType(field, typeName: node.name), + description: field.description?.value, + deprecationReason: getDeprecationReason(field), + args: buildArgumentMap( + field.arguments, + methodFormat: "\(node.name.printed).\(field.name.printed)" + ), + astNode: field + ) + } + } + return fieldConfigMap + } + + func buildFieldMap( + nodes: [InterfaceExtensionDefinition] + ) throws -> GraphQLFieldMap { + var fieldConfigMap = GraphQLFieldMap() + for node in nodes { + for field in node.definition.fields { + fieldConfigMap[field.name.value] = try .init( + type: checkedFieldType(field, typeName: node.name), + description: field.description?.value, + deprecationReason: getDeprecationReason(field), + args: buildArgumentMap( + field.arguments, + methodFormat: "\(node.name.printed).\(field.name.printed)" + ), + astNode: field + ) + } + } + return fieldConfigMap + } + + func buildFieldMap( + nodes: [ObjectTypeDefinition] + ) throws -> GraphQLFieldMap { + var fieldConfigMap = GraphQLFieldMap() + for node in nodes { + for field in node.fields { + fieldConfigMap[field.name.value] = try .init( + type: checkedFieldType(field, typeName: node.name), + description: field.description?.value, + deprecationReason: getDeprecationReason(field), + args: buildArgumentMap( + field.arguments, + methodFormat: "\(node.name.printed).\(field.name.printed)" + ), + astNode: field + ) + } + } + return fieldConfigMap + } + + func buildFieldMap( + nodes: [TypeExtensionDefinition] + ) throws -> GraphQLFieldMap { + var fieldConfigMap = GraphQLFieldMap() + for node in nodes { + for field in node.definition.fields { + fieldConfigMap[field.name.value] = try .init( + type: checkedFieldType(field, typeName: node.name), + description: field.description?.value, + deprecationReason: getDeprecationReason(field), + args: buildArgumentMap( + field.arguments, + methodFormat: "\(node.name.printed).\(field.name.printed)" + ), + astNode: field + ) + } + } + return fieldConfigMap + } + + func checkedFieldType(_ field: FieldDefinition, typeName: Name) throws -> GraphQLOutputType { + let wrappedType = try getWrappedType(field.type) + var checkType = wrappedType + // Must unwind List & NonNull types to work around not having conditional conformances + if let listType = wrappedType as? GraphQLList { + checkType = listType.ofType + } else if let nonNullType = wrappedType as? GraphQLNonNull { + checkType = nonNullType.ofType + } + guard let type = wrappedType as? GraphQLOutputType, checkType is GraphQLOutputType else { + throw GraphQLError( + message: "The type of \(typeName.printed).\(field.name.printed) must be Output Type but got: \(field.type)." + ) + } + return type + } + + func buildArgumentMap( + _ args: [InputValueDefinition]?, + methodFormat: String + ) throws -> GraphQLArgumentConfigMap { + let argsNodes = args ?? [] + + var argConfigMap = GraphQLArgumentConfigMap() + for arg in argsNodes { + guard let type = try getWrappedType(arg.type) as? GraphQLInputType else { + throw GraphQLError( + message: "The type of \(methodFormat)(\(arg.name):) must be Input Type but got: \(print(ast: arg.type))." + ) + } + + argConfigMap[arg.name.value] = try GraphQLArgument( + type: type, + description: arg.description?.value, + defaultValue: arg.defaultValue.map { try valueFromAST(valueAST: $0, type: type) }, + deprecationReason: getDeprecationReason(arg), + astNode: arg + ) + } + return argConfigMap + } + + func buildInputFieldMap( + nodes: [InputObjectTypeDefinition] + ) throws -> InputObjectFieldMap { + var inputFieldMap = InputObjectFieldMap() + for node in nodes { + for field in node.fields { + let type = try getWrappedType(field.type) + guard let type = type as? GraphQLInputType else { + throw GraphQLError( + message: "The type of \(node.name.printed).\(field.name.printed) must be Input Type but got: \(type)." + ) + } + + inputFieldMap[field.name.value] = try .init( + type: type, + defaultValue: field.defaultValue + .map { try valueFromAST(valueAST: $0, type: type) }, + description: field.description?.value, + deprecationReason: getDeprecationReason(field), + astNode: field + ) + } + } + return inputFieldMap + } + + func buildInputFieldMap( + nodes: [InputObjectExtensionDefinition] + ) throws -> InputObjectFieldMap { + var inputFieldMap = InputObjectFieldMap() + for node in nodes { + for field in node.definition.fields { + // Note: While this could make assertions to get the correctly typed + // value, that would throw immediately while type system validation + // with validateSchema() will produce more actionable results. + let type = try getWrappedType(field.type) + guard let type = type as? GraphQLInputType else { + throw GraphQLError( + message: "The type of \(node.name.printed).\(field.name.printed) must be Input Type but got: \(type)." + ) + } + + inputFieldMap[field.name.value] = try .init( + type: type, + defaultValue: field.defaultValue + .map { try valueFromAST(valueAST: $0, type: type) }, + description: field.description?.value, + deprecationReason: getDeprecationReason(field), + astNode: field + ) + } + } + return inputFieldMap + } + + func buildEnumValueMap( + nodes: [EnumTypeDefinition] // | EnumTypeExtension], + ) throws -> GraphQLEnumValueMap { + var enumValueMap = GraphQLEnumValueMap() + for node in nodes { + for value in node.values { + enumValueMap[value.name.value] = try GraphQLEnumValue( + value: .string(value.name.value), + description: value.description?.value, + deprecationReason: getDeprecationReason(value), + astNode: value + ) + } + } + return enumValueMap + } + + func buildEnumValueMap( + nodes: [EnumExtensionDefinition] + ) throws -> GraphQLEnumValueMap { + var enumValueMap = GraphQLEnumValueMap() + for node in nodes { + for value in node.definition.values { + enumValueMap[value.name.value] = try GraphQLEnumValue( + value: .string(value.name.value), + description: value.description?.value, + deprecationReason: getDeprecationReason(value), + astNode: value + ) + } + } + return enumValueMap + } + + func buildInterfaces( + nodes: [ObjectTypeDefinition] + ) throws -> [GraphQLInterfaceType] { + return try nodes.flatMap { node in + try checkedInterfaceTypes(node) + } + } + + func buildInterfaces( + nodes: [TypeExtensionDefinition] + ) throws -> [GraphQLInterfaceType] { + return try nodes.flatMap { node in + try checkedInterfaceTypes(node.definition) + } + } + + func buildInterfaces( + nodes: [InterfaceTypeDefinition] + ) throws -> [GraphQLInterfaceType] { + return try nodes.flatMap { node in + try checkedInterfaceTypes(node) + } + } + + func buildInterfaces( + nodes: [InterfaceExtensionDefinition] + ) throws -> [GraphQLInterfaceType] { + return try nodes.flatMap { node in + try checkedInterfaceTypes(node.definition) + } + } + + func checkedInterfaceTypes(_ type: ObjectTypeDefinition) throws -> [GraphQLInterfaceType] { + var interfaces = [GraphQLInterfaceType]() + for interface in type.interfaces { + let namedType = try getNamedType(interface) + guard let checkedInterface = namedType as? GraphQLInterfaceType else { + throw GraphQLError( + message: "Type \(type.name.printed) must only implement Interface types, it cannot implement \(namedType.name)." + ) + } + interfaces.append(checkedInterface) + } + return interfaces + } + + func checkedInterfaceTypes(_ type: InterfaceTypeDefinition) throws -> [GraphQLInterfaceType] { + var interfaces = [GraphQLInterfaceType]() + for interface in type.interfaces { + let namedType = try getNamedType(interface) + guard let checkedInterface = namedType as? GraphQLInterfaceType else { + throw GraphQLError( + message: "Type \(type.name.printed) must only implement Interface types, it cannot implement \(namedType.name)." + ) + } + interfaces.append(checkedInterface) + } + return interfaces + } + + func buildUnionTypes( + nodes: [UnionTypeDefinition] + ) throws -> [GraphQLObjectType] { + return try nodes.flatMap { node in + try checkedUnionTypes(node) + } + } + + func buildUnionTypes( + nodes: [UnionExtensionDefinition] + ) throws -> [GraphQLObjectType] { + return try nodes.flatMap { node in + try checkedUnionTypes(node.definition) + } + } + + func checkedUnionTypes(_ union: UnionTypeDefinition) throws -> [GraphQLObjectType] { + var types = [GraphQLObjectType]() + for type in union.types { + let namedType = try getNamedType(type) + guard let checkedType = namedType as? GraphQLObjectType else { + throw GraphQLError( + message: "Union type \(type.name.printed) can only include Object types, it cannot include \(namedType.name)." + ) + } + types.append(checkedType) + } + return types + } + + func buildType(astNode: TypeDefinition) throws -> GraphQLNamedType { + let name = astNode.name.value + + switch astNode.kind { + case Kind.objectTypeDefinition: + let node = astNode as! ObjectTypeDefinition + let extensionASTNodes = objectExtensions[name] ?? [] + + return try GraphQLObjectType( + name: name, + description: node.description?.value, + fields: { + var fields = try buildFieldMap(nodes: [node]) + for (name, value) in try buildFieldMap(nodes: extensionASTNodes) { + fields[name] = value + } + return fields + }, + interfaces: { + var interfaces = try buildInterfaces(nodes: [node]) + try interfaces.append(contentsOf: buildInterfaces(nodes: extensionASTNodes)) + return interfaces + }, + astNode: node, + extensionASTNodes: extensionASTNodes + ) + case Kind.interfaceTypeDefinition: + let node = astNode as! InterfaceTypeDefinition + let extensionASTNodes = interfaceExtensions[name] ?? [] + + return try GraphQLInterfaceType( + name: name, + description: node.description?.value, + fields: { + var fields = try buildFieldMap(nodes: [node]) + for (name, value) in try buildFieldMap(nodes: extensionASTNodes) { + fields[name] = value + } + return fields + }, + interfaces: { + var interfaces = try buildInterfaces(nodes: [node]) + try interfaces.append(contentsOf: buildInterfaces(nodes: extensionASTNodes)) + return interfaces + }, + astNode: node, + extensionASTNodes: extensionASTNodes + ) + case Kind.enumTypeDefinition: + let node = astNode as! EnumTypeDefinition + let extensionASTNodes = enumExtensions[name] ?? [] + + var enumValues = try buildEnumValueMap(nodes: [node]) + for (name, value) in try buildEnumValueMap(nodes: extensionASTNodes) { + enumValues[name] = value + } + + return try GraphQLEnumType( + name: name, + description: node.description?.value, + values: enumValues, + astNode: node, + extensionASTNodes: extensionASTNodes + ) + case Kind.unionTypeDefinition: + let node = astNode as! UnionTypeDefinition + let extensionASTNodes = unionExtensions[name] ?? [] + + return try GraphQLUnionType( + name: name, + description: node.description?.value, + types: { + var unionTypes = try buildUnionTypes(nodes: [node]) + try unionTypes.append(contentsOf: buildUnionTypes(nodes: extensionASTNodes)) + return unionTypes + }, + astNode: node, + extensionASTNodes: extensionASTNodes + ) + case Kind.scalarTypeDefinition: + let node = astNode as! ScalarTypeDefinition + let extensionASTNodes = scalarExtensions[name] ?? [] + + return try GraphQLScalarType( + name: name, + description: node.description?.value, + specifiedByURL: getSpecifiedByURL(node: node), + astNode: node, + extensionASTNodes: extensionASTNodes + ) + case Kind.inputObjectTypeDefinition: + let node = astNode as! InputObjectTypeDefinition + let extensionASTNodes = inputObjectExtensions[name] ?? [] + + return try GraphQLInputObjectType( + name: name, + description: node.description?.value, + fields: { + var fields = try buildInputFieldMap(nodes: [node]) + for (name, value) in try buildInputFieldMap(nodes: extensionASTNodes) { + fields[name] = value + } + return fields + }, + astNode: node, + extensionASTNodes: extensionASTNodes, + isOneOf: isOneOf(node: node) + ) + default: + throw GraphQLError(message: "Unsupported kind: \(astNode.kind)") + } + } +} + +func checkOperationType( + operationType: OperationType, + type: GraphQLNamedType +) throws -> GraphQLObjectType { + let operationTypeStr = operationType.rawValue.capitalized + let rootTypeStr = type.name + guard let objectType = type as? GraphQLObjectType else { + let message = operationType == .query + ? "\(operationTypeStr) root type must be Object type, it cannot be \(rootTypeStr)." + : "\(operationTypeStr) root type must be Object type, it cannot be \(rootTypeStr)." + throw GraphQLError(message: message) + } + return objectType +} + +let stdTypeMap = { + var types = [GraphQLNamedType]() + types.append(contentsOf: specifiedScalarTypes) + types.append(contentsOf: introspectionTypes) + + var typeMap = [String: GraphQLNamedType]() + for type in types { + typeMap[type.name] = type + } + return typeMap +}() + +/** + * Given a field or enum value node, returns the string value for the + * deprecation reason. + */ + +func getDeprecationReason( + _ node: EnumValueDefinition +) throws -> String? { + let deprecated = try getDirectiveValues( + directiveDef: GraphQLDeprecatedDirective, + directives: node.directives + ) + return deprecated?.dictionary?["reason"]?.string +} + +func getDeprecationReason( + _ node: FieldDefinition +) throws -> String? { + let deprecated = try getDirectiveValues( + directiveDef: GraphQLDeprecatedDirective, + directives: node.directives + ) + return deprecated?.dictionary?["reason"]?.string +} + +func getDeprecationReason( + _ node: InputValueDefinition +) throws -> String? { + let deprecated = try getDirectiveValues( + directiveDef: GraphQLDeprecatedDirective, + directives: node.directives + ) + return deprecated?.dictionary?["reason"]?.string +} + +/** + * Given a scalar node, returns the string value for the specifiedByURL. + */ +func getSpecifiedByURL( + node: ScalarTypeDefinition +) throws -> String? { + let specifiedBy = try getDirectiveValues( + directiveDef: GraphQLSpecifiedByDirective, + directives: node.directives + ) + return specifiedBy?.dictionary?["url"]?.string +} + +func getSpecifiedByURL( + node: ScalarExtensionDefinition +) throws -> String? { + let specifiedBy = try getDirectiveValues( + directiveDef: GraphQLSpecifiedByDirective, + directives: node.directives + ) + return specifiedBy?.dictionary?["url"]?.string +} + +/** + * Given an input object node, returns if the node should be OneOf. + */ +func isOneOf(node: InputObjectTypeDefinition) throws -> Bool { + let isOneOf = try getDirectiveValues( + directiveDef: GraphQLOneOfDirective, + directives: node.directives + ) + return isOneOf != nil +} diff --git a/Sources/GraphQL/Utilities/IsValidValue.swift b/Sources/GraphQL/Utilities/IsValidValue.swift index 581c169d..744b8386 100644 --- a/Sources/GraphQL/Utilities/IsValidValue.swift +++ b/Sources/GraphQL/Utilities/IsValidValue.swift @@ -53,7 +53,7 @@ func validate(value: Map, forType type: GraphQLInputType) throws -> [String] { return ["Expected \"\(objectType.name)\", found not an object."] } - let fields = objectType.fields + let fields = try objectType.getFields() var errors: [String] = [] // Ensure every provided field is defined. diff --git a/Sources/GraphQL/Utilities/PrintSchema.swift b/Sources/GraphQL/Utilities/PrintSchema.swift new file mode 100644 index 00000000..e02fd983 --- /dev/null +++ b/Sources/GraphQL/Utilities/PrintSchema.swift @@ -0,0 +1,307 @@ +import Foundation + +public func printSchema(schema: GraphQLSchema) -> String { + return printFilteredSchema( + schema: schema, + directiveFilter: { n in !isSpecifiedDirective(n) }, + typeFilter: isDefinedType + ) +} + +public func printIntrospectionSchema(schema: GraphQLSchema) -> String { + return printFilteredSchema( + schema: schema, + directiveFilter: isSpecifiedDirective, + typeFilter: isIntrospectionType + ) +} + +func isDefinedType(type: GraphQLNamedType) -> Bool { + return !isSpecifiedScalarType(type) && !isIntrospectionType(type: type) +} + +func printFilteredSchema( + schema: GraphQLSchema, + directiveFilter: (GraphQLDirective) -> Bool, + typeFilter: (GraphQLNamedType) -> Bool +) -> String { + let directives = schema.directives.filter { directiveFilter($0) } + let types = schema.typeMap.values.filter { typeFilter($0) } + + var result = [printSchemaDefinition(schema: schema)] + result.append(contentsOf: directives.map { printDirective(directive: $0) }) + result.append(contentsOf: types.map { printType(type: $0) }) + + return result.compactMap { $0 } + .joined(separator: "\n\n") +} + +func printSchemaDefinition(schema: GraphQLSchema) -> String? { + let queryType = schema.queryType + let mutationType = schema.mutationType + let subscriptionType = schema.subscriptionType + + // Special case: When a schema has no root operation types, no valid schema + // definition can be printed. + if queryType == nil, mutationType == nil, subscriptionType == nil { + return nil + } + + // Only print a schema definition if there is a description or if it should + // not be omitted because of having default type names. + if schema.description != nil || !hasDefaultRootOperationTypes(schema: schema) { + var result = printDescription(schema.description) + + "schema {\n" + if let queryType = queryType { + result = result + " query: \(queryType.name)\n" + } + if let mutationType = mutationType { + result = result + " mutation: \(mutationType.name)\n" + } + if let subscriptionType = subscriptionType { + result = result + " subscription: \(subscriptionType.name)\n" + } + result = result + "}" + return result + } + return nil +} + +/** + * GraphQL schema define root types for each type of operation. These types are + * the same as any other type and can be named in any manner, however there is + * a common naming convention: + * + * ```graphql + * schema { + * query: Query + * mutation: Mutation + * subscription: Subscription + * } + * ``` + * + * When using this naming convention, the schema description can be omitted so + * long as these names are only used for operation types. + * + * Note however that if any of these default names are used elsewhere in the + * schema but not as a root operation type, the schema definition must still + * be printed to avoid ambiguity. + */ +func hasDefaultRootOperationTypes(schema: GraphQLSchema) -> Bool { + // The goal here is to check if a type was declared using the default names of "Query", + // "Mutation" or "Subscription". We do so by comparing object IDs to determine if the + // schema operation object is the same as the type object by that name. + return ( + schema.queryType.map { ObjectIdentifier($0) } + == (schema.getType(name: "Query") as? GraphQLObjectType).map { ObjectIdentifier($0) } && + schema.mutationType.map { ObjectIdentifier($0) } + == (schema.getType(name: "Mutation") as? GraphQLObjectType) + .map { ObjectIdentifier($0) } && + schema.subscriptionType.map { ObjectIdentifier($0) } + == (schema.getType(name: "Subscription") as? GraphQLObjectType) + .map { ObjectIdentifier($0) } + ) +} + +public func printType(type: GraphQLNamedType) -> String { + if let type = type as? GraphQLScalarType { + return printScalar(type: type) + } + if let type = type as? GraphQLObjectType { + return printObject(type: type) + } + if let type = type as? GraphQLInterfaceType { + return printInterface(type: type) + } + if let type = type as? GraphQLUnionType { + return printUnion(type: type) + } + if let type = type as? GraphQLEnumType { + return printEnum(type: type) + } + if let type = type as? GraphQLInputObjectType { + return printInputObject(type: type) + } + + // Not reachable, all possible types have been considered. + fatalError("Unexpected type: " + type.name) +} + +func printScalar(type: GraphQLScalarType) -> String { + return printDescription(type.description) + + "scalar \(type.name)" + + printSpecifiedByURL(scalar: type) +} + +func printImplementedInterfaces( + interfaces: [GraphQLInterfaceType] +) -> String { + return interfaces.isEmpty + ? "" + : " implements " + interfaces.map { $0.name }.joined(separator: " & ") +} + +func printObject(type: GraphQLObjectType) -> String { + return + printDescription(type.description) + + "type \(type.name)" + + printImplementedInterfaces(interfaces: (try? type.getInterfaces()) ?? []) + + printFields(fields: (try? type.getFields()) ?? [:]) +} + +func printInterface(type: GraphQLInterfaceType) -> String { + return + printDescription(type.description) + + "interface \(type.name)" + + printImplementedInterfaces(interfaces: (try? type.getInterfaces()) ?? []) + + printFields(fields: (try? type.getFields()) ?? [:]) +} + +func printUnion(type: GraphQLUnionType) -> String { + let types = (try? type.getTypes()) ?? [] + return + printDescription(type.description) + + "union \(type.name)" + + (types.isEmpty ? "" : " = " + types.map { $0.name }.joined(separator: " | ")) +} + +func printEnum(type: GraphQLEnumType) -> String { + let values = type.values.enumerated().map { i, value in + printDescription(value.description, indentation: " ", firstInBlock: i == 0) + + " " + + value.name + + printDeprecated(reason: value.deprecationReason) + } + + return printDescription(type.description) + "enum \(type.name)" + printBlock(items: values) +} + +func printInputObject(type: GraphQLInputObjectType) -> String { + let inputFields = (try? type.getFields()) ?? [:] + let fields = inputFields.values.enumerated().map { i, f in + printDescription(f.description, indentation: " ", firstInBlock: i == 0) + " " + + printInputValue(arg: f) + } + + return + printDescription(type.description) + + "input \(type.name)" + + (type.isOneOf ? " @oneOf" : "") + + printBlock(items: fields) +} + +func printFields(fields: GraphQLFieldDefinitionMap) -> String { + let fields = fields.values.enumerated().map { i, f in + printDescription(f.description, indentation: " ", firstInBlock: i == 0) + + " " + + f.name + + printArgs(args: f.args, indentation: " ") + + ": " + + f.type.debugDescription + + printDeprecated(reason: f.deprecationReason) + } + return printBlock(items: fields) +} + +func printBlock(items: [String]) -> String { + return items.isEmpty ? "" : " {\n" + items.joined(separator: "\n") + "\n}" +} + +func printArgs( + args: [GraphQLArgumentDefinition], + indentation: String = "" +) -> String { + if args.isEmpty { + return "" + } + + // If every arg does not have a description, print them on one line. + if args.allSatisfy({ $0.description == nil }) { + return "(" + args.map { printArgValue(arg: $0) }.joined(separator: ", ") + ")" + } + + return + "(\n" + + args.enumerated().map { i, arg in + printDescription( + arg.description, + indentation: " " + indentation, + firstInBlock: i == 0 + ) + + " " + + indentation + + printArgValue(arg: arg) + }.joined(separator: "\n") + + "\n" + + indentation + + ")" +} + +func printArgValue(arg: GraphQLArgumentDefinition) -> String { + var argDecl = arg.name + ": " + arg.type.debugDescription + if let defaultValue = arg.defaultValue { + if defaultValue == .null { + argDecl = argDecl + " = null" + } else if let defaultAST = try! astFromValue(value: defaultValue, type: arg.type) { + argDecl = argDecl + " = \(print(ast: defaultAST))" + } + } + return argDecl + printDeprecated(reason: arg.deprecationReason) +} + +func printInputValue(arg: InputObjectFieldDefinition) -> String { + var argDecl = arg.name + ": " + arg.type.debugDescription + if let defaultAST = try? astFromValue(value: arg.defaultValue ?? .null, type: arg.type) { + argDecl = argDecl + " = \(print(ast: defaultAST))" + } + return argDecl + printDeprecated(reason: arg.deprecationReason) +} + +public func printDirective(directive: GraphQLDirective) -> String { + return + printDescription(directive.description) + + "directive @" + + directive.name + + printArgs(args: directive.args) + + (directive.isRepeatable ? " repeatable" : "") + + " on " + + directive.locations.map { $0.rawValue }.joined(separator: " | ") +} + +func printDeprecated(reason: String?) -> String { + guard let reason = reason else { + return "" + } + if reason != defaultDeprecationReason { + let astValue = print(ast: StringValue(value: reason)) + return " @deprecated(reason: \(astValue))" + } + return " @deprecated" +} + +func printSpecifiedByURL(scalar: GraphQLScalarType) -> String { + guard let specifiedByURL = scalar.specifiedByURL else { + return "" + } + let astValue = StringValue(value: specifiedByURL) + return " @specifiedBy(url: \"\(astValue.value)\")" +} + +func printDescription( + _ description: String?, + indentation: String = "", + firstInBlock: Bool = true +) -> String { + guard let description = description else { + return "" + } + + let blockString = print(ast: StringValue( + value: description, + block: isPrintableAsBlockString(description) + )) + + let prefix = (!indentation.isEmpty && !firstInBlock) ? "\n" + indentation : indentation + + return prefix + blockString.replacingOccurrences(of: "\n", with: "\n" + indentation) + "\n" +} diff --git a/Sources/GraphQL/Utilities/TypeComparators.swift b/Sources/GraphQL/Utilities/TypeComparators.swift index e3519971..1c00af26 100644 --- a/Sources/GraphQL/Utilities/TypeComparators.swift +++ b/Sources/GraphQL/Utilities/TypeComparators.swift @@ -55,10 +55,6 @@ func == (lhs: GraphQLType, rhs: GraphQLType) -> Bool { if let r = rhs as? GraphQLNonNull { return l == r } - case let l as GraphQLTypeReference: - if let r = rhs as? GraphQLTypeReference { - return l.name == r.name - } default: return false } diff --git a/Sources/GraphQL/Utilities/TypeInfo.swift b/Sources/GraphQL/Utilities/TypeInfo.swift index cff0fbcc..89e2a919 100644 --- a/Sources/GraphQL/Utilities/TypeInfo.swift +++ b/Sources/GraphQL/Utilities/TypeInfo.swift @@ -168,7 +168,8 @@ final class TypeInfo { var inputField: InputObjectFieldDefinition? if let objectType = objectType as? GraphQLInputObjectType { - inputField = objectType.fields[node.name.value] + let inputFields = (try? objectType.getFields()) ?? [:] + inputField = inputFields[node.name.value] if let inputField = inputField { inputFieldType = inputField.type } @@ -238,11 +239,11 @@ func getFieldDef( let name = fieldAST.name.value if let parentType = parentType as? GraphQLNamedType { - if name == SchemaMetaFieldDef.name, schema.queryType.name == parentType.name { + if name == SchemaMetaFieldDef.name, schema.queryType?.name == parentType.name { return SchemaMetaFieldDef } - if name == TypeMetaFieldDef.name, schema.queryType.name == parentType.name { + if name == TypeMetaFieldDef.name, schema.queryType?.name == parentType.name { return TypeMetaFieldDef } } @@ -256,11 +257,11 @@ func getFieldDef( } if let parentType = parentType as? GraphQLObjectType { - return parentType.fields[name] + return try? parentType.getFields()[name] } if let parentType = parentType as? GraphQLInterfaceType { - return parentType.fields[name] + return try? parentType.getFields()[name] } return nil diff --git a/Sources/GraphQL/Utilities/ValueFromAST.swift b/Sources/GraphQL/Utilities/ValueFromAST.swift index ae5baf42..88c5d8a0 100644 --- a/Sources/GraphQL/Utilities/ValueFromAST.swift +++ b/Sources/GraphQL/Utilities/ValueFromAST.swift @@ -79,7 +79,7 @@ func valueFromAST( throw GraphQLError(message: "Input object must be object type") } - let fields = objectType.fields + let fields = try objectType.getFields() let fieldASTs = objectValue.fields.keyMap { $0.name.value } var object = OrderedDictionary() diff --git a/Sources/GraphQL/Validation/Rules/Custom/NoDeprecatedCustomRule.swift b/Sources/GraphQL/Validation/Rules/Custom/NoDeprecatedCustomRule.swift index 230cff7c..14836758 100644 --- a/Sources/GraphQL/Validation/Rules/Custom/NoDeprecatedCustomRule.swift +++ b/Sources/GraphQL/Validation/Rules/Custom/NoDeprecatedCustomRule.swift @@ -54,7 +54,7 @@ public func NoDeprecatedCustomRule(context: ValidationContext) -> Visitor { if let node = node as? ObjectField { if let inputObjectDef = context.parentInputType as? GraphQLInputObjectType, - let inputFieldDef = inputObjectDef.fields[node.name.value], + let inputFieldDef = try? inputObjectDef.getFields()[node.name.value], let deprecationReason = inputFieldDef.deprecationReason { context.report( diff --git a/Sources/GraphQL/Validation/Rules/FieldsOnCorrectTypeRule.swift b/Sources/GraphQL/Validation/Rules/FieldsOnCorrectTypeRule.swift index 5328c31e..3994c0b4 100644 --- a/Sources/GraphQL/Validation/Rules/FieldsOnCorrectTypeRule.swift +++ b/Sources/GraphQL/Validation/Rules/FieldsOnCorrectTypeRule.swift @@ -33,11 +33,11 @@ func FieldsOnCorrectTypeRule(context: ValidationContext) -> Visitor { let fieldName = node.name.value // First determine if there are any suggested types to condition on. - let suggestedTypeNames = getSuggestedTypeNames( + let suggestedTypeNames = (try? getSuggestedTypeNames( schema: schema, type: type, fieldName: fieldName - ) + )) ?? [] // If there are no suggested types, then perhaps this was a typo? let suggestedFieldNames = !suggestedTypeNames @@ -76,21 +76,21 @@ func getSuggestedTypeNames( schema: GraphQLSchema, type: GraphQLOutputType, fieldName: String -) -> [String] { +) throws -> [String] { if let type = type as? GraphQLAbstractType { var suggestedObjectTypes: [String] = [] var interfaceUsageCount: [String: Int] = [:] for possibleType in schema.getPossibleTypes(abstractType: type) { - if possibleType.fields[fieldName] == nil { + if try possibleType.getFields()[fieldName] == nil { return [] } // This object type defines this field. suggestedObjectTypes.append(possibleType.name) - for possibleInterface in possibleType.interfaces { - if possibleInterface.fields[fieldName] == nil { + for possibleInterface in try possibleType.getInterfaces() { + if try possibleInterface.getFields()[fieldName] == nil { return [] } // This interface type defines this field. @@ -123,7 +123,7 @@ func getSuggestedFieldNames( fieldName: String ) -> [String] { if let type = type as? GraphQLObjectType { - let possibleFieldNames = Array(type.fields.keys) + let possibleFieldNames = (try? Array(type.getFields().keys)) ?? [] return suggestionList( input: fieldName, options: possibleFieldNames @@ -131,7 +131,7 @@ func getSuggestedFieldNames( } if let type = type as? GraphQLInterfaceType { - let possibleFieldNames = Array(type.fields.keys) + let possibleFieldNames = (try? Array(type.getFields().keys)) ?? [] return suggestionList( input: fieldName, options: possibleFieldNames diff --git a/Sources/GraphQL/Validation/Rules/KnownArgumentNamesOnDirectivesRule.swift b/Sources/GraphQL/Validation/Rules/KnownArgumentNamesOnDirectivesRule.swift new file mode 100644 index 00000000..df62b18f --- /dev/null +++ b/Sources/GraphQL/Validation/Rules/KnownArgumentNamesOnDirectivesRule.swift @@ -0,0 +1,45 @@ +func KnownArgumentNamesOnDirectivesRule( + context: SDLorNormalValidationContext +) -> Visitor { + var directiveArgs = [String: [String]]() + + let schema = context.getSchema() + let definedDirectives = schema?.directives ?? specifiedDirectives + for directive in definedDirectives { + directiveArgs[directive.name] = directive.args.map(\.name) + } + + let astDefinitions = context.ast.definitions + for def in astDefinitions { + if let def = def as? DirectiveDefinition { + let argsNodes = def.arguments + directiveArgs[def.name.value] = argsNodes.map(\.name.value) + } + } + + return Visitor( + enter: { node, _, _, _, _ in + if let directiveNode = node as? Directive { + let directiveName = directiveNode.name.value + let knownArgs = directiveArgs[directiveName] + + if let knownArgs = knownArgs { + for argNode in directiveNode.arguments { + let argName = argNode.name.value + if !knownArgs.contains(argName) { + let suggestions = suggestionList(input: argName, options: knownArgs) + context.report( + error: GraphQLError( + message: "Unknown argument \"\(argName)\" on directive \"@\(directiveName)\"." + + didYouMean(suggestions: suggestions), + nodes: [argNode] + ) + ) + } + } + } + } + return .continue + } + ) +} diff --git a/Sources/GraphQL/Validation/Rules/KnownDirectivesRule.swift b/Sources/GraphQL/Validation/Rules/KnownDirectivesRule.swift index 18be3c0a..2e805254 100644 --- a/Sources/GraphQL/Validation/Rules/KnownDirectivesRule.swift +++ b/Sources/GraphQL/Validation/Rules/KnownDirectivesRule.swift @@ -7,11 +7,11 @@ * * See https://spec.graphql.org/draft/#sec-Directives-Are-Defined */ -func KnownDirectivesRule(context: ValidationContext) -> Visitor { +func KnownDirectivesRule(context: SDLorNormalValidationContext) -> Visitor { var locationsMap = [String: [String]]() - let schema = context.schema - let definedDirectives = schema.directives + let schema = context.getSchema() + let definedDirectives = schema?.directives ?? specifiedDirectives for directive in definedDirectives { locationsMap[directive.name] = directive.locations.map { $0.rawValue } } @@ -74,7 +74,7 @@ func getDirectiveLocationForASTPath(_ ancestors: [NodeResult]) -> DirectiveLocat return DirectiveLocation.fragmentDefinition case is VariableDefinition: return DirectiveLocation.variableDefinition - case is SchemaDefinition: + case is SchemaDefinition, is SchemaExtensionDefinition: return DirectiveLocation.schema case is ScalarTypeDefinition, is ScalarExtensionDefinition: return DirectiveLocation.scalar diff --git a/Sources/GraphQL/Validation/Rules/KnownTypeNamesRule.swift b/Sources/GraphQL/Validation/Rules/KnownTypeNamesRule.swift index 08c73a05..6c47ea52 100644 --- a/Sources/GraphQL/Validation/Rules/KnownTypeNamesRule.swift +++ b/Sources/GraphQL/Validation/Rules/KnownTypeNamesRule.swift @@ -7,9 +7,9 @@ * * See https://spec.graphql.org/draft/#sec-Fragment-Spread-Type-Existence */ -func KnownTypeNamesRule(context: ValidationContext) -> Visitor { +func KnownTypeNamesRule(context: SDLorNormalValidationContext) -> Visitor { let definitions = context.ast.definitions - let existingTypesMap = context.schema.typeMap + let existingTypesMap = context.getSchema()?.typeMap ?? [:] var typeNames = Set() for typeName in existingTypesMap.keys { @@ -17,8 +17,8 @@ func KnownTypeNamesRule(context: ValidationContext) -> Visitor { } for definition in definitions { if - let type = definition as? TypeDefinition, - let nameResult = type.get(key: "name"), + isTypeSystemDefinitionNode(definition), + let nameResult = definition.get(key: "name"), case let .node(nameNode) = nameResult, let name = nameNode as? Name { @@ -27,11 +27,18 @@ func KnownTypeNamesRule(context: ValidationContext) -> Visitor { } return Visitor( - enter: { node, _, _, _, _ in + enter: { node, _, parent, _, ancestors in if let type = node as? NamedType { let typeName = type.name.value if !typeNames.contains(typeName) { - // TODO: Add SDL support + let definitionNode = ancestors.count > 2 ? ancestors[2] : parent + var isSDL = false + if let definitionNode = definitionNode, case let .node(node) = definitionNode { + isSDL = isSDLNode(node) + } + if isSDL, standardTypeNames.contains(typeName) { + return .continue + } let suggestedTypes = suggestionList( input: typeName, @@ -50,3 +57,13 @@ func KnownTypeNamesRule(context: ValidationContext) -> Visitor { } ) } + +let standardTypeNames: Set = { + var result = specifiedScalarTypes.map { $0.name } + result.append(contentsOf: introspectionTypes.map { $0.name }) + return Set(result) +}() + +func isSDLNode(_ value: Node) -> Bool { + return isTypeSystemDefinitionNode(value) || isTypeSystemExtensionNode(value) +} diff --git a/Sources/GraphQL/Validation/Rules/LoneSchemaDefinitionRule.swift b/Sources/GraphQL/Validation/Rules/LoneSchemaDefinitionRule.swift new file mode 100644 index 00000000..b1aa4428 --- /dev/null +++ b/Sources/GraphQL/Validation/Rules/LoneSchemaDefinitionRule.swift @@ -0,0 +1,42 @@ + +/** + * Lone Schema definition + * + * A GraphQL document is only valid if it contains only one schema definition. + */ +func LoneSchemaDefinitionRule(context: SDLValidationContext) -> Visitor { + let oldSchema = context.getSchema() + let alreadyDefined = + oldSchema?.astNode != nil || + oldSchema?.queryType != nil || + oldSchema?.mutationType != nil || + oldSchema?.subscriptionType != nil + + var schemaDefinitionsCount = 0 + return Visitor( + enter: { node, _, _, _, _ in + if let node = node as? SchemaDefinition { + if alreadyDefined { + context.report( + error: GraphQLError( + message: "Cannot define a new schema within a schema extension.", + nodes: [node] + ) + ) + } + + if schemaDefinitionsCount > 0 { + context.report( + error: GraphQLError( + message: "Must provide only one schema definition.", + nodes: [node] + ) + ) + } + + schemaDefinitionsCount = schemaDefinitionsCount + 1 + } + return .continue + } + ) +} diff --git a/Sources/GraphQL/Validation/Rules/PossibleTypeExtensionsRule.swift b/Sources/GraphQL/Validation/Rules/PossibleTypeExtensionsRule.swift new file mode 100644 index 00000000..9e5d3a53 --- /dev/null +++ b/Sources/GraphQL/Validation/Rules/PossibleTypeExtensionsRule.swift @@ -0,0 +1,133 @@ + +/** + * Possible type extension + * + * A type extension is only valid if the type is defined and has the same kind. + */ +func PossibleTypeExtensionsRule( + context: SDLValidationContext +) -> Visitor { + let schema = context.getSchema() + var definedTypes = [String: TypeDefinition]() + + for def in context.getDocument().definitions { + if let def = def as? TypeDefinition { + definedTypes[def.name.value] = def + } + } + + return Visitor( + enter: { node, _, _, _, _ in + if let node = node as? ScalarExtensionDefinition { + checkExtension(node: node) + } else if let node = node as? TypeExtensionDefinition { + checkExtension(node: node) + } else if let node = node as? InterfaceExtensionDefinition { + checkExtension(node: node) + } else if let node = node as? UnionExtensionDefinition { + checkExtension(node: node) + } else if let node = node as? EnumExtensionDefinition { + checkExtension(node: node) + } else if let node = node as? InputObjectExtensionDefinition { + checkExtension(node: node) + } + return .continue + } + ) + + func checkExtension(node: TypeExtension) { + let typeName = node.name.value + let defNode = definedTypes[typeName] + let existingType = schema?.getType(name: typeName) + + var expectedKind: Kind? = nil + if let defNode = defNode { + expectedKind = defKindToExtKind[defNode.kind] + } else if let existingType = existingType { + expectedKind = typeToExtKind(type: existingType) + } + + if let expectedKind = expectedKind { + if expectedKind != node.kind { + let kindStr = extensionKindToTypeName(kind: node.kind) + var nodes: [any Node] = [] + if let defNode = defNode { + nodes.append(defNode) + } + nodes.append(node) + context.report( + error: GraphQLError( + message: "Cannot extend non-\(kindStr) type \"\(typeName)\".", + nodes: nodes + ) + ) + } + } else { + var allTypeNames = Array(definedTypes.keys) + allTypeNames.append(contentsOf: schema?.typeMap.keys ?? []) + + context.report( + error: GraphQLError( + message: "Cannot extend type \"\(typeName)\" because it is not defined." + + didYouMean(suggestions: suggestionList( + input: typeName, + options: allTypeNames + )), + nodes: [node.name] + ) + ) + } + } +} + +let defKindToExtKind: [Kind: Kind] = [ + .scalarTypeDefinition: .scalarExtensionDefinition, + .objectTypeDefinition: .typeExtensionDefinition, + .interfaceTypeDefinition: .interfaceExtensionDefinition, + .unionTypeDefinition: .unionExtensionDefinition, + .enumTypeDefinition: .enumExtensionDefinition, + .inputObjectTypeDefinition: .inputObjectExtensionDefinition, +] + +func typeToExtKind(type: GraphQLNamedType) -> Kind { + if type is GraphQLScalarType { + return .scalarExtensionDefinition + } + if type is GraphQLObjectType { + return .typeExtensionDefinition + } + if type is GraphQLInterfaceType { + return .interfaceExtensionDefinition + } + if type is GraphQLUnionType { + return .unionExtensionDefinition + } + if type is GraphQLEnumType { + return .enumExtensionDefinition + } + if type is GraphQLInputObjectType { + return .inputObjectExtensionDefinition + } + // Not reachable. All possible types have been considered + fatalError("Unexpected type: \(type)") +} + +func extensionKindToTypeName(kind: Kind) -> String { + switch kind { + case .scalarExtensionDefinition: + return "scalar" + case .typeExtensionDefinition: + return "object" + case .interfaceExtensionDefinition: + return "interface" + case .unionExtensionDefinition: + return "union" + case .enumExtensionDefinition: + return "enum" + case .inputObjectExtensionDefinition: + return "input object" + // Not reachable. All possible types have been considered + default: + fatalError("Unexpected kind: \(kind)") + } +} diff --git a/Sources/GraphQL/Validation/Rules/ProvidedRequiredArgumentsOnDirectivesRule.swift b/Sources/GraphQL/Validation/Rules/ProvidedRequiredArgumentsOnDirectivesRule.swift new file mode 100644 index 00000000..f4211e15 --- /dev/null +++ b/Sources/GraphQL/Validation/Rules/ProvidedRequiredArgumentsOnDirectivesRule.swift @@ -0,0 +1,64 @@ + +func ProvidedRequiredArgumentsOnDirectivesRule( + context: SDLorNormalValidationContext +) -> Visitor { + var requiredArgsMap = [String: [String: String]]() + + let schema = context.getSchema() + let definedDirectives = schema?.directives ?? specifiedDirectives + for directive in definedDirectives { + var requiredArgs = [String: String]() + for arg in directive.args.filter({ isRequiredArgument($0) }) { + requiredArgs[arg.name] = arg.type.debugDescription + } + requiredArgsMap[directive.name] = requiredArgs + } + + let astDefinitions = context.ast.definitions + for def in astDefinitions { + if let def = def as? DirectiveDefinition { + let argNodes = def.arguments + var requiredArgs = [String: String]() + for arg in argNodes.filter({ isRequiredArgumentNode($0) }) { + requiredArgs[arg.name.value] = print(ast: arg.type) + } + requiredArgsMap[def.name.value] = requiredArgs + } + } + + return Visitor( + // Validate on leave to allow for deeper errors to appear first. + leave: { node, _, _, _, _ in + if let directiveNode = node as? Directive { + let directiveName = directiveNode.name.value + if let requiredArgs = requiredArgsMap[directiveName] { + let argNodes = directiveNode.arguments + let argNodeMap = Set(argNodes.map(\.name.value)) + for (argName, argType) in requiredArgs { + if !argNodeMap.contains(argName) { + context.report( + error: GraphQLError( + message: "Argument \"@\(directiveName)(\(argName):)\" of type \"\(argType)\" is required, but it was not provided.", + nodes: [directiveNode] + ) + ) + } + } + } + } + return .continue + } + ) +} + +func isRequiredArgumentNode( + arg: InputValueDefinition +) -> Bool { + return arg.type.kind == .nonNullType && arg.defaultValue == nil +} + +func isRequiredArgumentNode( + arg: VariableDefinition +) -> Bool { + return arg.type.kind == .nonNullType && arg.defaultValue == nil +} diff --git a/Sources/GraphQL/Validation/Rules/UniqueArgumentDefinitionNamesRule.swift b/Sources/GraphQL/Validation/Rules/UniqueArgumentDefinitionNamesRule.swift new file mode 100644 index 00000000..76ea7e2b --- /dev/null +++ b/Sources/GraphQL/Validation/Rules/UniqueArgumentDefinitionNamesRule.swift @@ -0,0 +1,71 @@ + +/** + * Unique argument definition names + * + * A GraphQL Object or Interface type is only valid if all its fields have uniquely named arguments. + * A GraphQL Directive is only valid if all its arguments are uniquely named. + */ +func UniqueArgumentDefinitionNamesRule( + context: SDLValidationContext +) -> Visitor { + return Visitor( + enter: { node, _, _, _, _ in + if let directiveNode = node as? DirectiveDefinition { + let argumentNodes = directiveNode.arguments + checkArgUniqueness( + parentName: "@\(directiveNode.name.value)", + argumentNodes: argumentNodes + ) + } else if let node = node as? InterfaceTypeDefinition { + checkArgUniquenessPerField(name: node.name, fields: node.fields) + } else if let node = node as? InterfaceExtensionDefinition { + checkArgUniquenessPerField( + name: node.definition.name, + fields: node.definition.fields + ) + } else if let node = node as? ObjectTypeDefinition { + checkArgUniquenessPerField(name: node.name, fields: node.fields) + } else if let node = node as? TypeExtensionDefinition { + checkArgUniquenessPerField( + name: node.definition.name, + fields: node.definition.fields + ) + } + return .continue + } + ) + + func checkArgUniquenessPerField( + name: Name, + fields: [FieldDefinition] + ) { + let typeName = name.value + let fieldNodes = fields + for fieldDef in fieldNodes { + let fieldName = fieldDef.name.value + + let argumentNodes = fieldDef.arguments + + checkArgUniqueness(parentName: "\(typeName).\(fieldName)", argumentNodes: argumentNodes) + } + } + + func checkArgUniqueness( + parentName: String, + argumentNodes: [InputValueDefinition] + ) { + let seenArgs = [String: [InputValueDefinition]](grouping: argumentNodes) { arg in + arg.name.value + } + for (argName, argNodes) in seenArgs { + if argNodes.count > 1 { + context.report( + error: GraphQLError( + message: "Argument \"\(parentName)(\(argName):)\" can only be defined once.", + nodes: argNodes.map { node in node.name } + ) + ) + } + } + } +} diff --git a/Sources/GraphQL/Validation/Rules/UniqueArgumentNamesRule.swift b/Sources/GraphQL/Validation/Rules/UniqueArgumentNamesRule.swift index eca433c0..20445a3f 100644 --- a/Sources/GraphQL/Validation/Rules/UniqueArgumentNamesRule.swift +++ b/Sources/GraphQL/Validation/Rules/UniqueArgumentNamesRule.swift @@ -7,7 +7,7 @@ * * See https://spec.graphql.org/draft/#sec-Argument-Names */ -func UniqueArgumentNamesRule(context: ValidationContext) -> Visitor { +func UniqueArgumentNamesRule(context: ASTValidationContext) -> Visitor { return Visitor( enter: { node, _, _, _, _ in let argumentNodes: [Argument] diff --git a/Sources/GraphQL/Validation/Rules/UniqueDirectiveNamesRule.swift b/Sources/GraphQL/Validation/Rules/UniqueDirectiveNamesRule.swift new file mode 100644 index 00000000..083f92aa --- /dev/null +++ b/Sources/GraphQL/Validation/Rules/UniqueDirectiveNamesRule.swift @@ -0,0 +1,40 @@ + +/** + * Unique directive names + * + * A GraphQL document is only valid if all defined directives have unique names. + */ +func UniqueDirectiveNamesRule( + context: SDLValidationContext +) -> Visitor { + var knownDirectiveNames = [String: Name]() + let schema = context.getSchema() + + return Visitor( + enter: { node, _, _, _, _ in + if let node = node as? DirectiveDefinition { + let directiveName = node.name.value + if schema?.getDirective(name: directiveName) != nil { + context.report( + error: GraphQLError( + message: "Directive \"@\(directiveName)\" already exists in the schema. It cannot be redefined.", + nodes: [node.name] + ) + ) + return .continue + } + if let knownName = knownDirectiveNames[directiveName] { + context.report( + error: GraphQLError( + message: "There can be only one directive named \"@\(directiveName)\".", + nodes: [knownName, node.name] + ) + ) + } else { + knownDirectiveNames[directiveName] = node.name + } + } + return .continue + } + ) +} diff --git a/Sources/GraphQL/Validation/Rules/UniqueDirectivesPerLocationRule.swift b/Sources/GraphQL/Validation/Rules/UniqueDirectivesPerLocationRule.swift index 331ebc00..d5393973 100644 --- a/Sources/GraphQL/Validation/Rules/UniqueDirectivesPerLocationRule.swift +++ b/Sources/GraphQL/Validation/Rules/UniqueDirectivesPerLocationRule.swift @@ -7,11 +7,11 @@ * * See https://spec.graphql.org/draft/#sec-Directives-Are-Unique-Per-Location */ -func UniqueDirectivesPerLocationRule(context: ValidationContext) -> Visitor { +func UniqueDirectivesPerLocationRule(context: SDLorNormalValidationContext) -> Visitor { var uniqueDirectiveMap = [String: Bool]() - let schema = context.schema - let definedDirectives = schema.directives + let schema = context.getSchema() + let definedDirectives = schema?.directives ?? [] for directive in definedDirectives { uniqueDirectiveMap[directive.name] = !directive.isRepeatable } diff --git a/Sources/GraphQL/Validation/Rules/UniqueEnumValueNamesRule.swift b/Sources/GraphQL/Validation/Rules/UniqueEnumValueNamesRule.swift new file mode 100644 index 00000000..9f162849 --- /dev/null +++ b/Sources/GraphQL/Validation/Rules/UniqueEnumValueNamesRule.swift @@ -0,0 +1,59 @@ + +/** + * Unique enum value names + * + * A GraphQL enum type is only valid if all its values are uniquely named. + */ +func UniqueEnumValueNamesRule( + context: SDLValidationContext +) -> Visitor { + let schema = context.getSchema() + let existingTypeMap = schema?.typeMap ?? [:] + var knownValueNames = [String: [String: Name]]() + + return Visitor( + enter: { node, _, _, _, _ in + if let definition = node as? EnumTypeDefinition { + checkValueUniqueness(node: definition) + } else if let definition = node as? EnumExtensionDefinition { + checkValueUniqueness(node: definition.definition) + } + return .continue + } + ) + + func checkValueUniqueness(node: EnumTypeDefinition) { + let typeName = node.name.value + var valueNames = knownValueNames[typeName] ?? [:] + let valueNodes = node.values + for valueDef in valueNodes { + let valueName = valueDef.name.value + + let existingType = existingTypeMap[typeName] + if + let existingType = existingType as? GraphQLEnumType, + existingType.nameLookup[valueName] != nil + { + context.report( + error: GraphQLError( + message: "Enum value \"\(typeName).\(valueName)\" already exists in the schema. It cannot also be defined in this type extension.", + nodes: [valueDef.name] + ) + ) + continue + } + + if let knownValueName = valueNames[valueName] { + context.report( + error: GraphQLError( + message: "Enum value \"\(typeName).\(valueName)\" can only be defined once.", + nodes: [knownValueName, valueDef.name] + ) + ) + } else { + valueNames[valueName] = valueDef.name + } + } + knownValueNames[typeName] = valueNames + } +} diff --git a/Sources/GraphQL/Validation/Rules/UniqueFieldDefinitionNamesRule.swift b/Sources/GraphQL/Validation/Rules/UniqueFieldDefinitionNamesRule.swift new file mode 100644 index 00000000..ff8f8333 --- /dev/null +++ b/Sources/GraphQL/Validation/Rules/UniqueFieldDefinitionNamesRule.swift @@ -0,0 +1,113 @@ + +/** + * Unique field definition names + * + * A GraphQL complex type is only valid if all its fields are uniquely named. + */ +func UniqueFieldDefinitionNamesRule( + context: SDLValidationContext +) -> Visitor { + let schema = context.getSchema() + let existingTypeMap = schema?.typeMap ?? [:] + var knownFieldNames = [String: [String: Name]]() + + return Visitor( + enter: { node, _, _, _, _ in + if let node = node as? InputObjectTypeDefinition { + checkFieldUniqueness(name: node.name, fields: node.fields) + } else if let node = node as? InputObjectExtensionDefinition { + checkFieldUniqueness(name: node.name, fields: node.definition.fields) + } else if let node = node as? InterfaceTypeDefinition { + checkFieldUniqueness(name: node.name, fields: node.fields) + } else if let node = node as? InterfaceExtensionDefinition { + checkFieldUniqueness(name: node.name, fields: node.definition.fields) + } else if let node = node as? ObjectTypeDefinition { + checkFieldUniqueness(name: node.name, fields: node.fields) + } else if let node = node as? TypeExtensionDefinition { + checkFieldUniqueness(name: node.name, fields: node.definition.fields) + } + return .continue + } + ) + + func checkFieldUniqueness( + name: Name, + fields: [FieldDefinition] + ) { + let typeName = name.value + var fieldNames = knownFieldNames[typeName] ?? [String: Name]() + let fieldNodes = fields + for fieldDef in fieldNodes { + let fieldName = fieldDef.name.value + if + let existingType = existingTypeMap[typeName], + hasField(type: existingType, fieldName: fieldName) + { + context.report( + error: GraphQLError( + message: "Field \"\(typeName).\(fieldName)\" already exists in the schema. It cannot also be defined in this type extension.", + nodes: [fieldDef.name] + ) + ) + continue + } + if let knownFieldName = fieldNames[fieldName] { + context.report( + error: GraphQLError( + message: "Field \"\(typeName).\(fieldName)\" can only be defined once.", + nodes: [knownFieldName, fieldDef.name] + ) + ) + } else { + fieldNames[fieldName] = fieldDef.name + } + } + knownFieldNames[typeName] = fieldNames + } + + func checkFieldUniqueness( + name: Name, + fields: [InputValueDefinition] + ) { + let typeName = name.value + var fieldNames = knownFieldNames[typeName] ?? [String: Name]() + let fieldNodes = fields + for fieldDef in fieldNodes { + let fieldName = fieldDef.name.value + if + let existingType = existingTypeMap[typeName], + hasField(type: existingType, fieldName: fieldName) + { + context.report( + error: GraphQLError( + message: "Field \"\(typeName).\(fieldName)\" already exists in the schema. It cannot also be defined in this type extension.", + nodes: [fieldDef.name] + ) + ) + continue + } + if let knownFieldName = fieldNames[fieldName] { + context.report( + error: GraphQLError( + message: "Field \"\(typeName).\(fieldName)\" can only be defined once.", + nodes: [knownFieldName, fieldDef.name] + ) + ) + } else { + fieldNames[fieldName] = fieldDef.name + } + } + knownFieldNames[typeName] = fieldNames + } +} + +func hasField(type: GraphQLNamedType, fieldName: String) -> Bool { + if let type = type as? GraphQLObjectType { + return (try? type.getFields()[fieldName]) != nil + } else if let type = type as? GraphQLInterfaceType { + return (try? type.getFields()[fieldName]) != nil + } else if let type = type as? GraphQLInputObjectType { + return (try? type.getFields()[fieldName]) != nil + } + return false +} diff --git a/Sources/GraphQL/Validation/Rules/UniqueInputFieldNamesRule.swift b/Sources/GraphQL/Validation/Rules/UniqueInputFieldNamesRule.swift index 1b847217..374eea72 100644 --- a/Sources/GraphQL/Validation/Rules/UniqueInputFieldNamesRule.swift +++ b/Sources/GraphQL/Validation/Rules/UniqueInputFieldNamesRule.swift @@ -7,7 +7,7 @@ * * See https://spec.graphql.org/draft/#sec-Input-Object-Field-Uniqueness */ -func UniqueInputFieldNamesRule(context: ValidationContext) -> Visitor { +func UniqueInputFieldNamesRule(context: ASTValidationContext) -> Visitor { var knownNameStack = [[String: Name]]() var knownNames = [String: Name]() diff --git a/Sources/GraphQL/Validation/Rules/UniqueOperationTypesRule.swift b/Sources/GraphQL/Validation/Rules/UniqueOperationTypesRule.swift new file mode 100644 index 00000000..95e52787 --- /dev/null +++ b/Sources/GraphQL/Validation/Rules/UniqueOperationTypesRule.swift @@ -0,0 +1,62 @@ + +/** + * Unique operation types + * + * A GraphQL document is only valid if it has only one type per operation. + */ +func UniqueOperationTypesRule( + context: SDLValidationContext +) -> Visitor { + let schema = context.getSchema() + var definedOperationTypes: [OperationType: OperationTypeDefinition] = .init() + let existingOperationTypes = { + var result = [OperationType: GraphQLObjectType]() + if let queryType = schema?.queryType { + result[.query] = queryType + } + if let mutationType = schema?.mutationType { + result[.mutation] = mutationType + } + if let subscriptionType = schema?.subscriptionType { + result[.subscription] = subscriptionType + } + return result + }() + + return Visitor( + enter: { node, _, _, _, _ in + if let operation = node as? SchemaDefinition { + checkOperationTypes(operation.operationTypes) + } else if let operation = node as? SchemaExtensionDefinition { + checkOperationTypes(operation.definition.operationTypes) + } + return .continue + } + ) + + func checkOperationTypes( + _ operationTypesNodes: [OperationTypeDefinition] + ) { + for operationType in operationTypesNodes { + let operation = operationType.operation + + if existingOperationTypes[operation] != nil { + context.report( + error: GraphQLError( + message: "Type for \(operation) already defined in the schema. It cannot be redefined.", + nodes: [operationType] + ) + ) + } else if let alreadyDefinedOperationType = definedOperationTypes[operation] { + context.report( + error: GraphQLError( + message: "There can be only one \(operation) type in schema.", + nodes: [alreadyDefinedOperationType, operationType] + ) + ) + } else { + definedOperationTypes[operation] = operationType + } + } + } +} diff --git a/Sources/GraphQL/Validation/Rules/UniqueTypeNamesRule.swift b/Sources/GraphQL/Validation/Rules/UniqueTypeNamesRule.swift new file mode 100644 index 00000000..de20290b --- /dev/null +++ b/Sources/GraphQL/Validation/Rules/UniqueTypeNamesRule.swift @@ -0,0 +1,56 @@ + +/** + * Unique type names + * + * A GraphQL document is only valid if all defined types have unique names. + */ +func UniqueTypeNamesRule(context: SDLValidationContext) -> Visitor { + var knownTypeNames = [String: Name]() + let schema = context.getSchema() + + return Visitor( + enter: { node, _, _, _, _ in + if let definition = node as? ScalarTypeDefinition { + checkTypeName(node: definition) + } else if let definition = node as? ObjectTypeDefinition { + checkTypeName(node: definition) + } else if let definition = node as? InterfaceTypeDefinition { + checkTypeName(node: definition) + } else if let definition = node as? InterfaceTypeDefinition { + checkTypeName(node: definition) + } else if let definition = node as? UnionTypeDefinition { + checkTypeName(node: definition) + } else if let definition = node as? EnumTypeDefinition { + checkTypeName(node: definition) + } else if let definition = node as? InputObjectTypeDefinition { + checkTypeName(node: definition) + } + return .continue + } + ) + + func checkTypeName(node: TypeDefinition) { + let typeName = node.name.value + + if schema?.getType(name: typeName) != nil { + context.report( + error: GraphQLError( + message: "Type \"\(typeName)\" already exists in the schema. It cannot also be defined in this type definition.", + nodes: [node.name] + ) + ) + return + } + + if let knownNameNode = knownTypeNames[typeName] { + context.report( + error: GraphQLError( + message: "There can be only one type named \"\(typeName)\".", + nodes: [knownNameNode, node.name] + ) + ) + } else { + knownTypeNames[typeName] = node.name + } + } +} diff --git a/Sources/GraphQL/Validation/Rules/ValuesOfCorrectTypeRule.swift b/Sources/GraphQL/Validation/Rules/ValuesOfCorrectTypeRule.swift index b90013dc..4ce84608 100644 --- a/Sources/GraphQL/Validation/Rules/ValuesOfCorrectTypeRule.swift +++ b/Sources/GraphQL/Validation/Rules/ValuesOfCorrectTypeRule.swift @@ -41,7 +41,8 @@ func ValuesOfCorrectTypeRule(context: ValidationContext) -> Visitor { for field in object.fields { fieldNodeMap[field.name.value] = field } - for (fieldName, fieldDef) in type.fields { + let fields = (try? type.getFields()) ?? [:] + for (fieldName, fieldDef) in fields { if fieldNodeMap[fieldName] == nil, isRequiredInputField(fieldDef) { let typeStr = fieldDef.type context.report( @@ -70,9 +71,10 @@ func ValuesOfCorrectTypeRule(context: ValidationContext) -> Visitor { context.inputType == nil, let parentType = parentType as? GraphQLInputObjectType { + let parentFields = (try? parentType.getFields()) ?? [:] let suggestions = suggestionList( input: field.name.value, - options: Array(parentType.fields.keys) + options: Array(parentFields.keys) ) context.report( error: GraphQLError( diff --git a/Sources/GraphQL/Validation/SpecifiedRules.swift b/Sources/GraphQL/Validation/SpecifiedRules.swift index e414c0a0..fe46dd29 100644 --- a/Sources/GraphQL/Validation/SpecifiedRules.swift +++ b/Sources/GraphQL/Validation/SpecifiedRules.swift @@ -32,3 +32,24 @@ public let specifiedRules: [(ValidationContext) -> Visitor] = [ // OverlappingFieldsCanBeMergedRule, UniqueInputFieldNamesRule, ] + +/** + * @internal + */ +public let specifiedSDLRules: [SDLValidationRule] = [ + LoneSchemaDefinitionRule, + UniqueOperationTypesRule, + UniqueTypeNamesRule, + UniqueEnumValueNamesRule, + UniqueFieldDefinitionNamesRule, + UniqueArgumentDefinitionNamesRule, + UniqueDirectiveNamesRule, + KnownTypeNamesRule, + KnownDirectivesRule, + UniqueDirectivesPerLocationRule, + PossibleTypeExtensionsRule, + KnownArgumentNamesOnDirectivesRule, + UniqueArgumentNamesRule, + UniqueInputFieldNamesRule, + ProvidedRequiredArgumentsOnDirectivesRule, +] diff --git a/Sources/GraphQL/Validation/Validate.swift b/Sources/GraphQL/Validation/Validate.swift index 3dfb4721..362d4ad4 100644 --- a/Sources/GraphQL/Validation/Validate.swift +++ b/Sources/GraphQL/Validation/Validate.swift @@ -52,6 +52,29 @@ public func validate( return errors } +/** + * @internal + */ +func validateSDL( + documentAST: Document, + schemaToExtend: GraphQLSchema? = nil, + rules: [SDLValidationRule] = specifiedSDLRules +) -> [GraphQLError] { + var errors: [GraphQLError] = [] + let context = SDLValidationContext( + ast: documentAST, + schema: schemaToExtend + ) { error in + errors.append(error) + } + + let visitors = rules.map { rule in + rule(context) + } + visit(root: documentAST, visitor: visitInParallel(visitors: visitors)) + return errors +} + /** * This uses a specialized visitor which runs multiple visitors in parallel, * while maintaining the visitor skip and break API. @@ -74,231 +97,37 @@ func visit( return context.errors } -public enum HasSelectionSet { - case operation(OperationDefinition) - case fragment(FragmentDefinition) - - public var node: Node { - switch self { - case let .operation(operation): - return operation - case let .fragment(fragment): - return fragment - } - } -} - -extension HasSelectionSet: Hashable { - public func hash(into hasher: inout Hasher) { - switch self { - case let .operation(operation): - return hasher.combine(operation.hashValue) - case let .fragment(fragment): - return hasher.combine(fragment.hashValue) - } - } - - public static func == (lhs: HasSelectionSet, rhs: HasSelectionSet) -> Bool { - switch (lhs, rhs) { - case let (.operation(l), .operation(r)): - return l == r - case let (.fragment(l), .fragment(r)): - return l == r - default: - return false - } +/** + * Utility function which asserts a SDL document is valid by throwing an error + * if it is invalid. + * + * @internal + */ +func assertValidSDL(documentAST: Document) throws { + let errors = validateSDL(documentAST: documentAST) + if !errors.isEmpty { + throw GraphQLError( + message: errors.map { $0.message }.joined(separator: "\n\n"), + locations: [] + ) } } -public typealias VariableUsage = (node: Variable, type: GraphQLInputType?, defaultValue: Map?) - /** - * An instance of this class is passed as the "this" context to all validators, - * allowing access to commonly useful contextual information from within a - * validation rule. + * Utility function which asserts a SDL document is valid by throwing an error + * if it is invalid. + * + * @internal */ -public final class ValidationContext { - public let schema: GraphQLSchema - let ast: Document - let typeInfo: TypeInfo - var errors: [GraphQLError] - var fragments: [String: FragmentDefinition] - var fragmentSpreads: [SelectionSet: [FragmentSpread]] - var recursivelyReferencedFragments: [OperationDefinition: [FragmentDefinition]] - var variableUsages: [HasSelectionSet: [VariableUsage]] - var recursiveVariableUsages: [OperationDefinition: [VariableUsage]] - - init(schema: GraphQLSchema, ast: Document, typeInfo: TypeInfo) { - self.schema = schema - self.ast = ast - self.typeInfo = typeInfo - errors = [] - fragments = [:] - fragmentSpreads = [:] - recursivelyReferencedFragments = [:] - variableUsages = [:] - recursiveVariableUsages = [:] - } - - public func report(error: GraphQLError) { - errors.append(error) - } - - public func getFragment(name: String) -> FragmentDefinition? { - var fragments = self.fragments - - if fragments.isEmpty { - fragments = ast.definitions.reduce([:]) { frags, statement in - var frags = frags - - if let statement = statement as? FragmentDefinition { - frags[statement.name.value] = statement - } - - return frags - } - - self.fragments = fragments - } - - return fragments[name] - } - - public func getFragmentSpreads(node: SelectionSet) -> [FragmentSpread] { - // Uncommenting this creates unpredictably wrong fragment path matching. - // Failures can be seen in NoFragmentCyclesRuleTests.testNoSpreadingItselfDeeplyTwoPaths -// if let spreads = fragmentSpreads[node] { -// return spreads -// } - - var spreads = [FragmentSpread]() - var setsToVisit: [SelectionSet] = [node] - - while let set = setsToVisit.popLast() { - for selection in set.selections { - if let selection = selection as? FragmentSpread { - spreads.append(selection) - } else if let selection = selection as? InlineFragment { - setsToVisit.append(selection.selectionSet) - } else if - let selection = selection as? Field, - let selectionSet = selection.selectionSet - { - setsToVisit.append(selectionSet) - } - } - } - -// fragmentSpreads[node] = spreads - return spreads - } - - public func getRecursivelyReferencedFragments(operation: OperationDefinition) - -> [FragmentDefinition] - { - if let fragments = recursivelyReferencedFragments[operation] { - return fragments - } - - var fragments = [FragmentDefinition]() - var collectedNames: [String: Bool] = [:] - var nodesToVisit: [SelectionSet] = [operation.selectionSet] - - while let node = nodesToVisit.popLast() { - let spreads = getFragmentSpreads(node: node) - - for spread in spreads { - let fragName = spread.name.value - if collectedNames[fragName] != true { - collectedNames[fragName] = true - if let fragment = getFragment(name: fragName) { - fragments.append(fragment) - nodesToVisit.append(fragment.selectionSet) - } - } - } - } - - recursivelyReferencedFragments[operation] = fragments - return fragments - } - - public func getVariableUsages(node: HasSelectionSet) -> [VariableUsage] { - if let usages = variableUsages[node] { - return usages - } - - var usages = [VariableUsage]() - let typeInfo = TypeInfo(schema: schema) - - visit( - root: node.node, - visitor: visitWithTypeInfo( - typeInfo: typeInfo, - visitor: Visitor(enter: { node, _, _, _, _ in - if node is VariableDefinition { - return .skip - } - - if let variable = node as? Variable { - usages.append(VariableUsage( - node: variable, - type: typeInfo.inputType, - defaultValue: typeInfo.defaultValue - )) - } - - return .continue - }) - ) +func assertValidSDLExtension( + documentAST: Document, + schema: GraphQLSchema +) throws { + let errors = validateSDL(documentAST: documentAST, schemaToExtend: schema) + if !errors.isEmpty { + throw GraphQLError( + message: errors.map { $0.message }.joined(separator: "\n\n"), + locations: [] ) - - variableUsages[node] = usages - return usages - } - - public func getRecursiveVariableUsages(operation: OperationDefinition) -> [VariableUsage] { - if let usages = recursiveVariableUsages[operation] { - return usages - } - - var usages = getVariableUsages(node: .operation(operation)) - let fragments = getRecursivelyReferencedFragments(operation: operation) - - for fragment in fragments { - let newUsages = getVariableUsages(node: .fragment(fragment)) - usages.append(contentsOf: newUsages) - } - - recursiveVariableUsages[operation] = usages - return usages - } - - public var type: GraphQLOutputType? { - return typeInfo.type - } - - public var parentType: GraphQLCompositeType? { - return typeInfo.parentType - } - - public var inputType: GraphQLInputType? { - return typeInfo.inputType - } - - public var parentInputType: GraphQLInputType? { - return typeInfo.parentInputType - } - - public var fieldDef: GraphQLFieldDefinition? { - return typeInfo.fieldDef - } - - public var directive: GraphQLDirective? { - return typeInfo.directive - } - - public var argument: GraphQLArgumentDefinition? { - return typeInfo.argument } } diff --git a/Sources/GraphQL/Validation/ValidationContext.swift b/Sources/GraphQL/Validation/ValidationContext.swift new file mode 100644 index 00000000..6732a9c8 --- /dev/null +++ b/Sources/GraphQL/Validation/ValidationContext.swift @@ -0,0 +1,287 @@ + + +public enum HasSelectionSet { + case operation(OperationDefinition) + case fragment(FragmentDefinition) + + public var node: Node { + switch self { + case let .operation(operation): + return operation + case let .fragment(fragment): + return fragment + } + } +} + +extension HasSelectionSet: Hashable { + public func hash(into hasher: inout Hasher) { + switch self { + case let .operation(operation): + return hasher.combine(operation.hashValue) + case let .fragment(fragment): + return hasher.combine(fragment.hashValue) + } + } + + public static func == (lhs: HasSelectionSet, rhs: HasSelectionSet) -> Bool { + switch (lhs, rhs) { + case let (.operation(l), .operation(r)): + return l == r + case let (.fragment(l), .fragment(r)): + return l == r + default: + return false + } + } +} + +public typealias VariableUsage = (node: Variable, type: GraphQLInputType?, defaultValue: Map?) + +/** + * An instance of this class is passed as the "this" context to all validators, + * allowing access to commonly useful contextual information from within a + * validation rule. + */ +public class ASTValidationContext { + let ast: Document + var onError: (GraphQLError) -> Void + var fragments: [String: FragmentDefinition]? + var fragmentSpreads: [SelectionSet: [FragmentSpread]] + var recursivelyReferencedFragments: [OperationDefinition: [FragmentDefinition]] + + init(ast: Document, onError: @escaping (GraphQLError) -> Void) { + self.ast = ast + fragments = nil + fragmentSpreads = [:] + recursivelyReferencedFragments = [:] + self.onError = onError + } + + // get [Symbol.toStringTag]() { + // return 'ASTValidationContext'; + // } + + public func report(error: GraphQLError) { + onError(error) + } + + func getDocument() -> Document { + return ast + } + + public func getFragment(name: String) -> FragmentDefinition? { + if let fragments = fragments { + return fragments[name] + } else { + var fragments: [String: FragmentDefinition] = [:] + for defNode in getDocument().definitions { + if let defNode = defNode as? FragmentDefinition { + fragments[defNode.name.value] = defNode + } + } + self.fragments = fragments + return fragments[name] + } + } + + public func getFragmentSpreads(node: SelectionSet) -> [FragmentSpread] { + // Uncommenting this creates unpredictably wrong fragment path matching. + // Failures can be seen in NoFragmentCyclesRuleTests.testNoSpreadingItselfDeeplyTwoPaths +// if let spreads = fragmentSpreads[node] { +// return spreads +// } + + var spreads = [FragmentSpread]() + var setsToVisit: [SelectionSet] = [node] + while let set = setsToVisit.popLast() { + for selection in set.selections { + if let spread = selection as? FragmentSpread { + spreads.append(spread) + } else if let fragment = selection as? InlineFragment { + setsToVisit.append(fragment.selectionSet) + } else if + let field = selection as? Field, + let selectionSet = field.selectionSet + { + setsToVisit.append(selectionSet) + } + } + } +// fragmentSpreads[node] = spreads + return spreads + } + + public func getRecursivelyReferencedFragments(operation: OperationDefinition) + -> [FragmentDefinition] { + if let fragments = recursivelyReferencedFragments[operation] { + return fragments + } + var fragments = [FragmentDefinition]() + var collectedNames = Set() + var nodesToVisit = [operation.selectionSet] + while let node = nodesToVisit.popLast() { + for spread in getFragmentSpreads(node: node) { + let fragName = spread.name.value + if !collectedNames.contains(fragName) { + collectedNames.insert(fragName) + if let fragment = getFragment(name: fragName) { + fragments.append(fragment) + nodesToVisit.append(fragment.selectionSet) + } + } + } + } + recursivelyReferencedFragments[operation] = fragments + return fragments + } +} + +typealias ValidationRule = (ValidationContext) -> Visitor + +public class SDLValidationContext: ASTValidationContext { + public let schema: GraphQLSchema? + + init( + ast: Document, + schema: GraphQLSchema?, + onError: @escaping (GraphQLError) -> Void + ) { + self.schema = schema + super.init(ast: ast, onError: onError) + } + + // get [Symbol.toStringTag]() { + // return "SDLValidationContext"; + // } + + func getSchema() -> GraphQLSchema? { + return schema + } +} + +public typealias SDLValidationRule = (SDLValidationContext) -> Visitor + +/** + * An instance of this class is passed as the "this" context to all validators, + * allowing access to commonly useful contextual information from within a + * validation rule. + */ +public final class ValidationContext: ASTValidationContext { + public let schema: GraphQLSchema + let typeInfo: TypeInfo + var errors: [GraphQLError] + var variableUsages: [HasSelectionSet: [VariableUsage]] + var recursiveVariableUsages: [OperationDefinition: [VariableUsage]] + + init(schema: GraphQLSchema, ast: Document, typeInfo: TypeInfo) { + self.schema = schema + self.typeInfo = typeInfo + errors = [] + variableUsages = [:] + recursiveVariableUsages = [:] + + super.init(ast: ast) { _ in } + onError = { error in + self.errors.append(error) + } + } + + func getSchema() -> GraphQLSchema? { + return schema + } + + public func getVariableUsages(node: HasSelectionSet) -> [VariableUsage] { + if let usages = variableUsages[node] { + return usages + } + + var usages = [VariableUsage]() + let typeInfo = TypeInfo(schema: schema) + + visit( + root: node.node, + visitor: visitWithTypeInfo( + typeInfo: typeInfo, + visitor: Visitor(enter: { node, _, _, _, _ in + if node is VariableDefinition { + return .skip + } + + if let variable = node as? Variable { + usages.append(VariableUsage( + node: variable, + type: typeInfo.inputType, + defaultValue: typeInfo.defaultValue + )) + } + + return .continue + }) + ) + ) + + variableUsages[node] = usages + return usages + } + + public func getRecursiveVariableUsages(operation: OperationDefinition) -> [VariableUsage] { + if let usages = recursiveVariableUsages[operation] { + return usages + } + + var usages = getVariableUsages(node: .operation(operation)) + let fragments = getRecursivelyReferencedFragments(operation: operation) + + for fragment in fragments { + let newUsages = getVariableUsages(node: .fragment(fragment)) + usages.append(contentsOf: newUsages) + } + + recursiveVariableUsages[operation] = usages + return usages + } + + public var type: GraphQLOutputType? { + return typeInfo.type + } + + public var parentType: GraphQLCompositeType? { + return typeInfo.parentType + } + + public var inputType: GraphQLInputType? { + return typeInfo.inputType + } + + public var parentInputType: GraphQLInputType? { + return typeInfo.parentInputType + } + + public var fieldDef: GraphQLFieldDefinition? { + return typeInfo.fieldDef + } + + public var directive: GraphQLDirective? { + return typeInfo.directive + } + + public var argument: GraphQLArgumentDefinition? { + return typeInfo.argument + } + + public var getEnumValue: GraphQLEnumValueDefinition? { + return typeInfo.enumValue + } +} + +protocol SDLorNormalValidationContext { + func getSchema() -> GraphQLSchema? + var ast: Document { get } + func report(error: GraphQLError) +} + +extension ValidationContext: SDLorNormalValidationContext {} +extension SDLValidationContext: SDLorNormalValidationContext {} + +let emptySchema = try! GraphQLSchema() diff --git a/Tests/GraphQLTests/StarWarsTests/StarWarsQueryTests.swift b/Tests/GraphQLTests/StarWarsTests/StarWarsQueryTests.swift index 5d2d9eb7..368436ad 100644 --- a/Tests/GraphQLTests/StarWarsTests/StarWarsQueryTests.swift +++ b/Tests/GraphQLTests/StarWarsTests/StarWarsQueryTests.swift @@ -689,31 +689,32 @@ class StarWarsQueryTests: XCTestCase { let A = try GraphQLObjectType( name: "A", - fields: [ - "nullableA": GraphQLField( - type: GraphQLTypeReference("A"), - resolve: { _, _, _, _ -> [String: String]? in - [:] as [String: String] - } - ), - "nonNullA": GraphQLField( - type: GraphQLNonNull(GraphQLTypeReference("A")), - resolve: { _, _, _, _ -> [String: String]? in - [:] as [String: String] + fields: [:] + ) + A.fields = { [ + "nullableA": GraphQLField( + type: A, + resolve: { _, _, _, _ -> [String: String]? in + [:] as [String: String] + } + ), + "nonNullA": GraphQLField( + type: GraphQLNonNull(A), + resolve: { _, _, _, _ -> [String: String]? in + [:] as [String: String] + } + ), + "throws": GraphQLField( + type: GraphQLNonNull(GraphQLString), + resolve: { _, _, _, _ -> [String: String]? in + struct 🏃: Error, CustomStringConvertible { + let description: String } - ), - "throws": GraphQLField( - type: GraphQLNonNull(GraphQLString), - resolve: { _, _, _, _ -> [String: String]? in - struct 🏃: Error, CustomStringConvertible { - let description: String - } - throw 🏃(description: "catch me if you can.") - } - ), - ] - ) + throw 🏃(description: "catch me if you can.") + } + ), + ] } let queryType = try GraphQLObjectType( name: "query", diff --git a/Tests/GraphQLTests/StarWarsTests/StarWarsSchema.swift b/Tests/GraphQLTests/StarWarsTests/StarWarsSchema.swift index 09202e43..d9d70562 100644 --- a/Tests/GraphQLTests/StarWarsTests/StarWarsSchema.swift +++ b/Tests/GraphQLTests/StarWarsTests/StarWarsSchema.swift @@ -89,7 +89,7 @@ let EpisodeEnum = try! GraphQLEnumType( let CharacterInterface = try! GraphQLInterfaceType( name: "Character", description: "A character in the Star Wars Trilogy", - fields: [ + fields: { [ "id": GraphQLField( type: GraphQLNonNull(GraphQLString), description: "The id of the character." @@ -99,7 +99,7 @@ let CharacterInterface = try! GraphQLInterfaceType( description: "The name of the character." ), "friends": GraphQLField( - type: GraphQLList(GraphQLTypeReference("Character")), + type: GraphQLList(CharacterInterface), description: "The friends of the character, or an empty list if they have none." ), "appearsIn": GraphQLField( @@ -110,7 +110,7 @@ let CharacterInterface = try! GraphQLInterfaceType( type: GraphQLString, description: "All secrets about their past." ), - ], + ] }, resolveType: { character, _, _ in switch character { case is Human: diff --git a/Tests/GraphQLTests/TypeTests/GraphQLSchemaTests.swift b/Tests/GraphQLTests/TypeTests/GraphQLSchemaTests.swift index 9de467a4..915331bd 100644 --- a/Tests/GraphQLTests/TypeTests/GraphQLSchemaTests.swift +++ b/Tests/GraphQLTests/TypeTests/GraphQLSchemaTests.swift @@ -118,95 +118,43 @@ class GraphQLSchemaTests: XCTestCase { _ = try GraphQLSchema(query: object, types: [interface, object]) } - func testAssertObjectImplementsInterfaceFailsWhenObjectFieldHasRequiredArgumentMissingInInterface( - ) throws { - let interface = try GraphQLInterfaceType( - name: "Interface", - fields: [ - "fieldWithoutArg": GraphQLField( - type: GraphQLInt, - args: [:] - ), - ] - ) - - let object = try GraphQLObjectType( - name: "Object", - fields: [ - "fieldWithoutArg": GraphQLField( - type: GraphQLInt, - args: [ - "addedRequiredArg": GraphQLArgument(type: GraphQLNonNull(GraphQLInt)), - ] - ), - ], - interfaces: [interface], - isTypeOf: { _, _, _ -> Bool in - preconditionFailure("Should not be called") - } - ) - - do { - _ = try GraphQLSchema(query: object, types: [interface, object]) - XCTFail("Expected errors when creating schema") - } catch { - let graphQLError = try XCTUnwrap(error as? GraphQLError) - XCTAssertEqual( - graphQLError.message, - "Object.fieldWithoutArg includes required argument (addedRequiredArg:) that is missing from the Interface field Interface.fieldWithoutArg." - ) - } - } - func testAssertSchemaCircularReference() throws { let object1 = try GraphQLObjectType( - name: "Object1", - fields: [ - "object2": GraphQLField( - type: GraphQLTypeReference("Object2") - ), - ] + name: "Object1" ) let object2 = try GraphQLObjectType( - name: "Object2", - fields: [ - "object1": GraphQLField( - type: GraphQLTypeReference("Object1") - ), - ] + name: "Object2" ) - let query = try GraphQLObjectType( - name: "Query", - fields: [ - "object1": GraphQLField(type: GraphQLTypeReference("Object1")), - "object2": GraphQLField(type: GraphQLTypeReference("Object2")), + object1.fields = { [weak object2] in + guard let object2 = object2 else { + return [:] + } + return [ + "object2": GraphQLField( + type: object2 + ), ] - ) - - let schema = try GraphQLSchema(query: query, types: [object1, object2]) - for (_, graphQLNamedType) in schema.typeMap { - XCTAssertFalse(graphQLNamedType is GraphQLTypeReference) } - } - - func testAssertSchemaFailsWhenObjectNotDefined() throws { - let object1 = try GraphQLObjectType( - name: "Object1", - fields: [ - "object2": GraphQLField( - type: GraphQLTypeReference("Object2") + object2.fields = { [weak object1] in + guard let object1 = object1 else { + return [:] + } + return [ + "object1": GraphQLField( + type: object1 ), ] - ) + } let query = try GraphQLObjectType( name: "Query", fields: [ - "object1": GraphQLField(type: GraphQLTypeReference("Object1")), + "object1": GraphQLField(type: object1), + "object2": GraphQLField(type: object2), ] ) - XCTAssertThrowsError( - _ = try GraphQLSchema(query: query, types: [object1]) + XCTAssertNoThrow( + try GraphQLSchema(query: query, types: [object1, object2]) ) } } diff --git a/Tests/GraphQLTests/TypeTests/ValidateSchemaTests.swift b/Tests/GraphQLTests/TypeTests/ValidateSchemaTests.swift new file mode 100644 index 00000000..8109c11c --- /dev/null +++ b/Tests/GraphQLTests/TypeTests/ValidateSchemaTests.swift @@ -0,0 +1,2360 @@ +@testable import GraphQL +import XCTest + +let SomeSchema = try! buildSchema(source: """ +scalar SomeScalar + +interface SomeInterface { f: SomeObject } + +type SomeObject implements SomeInterface { f: SomeObject } + +union SomeUnion = SomeObject + +enum SomeEnum { ONLY } + +input SomeInputObject { val: String = "hello" } + +directive @SomeDirective on QUERY +""") +let SomeScalarType = SomeSchema.getType(name: "SomeScalar") as! GraphQLScalarType +let SomeInterfaceType = SomeSchema.getType(name: "SomeInterface") as! GraphQLInterfaceType +let SomeObjectType = SomeSchema.getType(name: "SomeObject") as! GraphQLObjectType +let SomeUnionType = SomeSchema.getType(name: "SomeUnion") as! GraphQLUnionType +let SomeEnumType = SomeSchema.getType(name: "SomeEnum") as! GraphQLEnumType +let SomeInputObjectType = SomeSchema.getType(name: "SomeInputObject") as! GraphQLInputObjectType +let SomeDirective = SomeSchema.getDirective(name: "SomeDirective") + +let outputTypes: [GraphQLOutputType] = [ + GraphQLString, GraphQLList(GraphQLString), GraphQLNonNull(GraphQLString), + GraphQLNonNull(GraphQLList(GraphQLString)), + SomeScalarType, GraphQLList(SomeScalarType), GraphQLNonNull(SomeScalarType), + GraphQLNonNull(GraphQLList(SomeScalarType)), + SomeEnumType, GraphQLList(SomeEnumType), GraphQLNonNull(SomeEnumType), + GraphQLNonNull(GraphQLList(SomeEnumType)), + SomeObjectType, GraphQLList(SomeObjectType), GraphQLNonNull(SomeObjectType), + GraphQLNonNull(GraphQLList(SomeObjectType)), + SomeUnionType, GraphQLList(SomeUnionType), GraphQLNonNull(SomeUnionType), + GraphQLNonNull(GraphQLList(SomeUnionType)), + SomeInterfaceType, GraphQLList(SomeInterfaceType), GraphQLNonNull(SomeInterfaceType), + GraphQLNonNull(GraphQLList(SomeInterfaceType)), +] +let notOutputTypes: [GraphQLInputType] = [ + SomeInputObjectType, GraphQLList(SomeInputObjectType), GraphQLNonNull(SomeInputObjectType), + GraphQLNonNull(GraphQLList(SomeInputObjectType)), +] +let inputTypes: [GraphQLInputType] = [ + GraphQLString, GraphQLList(GraphQLString), GraphQLNonNull(GraphQLString), + GraphQLNonNull(GraphQLList(GraphQLString)), + SomeScalarType, GraphQLList(SomeScalarType), GraphQLNonNull(SomeScalarType), + GraphQLNonNull(GraphQLList(SomeScalarType)), + SomeEnumType, GraphQLList(SomeEnumType), GraphQLNonNull(SomeEnumType), + GraphQLNonNull(GraphQLList(SomeEnumType)), + SomeInputObjectType, GraphQLList(SomeInputObjectType), GraphQLNonNull(SomeInputObjectType), + GraphQLNonNull(GraphQLList(SomeInputObjectType)), +] +let notInputTypes: [GraphQLOutputType] = [ + SomeObjectType, GraphQLList(SomeObjectType), GraphQLNonNull(SomeObjectType), + GraphQLNonNull(GraphQLList(SomeObjectType)), + SomeUnionType, GraphQLList(SomeUnionType), GraphQLNonNull(SomeUnionType), + GraphQLNonNull(GraphQLList(SomeUnionType)), + SomeInterfaceType, GraphQLList(SomeInterfaceType), GraphQLNonNull(SomeInterfaceType), + GraphQLNonNull(GraphQLList(SomeInterfaceType)), +] + +func schemaWithFieldType(type: GraphQLOutputType) throws -> GraphQLSchema { + return try GraphQLSchema( + query: GraphQLObjectType( + name: "Query", + fields: [ + "f": .init(type: type), + ] + ) + ) +} + +class ValidateSchemaTests: XCTestCase { + // MARK: Type System: A Schema must have Object root types + + func testAcceptsASchemaWhoseQueryTypeIsAnObjectType() throws { + let schema = try buildSchema(source: """ + type Query { + test: String + } + """) + try XCTAssertEqual(validateSchema(schema: schema), []) + + let schemaWithDef = try buildSchema(source: """ + schema { + query: QueryRoot + } + + type QueryRoot { + test: String + } + """) + try XCTAssertEqual(validateSchema(schema: schemaWithDef), []) + } + + func testAcceptsASchemaWhoseQueryAndMutationTypesAreObjectTypes() throws { + let schema = try buildSchema(source: """ + type Query { + test: String + } + + type Mutation { + test: String + } + """) + try XCTAssertEqual(validateSchema(schema: schema), []) + + let schemaWithDef = try buildSchema(source: """ + schema { + query: QueryRoot + mutation: MutationRoot + } + + type QueryRoot { + test: String + } + + type MutationRoot { + test: String + } + """) + try XCTAssertEqual(validateSchema(schema: schemaWithDef), []) + } + + func testAcceptsASchemaWhoseQueryAndSubscriptionTypesAreObjectTypes() throws { + let schema = try buildSchema(source: """ + type Query { + test: String + } + + type Subscription { + test: String + } + """) + try XCTAssertEqual(validateSchema(schema: schema), []) + + let schemaWithDef = try buildSchema(source: """ + schema { + query: QueryRoot + subscription: SubscriptionRoot + } + + type QueryRoot { + test: String + } + + type SubscriptionRoot { + test: String + } + """) + try XCTAssertEqual(validateSchema(schema: schemaWithDef), []) + } + + func testRejectsASchemaWithoutAQueryType() throws { + let schema = try buildSchema(source: """ + type Mutation { + test: String + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError(message: "Query root type must be provided."), + ]) + + let schemaWithDef = try buildSchema(source: """ + schema { + mutation: MutationRoot + } + + type MutationRoot { + test: String + } + """) + try XCTAssertEqual(validateSchema(schema: schemaWithDef), [ + GraphQLError( + message: "Query root type must be provided.", + locations: [.init(line: 2, column: 7)] + ), + ]) + } + + func testRejectsASchemaWhoseQueryRootTypeIsNotAnObjectType() throws { + XCTAssertThrowsError( + try buildSchema(source: """ + input Query { + test: String + } + """), + "Query root type must be Object type, it cannot be Query." + ) + + XCTAssertThrowsError( + try buildSchema(source: """ + schema { + query: SomeInputObject + } + + input SomeInputObject { + test: String + } + """), + "Query root type must be Object type, it cannot be SomeInputObject." + ) + } + + func testRejectsASchemaWhoseMutationTypeIsAnInputType() throws { + XCTAssertThrowsError( + try buildSchema(source: """ + type Query { + field: String + } + + input Mutation { + test: String + } + """), + "Mutation root type must be Object type if provided, it cannot be Mutation." + ) + + XCTAssertThrowsError( + try buildSchema(source: """ + schema { + query: Query + mutation: SomeInputObject + } + + type Query { + field: String + } + + input SomeInputObject { + test: String + } + """), + "Mutation root type must be Object type if provided, it cannot be SomeInputObject." + ) + } + + func testRejectsASchemaWhoseSubscriptionTypeIsAnInputType() throws { + XCTAssertThrowsError( + try buildSchema(source: """ + type Query { + field: String + } + + input Subscription { + test: String + } + """), + "Subscription root type must be Object type if provided, it cannot be Subscription." + ) + + XCTAssertThrowsError( + try buildSchema(source: """ + schema { + query: Query + subscription: SomeInputObject + } + + type Query { + field: String + } + + input SomeInputObject { + test: String + } + """), + "Subscription root type must be Object type if provided, it cannot be SomeInputObject." + ) + } + + func testRejectsASchemaExtendedWithInvalidRootTypes() throws { + let schema = try buildSchema(source: """ + input SomeInputObject { + test: String + } + + scalar SomeScalar + + enum SomeEnum { + ENUM_VALUE + } + """) + + XCTAssertThrowsError( + try extendSchema( + schema: schema, + documentAST: parse(source: """ + extend schema { + query: SomeInputObject + } + """) + ), + "Query root type must be Object type, it cannot be SomeInputObject." + ) + + XCTAssertThrowsError( + try extendSchema( + schema: schema, + documentAST: parse(source: """ + extend schema { + mutation: SomeScalar + } + """) + ), + "Mutation root type must be Object type if provided, it cannot be SomeScalar." + ) + + XCTAssertThrowsError( + try extendSchema( + schema: schema, + documentAST: parse(source: """ + extend schema { + subscription: SomeEnum + } + """) + ), + "Subscription root type must be Object type if provided, it cannot be SomeEnum." + ) + } + + func testRejectsASchemaWhoseDirectivesHaveEmptyLocations() throws { + let badDirective = try GraphQLDirective( + name: "BadDirective", + locations: [], + args: [:] + ) + let schema = try GraphQLSchema( + query: SomeObjectType, + directives: [badDirective] + ) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError(message: "Directive @BadDirective must include 1 or more locations."), + ]) + } + + // MARK: Type System: Root types must all be different if provided + + func testAcceptsASchemaWithDifferentRootTypes() throws { + let schema = try buildSchema(source: """ + type SomeObject1 { + field: String + } + + type SomeObject2 { + field: String + } + + type SomeObject3 { + field: String + } + + schema { + query: SomeObject1 + mutation: SomeObject2 + subscription: SomeObject3 + } + """) + try XCTAssertEqual(validateSchema(schema: schema), []) + } + + func testRejectsASchemaWhereTheSameTypeIsUsedForMultipleRootTypes() throws { + let schema = try buildSchema(source: """ + type SomeObject { + field: String + } + + type UniqueObject { + field: String + } + + schema { + query: SomeObject + mutation: UniqueObject + subscription: SomeObject + } + """) + + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + "All root types must be different, \"SomeObject\" type is used as query and subscription root types.", + locations: [ + .init(line: 11, column: 16), + .init(line: 13, column: 23), + ] + ), + ]) + } + + func testRejectsASchemaWhereTheSameTypeIsUsedForAllRootTypes() throws { + let schema = try buildSchema(source: """ + type SomeObject { + field: String + } + + schema { + query: SomeObject + mutation: SomeObject + subscription: SomeObject + } + """) + + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + "All root types must be different, \"SomeObject\" type is used as query, mutation, and subscription root types.", + locations: [ + .init(line: 7, column: 16), + .init(line: 8, column: 19), + .init(line: 9, column: 23), + ] + ), + ]) + } + + // MARK: Type System: Objects must have fields + + func testAcceptsAnObjectTypeWithFieldsObject() throws { + let schema = try buildSchema(source: """ + type Query { + field: SomeObject + } + + type SomeObject { + field: String + } + """) + try XCTAssertEqual(validateSchema(schema: schema), []) + } + + func testRejectsAnObjectTypeWithMissingFields() throws { + let schema = try buildSchema(source: """ + type Query { + test: IncompleteObject + } + + type IncompleteObject + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: "Type IncompleteObject must define one or more fields.", + locations: [.init(line: 6, column: 7)] + ), + ]) + + let manualSchema = try schemaWithFieldType( + type: GraphQLObjectType( + name: "IncompleteObject", + fields: [:] + ) + ) + try XCTAssertEqual(validateSchema(schema: manualSchema), [ + GraphQLError(message: "Type IncompleteObject must define one or more fields."), + ]) + + let manualSchema2 = try schemaWithFieldType( + type: + GraphQLObjectType( + name: "IncompleteObject", + fields: { + [:] + } + ) + ) + try XCTAssertEqual(validateSchema(schema: manualSchema2), [ + GraphQLError(message: "Type IncompleteObject must define one or more fields."), + ]) + } + + func testRejectsAnObjectTypeWithIncorrectlyNamedFields() throws { + let schema = try schemaWithFieldType( + type: + GraphQLObjectType( + name: "SomeObject", + fields: { + ["__badName": .init(type: GraphQLString)] + } + ) + ) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: "Name \"__badName\" must not begin with \"__\", which is reserved by GraphQL introspection." + ), + ]) + } + + // MARK: Type System: Fields args must be properly named + + func testAcceptsFieldArgsWithValidNames() throws { + let schema = try schemaWithFieldType( + type: + GraphQLObjectType( + name: "SomeObject", + fields: [ + "goodField": .init( + type: GraphQLString, + args: [ + "goodArg": .init(type: GraphQLString), + ] + ), + ] + ) + ) + try XCTAssertEqual(validateSchema(schema: schema), []) + } + + func testRejectsFieldArgWithInvalidNames() throws { + let schema = try schemaWithFieldType( + type: + GraphQLObjectType( + name: "SomeObject", + fields: [ + "badField": .init( + type: GraphQLString, + args: [ + "__badName": .init(type: GraphQLString), + ] + ), + ] + ) + ) + + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: "Name \"__badName\" must not begin with \"__\", which is reserved by GraphQL introspection." + ), + ]) + } + + // MARK: Type System: Union types must be valid + + func testAcceptsAUnionTypeWithMemberTypes() throws { + let schema = try buildSchema(source: """ + type Query { + test: GoodUnion + } + + type TypeA { + field: String + } + + type TypeB { + field: String + } + + union GoodUnion = + | TypeA + | TypeB + """) + try XCTAssertEqual(validateSchema(schema: schema), []) + } + + func testRejectsAUnionTypeWithEmptyTypes() throws { + var schema = try buildSchema(source: """ + type Query { + test: BadUnion + } + + union BadUnion + """) + + schema = try extendSchema( + schema: schema, + documentAST: parse(source: """ + directive @test on UNION + + extend union BadUnion @test + """) + ) + + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: "Union type BadUnion must define one or more member types.", + locations: [ + .init(line: 6, column: 7), + .init(line: 4, column: 9), + ] + ), + ]) + } + + func testRejectsAUnionTypeWithDuplicatedMemberType() throws { + var schema = try buildSchema(source: """ + type Query { + test: BadUnion + } + + type TypeA { + field: String + } + + type TypeB { + field: String + } + + union BadUnion = + | TypeA + | TypeB + | TypeA + """) + + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: "Union type BadUnion can only include type TypeA once.", + locations: [ + .init(line: 15, column: 11), + .init(line: 17, column: 11), + ] + ), + ]) + + schema = try extendSchema( + schema: schema, + documentAST: parse(source: "extend union BadUnion = TypeB") + ) + + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: "Union type BadUnion can only include type TypeA once.", + locations: [ + .init(line: 15, column: 11), + .init(line: 17, column: 11), + ] + ), + GraphQLError( + message: "Union type BadUnion can only include type TypeB once.", + locations: [ + .init(line: 16, column: 11), + .init(line: 1, column: 25), + ] + ), + ]) + } + + // MARK: Type System: Input Objects must have fields + + func testAcceptsAnInputObjectTypeWithFields() throws { + let schema = try buildSchema(source: """ + type Query { + field(arg: SomeInputObject): String + } + + input SomeInputObject { + field: String + } + """) + try XCTAssertEqual(validateSchema(schema: schema), []) + } + + func testRejectsAnInputObjectTypeWithMissingFields() throws { + var schema = try buildSchema(source: """ + type Query { + field(arg: SomeInputObject): String + } + + input SomeInputObject + """) + + schema = try extendSchema( + schema: schema, + documentAST: parse(source: """ + directive @test on INPUT_OBJECT + + extend input SomeInputObject @test + """) + ) + + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + "Input Object type SomeInputObject must define one or more fields.", + locations: [ + .init(line: 6, column: 7), + .init(line: 4, column: 9), + ] + ), + ]) + } + + func testAcceptsAnInputObjectWithBreakableCircularReference() throws { + let schema = try buildSchema(source: """ + type Query { + field(arg: SomeInputObject): String + } + + input SomeInputObject { + self: SomeInputObject + arrayOfSelf: [SomeInputObject] + nonNullArrayOfSelf: [SomeInputObject]! + nonNullArrayOfNonNullSelf: [SomeInputObject!]! + intermediateSelf: AnotherInputObject + } + + input AnotherInputObject { + parent: SomeInputObject + } + """) + + try XCTAssertEqual(validateSchema(schema: schema), []) + } + + func testRejectsAnInputObjectWithNonBreakableCircularReference() throws { + let schema = try buildSchema(source: """ + type Query { + field(arg: SomeInputObject): String + } + + input SomeInputObject { + nonNullSelf: SomeInputObject! + } + """) + + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: #"Cannot reference Input Object "SomeInputObject" within itself through a series of non-null fields: "nonNullSelf"."#, + locations: [.init(line: 7, column: 9)] + ), + ]) + } + + func testRejectsInputObjectsWithNonbreakableCircularReferenceSpreadAcrossThem() throws { + let schema = try buildSchema(source: """ + type Query { + field(arg: SomeInputObject): String + } + + input SomeInputObject { + startLoop: AnotherInputObject! + } + + input AnotherInputObject { + nextInLoop: YetAnotherInputObject! + } + + input YetAnotherInputObject { + closeLoop: SomeInputObject! + } + """) + + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + #"Cannot reference Input Object "SomeInputObject" within itself through a series of non-null fields: "startLoop.nextInLoop.closeLoop"."#, + locations: [ + .init(line: 7, column: 9), + .init(line: 11, column: 9), + .init(line: 15, column: 9), + ] + ), + ]) + } + + func testRejectsInputObjectsWithMultipleNonbreakableCircularReference() throws { + let schema = try buildSchema(source: """ + type Query { + field(arg: SomeInputObject): String + } + + input SomeInputObject { + startLoop: AnotherInputObject! + } + + input AnotherInputObject { + closeLoop: SomeInputObject! + startSecondLoop: YetAnotherInputObject! + } + + input YetAnotherInputObject { + closeSecondLoop: AnotherInputObject! + nonNullSelf: YetAnotherInputObject! + } + """) + + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + #"Cannot reference Input Object "SomeInputObject" within itself through a series of non-null fields: "startLoop.closeLoop"."#, + locations: [ + .init(line: 7, column: 9), + .init(line: 11, column: 9), + ] + ), + GraphQLError( + message: + #"Cannot reference Input Object "AnotherInputObject" within itself through a series of non-null fields: "startSecondLoop.closeSecondLoop"."#, + locations: [ + .init(line: 12, column: 9), + .init(line: 16, column: 9), + ] + ), + GraphQLError( + message: #"Cannot reference Input Object "YetAnotherInputObject" within itself through a series of non-null fields: "nonNullSelf"."#, + locations: [.init(line: 17, column: 9)] + ), + ]) + } + + func testRejectsAnInputObjectTypeWithIncorrectlyTypedFields() throws { + XCTAssertThrowsError( + try buildSchema(source: """ + type Query { + field(arg: SomeInputObject): String + } + + type SomeObject { + field: String + } + + union SomeUnion = SomeObject + + input SomeInputObject { + badObject: SomeObject + badUnion: SomeUnion + goodInputObject: SomeInputObject + } + """), + "The type of SomeInputObject.badObject must be Input Type but got: SomeObject." + ) + } + + func testRejectsAnInputObjectTypeWithRequiredArgumentThatIsDeprecated() throws { + let schema = try buildSchema(source: """ + type Query { + field(arg: SomeInputObject): String + } + + input SomeInputObject { + badField: String! @deprecated + optionalField: String @deprecated + anotherOptionalField: String! = "" @deprecated + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + "Required input field SomeInputObject.badField cannot be deprecated.", + locations: [ + .init(line: 7, column: 27), + .init(line: 7, column: 19), + ] + ), + ]) + } + + // MARK: Type System: Enum types must be well defined + + func testRejectsAnEnumTypeWithoutValues() throws { + var schema = try buildSchema(source: """ + type Query { + field: SomeEnum + } + + enum SomeEnum + """) + + schema = try extendSchema( + schema: schema, + documentAST: parse(source: """ + directive @test on ENUM + + extend enum SomeEnum @test + """) + ) + + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: "Enum type SomeEnum must define one or more values.", + locations: [ + .init(line: 6, column: 7), + .init(line: 4, column: 9), + ] + ), + ]) + } + + func testRejectsAnEnumTypeWithIncorrectlyNamedValues() throws { + let schema = try schemaWithFieldType( + type: + GraphQLEnumType( + name: "SomeEnum", + values: [ + "__badName": .init(value: .string("__badName")), + ] + ) + ) + + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: #"Name "__badName" must not begin with "__", which is reserved by GraphQL introspection."# + ), + ]) + } + + // MARK: Type System: Object fields must have output types + + func schemaWithObjectField( + fieldConfig: GraphQLField + ) throws -> GraphQLSchema { + let BadObjectType = try GraphQLObjectType( + name: "BadObject", + fields: [ + "badField": fieldConfig, + ] + ) + + return try GraphQLSchema( + query: GraphQLObjectType( + name: "Query", + fields: [ + "f": .init(type: BadObjectType), + ] + ), + types: [SomeObjectType] + ) + } + + func testRejectsWithRelevantLocationsForANonoutputTypeAsAnObjectFieldType() throws { + XCTAssertThrowsError( + try buildSchema(source: """ + type Query { + field: [SomeInputObject] + } + + input SomeInputObject { + field: String + } + """), + "The type of Query.field must be Output Type but got: [SomeInputObject]." + ) + } + + // MARK: Type System: Objects can only implement unique interfaces + + func testRejectsAnObjectImplementingANoninterfaceType() throws { + XCTAssertThrowsError( + try buildSchema(source: """ + type Query { + test: BadObject + } + + input SomeInputObject { + field: String + } + + type BadObject implements SomeInputObject { + field: String + } + """), + "Type BadObject must only implement Interface types, it cannot implement SomeInputObject." + ) + } + + func testRejectsAnObjectImplementingTheSameInterfaceTwice() throws { + let schema = try buildSchema(source: """ + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field: String + } + + type AnotherObject implements AnotherInterface & AnotherInterface { + field: String + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: "Type AnotherObject can only implement AnotherInterface once.", + locations: [ + .init(line: 10, column: 37), + .init(line: 10, column: 56), + ] + ), + ]) + } + + func testRejectsAnObjectImplementingTheSameInterfaceTwiceDueToExtension() throws { + let schema = try buildSchema(source: """ + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field: String + } + + type AnotherObject implements AnotherInterface { + field: String + } + """) + let extendedSchema = try extendSchema( + schema: schema, + documentAST: parse(source: "extend type AnotherObject implements AnotherInterface") + ) + try XCTAssertEqual(validateSchema(schema: extendedSchema), [ + GraphQLError( + message: "Type AnotherObject can only implement AnotherInterface once.", + locations: [ + .init(line: 10, column: 37), + .init(line: 1, column: 38), + ] + ), + ]) + } + + // MARK: Type System: Interface extensions should be valid + + func testRejectsAnObjectImplementingTheExtendedInterfaceDueToMissingField() throws { + let schema = try buildSchema(source: """ + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field: String + } + + type AnotherObject implements AnotherInterface { + field: String + } + """) + let extendedSchema = try extendSchema( + schema: schema, + documentAST: parse(source: """ + extend interface AnotherInterface { + newField: String + } + + extend type AnotherObject { + differentNewField: String + } + """) + ) + try XCTAssertEqual(validateSchema(schema: extendedSchema), [ + GraphQLError( + message: + "Interface field AnotherInterface.newField expected but AnotherObject does not provide it.", + locations: [ + .init(line: 3, column: 11), + .init(line: 10, column: 7), + .init(line: 6, column: 9), + ] + ), + ]) + } + + func testRejectsAnObjectImplementingTheExtendedInterfaceDueToMissingFieldArgs() throws { + let schema = try buildSchema(source: """ + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field: String + } + + type AnotherObject implements AnotherInterface { + field: String + } + """) + let extendedSchema = try extendSchema( + schema: schema, + documentAST: parse(source: """ + extend interface AnotherInterface { + newField(test: Boolean): String + } + + extend type AnotherObject { + newField: String + } + """) + ) + try XCTAssertEqual(validateSchema(schema: extendedSchema), [ + GraphQLError( + message: + "Interface field argument AnotherInterface.newField(test:) expected but AnotherObject.newField does not provide it.", + locations: [ + .init(line: 3, column: 20), + .init(line: 7, column: 11), + ] + ), + ]) + } + + func testRejectsObjectsImplementingTheExtendedInterfaceDueToMismatchingInterfaceType() throws { + let schema = try buildSchema(source: """ + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field: String + } + + type AnotherObject implements AnotherInterface { + field: String + } + """) + let extendedSchema = try extendSchema( + schema: schema, + documentAST: parse(source: """ + extend interface AnotherInterface { + newInterfaceField: NewInterface + } + + interface NewInterface { + newField: String + } + + interface MismatchingInterface { + newField: String + } + + extend type AnotherObject { + newInterfaceField: MismatchingInterface + } + + # Required to prevent unused interface errors + type DummyObject implements NewInterface & MismatchingInterface { + newField: String + } + """) + ) + try XCTAssertEqual(validateSchema(schema: extendedSchema), [ + GraphQLError( + message: + "Interface field AnotherInterface.newInterfaceField expects type NewInterface but AnotherObject.newInterfaceField is type MismatchingInterface.", + locations: [ + .init(line: 3, column: 30), + .init(line: 15, column: 30), + ] + ), + ]) + } + + // MARK: Type System: Interface fields must have output types + + func schemaWithInterfaceField( + fieldConfig: GraphQLField + ) throws -> GraphQLSchema { + let BadInterfaceType = try GraphQLInterfaceType( + name: "BadInterface", + fields: ["badField": fieldConfig] + ) + + let BadImplementingType = try GraphQLObjectType( + name: "BadImplementing", + fields: ["badField": fieldConfig], + interfaces: [BadInterfaceType] + ) + + return try GraphQLSchema( + query: GraphQLObjectType( + name: "Query", + fields: [ + "f": .init(type: BadInterfaceType), + ] + ), + types: [BadImplementingType, SomeObjectType] + ) + } + + func testAcceptsAnOutputTypeAsAnInterfaceFieldType() throws { + for type in outputTypes { + let schema = try schemaWithInterfaceField(fieldConfig: .init(type: type)) + try XCTAssertEqual(validateSchema(schema: schema), []) + } + } + + func testRejectsANonoutputTypeAsAnInterfaceFieldTypeWithLocations() throws { + XCTAssertThrowsError( + try buildSchema(source: """ + type Query { + test: SomeInterface + } + + interface SomeInterface { + field: SomeInputObject + } + + input SomeInputObject { + foo: String + } + + type SomeObject implements SomeInterface { + field: SomeInputObject + } + """), + "The type of SomeInterface.field must be Output Type but got: SomeInputObject." + ) + } + + func testAcceptsAnInterfaceNotImplementedByAtLeastOneObject() throws { + let schema = try buildSchema(source: """ + type Query { + test: SomeInterface + } + + interface SomeInterface { + foo: String + } + """) + try XCTAssertEqual(validateSchema(schema: schema), []) + } + + // MARK: Type System: Arguments must have input types + + func schemaWithArg(argConfig: GraphQLArgument) throws -> GraphQLSchema { + let BadObjectType = try GraphQLObjectType( + name: "BadObject", + fields: [ + "badField": .init( + type: GraphQLString, + args: [ + "badArg": argConfig, + ] + ), + ] + ) + + return try GraphQLSchema( + query: GraphQLObjectType( + name: "Query", + fields: [ + "f": .init(type: BadObjectType), + ] + ), + directives: [ + GraphQLDirective( + name: "BadDirective", + locations: [DirectiveLocation.query], + args: [ + "badArg": argConfig, + ] + ), + ] + ) + } + + func testAcceptsAnInputTypeAsAFieldArgType() throws { + for type in inputTypes { + let schema = try schemaWithArg(argConfig: .init(type: type)) + try XCTAssertEqual(validateSchema(schema: schema), []) + } + } + + func testRejectsARequiredArgumentThatIsDeprecated() throws { + let schema = try buildSchema(source: """ + directive @BadDirective( + badArg: String! @deprecated + optionalArg: String @deprecated + anotherOptionalArg: String! = "" @deprecated + ) on FIELD + + type Query { + test( + badArg: String! @deprecated + optionalArg: String @deprecated + anotherOptionalArg: String! = "" @deprecated + ): String + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + "Required argument @BadDirective(badArg:) cannot be deprecated.", + locations: [ + .init(line: 3, column: 25), + .init(line: 3, column: 17), + ] + ), + GraphQLError( + message: "Required argument Query.test(badArg:) cannot be deprecated.", + locations: [ + .init(line: 10, column: 27), + .init(line: 10, column: 19), + ] + ), + ]) + } + + func testRejectsANoninputTypeAsAFieldArgWithLocations() throws { + XCTAssertThrowsError( + try buildSchema(source: """ + type Query { + test(arg: SomeObject): String + } + + type SomeObject { + foo: String + } + """), + "The type of Query.test(arg:) must be Input Type but got: SomeObject." + ) + } + + // MARK: Type System: Input Object fields must have input types + + func schemaWithInputField( + inputFieldConfig: InputObjectField + ) throws -> GraphQLSchema { + let BadInputObjectType = try GraphQLInputObjectType( + name: "BadInputObject", + fields: [ + "badField": inputFieldConfig, + ] + ) + + return try GraphQLSchema( + query: GraphQLObjectType( + name: "Query", + fields: [ + "f": .init( + type: GraphQLString, + args: [ + "badArg": .init(type: BadInputObjectType), + ] + ), + ] + ) + ) + } + + func testAcceptsAnInputTypeAsAnInputFieldType() throws { + for type in inputTypes { + let schema = try schemaWithInputField(inputFieldConfig: .init(type: type)) + try XCTAssertEqual(validateSchema(schema: schema), []) + } + } + + func testRejectsANoninputTypeAsAnInputObjectFieldWithLocations() throws { + XCTAssertThrowsError( + try buildSchema(source: """ + type Query { + test(arg: SomeInputObject): String + } + + input SomeInputObject { + foo: SomeObject + } + + type SomeObject { + bar: String + } + """), + "The type of SomeInputObject.foo must be Input Type but got: SomeObject." + ) + } + + // MARK: Type System: OneOf Input Object fields must be nullable + + func testRejectsNonnullableFields() throws { + let schema = try buildSchema(source: """ + type Query { + test(arg: SomeInputObject): String + } + + input SomeInputObject @oneOf { + a: String + b: String! + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: "OneOf input field SomeInputObject.b must be nullable.", + locations: [.init(line: 8, column: 12)] + ), + ]) + } + + func testRejectsFieldsWithDefaultValues() throws { + let schema = try buildSchema(source: """ + type Query { + test(arg: SomeInputObject): String + } + + input SomeInputObject @oneOf { + a: String + b: String = "foo" + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: "OneOf input field SomeInputObject.b cannot have a default value.", + locations: [.init(line: 8, column: 9)] + ), + ]) + } + + // MARK: Objects must adhere to Interface they implement + + func testAcceptsAnObjectWhichImplementsAnInterface() throws { + let schema = try buildSchema(source: """ + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field(input: String): String + } + + type AnotherObject implements AnotherInterface { + field(input: String): String + } + """) + try XCTAssertEqual(validateSchema(schema: schema), []) + } + + func testAcceptsAnObjectWhichImplementsAnInterfaceAlongWithMoreFields() throws { + let schema = try buildSchema(source: """ + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field(input: String): String + } + + type AnotherObject implements AnotherInterface { + field(input: String): String + anotherField: String + } + """) + try XCTAssertEqual(validateSchema(schema: schema), []) + } + + func testAcceptsAnObjectWhichImplementsAnInterfaceFieldAlongWithAdditionalOptionalArguments( + ) throws { + let schema = try buildSchema(source: """ + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field(input: String): String + } + + type AnotherObject implements AnotherInterface { + field(input: String, anotherInput: String): String + } + """) + try XCTAssertEqual(validateSchema(schema: schema), []) + } + + func testRejectsAnObjectMissingAnInterfaceField() throws { + let schema = try buildSchema(source: """ + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field(input: String): String + } + + type AnotherObject implements AnotherInterface { + anotherField: String + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + "Interface field AnotherInterface.field expected but AnotherObject does not provide it.", + locations: [ + .init(line: 7, column: 9), + .init(line: 10, column: 7), + ] + ), + ]) + } + + func testRejectsAnObjectWithAnIncorrectlyTypedInterfaceField() throws { + let schema = try buildSchema(source: """ + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field(input: String): String + } + + type AnotherObject implements AnotherInterface { + field(input: String): Int + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + "Interface field AnotherInterface.field expects type String but AnotherObject.field is type Int.", + locations: [ + .init(line: 7, column: 31), + .init(line: 11, column: 31), + ] + ), + ]) + } + + func testRejectsAnObjectWithADifferentlyTypedInterfaceField() throws { + let schema = try buildSchema(source: """ + type Query { + test: AnotherObject + } + + type A { foo: String } + type B { foo: String } + + interface AnotherInterface { + field: A + } + + type AnotherObject implements AnotherInterface { + field: B + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + "Interface field AnotherInterface.field expects type A but AnotherObject.field is type B.", + locations: [ + .init(line: 10, column: 16), + .init(line: 14, column: 16), + ] + ), + ]) + } + + func testAcceptsAnObjectWithASubtypedInterfaceField_Interface() throws { + let schema = try buildSchema(source: """ + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field: AnotherInterface + } + + type AnotherObject implements AnotherInterface { + field: AnotherObject + } + """) + try XCTAssertEqual(validateSchema(schema: schema), []) + } + + func testAcceptsAnObjectWithASubtypedInterfaceField_Union() throws { + let schema = try buildSchema(source: """ + type Query { + test: AnotherObject + } + + type SomeObject { + field: String + } + + union SomeUnionType = SomeObject + + interface AnotherInterface { + field: SomeUnionType + } + + type AnotherObject implements AnotherInterface { + field: SomeObject + } + """) + try XCTAssertEqual(validateSchema(schema: schema), []) + } + + func testRejectsAnObjectMissingAnInterfaceArgument() throws { + let schema = try buildSchema(source: """ + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field(input: String): String + } + + type AnotherObject implements AnotherInterface { + field: String + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + "Interface field argument AnotherInterface.field(input:) expected but AnotherObject.field does not provide it.", + locations: [ + .init(line: 7, column: 15), + .init(line: 11, column: 9), + ] + ), + ]) + } + + func testRejectsAnObjectWithAnIncorrectlyTypedInterfaceArgument() throws { + let schema = try buildSchema(source: """ + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field(input: String): String + } + + type AnotherObject implements AnotherInterface { + field(input: Int): String + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + "Interface field argument AnotherInterface.field(input:) expects type String but AnotherObject.field(input:) is type Int.", + locations: [ + .init(line: 7, column: 22), + .init(line: 11, column: 22), + ] + ), + ]) + } + + func testRejectsAnObjectWithBothAnIncorrectlyTypedFieldAndArgument() throws { + let schema = try buildSchema(source: """ + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field(input: String): String + } + + type AnotherObject implements AnotherInterface { + field(input: Int): Int + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + "Interface field AnotherInterface.field expects type String but AnotherObject.field is type Int.", + locations: [ + .init(line: 7, column: 31), + .init(line: 11, column: 28), + ] + ), + GraphQLError( + message: + "Interface field argument AnotherInterface.field(input:) expects type String but AnotherObject.field(input:) is type Int.", + locations: [ + .init(line: 7, column: 22), + .init(line: 11, column: 22), + ] + ), + ]) + } + + func testRejectsAnObjectWhichImplementsAnInterfaceFieldAlongWithAdditionalRequiredArguments( + ) throws { + let schema = try buildSchema(source: """ + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field(baseArg: String): String + } + + type AnotherObject implements AnotherInterface { + field( + baseArg: String, + requiredArg: String! + optionalArg1: String, + optionalArg2: String = "", + ): String + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + #"Argument "AnotherObject.field(requiredArg:)" must not be required type "String!" if not provided by the Interface field "AnotherInterface.field"."#, + locations: [ + .init(line: 13, column: 11), + .init(line: 7, column: 9), + ] + ), + ]) + } + + func testAcceptsAnObjectWithAnEquivalentlyWrappedInterfaceFieldType() throws { + let schema = try buildSchema(source: """ + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field: [String]! + } + + type AnotherObject implements AnotherInterface { + field: [String]! + } + """) + try XCTAssertEqual(validateSchema(schema: schema), []) + } + + func testRejectsAnObjectWithANonlistInterfaceFieldListType() throws { + let schema = try buildSchema(source: """ + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field: [String] + } + + type AnotherObject implements AnotherInterface { + field: String + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + "Interface field AnotherInterface.field expects type [String] but AnotherObject.field is type String.", + locations: [ + .init(line: 7, column: 16), + .init(line: 11, column: 16), + ] + ), + ]) + } + + func testRejectsAnObjectWithAListInterfaceFieldNonlistType() throws { + let schema = try buildSchema(source: """ + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field: String + } + + type AnotherObject implements AnotherInterface { + field: [String] + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + "Interface field AnotherInterface.field expects type String but AnotherObject.field is type [String].", + locations: [ + .init(line: 7, column: 16), + .init(line: 11, column: 16), + ] + ), + ]) + } + + func testAcceptsAnObjectWithASubsetNonnullInterfaceFieldType() throws { + let schema = try buildSchema(source: """ + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field: String + } + + type AnotherObject implements AnotherInterface { + field: String! + } + """) + try XCTAssertEqual(validateSchema(schema: schema), []) + } + + func testRejectsAnObjectWithASupersetNullableInterfaceFieldType() throws { + let schema = try buildSchema(source: """ + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field: String! + } + + type AnotherObject implements AnotherInterface { + field: String + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + "Interface field AnotherInterface.field expects type String! but AnotherObject.field is type String.", + locations: [ + .init(line: 7, column: 16), + .init(line: 11, column: 16), + ] + ), + ]) + } + + func testRejectsAnObjectMissingATransitiveInterface_Object() throws { + let schema = try buildSchema(source: """ + type Query { + test: AnotherObject + } + + interface SuperInterface { + field: String! + } + + interface AnotherInterface implements SuperInterface { + field: String! + } + + type AnotherObject implements AnotherInterface { + field: String! + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + "Type AnotherObject must implement SuperInterface because it is implemented by AnotherInterface.", + locations: [ + .init(line: 10, column: 45), + .init(line: 14, column: 37), + ] + ), + ]) + } + + // MARK: Interfaces must adhere to Interface they implement + + func testAcceptsAnInterfaceWhichImplementsAnInterface() throws { + let schema = try buildSchema(source: """ + type Query { + test: ChildInterface + } + + interface ParentInterface { + field(input: String): String + } + + interface ChildInterface implements ParentInterface { + field(input: String): String + } + """) + try XCTAssertEqual(validateSchema(schema: schema), []) + } + + func testAcceptsAnInterfaceWhichImplementsAnInterfaceAlongWithMoreFields() throws { + let schema = try buildSchema(source: """ + type Query { + test: ChildInterface + } + + interface ParentInterface { + field(input: String): String + } + + interface ChildInterface implements ParentInterface { + field(input: String): String + anotherField: String + } + """) + try XCTAssertEqual(validateSchema(schema: schema), []) + } + + func testAcceptsAnInterfaceWhichImplementsAnInterfaceFieldAlongWithAdditionalOptionalArguments( + ) throws { + let schema = try buildSchema(source: """ + type Query { + test: ChildInterface + } + + interface ParentInterface { + field(input: String): String + } + + interface ChildInterface implements ParentInterface { + field(input: String, anotherInput: String): String + } + """) + try XCTAssertEqual(validateSchema(schema: schema), []) + } + + func testRejectsAnInterfaceMissingAnInterfaceField() throws { + let schema = try buildSchema(source: """ + type Query { + test: ChildInterface + } + + interface ParentInterface { + field(input: String): String + } + + interface ChildInterface implements ParentInterface { + anotherField: String + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + "Interface field ParentInterface.field expected but ChildInterface does not provide it.", + locations: [ + .init(line: 7, column: 9), + .init(line: 10, column: 7), + ] + ), + ]) + } + + func testRejectsAnInterfaceWithAnIncorrectlyTypedInterfaceField() throws { + let schema = try buildSchema(source: """ + type Query { + test: ChildInterface + } + + interface ParentInterface { + field(input: String): String + } + + interface ChildInterface implements ParentInterface { + field(input: String): Int + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + "Interface field ParentInterface.field expects type String but ChildInterface.field is type Int.", + locations: [ + .init(line: 7, column: 31), + .init(line: 11, column: 31), + ] + ), + ]) + } + + func testRejectsAnInterfaceWithADifferentlyTypedInterfaceField() throws { + let schema = try buildSchema(source: """ + type Query { + test: ChildInterface + } + + type A { foo: String } + type B { foo: String } + + interface ParentInterface { + field: A + } + + interface ChildInterface implements ParentInterface { + field: B + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + "Interface field ParentInterface.field expects type A but ChildInterface.field is type B.", + locations: [ + .init(line: 10, column: 16), + .init(line: 14, column: 16), + ] + ), + ]) + } + + func testAcceptsAnInterfaceWithASubtypedInterfaceField_Interface() throws { + let schema = try buildSchema(source: """ + type Query { + test: ChildInterface + } + + interface ParentInterface { + field: ParentInterface + } + + interface ChildInterface implements ParentInterface { + field: ChildInterface + } + """) + try XCTAssertEqual(validateSchema(schema: schema), []) + } + + func testAcceptsAnInterfaceWithASubtypedInterfaceField_Union() throws { + let schema = try buildSchema(source: """ + type Query { + test: ChildInterface + } + + type SomeObject { + field: String + } + + union SomeUnionType = SomeObject + + interface ParentInterface { + field: SomeUnionType + } + + interface ChildInterface implements ParentInterface { + field: SomeObject + } + """) + try XCTAssertEqual(validateSchema(schema: schema), []) + } + + func testRejectsAnInterfaceImplementingANoninterfaceType() throws { + XCTAssertThrowsError( + try buildSchema(source: """ + type Query { + field: String + } + + input SomeInputObject { + field: String + } + + interface BadInterface implements SomeInputObject { + field: String + } + """), + "Type BadInterface must only implement Interface types, it cannot implement SomeInputObject." + ) + } + + func testRejectsAnInterfaceMissingAnInterfaceArgument() throws { + let schema = try buildSchema(source: """ + type Query { + test: ChildInterface + } + + interface ParentInterface { + field(input: String): String + } + + interface ChildInterface implements ParentInterface { + field: String + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + "Interface field argument ParentInterface.field(input:) expected but ChildInterface.field does not provide it.", + locations: [ + .init(line: 7, column: 15), + .init(line: 11, column: 9), + ] + ), + ]) + } + + func testRejectsAnInterfaceWithAnIncorrectlyTypedInterfaceArgument() throws { + let schema = try buildSchema(source: """ + type Query { + test: ChildInterface + } + + interface ParentInterface { + field(input: String): String + } + + interface ChildInterface implements ParentInterface { + field(input: Int): String + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + "Interface field argument ParentInterface.field(input:) expects type String but ChildInterface.field(input:) is type Int.", + locations: [ + .init(line: 7, column: 22), + .init(line: 11, column: 22), + ] + ), + ]) + } + + func testRejectsAnInterfaceWithBothAnIncorrectlyTypedFieldAndArgument() throws { + let schema = try buildSchema(source: """ + type Query { + test: ChildInterface + } + + interface ParentInterface { + field(input: String): String + } + + interface ChildInterface implements ParentInterface { + field(input: Int): Int + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + "Interface field ParentInterface.field expects type String but ChildInterface.field is type Int.", + locations: [ + .init(line: 7, column: 31), + .init(line: 11, column: 28), + ] + ), + GraphQLError( + message: + "Interface field argument ParentInterface.field(input:) expects type String but ChildInterface.field(input:) is type Int.", + locations: [ + .init(line: 7, column: 22), + .init(line: 11, column: 22), + ] + ), + ]) + } + + func testRejectsAnInterfaceWhichImplementsAnInterfaceFieldAlongWithAdditionalRequiredArguments( + ) throws { + let schema = try buildSchema(source: """ + type Query { + test: ChildInterface + } + + interface ParentInterface { + field(baseArg: String): String + } + + interface ChildInterface implements ParentInterface { + field( + baseArg: String, + requiredArg: String! + optionalArg1: String, + optionalArg2: String = "", + ): String + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + #"Argument "ChildInterface.field(requiredArg:)" must not be required type "String!" if not provided by the Interface field "ParentInterface.field"."#, + locations: [ + .init(line: 13, column: 11), + .init(line: 7, column: 9), + ] + ), + ]) + } + + func testAcceptsAnInterfaceWithAnEquivalentlyWrappedInterfaceFieldType() throws { + let schema = try buildSchema(source: """ + type Query { + test: ChildInterface + } + + interface ParentInterface { + field: [String]! + } + + interface ChildInterface implements ParentInterface { + field: [String]! + } + """) + try XCTAssertEqual(validateSchema(schema: schema), []) + } + + func testRejectsAnInterfaceWithANonlistInterfaceFieldListType() throws { + let schema = try buildSchema(source: """ + type Query { + test: ChildInterface + } + + interface ParentInterface { + field: [String] + } + + interface ChildInterface implements ParentInterface { + field: String + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + "Interface field ParentInterface.field expects type [String] but ChildInterface.field is type String.", + locations: [ + .init(line: 7, column: 16), + .init(line: 11, column: 16), + ] + ), + ]) + } + + func testRejectsAnInterfaceWithAListInterfaceFieldNonlistType() throws { + let schema = try buildSchema(source: """ + type Query { + test: ChildInterface + } + + interface ParentInterface { + field: String + } + + interface ChildInterface implements ParentInterface { + field: [String] + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + "Interface field ParentInterface.field expects type String but ChildInterface.field is type [String].", + locations: [ + .init(line: 7, column: 16), + .init(line: 11, column: 16), + ] + ), + ]) + } + + func testAcceptsAnInterfaceWithASubsetNonnullInterfaceFieldType() throws { + let schema = try buildSchema(source: """ + type Query { + test: ChildInterface + } + + interface ParentInterface { + field: String + } + + interface ChildInterface implements ParentInterface { + field: String! + } + """) + try XCTAssertEqual(validateSchema(schema: schema), []) + } + + func testRejectsAnInterfaceWithASupersetNullableInterfaceFieldType() throws { + let schema = try buildSchema(source: """ + type Query { + test: ChildInterface + } + + interface ParentInterface { + field: String! + } + + interface ChildInterface implements ParentInterface { + field: String + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + "Interface field ParentInterface.field expects type String! but ChildInterface.field is type String.", + locations: [ + .init(line: 7, column: 16), + .init(line: 11, column: 16), + ] + ), + ]) + } + + func testRejectsAnObjectMissingATransitiveInterface_Interface() throws { + let schema = try buildSchema(source: """ + type Query { + test: ChildInterface + } + + interface SuperInterface { + field: String! + } + + interface ParentInterface implements SuperInterface { + field: String! + } + + interface ChildInterface implements ParentInterface { + field: String! + } + """) + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + "Type ChildInterface must implement SuperInterface because it is implemented by ParentInterface.", + locations: [ + .init(line: 10, column: 44), + .init(line: 14, column: 43), + ] + ), + ]) + } + + func testRejectsASelfReferenceInterface() throws { + let schema = try buildSchema(source: """ + type Query { + test: FooInterface + } + + interface FooInterface implements FooInterface { + field: String + } + """) + + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: "Type FooInterface cannot implement itself because it would create a circular reference.", + locations: [.init(line: 6, column: 41)] + ), + ]) + } + + func testRejectsACircularInterfaceImplementation() throws { + let schema = try buildSchema(source: """ + type Query { + test: FooInterface + } + + interface FooInterface implements BarInterface { + field: String + } + + interface BarInterface implements FooInterface { + field: String + } + """) + + try XCTAssertEqual(validateSchema(schema: schema), [ + GraphQLError( + message: + "Type FooInterface cannot implement BarInterface because it would create a circular reference.", + locations: [ + .init(line: 10, column: 41), + .init(line: 6, column: 41), + ] + ), + GraphQLError( + message: + "Type BarInterface cannot implement FooInterface because it would create a circular reference.", + locations: [ + .init(line: 6, column: 41), + .init(line: 10, column: 41), + ] + ), + ]) + } + + // MARK: assertValidSchema + + func testDoesNotThrowOnValidSchemas() throws { + let schema = try buildSchema(source: """ + type Query { + foo: String + } + """) + try XCTAssertNoThrow(assertValidSchema(schema: schema)) + } + + func testCombinesMultipleErrors() throws { + let schema = try buildSchema(source: "type SomeType") + try XCTAssertThrowsError( + assertValidSchema(schema: schema), + """ + Query root type must be provided. + + Type SomeType must define one or more fields. + """ + ) + } +} diff --git a/Tests/GraphQLTests/UtilitiesTests/BuildASTSchemaTests.swift b/Tests/GraphQLTests/UtilitiesTests/BuildASTSchemaTests.swift new file mode 100644 index 00000000..b7a34a44 --- /dev/null +++ b/Tests/GraphQLTests/UtilitiesTests/BuildASTSchemaTests.swift @@ -0,0 +1,1180 @@ +@testable import GraphQL +import NIO +import XCTest + +class BuildASTSchemaTests: XCTestCase { + /** + * This function does a full cycle of going from a string with the contents of + * the SDL, parsed in a schema AST, materializing that schema AST into an + * in-memory GraphQLSchema, and then finally printing that object into the SDL + */ + func cycleSDL(sdl: String) throws -> String { + return try printSchema(schema: buildSchema(source: sdl)) + } + + func testCanUseBuiltSchemaForLimitedExecution() throws { + let schema = try buildASTSchema( + documentAST: parse( + source: """ + type Query { + str: String + } + """ + ) + ) + + let result = try graphql( + schema: schema, + request: "{ str }", + rootValue: ["str": 123], + eventLoopGroup: MultiThreadedEventLoopGroup(numberOfThreads: 1) + ).wait() + + XCTAssertEqual( + result, + GraphQLResult(data: [ + "str": "123", + ]) + ) + } + + // Closures are invalid Map keys in Swift. +// func testCanBuildASchemaDirectlyFromTheSource() throws { +// let schema = try buildASTSchema( +// documentAST: try parse( +// source: """ +// type Query { +// add(x: Int, y: Int): Int +// } +// """ +// ) +// ) +// +// let result = try graphql( +// schema: schema, +// request: "{ add(x: 34, y: 55) }", +// rootValue: [ +// "add": { (x: Int, y: Int) in +// return x + y +// } +// ], +// eventLoopGroup: MultiThreadedEventLoopGroup(numberOfThreads: 1) +// ).wait() +// +// XCTAssertEqual( +// result, +// GraphQLResult(data: [ +// "add": 89 +// ]) +// ) +// } + + func testIgnoresNonTypeSystemDefinitions() throws { + let sdl = """ + type Query { + str: String + } + + fragment SomeFragment on Query { + str + } + """ + + XCTAssertNoThrow(try buildSchema(source: sdl)) + } + + func testMatchOrderOfDefaultTypesAndDirectives() throws { + let schema = try GraphQLSchema() + let sdlSchema = try buildASTSchema(documentAST: .init(definitions: [])) + + XCTAssertEqual(sdlSchema.directives.map { $0.name }, schema.directives.map { $0.name }) + XCTAssertEqual( + sdlSchema.typeMap.mapValues { $0.name }, + schema.typeMap.mapValues { $0.name } + ) + } + + func testEmptyType() throws { + let sdl = """ + type EmptyType + """ + + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testSimpleType() throws { + let sdl = """ + type Query { + str: String + int: Int + float: Float + id: ID + bool: Boolean + } + """ + + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + + let schema = try buildSchema(source: sdl) + // Built-ins are used + XCTAssertIdentical( + schema.getType(name: "Int") as? GraphQLScalarType, + GraphQLInt + ) + XCTAssertEqual( + schema.getType(name: "Float") as? GraphQLScalarType, + GraphQLFloat + ) + XCTAssertEqual( + schema.getType(name: "String") as? GraphQLScalarType, + GraphQLString + ) + XCTAssertEqual( + schema.getType(name: "Boolean") as? GraphQLScalarType, + GraphQLBoolean + ) + XCTAssertEqual( + schema.getType(name: "ID") as? GraphQLScalarType, + GraphQLID + ) + } + + func testIncludeStandardTypeOnlyIfItIsUsed() throws { + let schema = try buildSchema(source: "type Query") + + // String and Boolean are always included through introspection types + XCTAssertNil(schema.getType(name: "Int")) + XCTAssertNil(schema.getType(name: "Float")) + XCTAssertNil(schema.getType(name: "ID")) + } + + func testWithDirectives() throws { + let sdl = """ + directive @foo(arg: Int) on FIELD + + directive @repeatableFoo(arg: Int) repeatable on FIELD + """ + + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testSupportsDescriptions() throws { + let sdl = #""" + """Do you agree that this is the most creative schema ever?""" + schema { + query: Query + } + + """This is a directive""" + directive @foo( + """It has an argument""" + arg: Int + ) on FIELD + + """Who knows what inside this scalar?""" + scalar MysteryScalar + + """This is a input object type""" + input FooInput { + """It has a field""" + field: Int + } + + """This is a interface type""" + interface Energy { + """It also has a field""" + str: String + } + + """There is nothing inside!""" + union BlackHole + + """With an enum""" + enum Color { + RED + + """Not a creative color""" + GREEN + BLUE + } + + """What a great type""" + type Query { + """And a field to boot""" + str: String + } + """# + + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testMaintainsIncludeSkipAndSpecifiedBy() throws { + let schema = try buildSchema(source: "type Query") + + XCTAssertEqual(schema.directives.count, 5) + XCTAssertIdentical( + schema.getDirective(name: GraphQLSkipDirective.name), + GraphQLSkipDirective + ) + XCTAssertIdentical( + schema.getDirective(name: GraphQLIncludeDirective.name), + GraphQLIncludeDirective + ) + XCTAssertIdentical( + schema.getDirective(name: GraphQLDeprecatedDirective.name), + GraphQLDeprecatedDirective + ) + XCTAssertIdentical( + schema.getDirective(name: GraphQLSpecifiedByDirective.name), + GraphQLSpecifiedByDirective + ) + XCTAssertIdentical( + schema.getDirective(name: GraphQLOneOfDirective.name), + GraphQLOneOfDirective + ) + } + + func testOverridingDirectivesExcludesSpecified() throws { + let schema = try buildSchema(source: """ + directive @skip on FIELD + directive @include on FIELD + directive @deprecated on FIELD_DEFINITION + directive @specifiedBy on FIELD_DEFINITION + directive @oneOf on OBJECT + """) + + XCTAssertEqual(schema.directives.count, 5) + XCTAssertNotIdentical( + schema.getDirective(name: GraphQLSkipDirective.name), + GraphQLSkipDirective + ) + XCTAssertNotIdentical( + schema.getDirective(name: GraphQLIncludeDirective.name), + GraphQLIncludeDirective + ) + XCTAssertNotIdentical( + schema.getDirective(name: GraphQLDeprecatedDirective.name), + GraphQLDeprecatedDirective + ) + XCTAssertNotIdentical( + schema.getDirective(name: GraphQLSpecifiedByDirective.name), + GraphQLSpecifiedByDirective + ) + XCTAssertNotIdentical( + schema.getDirective(name: GraphQLOneOfDirective.name), + GraphQLOneOfDirective + ) + } + + func testAddingDirectivesMaintainsIncludeSkipDeprecatedSpecifiedByAndOneOf() throws { + let schema = try buildSchema(source: """ + directive @foo(arg: Int) on FIELD + """) + + XCTAssertEqual(schema.directives.count, 6) + XCTAssertNotNil(schema.getDirective(name: GraphQLSkipDirective.name)) + XCTAssertNotNil(schema.getDirective(name: GraphQLIncludeDirective.name)) + XCTAssertNotNil(schema.getDirective(name: GraphQLDeprecatedDirective.name)) + XCTAssertNotNil(schema.getDirective(name: GraphQLSpecifiedByDirective.name)) + XCTAssertNotNil(schema.getDirective(name: GraphQLOneOfDirective.name)) + } + + func testTypeModifiers() throws { + let sdl = """ + type Query { + nonNullStr: String! + listOfStrings: [String] + listOfNonNullStrings: [String!] + nonNullListOfStrings: [String]! + nonNullListOfNonNullStrings: [String!]! + } + """ + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testRecursiveType() throws { + let sdl = """ + type Query { + str: String + recurse: Query + } + """ + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testTwoTypesCircular() throws { + let sdl = """ + type TypeOne { + str: String + typeTwo: TypeTwo + } + + type TypeTwo { + str: String + typeOne: TypeOne + } + """ + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testSingleArgumentField() throws { + let sdl = """ + type Query { + str(int: Int): String + floatToStr(float: Float): String + idToStr(id: ID): String + booleanToStr(bool: Boolean): String + strToStr(bool: String): String + } + """ + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testSimpleTypeWithMultipleArguments() throws { + let sdl = """ + type Query { + str(int: Int, bool: Boolean): String + } + """ + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testEmptyInterface() throws { + let sdl = """ + interface EmptyInterface + """ + let definition = try XCTUnwrap( + parse(source: sdl) + .definitions[0] as? InterfaceTypeDefinition + ) + XCTAssertEqual(definition.interfaces, []) + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testSimpleTypeWithInterface() throws { + let sdl = """ + type Query implements WorldInterface { + str: String + } + + interface WorldInterface { + str: String + } + """ + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testSimpleInterfaceHierarchy() throws { + let sdl = """ + interface Child implements Parent { + str: String + } + + type Hello implements Parent & Child { + str: String + } + + interface Parent { + str: String + } + """ + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testEmptyEnum() throws { + let sdl = """ + enum EmptyEnum + """ + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testSimpleOutputEnum() throws { + let sdl = """ + enum Hello { + WORLD + } + + type Query { + hello: Hello + } + """ + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testSimpleInputEnum() throws { + let sdl = """ + enum Hello { + WORLD + } + + type Query { + str(hello: Hello): String + } + """ + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testMultipleValueEnum() throws { + let sdl = """ + enum Hello { + WO + RLD + } + + type Query { + hello: Hello + } + """ + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testEmptyUnion() throws { + let sdl = """ + union EmptyUnion + """ + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testSimpleUnion() throws { + let sdl = """ + union Hello = World + + type Query { + hello: Hello + } + + type World { + str: String + } + """ + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testMultipleUnion() throws { + let sdl = """ + union Hello = WorldOne | WorldTwo + + type Query { + hello: Hello + } + + type WorldOne { + str: String + } + + type WorldTwo { + str: String + } + """ + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testCanBuildRecursiveUnion() throws { + XCTAssertThrowsError( + try buildSchema(source: """ + union Hello = Hello + + type Query { + hello: Hello + } + """), + "Union type Hello can only include Object types, it cannot include Hello" + ) + } + + func testCustomScalar() throws { + let sdl = """ + scalar CustomScalar + + type Query { + customScalar: CustomScalar + } + """ + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testEmptyInputObject() throws { + let sdl = """ + input EmptyInputObject + """ + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testSimpleInputObject() throws { + let sdl = """ + input Input { + int: Int + } + + type Query { + field(in: Input): String + } + """ + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testSimpleArgumentFieldWithDefault() throws { + let sdl = """ + type Query { + str(int: Int = 2): String + } + """ + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testCustomScalarArgumentFieldWithDefault() throws { + let sdl = """ + scalar CustomScalar + + type Query { + str(int: CustomScalar = 2): String + } + """ + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testSimpleTypeWithMutation() throws { + let sdl = """ + schema { + query: HelloScalars + mutation: Mutation + } + + type HelloScalars { + str: String + int: Int + bool: Boolean + } + + type Mutation { + addHelloScalars(str: String, int: Int, bool: Boolean): HelloScalars + } + """ + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testSimpleTypeWithSubscription() throws { + let sdl = """ + schema { + query: HelloScalars + subscription: Subscription + } + + type HelloScalars { + str: String + int: Int + bool: Boolean + } + + type Subscription { + subscribeHelloScalars(str: String, int: Int, bool: Boolean): HelloScalars + } + """ + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testUnreferencedTypeImplementingReferencedInterface() throws { + let sdl = """ + type Concrete implements Interface { + key: String + } + + interface Interface { + key: String + } + + type Query { + interface: Interface + } + """ + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testUnreferencedInterfaceImplementingReferencedInterface() throws { + let sdl = """ + interface Child implements Parent { + key: String + } + + interface Parent { + key: String + } + + type Query { + interfaceField: Parent + } + """ + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testUnreferencedTypeImplementingReferencedUnion() throws { + let sdl = """ + type Concrete { + key: String + } + + type Query { + union: Union + } + + union Union = Concrete + """ + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + } + + func testSupportsDeprecated() throws { + let sdl = """ + enum MyEnum { + VALUE + OLD_VALUE @deprecated + OTHER_VALUE @deprecated(reason: "Terrible reasons") + } + + input MyInput { + oldInput: String @deprecated + otherInput: String @deprecated(reason: "Use newInput") + newInput: String + } + + type Query { + field1: String @deprecated + field2: Int @deprecated(reason: "Because I said so") + enum: MyEnum + field3(oldArg: String @deprecated, arg: String): String + field4(oldArg: String @deprecated(reason: "Why not?"), arg: String): String + field5(arg: MyInput): String + } + """ + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + + let schema = try buildSchema(source: sdl) + + let myEnum = try XCTUnwrap(schema.getType(name: "MyEnum") as? GraphQLEnumType) + + let value = try XCTUnwrap(myEnum.nameLookup["VALUE"]) + XCTAssertNil(value.deprecationReason) + + let oldValue = try XCTUnwrap(myEnum.nameLookup["OLD_VALUE"]) + XCTAssertEqual(oldValue.deprecationReason, "No longer supported") + + let otherValue = try XCTUnwrap(myEnum.nameLookup["OTHER_VALUE"]) + XCTAssertEqual(otherValue.deprecationReason, "Terrible reasons") + + let rootFields = try XCTUnwrap(schema.getType(name: "Query") as? GraphQLObjectType) + .getFields() + XCTAssertEqual(rootFields["field1"]?.deprecationReason, "No longer supported") + XCTAssertEqual(rootFields["field2"]?.deprecationReason, "Because I said so") + + let inputFields = try XCTUnwrap( + schema.getType(name: "MyInput") as? GraphQLInputObjectType + ).getFields() + XCTAssertNil(inputFields["newInput"]?.deprecationReason) + XCTAssertEqual(inputFields["oldInput"]?.deprecationReason, "No longer supported") + XCTAssertEqual(inputFields["otherInput"]?.deprecationReason, "Use newInput") + XCTAssertEqual(rootFields["field3"]?.args[0].deprecationReason, "No longer supported") + XCTAssertEqual(rootFields["field4"]?.args[0].deprecationReason, "Why not?") + } + + func testSupportsSpecifiedBy() throws { + let sdl = """ + scalar Foo @specifiedBy(url: "https://example.com/foo_spec") + + type Query { + foo: Foo @deprecated + } + """ + try XCTAssertEqual(cycleSDL(sdl: sdl), sdl) + + let schema = try buildSchema(source: sdl) + + let fooScalar = try XCTUnwrap(schema.getType(name: "Foo") as? GraphQLScalarType) + XCTAssertEqual(fooScalar.specifiedByURL, "https://example.com/foo_spec") + } + + func testCorrectlyExtendScalarType() throws { + let schema = try buildSchema(source: """ + scalar SomeScalar + extend scalar SomeScalar @foo + extend scalar SomeScalar @bar + + directive @foo on SCALAR + directive @bar on SCALAR + """) + let someScalar = try XCTUnwrap(schema.getType(name: "SomeScalar") as? GraphQLScalarType) + XCTAssertEqual( + printType(type: someScalar), + """ + scalar SomeScalar + """ + ) + try XCTAssertEqual(print(ast: XCTUnwrap(someScalar.astNode)), "scalar SomeScalar") + XCTAssertEqual( + someScalar.extensionASTNodes.map { print(ast: $0) }, + [ + "extend scalar SomeScalar @foo", + "extend scalar SomeScalar @bar", + ] + ) + } + + func testCorrectlyExtendObjectType() throws { + let schema = try buildSchema(source: """ + type SomeObject implements Foo { + first: String + } + + extend type SomeObject implements Bar { + second: Int + } + + extend type SomeObject implements Baz { + third: Float + } + + interface Foo + interface Bar + interface Baz + """) + let someObject = try XCTUnwrap(schema.getType(name: "SomeObject") as? GraphQLObjectType) + XCTAssertEqual( + printType(type: someObject), + """ + type SomeObject implements Foo & Bar & Baz { + first: String + second: Int + third: Float + } + """ + ) + try XCTAssertEqual( + print(ast: XCTUnwrap(someObject.astNode)), + """ + type SomeObject implements Foo { + first: String + } + """ + ) + XCTAssertEqual( + someObject.extensionASTNodes.map { print(ast: $0) }, + [ + """ + extend type SomeObject implements Bar { + second: Int + } + """, + """ + extend type SomeObject implements Baz { + third: Float + } + """, + ] + ) + } + + func testCorrectlyExtendInterfaceType() throws { + let schema = try buildSchema(source: """ + interface SomeInterface { + first: String + } + + extend interface SomeInterface { + second: Int + } + + extend interface SomeInterface { + third: Float + } + """) + let someInterface = try XCTUnwrap( + schema.getType(name: "SomeInterface") as? GraphQLInterfaceType + ) + XCTAssertEqual( + printType(type: someInterface), + """ + interface SomeInterface { + first: String + second: Int + third: Float + } + """ + ) + try XCTAssertEqual( + print(ast: XCTUnwrap(someInterface.astNode)), + """ + interface SomeInterface { + first: String + } + """ + ) + XCTAssertEqual( + someInterface.extensionASTNodes.map { print(ast: $0) }, + [ + """ + extend interface SomeInterface { + second: Int + } + """, + """ + extend interface SomeInterface { + third: Float + } + """, + ] + ) + } + + func testCorrectlyExtendUnionType() throws { + let schema = try buildSchema(source: """ + union SomeUnion = FirstType + extend union SomeUnion = SecondType + extend union SomeUnion = ThirdType + + type FirstType + type SecondType + type ThirdType + """) + let someUnion = try XCTUnwrap(schema.getType(name: "SomeUnion") as? GraphQLUnionType) + XCTAssertEqual( + printType(type: someUnion), + """ + union SomeUnion = FirstType | SecondType | ThirdType + """ + ) + try XCTAssertEqual( + print(ast: XCTUnwrap(someUnion.astNode)), + "union SomeUnion = FirstType" + ) + XCTAssertEqual( + someUnion.extensionASTNodes.map { print(ast: $0) }, + [ + "extend union SomeUnion = SecondType", + "extend union SomeUnion = ThirdType", + ] + ) + } + + func testCorrectlyExtendEnumType() throws { + let schema = try buildSchema(source: """ + enum SomeEnum { + FIRST + } + + extend enum SomeEnum { + SECOND + } + + extend enum SomeEnum { + THIRD + } + """) + let someEnum = try XCTUnwrap(schema.getType(name: "SomeEnum") as? GraphQLEnumType) + XCTAssertEqual( + printType(type: someEnum), + """ + enum SomeEnum { + FIRST + SECOND + THIRD + } + """ + ) + try XCTAssertEqual( + print(ast: XCTUnwrap(someEnum.astNode)), + """ + enum SomeEnum { + FIRST + } + """ + ) + XCTAssertEqual( + someEnum.extensionASTNodes.map { print(ast: $0) }, + [ + """ + extend enum SomeEnum { + SECOND + } + """, + """ + extend enum SomeEnum { + THIRD + } + """, + ] + ) + } + + func testCorrectlyExtendInputObjectType() throws { + let schema = try buildSchema(source: """ + input SomeInput { + first: String + } + + extend input SomeInput { + second: Int + } + + extend input SomeInput { + third: Float + } + """) + let someInput = try XCTUnwrap(schema.getType(name: "SomeInput") as? GraphQLInputObjectType) + XCTAssertEqual( + printType(type: someInput), + """ + input SomeInput { + first: String + second: Int + third: Float + } + """ + ) + try XCTAssertEqual( + print(ast: XCTUnwrap(someInput.astNode)), + """ + input SomeInput { + first: String + } + """ + ) + XCTAssertEqual( + someInput.extensionASTNodes.map { print(ast: $0) }, + [ + """ + extend input SomeInput { + second: Int + } + """, + """ + extend input SomeInput { + third: Float + } + """, + ] + ) + } + + func testCorrectlyAssignASTNodes() throws { + let sdl = """ + schema { + query: Query + } + + type Query { + testField(testArg: TestInput): TestUnion + } + + input TestInput { + testInputField: TestEnum + } + + enum TestEnum { + TEST_VALUE + } + + union TestUnion = TestType + + interface TestInterface { + interfaceField: String + } + + type TestType implements TestInterface { + interfaceField: String + } + + scalar TestScalar + + directive @test(arg: TestScalar) on FIELD + """ + let ast = try parse(source: sdl, noLocation: true) + + let schema = try buildASTSchema(documentAST: ast) + let query = try XCTUnwrap(schema.getType(name: "Query") as? GraphQLObjectType) + let testInput = try XCTUnwrap(schema.getType(name: "TestInput") as? GraphQLInputObjectType) + let testEnum = try XCTUnwrap(schema.getType(name: "TestEnum") as? GraphQLEnumType) + let _ = try XCTUnwrap(schema.getType(name: "TestUnion") as? GraphQLUnionType) + let testInterface = try XCTUnwrap( + schema.getType(name: "TestInterface") as? GraphQLInterfaceType + ) + let testType = try XCTUnwrap(schema.getType(name: "TestType") as? GraphQLObjectType) + let _ = try XCTUnwrap(schema.getType(name: "TestScalar") as? GraphQLScalarType) + let testDirective = try XCTUnwrap(schema.getDirective(name: "test")) + + // No `Equatable` conformance +// XCTAssertEqual( +// [ +// schema.astNode, +// query.astNode, +// testInput.astNode, +// testEnum.astNode, +// testUnion.astNode, +// testInterface.astNode, +// testType.astNode, +// testScalar.astNode, +// testDirective.astNode, +// ], +// ast.definitions +// ) + + let testField = try XCTUnwrap(query.getFields()["testField"]) + try XCTAssertEqual( + print(ast: XCTUnwrap(testField.astNode)), + "testField(testArg: TestInput): TestUnion" + ) + try XCTAssertEqual( + print(ast: XCTUnwrap(testField.args[0].astNode)), + "testArg: TestInput" + ) + try XCTAssertEqual( + print(ast: XCTUnwrap(testInput.getFields()["testInputField"]?.astNode)), + "testInputField: TestEnum" + ) + + try XCTAssertEqual( + print(ast: XCTUnwrap(testEnum.nameLookup["TEST_VALUE"]?.astNode)), + "TEST_VALUE" + ) + + try XCTAssertEqual( + print(ast: XCTUnwrap(testInterface.getFields()["interfaceField"]?.astNode)), + "interfaceField: String" + ) + try XCTAssertEqual( + print(ast: XCTUnwrap(testType.getFields()["interfaceField"]?.astNode)), + "interfaceField: String" + ) + try XCTAssertEqual( + print(ast: XCTUnwrap(testDirective.args[0].astNode)), + "arg: TestScalar" + ) + } + + func testRootOperationTypesWithCustomNames() throws { + let schema = try buildSchema(source: """ + schema { + query: SomeQuery + mutation: SomeMutation + subscription: SomeSubscription + } + type SomeQuery + type SomeMutation + type SomeSubscription + """) + XCTAssertEqual(schema.queryType?.name, "SomeQuery") + XCTAssertEqual(schema.mutationType?.name, "SomeMutation") + XCTAssertEqual(schema.subscriptionType?.name, "SomeSubscription") + } + + func testDefaultRootOperationTypeNames() throws { + let schema = try buildSchema(source: """ + type Query + type Mutation + type Subscription + """) + XCTAssertEqual(schema.queryType?.name, "Query") + XCTAssertEqual(schema.mutationType?.name, "Mutation") + XCTAssertEqual(schema.subscriptionType?.name, "Subscription") + } + + func testCanBuildInvalidSchema() throws { + let schema = try buildSchema(source: "type Mutation") + let errors = try validateSchema(schema: schema) + XCTAssertGreaterThan(errors.count, 0) + } + + func testDoNotOverrideStandardTypes() throws { + let schema = try buildSchema(source: """ + scalar ID + + scalar __Schema + """) + XCTAssertIdentical( + schema.getType(name: "ID") as? GraphQLScalarType, + GraphQLID + ) + XCTAssertIdentical( + schema.getType(name: "__Schema") as? GraphQLObjectType, + __Schema + ) + } + + func testAllowsToReferenceIntrospectionTypes() throws { + let schema = try buildSchema(source: """ + type Query { + introspectionField: __EnumValue + } + """) + let queryType = try XCTUnwrap(schema.getType(name: "Query") as? GraphQLObjectType) + try XCTAssert( + queryType.getFields().contains { key, field in + key == "introspectionField" && + (field.type as? GraphQLObjectType) === __EnumValue + } + ) + XCTAssertIdentical( + schema.getType(name: "__EnumValue") as? GraphQLObjectType, + __EnumValue + ) + } + + func testRejectsInvalidSDL() throws { + let sdl = """ + type Query { + foo: String @unknown + } + """ + XCTAssertThrowsError( + try buildSchema(source: sdl), + "Unknown directive \"@unknown\"." + ) + } + + func testAllowsToDisableSDLValidation() throws { + let sdl = """ + type Query { + foo: String @unknown + } + """ + _ = try buildSchema(source: sdl, assumeValid: true) + _ = try buildSchema(source: sdl, assumeValidSDL: true) + } + + func testThrowsOnUnknownTypes() throws { + let sdl = """ + type Query { + unknown: UnknownType + } + """ + XCTAssertThrowsError( + try buildSchema(source: sdl), + "Unknown type: \"@UnknownType\"." + ) + } + + func testCorrectlyProcessesViralSchema() throws { + let schema = try buildSchema(source: """ + schema { + query: Query + } + + type Query { + viruses: [Virus!] + } + + type Virus { + name: String! + knownMutations: [Mutation!]! + } + + type Mutation { + name: String! + geneSequence: String! + } + """) + XCTAssertEqual(schema.queryType?.name, "Query") + XCTAssertEqual(schema.getType(name: "Virus")?.name, "Virus") + XCTAssertEqual(schema.getType(name: "Mutation")?.name, "Mutation") + // Though the viral schema has a 'Mutation' type, it is not used for the + // 'mutation' operation. + XCTAssertNil(schema.mutationType) + } +} diff --git a/Tests/GraphQLTests/UtilitiesTests/ConcatASTTests.swift b/Tests/GraphQLTests/UtilitiesTests/ConcatASTTests.swift new file mode 100644 index 00000000..96f4263a --- /dev/null +++ b/Tests/GraphQLTests/UtilitiesTests/ConcatASTTests.swift @@ -0,0 +1,35 @@ +@testable import GraphQL +import XCTest + +class ConcatASTTests: XCTestCase { + func testConcatenatesTwoASTsTogether() throws { + let sourceA = Source(body: """ + { a, b, ...Frag } + """) + + let sourceB = Source(body: """ + fragment Frag on T { + c + } + """) + + let astA = try parse(source: sourceA) + let astB = try parse(source: sourceB) + let astC = concatAST(documents: [astA, astB]) + + XCTAssertEqual( + print(ast: astC), + """ + { + a + b + ...Frag + } + + fragment Frag on T { + c + } + """ + ) + } +} diff --git a/Tests/GraphQLTests/UtilitiesTests/ExtendSchemaTests.swift b/Tests/GraphQLTests/UtilitiesTests/ExtendSchemaTests.swift new file mode 100644 index 00000000..6c3a9625 --- /dev/null +++ b/Tests/GraphQLTests/UtilitiesTests/ExtendSchemaTests.swift @@ -0,0 +1,1350 @@ +@testable import GraphQL +import NIO +import XCTest + +class ExtendSchemaTests: XCTestCase { + private var eventLoopGroup: EventLoopGroup! + + override func setUp() { + eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) + } + + override func tearDown() { + XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) + } + + func schemaChanges( + _ schema: GraphQLSchema, + _ extendedSchema: GraphQLSchema + ) throws -> String { + let schemaDefinitions = try parse(source: printSchema(schema: schema)).definitions + .map(print) + return try parse(source: printSchema(schema: extendedSchema)) + .definitions.map(print) + .filter { def in !schemaDefinitions.contains(def) } + .joined(separator: "\n\n") + } + + func extensionASTNodes(_ extensionASTNodes: [Node]) -> String { + return extensionASTNodes.map(print).joined(separator: "\n\n") + } + + func astNode(_ astNode: Node?) throws -> String { + let astNode = try XCTUnwrap(astNode) + return print(ast: astNode) + } + + func testReturnsTheOriginalSchemaWhenThereAreNoTypeDefinitions() throws { + let schema = try buildSchema(source: "type Query") + let extendedSchema = try extendSchema( + schema: schema, + documentAST: parse(source: "{ field }") + ) + XCTAssertEqual( + ObjectIdentifier(extendedSchema), + ObjectIdentifier(schema) + ) + } + + func testCanBeUsedForLimitedExecution() throws { + let schema = try buildSchema(source: "type Query") + let extendAST = try parse(source: """ + extend type Query { + newField: String + } + """) + let extendedSchema = try extendSchema(schema: schema, documentAST: extendAST) + let result = try graphql( + schema: extendedSchema, + request: "{ newField }", + rootValue: ["newField": 123], + eventLoopGroup: eventLoopGroup + ).wait() + XCTAssertEqual( + result, + .init(data: ["newField": "123"]) + ) + } + + func testDoNotModifyBuiltInTypesAnDirectives() throws { + let schema = try buildSchema(source: """ + type Query { + str: String + int: Int + float: Float + id: ID + bool: Boolean + } + """) + let extendAST = try parse(source: """ + extend type Query { + foo: String + } + """) + let extendedSchema = try extendSchema(schema: schema, documentAST: extendAST) + + // Built-ins are used + XCTAssertIdentical( + extendedSchema.getType(name: "Int") as? GraphQLScalarType, + GraphQLInt + ) + XCTAssertIdentical( + extendedSchema.getType(name: "Float") as? GraphQLScalarType, + GraphQLFloat + ) + XCTAssertIdentical( + extendedSchema.getType(name: "String") as? GraphQLScalarType, + GraphQLString + ) + XCTAssertIdentical( + extendedSchema.getType(name: "Boolean") as? GraphQLScalarType, + GraphQLBoolean + ) + XCTAssertIdentical( + extendedSchema.getType(name: "ID") as? GraphQLScalarType, + GraphQLID + ) + + XCTAssertIdentical( + extendedSchema.getDirective(name: "include"), + GraphQLIncludeDirective + ) + XCTAssertIdentical( + extendedSchema.getDirective(name: "skip"), + GraphQLSkipDirective + ) + XCTAssertIdentical( + extendedSchema.getDirective(name: "deprecated"), + GraphQLDeprecatedDirective + ) + XCTAssertIdentical( + extendedSchema.getDirective(name: "specifiedBy"), + GraphQLSpecifiedByDirective + ) + XCTAssertIdentical( + extendedSchema.getDirective(name: "oneOf"), + GraphQLOneOfDirective + ) + } + + func testPreservesOriginalSchemaConfig() throws { + let description = "A schema description" + let extensions: GraphQLSchemaExtensions = ["foo": "bar"] + let schema = try GraphQLSchema(description: description, extensions: [extensions]) + + let extendedSchema = try extendSchema( + schema: schema, + documentAST: parse(source: "scalar Bar") + ) + + XCTAssertEqual(extendedSchema.description, description) + XCTAssertEqual(extendedSchema.extensions, [extensions]) + } + + func testExtendsObjectsByAddingNewFields() throws { + let schema = try buildSchema(source: #""" + type Query { + someObject: SomeObject + } + + type SomeObject implements AnotherInterface & SomeInterface { + self: SomeObject + tree: [SomeObject]! + """Old field description.""" + oldField: String + } + + interface SomeInterface { + self: SomeInterface + } + + interface AnotherInterface { + self: SomeObject + } + """#) + let extensionSDL = #""" + extend type SomeObject { + """New field description.""" + newField(arg: Boolean): String + } + """# + let extendedSchema = try extendSchema( + schema: schema, + documentAST: parse(source: extensionSDL) + ) + + try XCTAssertEqual(validateSchema(schema: extendedSchema), []) + try XCTAssertEqual( + schemaChanges(schema, extendedSchema), + #""" + type SomeObject implements AnotherInterface & SomeInterface { + self: SomeObject + tree: [SomeObject]! + """Old field description.""" + oldField: String + """New field description.""" + newField(arg: Boolean): String + } + """# + ) + } + + func testExtendsScalarsByAddingNewDirectives() throws { + let schema = try buildSchema(source: """ + type Query { + someScalar(arg: SomeScalar): SomeScalar + } + + directive @foo(arg: SomeScalar) on SCALAR + + input FooInput { + foo: SomeScalar + } + + scalar SomeScalar + """) + let extensionSDL = """ + extend scalar SomeScalar @foo + """ + let extendedSchema = try extendSchema( + schema: schema, + documentAST: parse(source: extensionSDL) + ) + let someScalar = + try XCTUnwrap((extendedSchema.getType(name: "SomeScalar") as? GraphQLScalarType)) + + try XCTAssertEqual(validateSchema(schema: extendedSchema), []) + XCTAssertEqual(extensionASTNodes(someScalar.extensionASTNodes), extensionSDL) + } + + func testExtendsScalarsByAddingSpecifiedByDirective() throws { + let schema = try buildSchema(source: """ + type Query { + foo: Foo + } + + scalar Foo + + directive @foo on SCALAR + """) + let extensionSDL = """ + extend scalar Foo @foo + + extend scalar Foo @specifiedBy(url: "https://example.com/foo_spec") + """ + + let extendedSchema = try extendSchema( + schema: schema, + documentAST: parse(source: extensionSDL) + ) + let foo = try XCTUnwrap(extendedSchema.getType(name: "Foo") as? GraphQLScalarType) + + XCTAssertEqual(foo.specifiedByURL, "https://example.com/foo_spec") + + try XCTAssertEqual(validateSchema(schema: extendedSchema), []) + XCTAssertEqual(extensionASTNodes(foo.extensionASTNodes), extensionSDL) + } + + func testCorrectlyAssignASTNodesToNewAndExtendedTypes() throws { + let schema = try buildSchema(source: """ + type Query + + scalar SomeScalar + enum SomeEnum + union SomeUnion + input SomeInput + type SomeObject + interface SomeInterface + + directive @foo on SCALAR + """) + let firstExtensionAST = try parse(source: """ + extend type Query { + newField(testArg: TestInput): TestEnum + } + + extend scalar SomeScalar @foo + + extend enum SomeEnum { + NEW_VALUE + } + + extend union SomeUnion = SomeObject + + extend input SomeInput { + newField: String + } + + extend interface SomeInterface { + newField: String + } + + enum TestEnum { + TEST_VALUE + } + + input TestInput { + testInputField: TestEnum + } + """) + let extendedSchema = try extendSchema(schema: schema, documentAST: firstExtensionAST) + + let secondExtensionAST = try parse(source: """ + extend type Query { + oneMoreNewField: TestUnion + } + + extend scalar SomeScalar @test + + extend enum SomeEnum { + ONE_MORE_NEW_VALUE + } + + extend union SomeUnion = TestType + + extend input SomeInput { + oneMoreNewField: String + } + + extend interface SomeInterface { + oneMoreNewField: String + } + + union TestUnion = TestType + + interface TestInterface { + interfaceField: String + } + + type TestType implements TestInterface { + interfaceField: String + } + + directive @test(arg: Int) repeatable on FIELD | SCALAR + """) + let extendedTwiceSchema = try extendSchema( + schema: extendedSchema, + documentAST: secondExtensionAST + ) + + let extendedInOneGoSchema = try extendSchema( + schema: schema, + documentAST: concatAST(documents: [firstExtensionAST, secondExtensionAST]) + ) + XCTAssertEqual( + printSchema(schema: extendedInOneGoSchema), + printSchema(schema: extendedTwiceSchema) + ) + + let query = try XCTUnwrap(extendedTwiceSchema.getType(name: "Query") as? GraphQLObjectType) + let someEnum = try XCTUnwrap( + extendedTwiceSchema + .getType(name: "SomeEnum") as? GraphQLEnumType + ) + let someUnion = try XCTUnwrap( + extendedTwiceSchema + .getType(name: "SomeUnion") as? GraphQLUnionType + ) + let someScalar = try XCTUnwrap( + extendedTwiceSchema + .getType(name: "SomeScalar") as? GraphQLScalarType + ) + let someInput = try XCTUnwrap( + extendedTwiceSchema + .getType(name: "SomeInput") as? GraphQLInputObjectType + ) + let someInterface = try XCTUnwrap( + extendedTwiceSchema + .getType(name: "SomeInterface") as? GraphQLInterfaceType + ) + + let testInput = try XCTUnwrap( + extendedTwiceSchema + .getType(name: "TestInput") as? GraphQLInputObjectType + ) + let testEnum = try XCTUnwrap( + extendedTwiceSchema + .getType(name: "TestEnum") as? GraphQLEnumType + ) + let testUnion = try XCTUnwrap( + extendedTwiceSchema + .getType(name: "TestUnion") as? GraphQLUnionType + ) + let testType = try XCTUnwrap( + extendedTwiceSchema + .getType(name: "TestType") as? GraphQLObjectType + ) + let testInterface = try XCTUnwrap( + extendedTwiceSchema + .getType(name: "TestInterface") as? GraphQLInterfaceType + ) + let testDirective = try XCTUnwrap(extendedTwiceSchema.getDirective(name: "test")) + + XCTAssertEqual(testType.extensionASTNodes, []) + XCTAssertEqual(testEnum.extensionASTNodes, []) + XCTAssertEqual(testUnion.extensionASTNodes, []) + XCTAssertEqual(testInput.extensionASTNodes, []) + XCTAssertEqual(testInterface.extensionASTNodes, []) + + var astNodes: [Definition] = try [ + XCTUnwrap(testInput.astNode), + XCTUnwrap(testEnum.astNode), + XCTUnwrap(testUnion.astNode), + XCTUnwrap(testInterface.astNode), + XCTUnwrap(testType.astNode), + XCTUnwrap(testDirective.astNode), + ] + astNodes.append(contentsOf: query.extensionASTNodes) + astNodes.append(contentsOf: someScalar.extensionASTNodes) + astNodes.append(contentsOf: someEnum.extensionASTNodes) + astNodes.append(contentsOf: someUnion.extensionASTNodes) + astNodes.append(contentsOf: someInput.extensionASTNodes) + astNodes.append(contentsOf: someInterface.extensionASTNodes) + for def in firstExtensionAST.definitions { + XCTAssert(astNodes.contains { $0.kind == def.kind && $0.loc == def.loc }) + } + for def in secondExtensionAST.definitions { + XCTAssert(astNodes.contains { $0.kind == def.kind && $0.loc == def.loc }) + } + + let newField = try XCTUnwrap(query.getFields()["newField"]) + try XCTAssertEqual(astNode(newField.astNode), "newField(testArg: TestInput): TestEnum") + try XCTAssertEqual( + astNode(newField.argConfigMap()["testArg"]?.astNode), + "testArg: TestInput" + ) + try XCTAssertEqual( + astNode(query.getFields()["oneMoreNewField"]?.astNode), + "oneMoreNewField: TestUnion" + ) + + try XCTAssertEqual(astNode(someEnum.nameLookup["NEW_VALUE"]?.astNode), "NEW_VALUE") + try XCTAssertEqual( + astNode(someEnum.nameLookup["ONE_MORE_NEW_VALUE"]?.astNode), + "ONE_MORE_NEW_VALUE" + ) + + try XCTAssertEqual(astNode(someInput.getFields()["newField"]?.astNode), "newField: String") + try XCTAssertEqual( + astNode(someInput.getFields()["oneMoreNewField"]?.astNode), + "oneMoreNewField: String" + ) + try XCTAssertEqual( + astNode(someInterface.getFields()["newField"]?.astNode), + "newField: String" + ) + try XCTAssertEqual( + astNode(someInterface.getFields()["oneMoreNewField"]?.astNode), + "oneMoreNewField: String" + ) + + try XCTAssertEqual( + astNode(testInput.getFields()["testInputField"]?.astNode), + "testInputField: TestEnum" + ) + + try XCTAssertEqual(astNode(testEnum.nameLookup["TEST_VALUE"]?.astNode), "TEST_VALUE") + + try XCTAssertEqual( + astNode(testInterface.getFields()["interfaceField"]?.astNode), + "interfaceField: String" + ) + try XCTAssertEqual( + astNode(testType.getFields()["interfaceField"]?.astNode), + "interfaceField: String" + ) + + try XCTAssertEqual(astNode(testDirective.argConfigMap()["arg"]?.astNode), "arg: Int") + } + + func testBuildsTypesWithDeprecatedFieldsValues() throws { + let schema = try GraphQLSchema() + let extendAST = try parse(source: """ + type SomeObject { + deprecatedField: String @deprecated(reason: "not used anymore") + } + + enum SomeEnum { + DEPRECATED_VALUE @deprecated(reason: "do not use") + } + """) + let extendedSchema = try extendSchema(schema: schema, documentAST: extendAST) + + let someType = try XCTUnwrap( + extendedSchema + .getType(name: "SomeObject") as? GraphQLObjectType + ) + try XCTAssertEqual( + someType.getFields()["deprecatedField"]?.deprecationReason, + "not used anymore" + ) + + let someEnum = try XCTUnwrap(extendedSchema.getType(name: "SomeEnum") as? GraphQLEnumType) + XCTAssertEqual( + someEnum.nameLookup["DEPRECATED_VALUE"]?.deprecationReason, + "do not use" + ) + } + + func testExtendsObjectsWithDeprecatedFields() throws { + let schema = try buildSchema(source: "type SomeObject") + let extendAST = try parse(source: """ + extend type SomeObject { + deprecatedField: String @deprecated(reason: "not used anymore") + } + """) + let extendedSchema = try extendSchema(schema: schema, documentAST: extendAST) + + let someType = try XCTUnwrap( + extendedSchema + .getType(name: "SomeObject") as? GraphQLObjectType + ) + try XCTAssertEqual( + someType.getFields()["deprecatedField"]?.deprecationReason, + "not used anymore" + ) + } + + func testExtendsEnumsWithDeprecatedValues() throws { + let schema = try buildSchema(source: "enum SomeEnum") + let extendAST = try parse(source: """ + extend enum SomeEnum { + DEPRECATED_VALUE @deprecated(reason: "do not use") + } + """) + let extendedSchema = try extendSchema(schema: schema, documentAST: extendAST) + + let someEnum = try XCTUnwrap(extendedSchema.getType(name: "SomeEnum") as? GraphQLEnumType) + XCTAssertEqual( + someEnum.nameLookup["DEPRECATED_VALUE"]?.deprecationReason, + "do not use" + ) + } + + func testAddsNewUnusedTypes() throws { + let schema = try buildSchema(source: """ + type Query { + dummy: String + } + """) + let extensionSDL = """ + type DummyUnionMember { + someField: String + } + + enum UnusedEnum { + SOME_VALUE + } + + input UnusedInput { + someField: String + } + + interface UnusedInterface { + someField: String + } + + type UnusedObject { + someField: String + } + + union UnusedUnion = DummyUnionMember + """ + let extendedSchema = try extendSchema( + schema: schema, + documentAST: parse(source: extensionSDL) + ) + + try XCTAssertEqual(validateSchema(schema: extendedSchema), []) + try XCTAssertEqual( + schemaChanges(schema, extendedSchema), + extensionSDL + ) + } + + func testExtendsObjectsByAddingNewFieldsWithArguments() throws { + let schema = try buildSchema(source: """ + type SomeObject + + type Query { + someObject: SomeObject + } + """) + let extendAST = try parse(source: """ + input NewInputObj { + field1: Int + field2: [Float] + field3: String! + } + + extend type SomeObject { + newField(arg1: String, arg2: NewInputObj!): String + } + """) + let extendedSchema = try extendSchema(schema: schema, documentAST: extendAST) + + try XCTAssertEqual(validateSchema(schema: extendedSchema), []) + try XCTAssertEqual( + schemaChanges(schema, extendedSchema), + """ + type SomeObject { + newField(arg1: String, arg2: NewInputObj!): String + } + + input NewInputObj { + field1: Int + field2: [Float] + field3: String! + } + """ + ) + } + + func testExtendsObjectsByAddingNewFieldsWithExistingTypes() throws { + let schema = try buildSchema(source: """ + type Query { + someObject: SomeObject + } + + type SomeObject + enum SomeEnum { VALUE } + """) + let extendAST = try parse(source: """ + extend type SomeObject { + newField(arg1: SomeEnum!): SomeEnum + } + """) + let extendedSchema = try extendSchema(schema: schema, documentAST: extendAST) + + try XCTAssertEqual(validateSchema(schema: extendedSchema), []) + try XCTAssertEqual( + schemaChanges(schema, extendedSchema), + """ + type SomeObject { + newField(arg1: SomeEnum!): SomeEnum + } + """ + ) + } + + func testExtendsObjectsByAddingImplementedInterfaces() throws { + let schema = try buildSchema(source: """ + type Query { + someObject: SomeObject + } + + type SomeObject { + foo: String + } + + interface SomeInterface { + foo: String + } + """) + let extendAST = try parse(source: """ + extend type SomeObject implements SomeInterface + """) + let extendedSchema = try extendSchema(schema: schema, documentAST: extendAST) + + try XCTAssertEqual(validateSchema(schema: extendedSchema), []) + try XCTAssertEqual( + schemaChanges(schema, extendedSchema), + """ + type SomeObject implements SomeInterface { + foo: String + } + """ + ) + } + + func testExtendsObjectsByIncludingNewTypes() throws { + let schema = try buildSchema(source: """ + type Query { + someObject: SomeObject + } + + type SomeObject { + oldField: String + } + """) + let newTypesSDL = """ + enum NewEnum { + VALUE + } + + interface NewInterface { + baz: String + } + + type NewObject implements NewInterface { + baz: String + } + + scalar NewScalar + + union NewUnion = NewObject + """ + let extendAST = try parse(source: """ + \(newTypesSDL) + extend type SomeObject { + newObject: NewObject + newInterface: NewInterface + newUnion: NewUnion + newScalar: NewScalar + newEnum: NewEnum + newTree: [SomeObject]! + } + """) + let extendedSchema = try extendSchema(schema: schema, documentAST: extendAST) + + try XCTAssertEqual(validateSchema(schema: extendedSchema), []) + try XCTAssertEqual( + schemaChanges(schema, extendedSchema), + """ + type SomeObject { + oldField: String + newObject: NewObject + newInterface: NewInterface + newUnion: NewUnion + newScalar: NewScalar + newEnum: NewEnum + newTree: [SomeObject]! + } + + \(newTypesSDL) + """ + ) + } + + func testExtendsObjectsByAddingImplementedNewInterfaces() throws { + let schema = try buildSchema(source: """ + type Query { + someObject: SomeObject + } + + type SomeObject implements OldInterface { + oldField: String + } + + interface OldInterface { + oldField: String + } + """) + let extendAST = try parse(source: """ + extend type SomeObject implements NewInterface { + newField: String + } + + interface NewInterface { + newField: String + } + """) + let extendedSchema = try extendSchema(schema: schema, documentAST: extendAST) + + try XCTAssertEqual(validateSchema(schema: extendedSchema), []) + try XCTAssertEqual( + schemaChanges(schema, extendedSchema), + """ + type SomeObject implements OldInterface & NewInterface { + oldField: String + newField: String + } + + interface NewInterface { + newField: String + } + """ + ) + } + + func testExtendsDifferentTypesMultipleTimes() throws { + let schema = try buildSchema(source: """ + type Query { + someScalar: SomeScalar + someObject(someInput: SomeInput): SomeObject + someInterface: SomeInterface + someEnum: SomeEnum + someUnion: SomeUnion + } + + scalar SomeScalar + + type SomeObject implements SomeInterface { + oldField: String + } + + interface SomeInterface { + oldField: String + } + + enum SomeEnum { + OLD_VALUE + } + + union SomeUnion = SomeObject + + input SomeInput { + oldField: String + } + """) + let newTypesSDL = """ + scalar NewScalar + + scalar AnotherNewScalar + + type NewObject { + foo: String + } + + type AnotherNewObject { + foo: String + } + + interface NewInterface { + newField: String + } + + interface AnotherNewInterface { + anotherNewField: String + } + """ + let schemaWithNewTypes = try extendSchema( + schema: schema, + documentAST: parse(source: newTypesSDL) + ) + try XCTAssertEqual( + schemaChanges(schema, schemaWithNewTypes), + newTypesSDL + ) + + let extendAST = try parse(source: """ + extend scalar SomeScalar @specifiedBy(url: "http://example.com/foo_spec") + + extend type SomeObject implements NewInterface { + newField: String + } + + extend type SomeObject implements AnotherNewInterface { + anotherNewField: String + } + + extend enum SomeEnum { + NEW_VALUE + } + + extend enum SomeEnum { + ANOTHER_NEW_VALUE + } + + extend union SomeUnion = NewObject + + extend union SomeUnion = AnotherNewObject + + extend input SomeInput { + newField: String + } + + extend input SomeInput { + anotherNewField: String + } + """) + let extendedSchema = try extendSchema(schema: schemaWithNewTypes, documentAST: extendAST) + + try XCTAssertEqual(validateSchema(schema: extendedSchema), []) + try XCTAssertEqual( + schemaChanges(schema, extendedSchema), + """ + scalar SomeScalar @specifiedBy(url: "http://example.com/foo_spec") + + type SomeObject implements SomeInterface & NewInterface & AnotherNewInterface { + oldField: String + newField: String + anotherNewField: String + } + + enum SomeEnum { + OLD_VALUE + NEW_VALUE + ANOTHER_NEW_VALUE + } + + union SomeUnion = SomeObject | NewObject | AnotherNewObject + + input SomeInput { + oldField: String + newField: String + anotherNewField: String + } + + \(newTypesSDL) + """ + ) + } + + func testExtendsInterfacesByAddingNewFields() throws { + let schema = try buildSchema(source: """ + interface SomeInterface { + oldField: String + } + + interface AnotherInterface implements SomeInterface { + oldField: String + } + + type SomeObject implements SomeInterface & AnotherInterface { + oldField: String + } + + type Query { + someInterface: SomeInterface + } + """) + let extendAST = try parse(source: """ + extend interface SomeInterface { + newField: String + } + + extend interface AnotherInterface { + newField: String + } + + extend type SomeObject { + newField: String + } + """) + let extendedSchema = try extendSchema(schema: schema, documentAST: extendAST) + + try XCTAssertEqual(validateSchema(schema: extendedSchema), []) + try XCTAssertEqual( + schemaChanges(schema, extendedSchema), + """ + interface SomeInterface { + oldField: String + newField: String + } + + interface AnotherInterface implements SomeInterface { + oldField: String + newField: String + } + + type SomeObject implements SomeInterface & AnotherInterface { + oldField: String + newField: String + } + """ + ) + } + + func testExtendsInterfacesByAddingNewImplementedInterfaces() throws { + let schema = try buildSchema(source: """ + interface SomeInterface { + oldField: String + } + + interface AnotherInterface implements SomeInterface { + oldField: String + } + + type SomeObject implements SomeInterface & AnotherInterface { + oldField: String + } + + type Query { + someInterface: SomeInterface + } + """) + let extendAST = try parse(source: """ + interface NewInterface { + newField: String + } + + extend interface AnotherInterface implements NewInterface { + newField: String + } + + extend type SomeObject implements NewInterface { + newField: String + } + """) + let extendedSchema = try extendSchema(schema: schema, documentAST: extendAST) + + try XCTAssertEqual(validateSchema(schema: extendedSchema), []) + try XCTAssertEqual( + schemaChanges(schema, extendedSchema), + """ + interface AnotherInterface implements SomeInterface & NewInterface { + oldField: String + newField: String + } + + type SomeObject implements SomeInterface & AnotherInterface & NewInterface { + oldField: String + newField: String + } + + interface NewInterface { + newField: String + } + """ + ) + } + + func testAllowsExtensionOfInterfaceWithMissingObjectFields() throws { + let schema = try buildSchema(source: """ + type Query { + someInterface: SomeInterface + } + + type SomeObject implements SomeInterface { + oldField: SomeInterface + } + + interface SomeInterface { + oldField: SomeInterface + } + """) + let extendAST = try parse(source: """ + extend interface SomeInterface { + newField: String + } + """) + let extendedSchema = try extendSchema(schema: schema, documentAST: extendAST) + + try XCTAssertGreaterThan(validateSchema(schema: extendedSchema).count, 0) + try XCTAssertEqual( + schemaChanges(schema, extendedSchema), + """ + interface SomeInterface { + oldField: SomeInterface + newField: String + } + """ + ) + } + + func testExtendsInterfacesMultipleTimes() throws { + let schema = try buildSchema(source: """ + type Query { + someInterface: SomeInterface + } + + interface SomeInterface { + some: SomeInterface + } + """) + let extendAST = try parse(source: """ + extend interface SomeInterface { + newFieldA: Int + } + + extend interface SomeInterface { + newFieldB(test: Boolean): String + } + """) + let extendedSchema = try extendSchema(schema: schema, documentAST: extendAST) + + try XCTAssertEqual(validateSchema(schema: extendedSchema), []) + try XCTAssertEqual( + schemaChanges(schema, extendedSchema), + """ + interface SomeInterface { + some: SomeInterface + newFieldA: Int + newFieldB(test: Boolean): String + } + """ + ) + } + + func testMayExtendMutationsAndSubscriptions() throws { + let mutationSchema = try buildSchema(source: """ + type Query { + queryField: String + } + + type Mutation { + mutationField: String + } + + type Subscription { + subscriptionField: String + } + """) + let ast = try parse(source: """ + extend type Query { + newQueryField: Int + } + + extend type Mutation { + newMutationField: Int + } + + extend type Subscription { + newSubscriptionField: Int + } + """) + let originalPrint = printSchema(schema: mutationSchema) + let extendedSchema = try extendSchema(schema: mutationSchema, documentAST: ast) + + XCTAssertEqual(printSchema(schema: mutationSchema), originalPrint) + XCTAssertEqual( + printSchema(schema: extendedSchema), + """ + type Query { + queryField: String + newQueryField: Int + } + + type Mutation { + mutationField: String + newMutationField: Int + } + + type Subscription { + subscriptionField: String + newSubscriptionField: Int + } + """ + ) + } + + func testMayExtendDirectivesWithNewDirective() throws { + let schema = try buildSchema(source: """ + type Query { + foo: String + } + """) + let extensionSDL = #""" + """New directive.""" + directive @new(enable: Boolean!, tag: String) repeatable on QUERY | FIELD + """# + let extendedSchema = try extendSchema( + schema: schema, + documentAST: parse(source: extensionSDL) + ) + + try XCTAssertEqual(validateSchema(schema: extendedSchema), []) + try XCTAssertEqual( + schemaChanges(schema, extendedSchema), + extensionSDL + ) + } + + func testRejectsInvalidSDL() throws { + let schema = try GraphQLSchema() + let extendAST = try parse(source: "extend schema @unknown") + + try XCTAssertThrowsError( + extendSchema(schema: schema, documentAST: extendAST), + "Unknown directive \"@unknown\"." + ) + } + + func testAllowsToDisableSDLValidation() throws { + let schema = try GraphQLSchema() + let extendAST = try parse(source: "extend schema @unknown") + + _ = try extendSchema(schema: schema, documentAST: extendAST, assumeValid: true) + _ = try extendSchema(schema: schema, documentAST: extendAST, assumeValidSDL: true) + } + + func testThrowsOnUnknownTypes() throws { + let schema = try GraphQLSchema() + let extendAST = try parse(source: """ + type Query { + unknown: UnknownType + } + """) + + try XCTAssertThrowsError( + extendSchema(schema: schema, documentAST: extendAST, assumeValidSDL: true), + "Unknown type: \"UnknownType\"." + ) + } + + func testDoesNotAllowReplacingADefaultDirective() throws { + let schema = try GraphQLSchema() + let extendAST = try parse(source: """ + directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD + """) + + try XCTAssertThrowsError( + extendSchema(schema: schema, documentAST: extendAST), + "Directive \"@include\" already exists in the schema. It cannot be redefined." + ) + } + + func testDoesNotAllowReplacingAnExistingEnumValue() throws { + let schema = try buildSchema(source: """ + enum SomeEnum { + ONE + } + """) + let extendAST = try parse(source: """ + extend enum SomeEnum { + ONE + } + """) + + try XCTAssertThrowsError( + extendSchema(schema: schema, documentAST: extendAST), + "Enum value \"SomeEnum.ONE\" already exists in the schema. It cannot also be defined in this type extension." + ) + } + + // MARK: can add additional root operation types + + func testDoesNotAutomaticallyIncludeCommonRootTypeNames() throws { + let schema = try GraphQLSchema() + let extendedSchema = try extendSchema( + schema: schema, + documentAST: parse(source: "type Mutation") + ) + + XCTAssertNotNil(extendedSchema.getType(name: "Mutation")) + XCTAssertNil(extendedSchema.mutationType) + } + + func testAddsSchemaDefinitionMissingInTheOriginalSchema() throws { + let schema = try buildSchema(source: """ + directive @foo on SCHEMA + type Foo + """) + XCTAssertNil(schema.queryType) + + let extensionSDL = """ + schema @foo { + query: Foo + } + """ + let extendedSchema = try extendSchema( + schema: schema, + documentAST: parse(source: extensionSDL) + ) + + let queryType = extendedSchema.queryType + XCTAssertEqual(queryType?.name, "Foo") + try XCTAssertEqual(astNode(extendedSchema.astNode), extensionSDL) + } + + func testAddsNewRootTypesViaSchemaExtension() throws { + let schema = try buildSchema(source: """ + type Query + type MutationRoot + """) + let extensionSDL = """ + extend schema { + mutation: MutationRoot + } + """ + let extendedSchema = try extendSchema( + schema: schema, + documentAST: parse(source: extensionSDL) + ) + + let mutationType = extendedSchema.mutationType + XCTAssertEqual(mutationType?.name, "MutationRoot") + XCTAssertEqual(extensionASTNodes(extendedSchema.extensionASTNodes), extensionSDL) + } + + func testAddsDirectiveViaSchemaExtension() throws { + let schema = try buildSchema(source: """ + type Query + + directive @foo on SCHEMA + """) + let extensionSDL = """ + extend schema @foo + """ + let extendedSchema = try extendSchema( + schema: schema, + documentAST: parse(source: extensionSDL) + ) + + XCTAssertEqual(extensionASTNodes(extendedSchema.extensionASTNodes), extensionSDL) + } + + func testAddsMultipleNewRootTypesViaSchemaExtension() throws { + let schema = try buildSchema(source: "type Query") + let extendAST = try parse(source: """ + extend schema { + mutation: Mutation + subscription: Subscription + } + + type Mutation + type Subscription + """) + let extendedSchema = try extendSchema(schema: schema, documentAST: extendAST) + + let mutationType = extendedSchema.mutationType + XCTAssertEqual(mutationType?.name, "Mutation") + + let subscriptionType = extendedSchema.subscriptionType + XCTAssertEqual(subscriptionType?.name, "Subscription") + } + + func testAppliesMultipleSchemaExtensions() throws { + let schema = try buildSchema(source: "type Query") + let extendAST = try parse(source: """ + extend schema { + mutation: Mutation + } + type Mutation + + extend schema { + subscription: Subscription + } + type Subscription + """) + let extendedSchema = try extendSchema(schema: schema, documentAST: extendAST) + + let mutationType = extendedSchema.mutationType + XCTAssertEqual(mutationType?.name, "Mutation") + + let subscriptionType = extendedSchema.subscriptionType + XCTAssertEqual(subscriptionType?.name, "Subscription") + } + + func testSchemaExtensionASTAreAvailableFromSchemaObject() throws { + let schema = try buildSchema(source: """ + type Query + + directive @foo on SCHEMA + """) + let extendAST = try parse(source: """ + extend schema { + mutation: Mutation + } + type Mutation + + extend schema { + subscription: Subscription + } + type Subscription + """) + let extendedSchema = try extendSchema(schema: schema, documentAST: extendAST) + + let secondExtendAST = try parse(source: "extend schema @foo") + let extendedTwiceSchema = try extendSchema( + schema: extendedSchema, + documentAST: secondExtendAST + ) + + XCTAssertEqual( + extensionASTNodes(extendedTwiceSchema.extensionASTNodes), + """ + extend schema { + mutation: Mutation + } + + extend schema { + subscription: Subscription + } + + extend schema @foo + """ + ) + } +} diff --git a/Tests/GraphQLTests/UtilitiesTests/PrintSchemaTests.swift b/Tests/GraphQLTests/UtilitiesTests/PrintSchemaTests.swift new file mode 100644 index 00000000..5b13769c --- /dev/null +++ b/Tests/GraphQLTests/UtilitiesTests/PrintSchemaTests.swift @@ -0,0 +1,1008 @@ +@testable import GraphQL +import OrderedCollections +import XCTest + +func expectPrintedSchema(schema: GraphQLSchema) throws -> String { + let schemaText = printSchema(schema: schema) + // keep printSchema and buildSchema in sync + XCTAssertEqual(try printSchema(schema: buildSchema(source: schemaText)), schemaText) + return schemaText +} + +func buildSingleFieldSchema( + fieldConfig: GraphQLField +) throws -> GraphQLSchema { + let Query = try GraphQLObjectType( + name: "Query", + fields: ["singleField": fieldConfig] + ) + return try GraphQLSchema(query: Query) +} + +class TypeSystemPrinterTests: XCTestCase { + func testPrintsStringField() throws { + let schema = try buildSingleFieldSchema(fieldConfig: GraphQLField(type: GraphQLString)) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + type Query { + singleField: String + } + """) + } + + func testPrintsStringListField() throws { + let schema = + try buildSingleFieldSchema(fieldConfig: GraphQLField(type: GraphQLList(GraphQLString))) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + type Query { + singleField: [String] + } + """) + } + + func testPrintsStringNonNullField() throws { + let schema = + try buildSingleFieldSchema( + fieldConfig: GraphQLField(type: GraphQLNonNull(GraphQLString)) + ) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + type Query { + singleField: String! + } + """) + } + + func testPrintsStringNonNullListField() throws { + let schema = + try buildSingleFieldSchema( + fieldConfig: GraphQLField(type: GraphQLNonNull(GraphQLList(GraphQLString))) + ) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + type Query { + singleField: [String]! + } + """) + } + + func testPrintsStringListNonNullsField() throws { + let schema = + try buildSingleFieldSchema( + fieldConfig: GraphQLField(type: GraphQLList(GraphQLNonNull(GraphQLString))) + ) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + type Query { + singleField: [String!] + } + """) + } + + func testPrintsStringNonNullListNonNullsField() throws { + let schema = + try buildSingleFieldSchema( + fieldConfig: GraphQLField( + type: GraphQLNonNull(GraphQLList(GraphQLNonNull(GraphQLString))) + ) + ) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + type Query { + singleField: [String!]! + } + """) + } + + func testPrintsObjectField() throws { + let FooType = try GraphQLObjectType( + name: "Foo", + fields: ["str": GraphQLField(type: GraphQLString)] + ) + let schema = try GraphQLSchema(types: [FooType]) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + type Foo { + str: String + } + """) + } + + func testPrintsStringFieldWithIntArg() throws { + let schema = try buildSingleFieldSchema(fieldConfig: GraphQLField( + type: GraphQLString, + args: ["argOne": GraphQLArgument(type: GraphQLInt)] + )) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + type Query { + singleField(argOne: Int): String + } + """) + } + + func testPrintsStringFieldWithIntArgWithDefault() throws { + let schema = try buildSingleFieldSchema(fieldConfig: GraphQLField( + type: GraphQLString, + args: ["argOne": GraphQLArgument(type: GraphQLInt, defaultValue: 2)] + )) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + type Query { + singleField(argOne: Int = 2): String + } + """) + } + + func testPrintsStringFieldWithStringArgWithDefault() throws { + let schema = try buildSingleFieldSchema(fieldConfig: GraphQLField( + type: GraphQLString, + args: ["argOne": GraphQLArgument(type: GraphQLString, defaultValue: "test default")] + )) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + type Query { + singleField(argOne: String = "test default"): String + } + """) + } + + func testPrintsStringFieldWithIntArgWithDefaultNull() throws { + let schema = try buildSingleFieldSchema(fieldConfig: GraphQLField( + type: GraphQLString, + args: ["argOne": GraphQLArgument(type: GraphQLInt, defaultValue: .null)] + )) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + type Query { + singleField(argOne: Int = null): String + } + """) + } + + func testPrintsStringFieldWithNonNullIntArg() throws { + let schema = try buildSingleFieldSchema(fieldConfig: GraphQLField( + type: GraphQLString, + args: ["argOne": GraphQLArgument(type: GraphQLNonNull(GraphQLInt))] + )) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + type Query { + singleField(argOne: Int!): String + } + """) + } + + func testPrintsStringFieldWithMultipleArgs() throws { + let schema = try buildSingleFieldSchema(fieldConfig: GraphQLField( + type: GraphQLString, + args: [ + "argOne": GraphQLArgument(type: GraphQLInt), + "argTwo": GraphQLArgument(type: GraphQLString), + ] + )) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + type Query { + singleField(argOne: Int, argTwo: String): String + } + """) + } + + func testPrintsStringFieldWithMultipleArgsFirstIsDefault() throws { + let schema = try buildSingleFieldSchema(fieldConfig: GraphQLField( + type: GraphQLString, + args: [ + "argOne": GraphQLArgument(type: GraphQLInt, defaultValue: 1), + "argTwo": GraphQLArgument(type: GraphQLString), + "argThree": GraphQLArgument(type: GraphQLBoolean), + ] + )) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + type Query { + singleField(argOne: Int = 1, argTwo: String, argThree: Boolean): String + } + """) + } + + func testPrintsStringFieldWithMultipleArgsSecondIsDefault() throws { + let schema = try buildSingleFieldSchema(fieldConfig: GraphQLField( + type: GraphQLString, + args: [ + "argOne": GraphQLArgument(type: GraphQLInt), + "argTwo": GraphQLArgument(type: GraphQLString, defaultValue: "foo"), + "argThree": GraphQLArgument(type: GraphQLBoolean), + ] + )) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + type Query { + singleField(argOne: Int, argTwo: String = "foo", argThree: Boolean): String + } + """) + } + + func testPrintsStringFieldWithMultipleArgsLastIsDefault() throws { + let schema = try buildSingleFieldSchema(fieldConfig: GraphQLField( + type: GraphQLString, + args: [ + "argOne": GraphQLArgument(type: GraphQLInt), + "argTwo": GraphQLArgument(type: GraphQLString), + "argThree": GraphQLArgument(type: GraphQLBoolean, defaultValue: .bool(false)), + ] + )) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + type Query { + singleField(argOne: Int, argTwo: String, argThree: Boolean = false): String + } + """) + } + + func testPrintsSchemaWithDescription() throws { + let schema = try GraphQLSchema( + description: "Schema description.", + query: GraphQLObjectType(name: "Query", fields: [:]) + ) + try XCTAssertEqual(expectPrintedSchema(schema: schema), #""" + """Schema description.""" + schema { + query: Query + } + + type Query + """#) + } + + func testOmitsSchemaOfCommonNames() throws { + let schema = try GraphQLSchema( + query: GraphQLObjectType(name: "Query", fields: [:]), + mutation: GraphQLObjectType(name: "Mutation", fields: [:]), + subscription: GraphQLObjectType(name: "Subscription", fields: [:]) + ) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + type Query + + type Mutation + + type Subscription + """) + } + + func testPrintsCustomQueryRootTypes() throws { + let schema = try GraphQLSchema( + query: GraphQLObjectType(name: "CustomType", fields: [:]) + ) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + schema { + query: CustomType + } + + type CustomType + """) + } + + func testPrintsCustomMutationRootTypes() throws { + let schema = try GraphQLSchema( + mutation: GraphQLObjectType(name: "CustomType", fields: [:]) + ) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + schema { + mutation: CustomType + } + + type CustomType + """) + } + + func testPrintsCustomSubscriptionRootTypes() throws { + let schema = try GraphQLSchema( + subscription: GraphQLObjectType(name: "CustomType", fields: [:]) + ) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + schema { + subscription: CustomType + } + + type CustomType + """) + } + + func testPrintInterface() throws { + let FooType = try GraphQLInterfaceType( + name: "Foo", + fields: ["str": GraphQLField(type: GraphQLString)] + ) + + let BarType = try GraphQLObjectType( + name: "Bar", + fields: ["str": GraphQLField(type: GraphQLString)], + interfaces: [FooType] + ) + + let schema = try GraphQLSchema(types: [BarType]) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + type Bar implements Foo { + str: String + } + + interface Foo { + str: String + } + """) + } + + func testPrintMultipleInterface() throws { + let FooType = try GraphQLInterfaceType( + name: "Foo", + fields: ["str": GraphQLField(type: GraphQLString)] + ) + + let BazType = try GraphQLInterfaceType( + name: "Baz", + fields: ["int": GraphQLField(type: GraphQLInt)] + ) + + let BarType = try GraphQLObjectType( + name: "Bar", + fields: [ + "str": GraphQLField(type: GraphQLString), + "int": GraphQLField(type: GraphQLInt), + ], + interfaces: [FooType, BazType] + ) + + let schema = try GraphQLSchema(types: [BarType]) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + type Bar implements Foo & Baz { + str: String + int: Int + } + + interface Foo { + str: String + } + + interface Baz { + int: Int + } + """) + } + + func testPrintHierarchicalInterface() throws { + let FooType = try GraphQLInterfaceType( + name: "Foo", + fields: ["str": GraphQLField(type: GraphQLString)] + ) + + let BazType = try GraphQLInterfaceType( + name: "Baz", + interfaces: [FooType], + fields: [ + "int": GraphQLField(type: GraphQLInt), + "str": GraphQLField(type: GraphQLString), + ] + ) + + let BarType = try GraphQLObjectType( + name: "Bar", + fields: [ + "str": GraphQLField(type: GraphQLString), + "int": GraphQLField(type: GraphQLInt), + ], + interfaces: [FooType, BazType] + ) + + let Query = try GraphQLObjectType( + name: "Query", + fields: [ + "bar": GraphQLField(type: BarType), + ] + ) + + let schema = try GraphQLSchema(query: Query, types: [BarType]) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + type Bar implements Foo & Baz { + str: String + int: Int + } + + interface Foo { + str: String + } + + interface Baz implements Foo { + int: Int + str: String + } + + type Query { + bar: Bar + } + """) + } + + func testPrintUnions() throws { + let FooType = try GraphQLObjectType( + name: "Foo", + fields: ["bool": GraphQLField(type: GraphQLBoolean)] + ) + + let BarType = try GraphQLObjectType( + name: "Bar", + fields: ["str": GraphQLField(type: GraphQLString)] + ) + + let SingleUnion = try GraphQLUnionType( + name: "SingleUnion", + types: [FooType] + ) + + let MultipleUnion = try GraphQLUnionType( + name: "MultipleUnion", + types: [FooType, BarType] + ) + + let schema = try GraphQLSchema(types: [SingleUnion, MultipleUnion]) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + union SingleUnion = Foo + + type Foo { + bool: Boolean + } + + union MultipleUnion = Foo | Bar + + type Bar { + str: String + } + """) + } + + func testPrintInputType() throws { + let InputType = try GraphQLInputObjectType( + name: "InputType", + fields: ["int": InputObjectField(type: GraphQLInt)] + ) + + let schema = try GraphQLSchema(types: [InputType]) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + input InputType { + int: Int + } + """) + } + + func testPrintInputTypewithOneOfDirective() throws { + let InputType = try GraphQLInputObjectType( + name: "InputType", + fields: ["int": InputObjectField(type: GraphQLInt)], + isOneOf: true + ) + + let schema = try GraphQLSchema(types: [InputType]) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + input InputType @oneOf { + int: Int + } + """) + } + + func testCustomScalar() throws { + let OddType = try GraphQLScalarType(name: "Odd") + + let schema = try GraphQLSchema(types: [OddType]) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + scalar Odd + """) + } + + func testCustomScalarWithSpecifiedByURL() throws { + let FooType = try GraphQLScalarType( + name: "Foo", + specifiedByURL: "https://example.com/foo_spec" + ) + + let schema = try GraphQLSchema(types: [FooType]) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + scalar Foo @specifiedBy(url: "https://example.com/foo_spec") + """) + } + + func testEnum() throws { + let RGBType = try GraphQLEnumType( + name: "RGB", + values: [ + "RED": GraphQLEnumValue(value: "RED"), + "GREEN": GraphQLEnumValue(value: "GREEN"), + "BLUE": GraphQLEnumValue(value: "BLUE"), + ] + ) + + let schema = try GraphQLSchema(types: [RGBType]) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + enum RGB { + RED + GREEN + BLUE + } + """) + } + + func testPrintsEmptyTypes() throws { + let schema = try GraphQLSchema( + types: [ + GraphQLEnumType(name: "SomeEnum", values: [:]), + GraphQLInputObjectType(name: "SomeInputObject", fields: [:]), + GraphQLInterfaceType(name: "SomeInterface", fields: [:]), + GraphQLObjectType(name: "SomeObject", fields: [:]), + GraphQLUnionType(name: "SomeUnion", types: []), + ] + ) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + enum SomeEnum + + input SomeInputObject + + interface SomeInterface + + type SomeObject + + union SomeUnion + """) + } + + func testPrintsCustomDirectives() throws { + let SimpleDirective = try GraphQLDirective( + name: "simpleDirective", + locations: [DirectiveLocation.field] + ) + let ComplexDirective = try GraphQLDirective( + name: "complexDirective", + description: "Complex Directive", + locations: [DirectiveLocation.field, DirectiveLocation.query], + args: [ + "stringArg": GraphQLArgument(type: GraphQLString), + "intArg": GraphQLArgument(type: GraphQLInt, defaultValue: -1), + ], + isRepeatable: true + ) + + let schema = try GraphQLSchema(directives: [SimpleDirective, ComplexDirective]) + try XCTAssertEqual(expectPrintedSchema(schema: schema), #""" + directive @simpleDirective on FIELD + + """Complex Directive""" + directive @complexDirective(stringArg: String, intArg: Int = -1) repeatable on FIELD | QUERY + """#) + } + + func testPrintsAnEmptyDescriptions() throws { + let args: OrderedDictionary = [ + "someArg": GraphQLArgument(type: GraphQLString, description: ""), + "anotherArg": GraphQLArgument(type: GraphQLString, description: ""), + ] + + let fields: OrderedDictionary = [ + "someField": GraphQLField(type: GraphQLString, description: "", args: args), + "anotherField": GraphQLField(type: GraphQLString, description: "", args: args), + ] + + let queryType = try GraphQLObjectType( + name: "Query", + description: "", + fields: fields + ) + + let scalarType = try GraphQLScalarType( + name: "SomeScalar", + description: "" + ) + + let interfaceType = try GraphQLInterfaceType( + name: "SomeInterface", + description: "", + fields: fields + ) + + let unionType = try GraphQLUnionType( + name: "SomeUnion", + description: "", + types: [queryType] + ) + + let enumType = try GraphQLEnumType( + name: "SomeEnum", + description: "", + values: [ + "SOME_VALUE": GraphQLEnumValue(value: "SOME_VALUE", description: ""), + "ANOTHER_VALUE": GraphQLEnumValue(value: "ANOTHER_VALUE", description: ""), + ] + ) + + let someDirective = try GraphQLDirective( + name: "someDirective", + description: "", + locations: [DirectiveLocation.query], + args: args + ) + + let schema = try GraphQLSchema( + description: "", + query: queryType, + types: [scalarType, interfaceType, unionType, enumType], + directives: [someDirective] + ) + try XCTAssertEqual(expectPrintedSchema(schema: schema), #""" + """""" + schema { + query: Query + } + + """""" + directive @someDirective( + """""" + someArg: String + + """""" + anotherArg: String + ) on QUERY + + """""" + scalar SomeScalar + + """""" + interface SomeInterface { + """""" + someField( + """""" + someArg: String + + """""" + anotherArg: String + ): String + + """""" + anotherField( + """""" + someArg: String + + """""" + anotherArg: String + ): String + } + + """""" + union SomeUnion = Query + + """""" + type Query { + """""" + someField( + """""" + someArg: String + + """""" + anotherArg: String + ): String + + """""" + anotherField( + """""" + someArg: String + + """""" + anotherArg: String + ): String + } + + """""" + enum SomeEnum { + """""" + SOME_VALUE + + """""" + ANOTHER_VALUE + } + """#) + } + + func testPrintsADescriptionWithOnlyWhitespace() throws { + let schema = try buildSingleFieldSchema( + fieldConfig: GraphQLField( + type: GraphQLString, + description: " " + ) + ) + try XCTAssertEqual(expectPrintedSchema(schema: schema), """ + type Query { + " " + singleField: String + } + """) + } + + func testOneLinePrintsAShortDescription() throws { + let schema = try buildSingleFieldSchema( + fieldConfig: GraphQLField( + type: GraphQLString, + description: "This field is awesome" + ) + ) + try XCTAssertEqual(expectPrintedSchema(schema: schema), #""" + type Query { + """This field is awesome""" + singleField: String + } + """#) + } + + func testPrintIntrospectionSchema() throws { + let schema = try GraphQLSchema() + XCTAssertEqual(printIntrospectionSchema(schema: schema), #""" + """ + Directs the executor to include this field or fragment only when the \`if\` argument is true. + """ + directive @include( + """Included when true.""" + if: Boolean! + ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + + """ + Directs the executor to skip this field or fragment when the \`if\` argument is true. + """ + directive @skip( + """Skipped when true.""" + if: Boolean! + ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + + """Marks an element of a GraphQL schema as no longer supported.""" + directive @deprecated( + """ + Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formatted using the Markdown syntax, as specified by [CommonMark](https://commonmark.org/). + """ + reason: String = "No longer supported" + ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | ENUM_VALUE + + """Exposes a URL that specifies the behavior of this scalar.""" + directive @specifiedBy( + """The URL that specifies the behavior of this scalar.""" + url: String! + ) on SCALAR + + """ + Indicates exactly one field must be supplied and this field must not be \`null\`. + """ + directive @oneOf on INPUT_OBJECT + + """ + A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation, and subscription operations. + """ + type __Schema { + description: String + + """A list of all types supported by this server.""" + types: [__Type!]! + + """The type that query operations will be rooted at.""" + queryType: __Type! + + """ + If this server supports mutation, the type that mutation operations will be rooted at. + """ + mutationType: __Type + + """ + If this server support subscription, the type that subscription operations will be rooted at. + """ + subscriptionType: __Type + + """A list of all directives supported by this server.""" + directives: [__Directive!]! + } + + """ + The fundamental unit of any GraphQL Schema is the type. There are many kinds of types in GraphQL as represented by the \`__TypeKind\` enum. + + Depending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name, 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. + """ + type __Type { + kind: __TypeKind! + name: String + description: String + specifiedByURL: String + fields(includeDeprecated: Boolean = false): [__Field!] + interfaces: [__Type!] + possibleTypes: [__Type!] + enumValues(includeDeprecated: Boolean = false): [__EnumValue!] + inputFields(includeDeprecated: Boolean = false): [__InputValue!] + ofType: __Type + isOneOf: Boolean + } + + """An enum describing what kind of type a given \`__Type\` is.""" + enum __TypeKind { + """Indicates this type is a scalar.""" + SCALAR + + """ + Indicates this type is an object. \`fields\` and \`interfaces\` are valid fields. + """ + OBJECT + + """ + Indicates this type is an interface. \`fields\`, \`interfaces\`, and \`possibleTypes\` are valid fields. + """ + INTERFACE + + """Indicates this type is a union. \`possibleTypes\` is a valid field.""" + UNION + + """Indicates this type is an enum. \`enumValues\` is a valid field.""" + ENUM + + """ + Indicates this type is an input object. \`inputFields\` is a valid field. + """ + INPUT_OBJECT + + """Indicates this type is a list. \`ofType\` is a valid field.""" + LIST + + """Indicates this type is a non-null. \`ofType\` is a valid field.""" + NON_NULL + } + + """ + Object and Interface types are described by a list of Fields, each of which has a name, potentially a list of arguments, and a return type. + """ + type __Field { + name: String! + description: String + args(includeDeprecated: Boolean = false): [__InputValue!]! + type: __Type! + isDeprecated: Boolean! + deprecationReason: String + } + + """ + Arguments provided to Fields or Directives and the input fields of an InputObject are represented as Input Values which describe their type and optionally a default value. + """ + type __InputValue { + name: String! + description: String + type: __Type! + + """ + A GraphQL-formatted string representing the default value for this input value. + """ + defaultValue: String + isDeprecated: Boolean! + deprecationReason: String + } + + """ + One possible value for a given Enum. Enum values are unique values, not a placeholder for a string or numeric value. However an Enum value is returned in a JSON response as a string. + """ + type __EnumValue { + name: String! + description: String + isDeprecated: Boolean! + deprecationReason: String + } + + """ + 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 in ways field arguments will not suffice, such as conditionally including or skipping a field. Directives provide this by describing additional information to the executor. + """ + type __Directive { + name: String! + description: String + isRepeatable: Boolean! + locations: [__DirectiveLocation!]! + args(includeDeprecated: Boolean = false): [__InputValue!]! + } + + """ + A Directive can be adjacent to many parts of the GraphQL language, a __DirectiveLocation describes one such possible adjacencies. + """ + enum __DirectiveLocation { + """Location adjacent to a query operation.""" + QUERY + + """Location adjacent to a mutation operation.""" + MUTATION + + """Location adjacent to a subscription operation.""" + SUBSCRIPTION + + """Location adjacent to a field.""" + FIELD + + """Location adjacent to a fragment definition.""" + FRAGMENT_DEFINITION + + """Location adjacent to a fragment spread.""" + FRAGMENT_SPREAD + + """Location adjacent to an inline fragment.""" + INLINE_FRAGMENT + + """Location adjacent to an operation variable definition.""" + VARIABLE_DEFINITION + + """Location adjacent to a fragment variable definition.""" + FRAGMENT_VARIABLE_DEFINITION + + """Location adjacent to a schema definition.""" + SCHEMA + + """Location adjacent to a scalar definition.""" + SCALAR + + """Location adjacent to an object type definition.""" + OBJECT + + """Location adjacent to a field definition.""" + FIELD_DEFINITION + + """Location adjacent to an argument definition.""" + ARGUMENT_DEFINITION + + """Location adjacent to an interface definition.""" + INTERFACE + + """Location adjacent to a union definition.""" + UNION + + """Location adjacent to an enum definition.""" + ENUM + + """Location adjacent to an enum value definition.""" + ENUM_VALUE + + """Location adjacent to an input object type definition.""" + INPUT_OBJECT + + """Location adjacent to an input object field definition.""" + INPUT_FIELD_DEFINITION + } + """#) + } + + func testPrintsViralSchemaCorrectly() throws { + let Mutation = try GraphQLObjectType( + name: "Mutation", + fields: [ + "name": GraphQLField(type: GraphQLNonNull(GraphQLString)), + "geneSequence": GraphQLField(type: GraphQLNonNull(GraphQLString)), + ] + ) + + let Virus = try GraphQLObjectType( + name: "Virus", + fields: [ + "name": GraphQLField(type: GraphQLNonNull(GraphQLString)), + "knownMutations": GraphQLField(type: GraphQLNonNull(GraphQLList(GraphQLNonNull(Mutation)))), + ] + ) + + let Query = try GraphQLObjectType( + name: "Query", + fields: [ + "viruses": GraphQLField(type: GraphQLList(GraphQLNonNull(Virus))), + ] + ) + + let viralSchema = try GraphQLSchema(query: Query) + XCTAssertEqual( + printSchema(schema: viralSchema), + """ + schema { + query: Query + } + + type Query { + viruses: [Virus!] + } + + type Virus { + name: String! + knownMutations: [Mutation!]! + } + + type Mutation { + name: String! + geneSequence: String! + } + """ + ) + } +} diff --git a/Tests/GraphQLTests/ValidationTests/ExampleSchema.swift b/Tests/GraphQLTests/ValidationTests/ExampleSchema.swift index 476998bf..2350c285 100644 --- a/Tests/GraphQLTests/ValidationTests/ExampleSchema.swift +++ b/Tests/GraphQLTests/ValidationTests/ExampleSchema.swift @@ -26,10 +26,12 @@ let ValidationExampleBeing = try! GraphQLInterfaceType( // } let ValidationExampleMammal = try! GraphQLInterfaceType( name: "Mammal", - fields: [ - "mother": GraphQLField(type: GraphQLTypeReference("Mammal")), - "father": GraphQLField(type: GraphQLTypeReference("Mammal")), - ], + fields: { + [ + "mother": GraphQLField(type: ValidationExampleMammal), + "father": GraphQLField(type: ValidationExampleMammal), + ] + }, resolveType: { _, _, _ in "Unknown" } @@ -70,10 +72,10 @@ let ValidationExampleCanine = try! GraphQLInterfaceType( args: ["surname": GraphQLArgument(type: GraphQLBoolean)] ), "mother": GraphQLField( - type: GraphQLTypeReference("Mammal") + type: ValidationExampleMammal ), "father": GraphQLField( - type: GraphQLTypeReference("Mammal") + type: ValidationExampleMammal ), ], resolveType: { _, _, _ in @@ -174,14 +176,14 @@ let ValidationExampleDog = try! GraphQLObjectType( } ), "mother": GraphQLField( - type: GraphQLTypeReference("Mammal"), + type: ValidationExampleMammal, resolve: { inputValue, _, _, _ -> String? in print(type(of: inputValue)) return nil } ), "father": GraphQLField( - type: GraphQLTypeReference("Mammal"), + type: ValidationExampleMammal, resolve: { inputValue, _, _, _ -> String? in print(type(of: inputValue)) return nil diff --git a/Tests/GraphQLTests/ValidationTests/KnownArgumentNamesOnDirectivesRuleTests.swift b/Tests/GraphQLTests/ValidationTests/KnownArgumentNamesOnDirectivesRuleTests.swift new file mode 100644 index 00000000..3d5467e5 --- /dev/null +++ b/Tests/GraphQLTests/ValidationTests/KnownArgumentNamesOnDirectivesRuleTests.swift @@ -0,0 +1,136 @@ +@testable import GraphQL +import XCTest + +class KnownArgumentNamesOnDirectivesRuleTests: SDLValidationTestCase { + override func setUp() { + rule = KnownArgumentNamesOnDirectivesRule + } + + func testKnownArgOnDirectiveDefinedInsideSDL() throws { + try assertValidationErrors( + """ + type Query { + foo: String @test(arg: "") + } + + directive @test(arg: String) on FIELD_DEFINITION + """, + [] + ) + } + + func testUnknownArgOnDirectiveDefinedInsideSDL() throws { + try assertValidationErrors( + """ + type Query { + foo: String @test(unknown: "") + } + + directive @test(arg: String) on FIELD_DEFINITION + """, + [ + GraphQLError( + message: #"Unknown argument "unknown" on directive "@test"."#, + locations: [.init(line: 2, column: 21)] + ), + ] + ) + } + + func testMisspelledArgNameIsReportedOnDirectiveDefinedInsideSDL() throws { + try assertValidationErrors( + """ + type Query { + foo: String @test(agr: "") + } + + directive @test(arg: String) on FIELD_DEFINITION + """, + [ + GraphQLError( + message: #"Unknown argument "agr" on directive "@test". Did you mean "arg"?"#, + locations: [.init(line: 2, column: 21)] + ), + ] + ) + } + + func testUnknownArgOnStandardDirective() throws { + try assertValidationErrors( + """ + type Query { + foo: String @deprecated(unknown: "") + } + """, + [ + GraphQLError( + message: #"Unknown argument "unknown" on directive "@deprecated"."#, + locations: [.init(line: 2, column: 27)] + ), + ] + ) + } + + func testUnknownArgOnOverriddenStandardDirective() throws { + try assertValidationErrors( + """ + type Query { + foo: String @deprecated(reason: "") + } + directive @deprecated(arg: String) on FIELD + """, + [ + GraphQLError( + message: #"Unknown argument "reason" on directive "@deprecated"."#, + locations: [.init(line: 2, column: 27)] + ), + ] + ) + } + + func testUnknownArgOnDirectiveDefinedInSchemaExtension() throws { + let schema = try buildSchema(source: """ + type Query { + foo: String + } + """) + let sdl = """ + directive @test(arg: String) on OBJECT + + extend type Query @test(unknown: "") + """ + try assertValidationErrors( + sdl, + schema: schema, + [ + GraphQLError( + message: #"Unknown argument "unknown" on directive "@test"."#, + locations: [.init(line: 3, column: 26)] + ), + ] + ) + } + + func testUnknownArgOnDirectiveUsedInSchemaExtension() throws { + let schema = try buildSchema(source: """ + directive @test(arg: String) on OBJECT + + type Query { + foo: String + } + """) + let sdl = """ + extend type Query @test(unknown: "") + """ + try assertValidationErrors( + sdl, + schema: schema, + [ + GraphQLError( + message: #"Unknown argument "unknown" on directive "@test"."#, + locations: [.init(line: 1, column: 25)] + ), + ] + ) + } +} diff --git a/Tests/GraphQLTests/ValidationTests/KnownDirectivesRuleTests.swift b/Tests/GraphQLTests/ValidationTests/KnownDirectivesRuleTests.swift index 6304ba46..eecfe3f2 100644 --- a/Tests/GraphQLTests/ValidationTests/KnownDirectivesRuleTests.swift +++ b/Tests/GraphQLTests/ValidationTests/KnownDirectivesRuleTests.swift @@ -246,28 +246,263 @@ class KnownDirectivesRuleTests: ValidationTestCase { return directives }() ) +} + +class KnownDirectivesRuleSDLTests: SDLValidationTestCase { + override func setUp() { + rule = KnownDirectivesRule + } + + func testWithDirectiveDefinedInsideSDL() throws { + try assertValidationErrors( + """ + type Query { + foo: String @test + } + + directive @test on FIELD_DEFINITION + """, + [] + ) + } + + func testWithStandardDirective() throws { + try assertValidationErrors( + """ + type Query { + foo: String @deprecated + } + """, + [] + ) + } + + func testWithOverriddenStandardDirective() throws { + try assertValidationErrors( + """ + schema @deprecated { + query: Query + } + directive @deprecated on SCHEMA + """, + [] + ) + } + + func testWithDirectiveDefinedInSchemaExtension() throws { + let schema = try buildSchema(source: """ + type Query { + foo: String + } + """) + let sdl = """ + directive @test on OBJECT + + extend type Query @test + """ + try assertValidationErrors(sdl, schema: schema, []) + } + + func testWithDirectiveUsedInSchemaExtension() throws { + let schema = try buildSchema(source: """ + directive @test on OBJECT + + type Query { + foo: String + } + """) + let sdl = """ + extend type Query @test + """ + try assertValidationErrors(sdl, schema: schema, []) + } + + func testWithUnknownDirectiveInSchemaExtension() throws { + let schema = try buildSchema(source: """ + type Query { + foo: String + } + """) + let sdl = """ + extend type Query @unknown + """ + try assertValidationErrors( + sdl, + schema: schema, + [ + GraphQLError( + message: #"Unknown directive "@unknown"."#, + locations: [.init(line: 1, column: 19)] + ), + ] + ) + } + + func testWithWellPlacedDirectives() throws { + try assertValidationErrors( + """ + type MyObj implements MyInterface @onObject { + myField(myArg: Int @onArgumentDefinition): String @onFieldDefinition + } + + extend type MyObj @onObject + + scalar MyScalar @onScalar + + extend scalar MyScalar @onScalar + + interface MyInterface @onInterface { + myField(myArg: Int @onArgumentDefinition): String @onFieldDefinition + } + + extend interface MyInterface @onInterface + + union MyUnion @onUnion = MyObj | Other + + extend union MyUnion @onUnion + + enum MyEnum @onEnum { + MY_VALUE @onEnumValue + } + + extend enum MyEnum @onEnum + + input MyInput @onInputObject { + myField: Int @onInputFieldDefinition + } - // TODO: Add SDL tests - -// let schemaWithSDLDirectives = try! GraphQLSchema( -// directives: { -// var directives = specifiedDirectives -// directives.append(contentsOf: [ -// try! GraphQLDirective(name: "onSchema", locations: [.schema]), -// try! GraphQLDirective(name: "onScalar", locations: [.scalar]), -// try! GraphQLDirective(name: "onObject", locations: [.object]), -// try! GraphQLDirective(name: "onFieldDefinition", locations: [.fieldDefinition]), -// try! GraphQLDirective(name: "onArgumentDefinition", locations: -// [.argumentDefinition]), -// try! GraphQLDirective(name: "onInterface", locations: [.interface]), -// try! GraphQLDirective(name: "onUnion", locations: [.union]), -// try! GraphQLDirective(name: "onEnum", locations: [.enum]), -// try! GraphQLDirective(name: "onEnumValue", locations: [.enumValue]), -// try! GraphQLDirective(name: "onInputObject", locations: [.inputObject]), -// try! GraphQLDirective(name: "onInputFieldDefinition", locations: -// [.inputFieldDefinition]), -// ]) -// return directives -// }() -// ) + extend input MyInput @onInputObject + + schema @onSchema { + query: MyQuery + } + + directive @myDirective(arg:String) on ARGUMENT_DEFINITION + directive @myDirective2(arg:String @myDirective) on FIELD + + extend schema @onSchema + """, + schema: schemaWithSDLDirectives, + [] + ) + } + + func testWithMisplacedDirectives() throws { + try assertValidationErrors( + """ + type MyObj implements MyInterface @onInterface { + myField(myArg: Int @onInputFieldDefinition): String @onInputFieldDefinition + } + + scalar MyScalar @onEnum + + interface MyInterface @onObject { + myField(myArg: Int @onInputFieldDefinition): String @onInputFieldDefinition + } + + union MyUnion @onEnumValue = MyObj | Other + + enum MyEnum @onScalar { + MY_VALUE @onUnion + } + + input MyInput @onEnum { + myField: Int @onArgumentDefinition + } + + schema @onObject { + query: MyQuery + } + + extend schema @onObject + """, + schema: schemaWithSDLDirectives, + [ + GraphQLError( + message: #"Directive "@onInterface" may not be used on OBJECT."#, + locations: [.init(line: 1, column: 35)] + ), + GraphQLError( + message: #"Directive "@onInputFieldDefinition" may not be used on ARGUMENT_DEFINITION."#, + locations: [.init(line: 2, column: 22)] + ), + GraphQLError( + message: #"Directive "@onInputFieldDefinition" may not be used on FIELD_DEFINITION."#, + locations: [.init(line: 2, column: 55)] + ), + GraphQLError( + message: #"Directive "@onEnum" may not be used on SCALAR."#, + locations: [.init(line: 5, column: 17)] + ), + GraphQLError( + message: #"Directive "@onObject" may not be used on INTERFACE."#, + locations: [.init(line: 7, column: 23)] + ), + GraphQLError( + message: #"Directive "@onInputFieldDefinition" may not be used on ARGUMENT_DEFINITION."#, + locations: [.init(line: 8, column: 22)] + ), + GraphQLError( + message: #"Directive "@onInputFieldDefinition" may not be used on FIELD_DEFINITION."#, + locations: [.init(line: 8, column: 55)] + ), + GraphQLError( + message: #"Directive "@onEnumValue" may not be used on UNION."#, + locations: [.init(line: 11, column: 15)] + ), + GraphQLError( + message: #"Directive "@onScalar" may not be used on ENUM."#, + locations: [.init(line: 13, column: 13)] + ), + GraphQLError( + message: #"Directive "@onUnion" may not be used on ENUM_VALUE."#, + locations: [.init(line: 14, column: 12)] + ), + GraphQLError( + message: #"Directive "@onEnum" may not be used on INPUT_OBJECT."#, + locations: [.init(line: 17, column: 15)] + ), + GraphQLError( + message: #"Directive "@onArgumentDefinition" may not be used on INPUT_FIELD_DEFINITION."#, + locations: [.init(line: 18, column: 16)] + ), + GraphQLError( + message: #"Directive "@onObject" may not be used on SCHEMA."#, + locations: [.init(line: 21, column: 8)] + ), + GraphQLError( + message: #"Directive "@onObject" may not be used on SCHEMA."#, + locations: [.init(line: 25, column: 15)] + ), + ] + ) + } + + let schemaWithSDLDirectives = try! GraphQLSchema( + directives: { + var directives = specifiedDirectives + directives.append(contentsOf: [ + try! GraphQLDirective(name: "onSchema", locations: [.schema]), + try! GraphQLDirective(name: "onScalar", locations: [.scalar]), + try! GraphQLDirective(name: "onObject", locations: [.object]), + try! GraphQLDirective(name: "onFieldDefinition", locations: [.fieldDefinition]), + try! GraphQLDirective( + name: "onArgumentDefinition", + locations: + [.argumentDefinition] + ), + try! GraphQLDirective(name: "onInterface", locations: [.interface]), + try! GraphQLDirective(name: "onUnion", locations: [.union]), + try! GraphQLDirective(name: "onEnum", locations: [.enum]), + try! GraphQLDirective(name: "onEnumValue", locations: [.enumValue]), + try! GraphQLDirective(name: "onInputObject", locations: [.inputObject]), + try! GraphQLDirective( + name: "onInputFieldDefinition", + locations: + [.inputFieldDefinition] + ), + ]) + return directives + }() + ) } diff --git a/Tests/GraphQLTests/ValidationTests/LoneSchemaDefinitionRuleTests.swift b/Tests/GraphQLTests/ValidationTests/LoneSchemaDefinitionRuleTests.swift new file mode 100644 index 00000000..6b96947a --- /dev/null +++ b/Tests/GraphQLTests/ValidationTests/LoneSchemaDefinitionRuleTests.swift @@ -0,0 +1,160 @@ +@testable import GraphQL +import XCTest + +class LoneSchemaDefinitionRuleTests: SDLValidationTestCase { + override func setUp() { + rule = LoneSchemaDefinitionRule + } + + func testNoSchema() throws { + try assertValidationErrors( + """ + type Query { + foo: String + } + """, + [] + ) + } + + func testOneSchemaDefinition() throws { + try assertValidationErrors( + """ + schema { + query: Foo + } + + type Foo { + foo: String + } + """, + [] + ) + } + + func testMultipleSchemaDefinitions() throws { + try assertValidationErrors( + """ + schema { + query: Foo + } + + type Foo { + foo: String + } + + schema { + mutation: Foo + } + + schema { + subscription: Foo + } + """, + [ + GraphQLError( + message: "Must provide only one schema definition.", + locations: [.init(line: 9, column: 1)] + ), + GraphQLError( + message: "Must provide only one schema definition.", + locations: [.init(line: 13, column: 1)] + ), + ] + ) + } + + func testDefineSchemaInSchemaExtension() throws { + let schema = try buildSchema(source: """ + type Foo { + foo: String + } + """) + + try assertValidationErrors( + """ + schema { + query: Foo + } + """, + schema: schema, + [] + ) + } + + func testRedefineSchemaInSchemaExtension() throws { + let schema = try buildSchema(source: """ + schema { + query: Foo + } + + type Foo { + foo: String + } + """) + + try assertValidationErrors( + """ + schema { + mutation: Foo + } + """, + schema: schema, + [ + GraphQLError( + message: "Cannot define a new schema within a schema extension.", + locations: [.init(line: 1, column: 1)] + ), + ] + ) + } + + func testRedefineImplicitSchemaInSchemaExtension() throws { + let schema = try buildSchema(source: """ + type Query { + fooField: Foo + } + + type Foo { + foo: String + } + """) + + try assertValidationErrors( + """ + schema { + mutation: Foo + } + """, + schema: schema, + [ + GraphQLError( + message: "Cannot define a new schema within a schema extension.", + locations: [.init(line: 1, column: 1)] + ), + ] + ) + } + + func testExtendSchemaInSchemaExtension() throws { + let schema = try buildSchema(source: """ + type Query { + fooField: Foo + } + + type Foo { + foo: String + } + """) + + try assertValidationErrors( + """ + extend schema { + mutation: Foo + } + """, + schema: schema, + [] + ) + } +} diff --git a/Tests/GraphQLTests/ValidationTests/PossibleTypeExtensionsRuleTests.swift b/Tests/GraphQLTests/ValidationTests/PossibleTypeExtensionsRuleTests.swift new file mode 100644 index 00000000..2fa079bf --- /dev/null +++ b/Tests/GraphQLTests/ValidationTests/PossibleTypeExtensionsRuleTests.swift @@ -0,0 +1,330 @@ +@testable import GraphQL +import XCTest + +class PossibleTypeExtensionsRuleTests: SDLValidationTestCase { + override func setUp() { + rule = PossibleTypeExtensionsRule + } + + func testNoExtensions() throws { + try assertValidationErrors( + """ + scalar FooScalar + type FooObject + interface FooInterface + union FooUnion + enum FooEnum + input FooInputObject + """, + [] + ) + } + + func testOneExtensionPerType() throws { + try assertValidationErrors( + """ + scalar FooScalar + type FooObject + interface FooInterface + union FooUnion + enum FooEnum + input FooInputObject + + extend scalar FooScalar @dummy + extend type FooObject @dummy + extend interface FooInterface @dummy + extend union FooUnion @dummy + extend enum FooEnum @dummy + extend input FooInputObject @dummy + """, + [] + ) + } + + func testManyExtensionsPerType() throws { + try assertValidationErrors( + """ + scalar FooScalar + type FooObject + interface FooInterface + union FooUnion + enum FooEnum + input FooInputObject + + extend scalar FooScalar @dummy + extend type FooObject @dummy + extend interface FooInterface @dummy + extend union FooUnion @dummy + extend enum FooEnum @dummy + extend input FooInputObject @dummy + + extend scalar FooScalar @dummy + extend type FooObject @dummy + extend interface FooInterface @dummy + extend union FooUnion @dummy + extend enum FooEnum @dummy + extend input FooInputObject @dummy + """, + [] + ) + } + + func testExtendingUnknownType() throws { + try assertValidationErrors( + """ + type Known + + extend scalar Unknown @dummy + extend type Unknown @dummy + extend interface Unknown @dummy + extend union Unknown @dummy + extend enum Unknown @dummy + extend input Unknown @dummy + """, + [ + GraphQLError( + message: #"Cannot extend type "Unknown" because it is not defined. Did you mean "Known"?"#, + locations: [.init(line: 3, column: 15)] + ), + GraphQLError( + message: #"Cannot extend type "Unknown" because it is not defined. Did you mean "Known"?"#, + locations: [.init(line: 4, column: 13)] + ), + GraphQLError( + message: #"Cannot extend type "Unknown" because it is not defined. Did you mean "Known"?"#, + locations: [.init(line: 5, column: 18)] + ), + GraphQLError( + message: #"Cannot extend type "Unknown" because it is not defined. Did you mean "Known"?"#, + locations: [.init(line: 6, column: 14)] + ), + GraphQLError( + message: #"Cannot extend type "Unknown" because it is not defined. Did you mean "Known"?"#, + locations: [.init(line: 7, column: 13)] + ), + GraphQLError( + message: #"Cannot extend type "Unknown" because it is not defined. Did you mean "Known"?"#, + locations: [.init(line: 8, column: 14)] + ), + ] + ) + } + + func testDoesNotConsiderNonTypeDefinitions() throws { + try assertValidationErrors( + """ + query Foo { __typename } + fragment Foo on Query { __typename } + directive @Foo on SCHEMA + + extend scalar Foo @dummy + extend type Foo @dummy + extend interface Foo @dummy + extend union Foo @dummy + extend enum Foo @dummy + extend input Foo @dummy + """, + [ + GraphQLError( + message: #"Cannot extend type "Foo" because it is not defined."#, + locations: [.init(line: 5, column: 15)] + ), + GraphQLError( + message: #"Cannot extend type "Foo" because it is not defined."#, + locations: [.init(line: 6, column: 13)] + ), + GraphQLError( + message: #"Cannot extend type "Foo" because it is not defined."#, + locations: [.init(line: 7, column: 18)] + ), + GraphQLError( + message: #"Cannot extend type "Foo" because it is not defined."#, + locations: [.init(line: 8, column: 14)] + ), + GraphQLError( + message: #"Cannot extend type "Foo" because it is not defined."#, + locations: [.init(line: 9, column: 13)] + ), + GraphQLError( + message: #"Cannot extend type "Foo" because it is not defined."#, + locations: [.init(line: 10, column: 14)] + ), + ] + ) + } + + func testExtendingWithDifferentKinds() throws { + try assertValidationErrors( + """ + scalar FooScalar + type FooObject + interface FooInterface + union FooUnion + enum FooEnum + input FooInputObject + + extend type FooScalar @dummy + extend interface FooObject @dummy + extend union FooInterface @dummy + extend enum FooUnion @dummy + extend input FooEnum @dummy + extend scalar FooInputObject @dummy + """, + [ + GraphQLError( + message: #"Cannot extend non-object type "FooScalar"."#, + locations: [ + .init(line: 1, column: 1), + .init(line: 8, column: 1), + ] + ), + GraphQLError( + message: #"Cannot extend non-interface type "FooObject"."#, + locations: [ + .init(line: 2, column: 1), + .init(line: 9, column: 1), + ] + ), + GraphQLError( + message: #"Cannot extend non-union type "FooInterface"."#, + locations: [ + .init(line: 3, column: 1), + .init(line: 10, column: 1), + ] + ), + GraphQLError( + message: #"Cannot extend non-enum type "FooUnion"."#, + locations: [ + .init(line: 4, column: 1), + .init(line: 11, column: 1), + ] + ), + GraphQLError( + message: #"Cannot extend non-input object type "FooEnum"."#, + locations: [ + .init(line: 5, column: 1), + .init(line: 12, column: 1), + ] + ), + GraphQLError( + message: #"Cannot extend non-scalar type "FooInputObject"."#, + locations: [ + .init(line: 6, column: 1), + .init(line: 13, column: 1), + ] + ), + ] + ) + } + + func testExtendingTypesWithinExistingSchema() throws { + let schema = try buildSchema(source: """ + scalar FooScalar + type FooObject + interface FooInterface + union FooUnion + enum FooEnum + input FooInputObject + """) + let sdl = """ + extend scalar FooScalar @dummy + extend type FooObject @dummy + extend interface FooInterface @dummy + extend union FooUnion @dummy + extend enum FooEnum @dummy + extend input FooInputObject @dummy + """ + try assertValidationErrors(sdl, schema: schema, []) + } + + func testExtendingUnknownTypesWithinExistingSchema() throws { + let schema = try buildSchema(source: "type Known") + let sdl = """ + extend scalar Unknown @dummy + extend type Unknown @dummy + extend interface Unknown @dummy + extend union Unknown @dummy + extend enum Unknown @dummy + extend input Unknown @dummy + """ + try assertValidationErrors( + sdl, + schema: schema, + [ + GraphQLError( + message: #"Cannot extend type "Unknown" because it is not defined. Did you mean "Known"?"#, + locations: [.init(line: 1, column: 15)] + ), + GraphQLError( + message: #"Cannot extend type "Unknown" because it is not defined. Did you mean "Known"?"#, + locations: [.init(line: 2, column: 13)] + ), + GraphQLError( + message: #"Cannot extend type "Unknown" because it is not defined. Did you mean "Known"?"#, + locations: [.init(line: 3, column: 18)] + ), + GraphQLError( + message: #"Cannot extend type "Unknown" because it is not defined. Did you mean "Known"?"#, + locations: [.init(line: 4, column: 14)] + ), + GraphQLError( + message: #"Cannot extend type "Unknown" because it is not defined. Did you mean "Known"?"#, + locations: [.init(line: 5, column: 13)] + ), + GraphQLError( + message: #"Cannot extend type "Unknown" because it is not defined. Did you mean "Known"?"#, + locations: [.init(line: 6, column: 14)] + ), + ] + ) + } + + func testExtendingTypesWithDifferentKindsWithinExistingSchema() throws { + let schema = try buildSchema(source: """ + scalar FooScalar + type FooObject + interface FooInterface + union FooUnion + enum FooEnum + input FooInputObject + """) + let sdl = """ + extend type FooScalar @dummy + extend interface FooObject @dummy + extend union FooInterface @dummy + extend enum FooUnion @dummy + extend input FooEnum @dummy + extend scalar FooInputObject @dummy + """ + try assertValidationErrors( + sdl, + schema: schema, + [ + GraphQLError( + message: #"Cannot extend non-object type "FooScalar"."#, + locations: [.init(line: 1, column: 1)] + ), + GraphQLError( + message: #"Cannot extend non-interface type "FooObject"."#, + locations: [.init(line: 2, column: 1)] + ), + GraphQLError( + message: #"Cannot extend non-union type "FooInterface"."#, + locations: [.init(line: 3, column: 1)] + ), + GraphQLError( + message: #"Cannot extend non-enum type "FooUnion"."#, + locations: [.init(line: 4, column: 1)] + ), + GraphQLError( + message: #"Cannot extend non-input object type "FooEnum"."#, + locations: [.init(line: 5, column: 1)] + ), + GraphQLError( + message: #"Cannot extend non-scalar type "FooInputObject"."#, + locations: [.init(line: 6, column: 1)] + ), + ] + ) + } +} diff --git a/Tests/GraphQLTests/ValidationTests/ProvidedRequiredArgumentsOnDirectivesRuleTests.swift b/Tests/GraphQLTests/ValidationTests/ProvidedRequiredArgumentsOnDirectivesRuleTests.swift new file mode 100644 index 00000000..6d8a5f67 --- /dev/null +++ b/Tests/GraphQLTests/ValidationTests/ProvidedRequiredArgumentsOnDirectivesRuleTests.swift @@ -0,0 +1,118 @@ +@testable import GraphQL +import XCTest + +class ProvidedRequiredArgumentsOnDirectivesRuleTests: SDLValidationTestCase { + override func setUp() { + rule = ProvidedRequiredArgumentsOnDirectivesRule + } + + func testMissingOptionalArgsOnDirectiveDefinedInsideSDL() throws { + try assertValidationErrors( + """ + type Query { + foo: String @test + } + + directive @test(arg1: String, arg2: String! = "") on FIELD_DEFINITION + """, + [] + ) + } + + func testMissingArgOnDirectiveDefinedInsideSDL() throws { + try assertValidationErrors( + """ + type Query { + foo: String @test + } + + directive @test(arg: String!) on FIELD_DEFINITION + """, + [ + GraphQLError( + message: #"Argument "@test(arg:)" of type "String!" is required, but it was not provided."#, + locations: [.init(line: 2, column: 15)] + ), + ] + ) + } + + func testMissingArgOnStandardDirective() throws { + try assertValidationErrors( + """ + type Query { + foo: String @include + } + """, + [ + GraphQLError( + message: #"Argument "@include(if:)" of type "Boolean!" is required, but it was not provided."#, + locations: [.init(line: 2, column: 15)] + ), + ] + ) + } + + func testMissingArgOnOveriddenStandardDirective() throws { + try assertValidationErrors( + """ + type Query { + foo: String @deprecated + } + directive @deprecated(reason: String!) on FIELD + """, + [ + GraphQLError( + message: #"Argument "@deprecated(reason:)" of type "String!" is required, but it was not provided."#, + locations: [.init(line: 2, column: 15)] + ), + ] + ) + } + + func testMissingArgOnDirectiveDefinedInSchemaExtension() throws { + let schema = try buildSchema(source: """ + type Query { + foo: String + } + """) + let sdl = """ + directive @test(arg: String!) on OBJECT + + extend type Query @test + """ + try assertValidationErrors( + sdl, + schema: schema, + [ + GraphQLError( + message: #"Argument "@test(arg:)" of type "String!" is required, but it was not provided."#, + locations: [.init(line: 3, column: 20)] + ), + ] + ) + } + + func testMissingArgOnDirectiveUsedInSchemaExtension() throws { + let schema = try buildSchema(source: """ + directive @test(arg: String!) on OBJECT + + type Query { + foo: String + } + """) + let sdl = """ + extend type Query @test + """ + try assertValidationErrors( + sdl, + schema: schema, + [ + GraphQLError( + message: #"Argument "@test(arg:)" of type "String!" is required, but it was not provided."#, + locations: [.init(line: 1, column: 19)] + ), + ] + ) + } +} diff --git a/Tests/GraphQLTests/ValidationTests/UniqueArgumentDefinitionNamesRuleTests.swift b/Tests/GraphQLTests/ValidationTests/UniqueArgumentDefinitionNamesRuleTests.swift new file mode 100644 index 00000000..b9281c29 --- /dev/null +++ b/Tests/GraphQLTests/ValidationTests/UniqueArgumentDefinitionNamesRuleTests.swift @@ -0,0 +1,171 @@ +@testable import GraphQL +import XCTest + +class UniqueArgumentDefinitionNamesRuleTests: SDLValidationTestCase { + override func setUp() { + rule = UniqueArgumentDefinitionNamesRule + } + + func testNoArgs() throws { + try assertValidationErrors( + """ + type SomeObject { + someField: String + } + + interface SomeInterface { + someField: String + } + + directive @someDirective on QUERY + """, + [] + ) + } + + func testOneArgument() throws { + try assertValidationErrors( + """ + type SomeObject { + someField(foo: String): String + } + + interface SomeInterface { + someField(foo: String): String + } + + extend type SomeObject { + anotherField(foo: String): String + } + + extend interface SomeInterface { + anotherField(foo: String): String + } + + directive @someDirective(foo: String) on QUERY + """, + [] + ) + } + + func testMultipleArguments() throws { + try assertValidationErrors( + """ + type SomeObject { + someField( + foo: String + bar: String + ): String + } + + interface SomeInterface { + someField( + foo: String + bar: String + ): String + } + + extend type SomeObject { + anotherField( + foo: String + bar: String + ): String + } + + extend interface SomeInterface { + anotherField( + foo: String + bar: String + ): String + } + + directive @someDirective( + foo: String + bar: String + ) on QUERY + """, + [] + ) + } + + func testDuplicatingArguments() throws { + try assertValidationErrors( + """ + type SomeObject { + someField( + foo: String + bar: String + foo: String + ): String + } + + interface SomeInterface { + someField( + foo: String + bar: String + foo: String + ): String + } + + extend type SomeObject { + anotherField( + foo: String + bar: String + bar: String + ): String + } + + extend interface SomeInterface { + anotherField( + bar: String + foo: String + foo: String + ): String + } + + directive @someDirective( + foo: String + bar: String + foo: String + ) on QUERY + """, + [ + GraphQLError( + message: #"Argument "SomeObject.someField(foo:)" can only be defined once."#, + locations: [ + .init(line: 3, column: 5), + .init(line: 5, column: 5), + ] + ), + GraphQLError( + message: #"Argument "SomeInterface.someField(foo:)" can only be defined once."#, + locations: [ + .init(line: 11, column: 5), + .init(line: 13, column: 5), + ] + ), + GraphQLError( + message: #"Argument "SomeObject.anotherField(bar:)" can only be defined once."#, + locations: [ + .init(line: 20, column: 5), + .init(line: 21, column: 5), + ] + ), + GraphQLError( + message: #"Argument "SomeInterface.anotherField(foo:)" can only be defined once."#, + locations: [ + .init(line: 28, column: 5), + .init(line: 29, column: 5), + ] + ), + GraphQLError( + message: #"Argument "@someDirective(foo:)" can only be defined once."#, + locations: [ + .init(line: 34, column: 3), + .init(line: 36, column: 3), + ] + ), + ] + ) + } +} diff --git a/Tests/GraphQLTests/ValidationTests/UniqueDirectiveNamesRuleTests.swift b/Tests/GraphQLTests/ValidationTests/UniqueDirectiveNamesRuleTests.swift new file mode 100644 index 00000000..81120778 --- /dev/null +++ b/Tests/GraphQLTests/ValidationTests/UniqueDirectiveNamesRuleTests.swift @@ -0,0 +1,107 @@ +@testable import GraphQL +import XCTest + +class UniqueDirectiveNamesRuleTests: SDLValidationTestCase { + override func setUp() { + rule = UniqueDirectiveNamesRule + } + + func testNoDirective() throws { + try assertValidationErrors( + """ + type Foo + """, + [] + ) + } + + func testOneDirective() throws { + try assertValidationErrors( + """ + directive @foo on SCHEMA + """, + [] + ) + } + + func testManyDirectives() throws { + try assertValidationErrors( + """ + directive @foo on SCHEMA + directive @bar on SCHEMA + directive @baz on SCHEMA + """, + [] + ) + } + + func testDirectiveAndNonDirectiveDefinitionsNamedTheSame() throws { + try assertValidationErrors( + """ + query foo { __typename } + fragment foo on foo { __typename } + type foo + + directive @foo on SCHEMA + """, + [] + ) + } + + func testDirectivesNamedTheSame() throws { + try assertValidationErrors( + """ + directive @foo on SCHEMA + + directive @foo on SCHEMA + """, + [ + GraphQLError( + message: #"There can be only one directive named "@foo"."#, + locations: [ + .init(line: 1, column: 12), + .init(line: 3, column: 12), + ] + ), + ] + ) + } + + func testAddingNewDirectiveToExistingSchema() throws { + let schema = try buildSchema(source: "directive @foo on SCHEMA") + try assertValidationErrors("directive @bar on SCHEMA", schema: schema, []) + } + + func testAddingNewDirectiveWithStandardNameToExistingSchema() throws { + let schema = try buildSchema(source: "type foo") + try assertValidationErrors( + "directive @skip on SCHEMA", + schema: schema, + [ + GraphQLError( + message: #"Directive "@skip" already exists in the schema. It cannot be redefined."#, + locations: [.init(line: 1, column: 12)] + ), + ] + ) + } + + func testAddingNewDirectiveToExistingSchemaWithSameNamedType() throws { + let schema = try buildSchema(source: "type foo") + try assertValidationErrors("directive @foo on SCHEMA", schema: schema, []) + } + + func testAddingConflictingDirectiveToExistingSchema() throws { + let schema = try buildSchema(source: "directive @foo on SCHEMA") + try assertValidationErrors( + "directive @foo on SCHEMA", + schema: schema, + [ + GraphQLError( + message: #"Directive "@foo" already exists in the schema. It cannot be redefined."#, + locations: [.init(line: 1, column: 12)] + ), + ] + ) + } +} diff --git a/Tests/GraphQLTests/ValidationTests/UniqueEnumValueNamesRuleTests.swift b/Tests/GraphQLTests/ValidationTests/UniqueEnumValueNamesRuleTests.swift new file mode 100644 index 00000000..48602a1b --- /dev/null +++ b/Tests/GraphQLTests/ValidationTests/UniqueEnumValueNamesRuleTests.swift @@ -0,0 +1,214 @@ +@testable import GraphQL +import XCTest + +class UniqueEnumValueNamesRuleTests: SDLValidationTestCase { + override func setUp() { + rule = UniqueEnumValueNamesRule + } + + func testNoValues() throws { + try assertValidationErrors( + """ + enum SomeEnum + """, + [] + ) + } + + func testOneValue() throws { + try assertValidationErrors( + """ + enum SomeEnum { + FOO + } + """, + [] + ) + } + + func testMultipleValues() throws { + try assertValidationErrors( + """ + enum SomeEnum { + FOO + BAR + } + """, + [] + ) + } + + func testDuplicateValuesInsideTheSameEnumDefinition() throws { + try assertValidationErrors( + """ + enum SomeEnum { + FOO + BAR + FOO + } + """, + [ + GraphQLError( + message: #"Enum value "SomeEnum.FOO" can only be defined once."#, + locations: [ + .init(line: 2, column: 3), + .init(line: 4, column: 3), + ] + ), + ] + ) + } + + func testExtendEnumWithNewValue() throws { + try assertValidationErrors( + """ + enum SomeEnum { + FOO + } + extend enum SomeEnum { + BAR + } + extend enum SomeEnum { + BAZ + } + """, + [] + ) + } + + func testExtendEnumWithDuplicateValue() throws { + try assertValidationErrors( + """ + extend enum SomeEnum { + FOO + } + enum SomeEnum { + FOO + } + """, + [ + GraphQLError( + message: #"Enum value "SomeEnum.FOO" can only be defined once."#, + locations: [ + .init(line: 2, column: 3), + .init(line: 5, column: 3), + ] + ), + ] + ) + } + + func testDuplicateValueInsideExtension() throws { + try assertValidationErrors( + """ + enum SomeEnum + extend enum SomeEnum { + FOO + BAR + FOO + } + """, + [ + GraphQLError( + message: #"Enum value "SomeEnum.FOO" can only be defined once."#, + locations: [ + .init(line: 3, column: 3), + .init(line: 5, column: 3), + ] + ), + ] + ) + } + + func testDuplicateValueInsideDifferentExtension() throws { + try assertValidationErrors( + """ + enum SomeEnum + extend enum SomeEnum { + FOO + } + extend enum SomeEnum { + FOO + } + """, + [ + GraphQLError( + message: #"Enum value "SomeEnum.FOO" can only be defined once."#, + locations: [ + .init(line: 3, column: 3), + .init(line: 6, column: 3), + ] + ), + ] + ) + } + + func testAddingNewValueToTheTypeInsideExistingSchema() throws { + let schema = try buildSchema(source: "enum SomeEnum") + let sdl = """ + extend enum SomeEnum { + FOO + } + """ + try assertValidationErrors(sdl, schema: schema, []) + } + + func testAddingConflictingValueToExistingSchemaTwice() throws { + let schema = try buildSchema(source: """ + enum SomeEnum { + FOO + } + """) + let sdl = """ + extend enum SomeEnum { + FOO + } + extend enum SomeEnum { + FOO + } + """ + try assertValidationErrors( + sdl, + schema: schema, + [ + GraphQLError( + message: #"Enum value "SomeEnum.FOO" already exists in the schema. It cannot also be defined in this type extension."#, + locations: [ + .init(line: 2, column: 3), + ] + ), + GraphQLError( + message: #"Enum value "SomeEnum.FOO" already exists in the schema. It cannot also be defined in this type extension."#, + locations: [ + .init(line: 5, column: 3), + ] + ), + ] + ) + } + + func testAddingEnumValuesToExistingSchemaTwice() throws { + let schema = try buildSchema(source: "enum SomeEnum") + let sdl = """ + extend enum SomeEnum { + FOO + } + extend enum SomeEnum { + FOO + } + """ + try assertValidationErrors( + sdl, + schema: schema, + [ + GraphQLError( + message: #"Enum value "SomeEnum.FOO" can only be defined once."#, + locations: [ + .init(line: 2, column: 3), + .init(line: 5, column: 3), + ] + ), + ] + ) + } +} diff --git a/Tests/GraphQLTests/ValidationTests/UniqueFieldDefinitionNamesRuleTests.swift b/Tests/GraphQLTests/ValidationTests/UniqueFieldDefinitionNamesRuleTests.swift new file mode 100644 index 00000000..7122824c --- /dev/null +++ b/Tests/GraphQLTests/ValidationTests/UniqueFieldDefinitionNamesRuleTests.swift @@ -0,0 +1,443 @@ +@testable import GraphQL +import XCTest + +class UniqueFieldDefinitionNamesRuleTests: SDLValidationTestCase { + override func setUp() { + rule = UniqueFieldDefinitionNamesRule + } + + func testNoFields() throws { + try assertValidationErrors( + """ + type SomeObject + interface SomeInterface + input SomeInputObject + """, + [] + ) + } + + func testOneField() throws { + try assertValidationErrors( + """ + type SomeObject { + foo: String + } + + interface SomeInterface { + foo: String + } + + input SomeInputObject { + foo: String + } + """, + [] + ) + } + + func testMultipleFields() throws { + try assertValidationErrors( + """ + type SomeObject { + foo: String + bar: String + } + + interface SomeInterface { + foo: String + bar: String + } + + input SomeInputObject { + foo: String + bar: String + } + """, + [] + ) + } + + func testDuplicateFieldsInsideTheSameTypeDefinition() throws { + try assertValidationErrors( + """ + type SomeObject { + foo: String + bar: String + foo: String + } + + interface SomeInterface { + foo: String + bar: String + foo: String + } + + input SomeInputObject { + foo: String + bar: String + foo: String + } + """, + [ + GraphQLError( + message: #"Field "SomeObject.foo" can only be defined once."#, + locations: [ + .init(line: 2, column: 3), + .init(line: 4, column: 3), + ] + ), + GraphQLError( + message: #"Field "SomeInterface.foo" can only be defined once."#, + locations: [ + .init(line: 8, column: 3), + .init(line: 10, column: 3), + ] + ), + GraphQLError( + message: #"Field "SomeInputObject.foo" can only be defined once."#, + locations: [ + .init(line: 14, column: 3), + .init(line: 16, column: 3), + ] + ), + ] + ) + } + + func testExtendTypeWithNewField() throws { + try assertValidationErrors( + """ + type SomeObject { + foo: String + } + extend type SomeObject { + bar: String + } + extend type SomeObject { + baz: String + } + + interface SomeInterface { + foo: String + } + extend interface SomeInterface { + bar: String + } + extend interface SomeInterface { + baz: String + } + + input SomeInputObject { + foo: String + } + extend input SomeInputObject { + bar: String + } + extend input SomeInputObject { + baz: String + } + """, + [] + ) + } + + func testExtendTypeWithDuplicateField() throws { + try assertValidationErrors( + """ + extend type SomeObject { + foo: String + } + type SomeObject { + foo: String + } + + extend interface SomeInterface { + foo: String + } + interface SomeInterface { + foo: String + } + + extend input SomeInputObject { + foo: String + } + input SomeInputObject { + foo: String + } + """, + [ + GraphQLError( + message: #"Field "SomeObject.foo" can only be defined once."#, + locations: [ + .init(line: 2, column: 3), + .init(line: 5, column: 3), + ] + ), + GraphQLError( + message: #"Field "SomeInterface.foo" can only be defined once."#, + locations: [ + .init(line: 9, column: 3), + .init(line: 12, column: 3), + ] + ), + GraphQLError( + message: #"Field "SomeInputObject.foo" can only be defined once."#, + locations: [ + .init(line: 16, column: 3), + .init(line: 19, column: 3), + ] + ), + ] + ) + } + + func testDuplicateFieldInsideExtension() throws { + try assertValidationErrors( + """ + type SomeObject + extend type SomeObject { + foo: String + bar: String + foo: String + } + + interface SomeInterface + extend interface SomeInterface { + foo: String + bar: String + foo: String + } + + input SomeInputObject + extend input SomeInputObject { + foo: String + bar: String + foo: String + } + """, + [ + GraphQLError( + message: #"Field "SomeObject.foo" can only be defined once."#, + locations: [ + .init(line: 3, column: 3), + .init(line: 5, column: 3), + ] + ), + GraphQLError( + message: #"Field "SomeInterface.foo" can only be defined once."#, + locations: [ + .init(line: 10, column: 3), + .init(line: 12, column: 3), + ] + ), + GraphQLError( + message: #"Field "SomeInputObject.foo" can only be defined once."#, + locations: [ + .init(line: 17, column: 3), + .init(line: 19, column: 3), + ] + ), + ] + ) + } + + func testDuplicateValueInsideDifferentExtension() throws { + try assertValidationErrors( + """ + type SomeObject + extend type SomeObject { + foo: String + } + extend type SomeObject { + foo: String + } + + interface SomeInterface + extend interface SomeInterface { + foo: String + } + extend interface SomeInterface { + foo: String + } + + input SomeInputObject + extend input SomeInputObject { + foo: String + } + extend input SomeInputObject { + foo: String + } + """, + [ + GraphQLError( + message: #"Field "SomeObject.foo" can only be defined once."#, + locations: [ + .init(line: 3, column: 3), + .init(line: 6, column: 3), + ] + ), + GraphQLError( + message: #"Field "SomeInterface.foo" can only be defined once."#, + locations: [ + .init(line: 11, column: 3), + .init(line: 14, column: 3), + ] + ), + GraphQLError( + message: #"Field "SomeInputObject.foo" can only be defined once."#, + locations: [ + .init(line: 19, column: 3), + .init(line: 22, column: 3), + ] + ), + ] + ) + } + + func testAddingNewFieldToTheTypeInsideExistingSchema() throws { + let schema = try buildSchema(source: """ + type SomeObject + interface SomeInterface + input SomeInputObject + """) + let sdl = """ + extend type SomeObject { + foo: String + } + + extend interface SomeInterface { + foo: String + } + + extend input SomeInputObject { + foo: String + } + """ + try assertValidationErrors(sdl, schema: schema, []) + } + + func testAddingConflictingFieldsToExistingSchemaTwice() throws { + let schema = try buildSchema(source: """ + type SomeObject { + foo: String + } + + interface SomeInterface { + foo: String + } + + input SomeInputObject { + foo: String + } + """) + let sdl = """ + extend type SomeObject { + foo: String + } + extend interface SomeInterface { + foo: String + } + extend input SomeInputObject { + foo: String + } + + extend type SomeObject { + foo: String + } + extend interface SomeInterface { + foo: String + } + extend input SomeInputObject { + foo: String + } + """ + try assertValidationErrors( + sdl, + schema: schema, + [ + GraphQLError( + message: #"Field "SomeObject.foo" already exists in the schema. It cannot also be defined in this type extension."#, + locations: [.init(line: 2, column: 3)] + ), + GraphQLError( + message: #"Field "SomeInterface.foo" already exists in the schema. It cannot also be defined in this type extension."#, + locations: [.init(line: 5, column: 3)] + ), + GraphQLError( + message: #"Field "SomeInputObject.foo" already exists in the schema. It cannot also be defined in this type extension."#, + locations: [.init(line: 8, column: 3)] + ), + GraphQLError( + message: #"Field "SomeObject.foo" already exists in the schema. It cannot also be defined in this type extension."#, + locations: [.init(line: 12, column: 3)] + ), + GraphQLError( + message: #"Field "SomeInterface.foo" already exists in the schema. It cannot also be defined in this type extension."#, + locations: [.init(line: 15, column: 3)] + ), + GraphQLError( + message: #"Field "SomeInputObject.foo" already exists in the schema. It cannot also be defined in this type extension."#, + locations: [.init(line: 18, column: 3)] + ), + ] + ) + } + + func testAddingFieldsToExistingSchemaTwice() throws { + let schema = try buildSchema(source: """ + type SomeObject + interface SomeInterface + input SomeInputObject + """) + let sdl = """ + extend type SomeObject { + foo: String + } + extend type SomeObject { + foo: String + } + + extend interface SomeInterface { + foo: String + } + extend interface SomeInterface { + foo: String + } + + extend input SomeInputObject { + foo: String + } + extend input SomeInputObject { + foo: String + } + """ + try assertValidationErrors( + sdl, + schema: schema, + [ + GraphQLError( + message: #"Field "SomeObject.foo" can only be defined once."#, + locations: [ + .init(line: 2, column: 3), + .init(line: 5, column: 3), + ] + ), + GraphQLError( + message: #"Field "SomeInterface.foo" can only be defined once."#, + locations: [ + .init(line: 9, column: 3), + .init(line: 12, column: 3), + ] + ), + GraphQLError( + message: #"Field "SomeInputObject.foo" can only be defined once."#, + locations: [ + .init(line: 16, column: 3), + .init(line: 19, column: 3), + ] + ), + ] + ) + } +} diff --git a/Tests/GraphQLTests/ValidationTests/UniqueOperationTypesRuleTests.swift b/Tests/GraphQLTests/ValidationTests/UniqueOperationTypesRuleTests.swift new file mode 100644 index 00000000..03748d93 --- /dev/null +++ b/Tests/GraphQLTests/ValidationTests/UniqueOperationTypesRuleTests.swift @@ -0,0 +1,337 @@ +@testable import GraphQL +import XCTest + +class UniqueOperationTypesRuleTests: SDLValidationTestCase { + override func setUp() { + rule = UniqueOperationTypesRule + } + + func testNoSchemaDefinition() throws { + try assertValidationErrors( + """ + type Foo + """, + [] + ) + } + + func testSchemaDefinitionWithAllTypes() throws { + try assertValidationErrors( + """ + type Foo + + schema { + query: Foo + mutation: Foo + subscription: Foo + } + """, + [] + ) + } + + func testSchemaDefinitionWithSingleExtension() throws { + try assertValidationErrors( + """ + type Foo + + schema { query: Foo } + + extend schema { + mutation: Foo + subscription: Foo + } + """, + [] + ) + } + + func testSchemaDefinitionWithSeparateExtensions() throws { + try assertValidationErrors( + """ + type Foo + + schema { query: Foo } + extend schema { mutation: Foo } + extend schema { subscription: Foo } + """, + [] + ) + } + + func testExtendSchemaBeforeDefinition() throws { + try assertValidationErrors( + """ + type Foo + + extend schema { mutation: Foo } + extend schema { subscription: Foo } + + schema { query: Foo } + """, + [] + ) + } + + func testDuplicateOperationTypesInsideSingleSchemaDefinition() throws { + try assertValidationErrors( + """ + type Foo + + schema { + query: Foo + mutation: Foo + subscription: Foo + + query: Foo + mutation: Foo + subscription: Foo + } + """, + [ + GraphQLError( + message: "There can be only one query type in schema.", + locations: [ + .init(line: 4, column: 3), + .init(line: 8, column: 3), + ] + ), + GraphQLError( + message: "There can be only one mutation type in schema.", + locations: [ + .init(line: 5, column: 3), + .init(line: 9, column: 3), + ] + ), + GraphQLError( + message: "There can be only one subscription type in schema.", + locations: [ + .init(line: 6, column: 3), + .init(line: 10, column: 3), + ] + ), + ] + ) + } + + func testDuplicateOperationTypesInsideSingleSchemaDefinitionTwice() throws { + try assertValidationErrors( + """ + type Foo + + schema { + query: Foo + mutation: Foo + subscription: Foo + } + + extend schema { + query: Foo + mutation: Foo + subscription: Foo + } + + extend schema { + query: Foo + mutation: Foo + subscription: Foo + } + """, + [ + GraphQLError( + message: "There can be only one query type in schema.", + locations: [ + .init(line: 4, column: 3), + .init(line: 10, column: 3), + ] + ), + GraphQLError( + message: "There can be only one mutation type in schema.", + locations: [ + .init(line: 5, column: 3), + .init(line: 11, column: 3), + ] + ), + GraphQLError( + message: "There can be only one subscription type in schema.", + locations: [ + .init(line: 6, column: 3), + .init(line: 12, column: 3), + ] + ), + GraphQLError( + message: "There can be only one query type in schema.", + locations: [ + .init(line: 4, column: 3), + .init(line: 16, column: 3), + ] + ), + GraphQLError( + message: "There can be only one mutation type in schema.", + locations: [ + .init(line: 5, column: 3), + .init(line: 17, column: 3), + ] + ), + GraphQLError( + message: "There can be only one subscription type in schema.", + locations: [ + .init(line: 6, column: 3), + .init(line: 18, column: 3), + ] + ), + ] + ) + } + + func testDuplicateOperationTypesInsideSecondSchemaExtension() throws { + try assertValidationErrors( + """ + type Foo + + schema { + query: Foo + } + + extend schema { + mutation: Foo + subscription: Foo + } + + extend schema { + query: Foo + mutation: Foo + subscription: Foo + } + """, + [ + GraphQLError( + message: "There can be only one query type in schema.", + locations: [ + .init(line: 4, column: 3), + .init(line: 13, column: 3), + ] + ), + GraphQLError( + message: "There can be only one mutation type in schema.", + locations: [ + .init(line: 8, column: 3), + .init(line: 14, column: 3), + ] + ), + GraphQLError( + message: "There can be only one subscription type in schema.", + locations: [ + .init(line: 9, column: 3), + .init(line: 15, column: 3), + ] + ), + ] + ) + } + + func testDefineAndExtendSchemaInsideExtensionSDL() throws { + let schema = try buildSchema(source: "type Foo") + let sdl = """ + schema { query: Foo } + extend schema { mutation: Foo } + extend schema { subscription: Foo } + """ + try assertValidationErrors(sdl, schema: schema, []) + } + + func testAddingNewOperationTypesToExistingSchema() throws { + let schema = try buildSchema(source: "type Query") + let sdl = """ + extend schema { mutation: Foo } + extend schema { subscription: Foo } + """ + try assertValidationErrors(sdl, schema: schema, []) + } + + func testAddingConflictingOperationTypesToExistingSchema() throws { + let schema = try buildSchema(source: """ + type Query + type Mutation + type Subscription + + type Foo + """) + let sdl = """ + extend schema { + query: Foo + mutation: Foo + subscription: Foo + } + """ + try assertValidationErrors( + sdl, + schema: schema, + [ + GraphQLError( + message: "Type for query already defined in the schema. It cannot be redefined.", + locations: [.init(line: 2, column: 3)] + ), + GraphQLError( + message: "Type for mutation already defined in the schema. It cannot be redefined.", + locations: [.init(line: 3, column: 3)] + ), + GraphQLError( + message: "Type for subscription already defined in the schema. It cannot be redefined.", + locations: [.init(line: 4, column: 3)] + ), + ] + ) + } + + func testAddingConflictingOperationTypesToExistingSchemaTwice() throws { + let schema = try buildSchema(source: """ + type Query + type Mutation + type Subscription + """) + let sdl = """ + extend schema { + query: Foo + mutation: Foo + subscription: Foo + } + + extend schema { + query: Foo + mutation: Foo + subscription: Foo + } + """ + try assertValidationErrors( + sdl, + schema: schema, + [ + GraphQLError( + message: "Type for query already defined in the schema. It cannot be redefined.", + locations: [.init(line: 2, column: 3)] + ), + GraphQLError( + message: "Type for mutation already defined in the schema. It cannot be redefined.", + locations: [.init(line: 3, column: 3)] + ), + GraphQLError( + message: "Type for subscription already defined in the schema. It cannot be redefined.", + locations: [.init(line: 4, column: 3)] + ), + GraphQLError( + message: "Type for query already defined in the schema. It cannot be redefined.", + locations: [.init(line: 8, column: 3)] + ), + GraphQLError( + message: "Type for mutation already defined in the schema. It cannot be redefined.", + locations: [.init(line: 9, column: 3)] + ), + GraphQLError( + message: "Type for subscription already defined in the schema. It cannot be redefined.", + locations: [.init(line: 10, column: 3)] + ), + ] + ) + } +} diff --git a/Tests/GraphQLTests/ValidationTests/UniqueTypeNamesRuleTests.swift b/Tests/GraphQLTests/ValidationTests/UniqueTypeNamesRuleTests.swift new file mode 100644 index 00000000..809455f8 --- /dev/null +++ b/Tests/GraphQLTests/ValidationTests/UniqueTypeNamesRuleTests.swift @@ -0,0 +1,161 @@ +@testable import GraphQL +import XCTest + +class UniqueTypeNamesRuleTests: SDLValidationTestCase { + override func setUp() { + rule = UniqueTypeNamesRule + } + + func testNoTypes() throws { + try assertValidationErrors( + """ + directive @test on SCHEMA + """, + [] + ) + } + + func testOneType() throws { + try assertValidationErrors( + """ + type Foo + """, + [] + ) + } + + func testManyTypes() throws { + try assertValidationErrors( + """ + type Foo + type Bar + type Baz + """, + [] + ) + } + + func testTypeAndNonTypeDefinitionsNamedTheSame() throws { + try assertValidationErrors( + """ + query Foo { __typename } + fragment Foo on Query { __typename } + directive @Foo on SCHEMA + + type Foo + """, + [] + ) + } + + func testTypesNamedTheSame() throws { + try assertValidationErrors( + """ + type Foo + + scalar Foo + type Foo + interface Foo + union Foo + enum Foo + input Foo + """, + [ + GraphQLError( + message: #"There can be only one type named "Foo"."#, + locations: [ + .init(line: 1, column: 6), + .init(line: 3, column: 8), + ] + ), + GraphQLError( + message: #"There can be only one type named "Foo"."#, + locations: [ + .init(line: 1, column: 6), + .init(line: 4, column: 6), + ] + ), + GraphQLError( + message: #"There can be only one type named "Foo"."#, + locations: [ + .init(line: 1, column: 6), + .init(line: 5, column: 11), + ] + ), + GraphQLError( + message: #"There can be only one type named "Foo"."#, + locations: [ + .init(line: 1, column: 6), + .init(line: 6, column: 7), + ] + ), + GraphQLError( + message: #"There can be only one type named "Foo"."#, + locations: [ + .init(line: 1, column: 6), + .init(line: 7, column: 6), + ] + ), + GraphQLError( + message: #"There can be only one type named "Foo"."#, + locations: [ + .init(line: 1, column: 6), + .init(line: 8, column: 7), + ] + ), + ] + ) + } + + func testAddingNewTypeToExistingSchema() throws { + let schema = try buildSchema(source: "type Foo") + try assertValidationErrors("type Bar", schema: schema, []) + } + + func testAddingNewTypeToExistingSchemaWithSameNamedDirective() throws { + let schema = try buildSchema(source: "directive @Foo on SCHEMA") + try assertValidationErrors("type Foo", schema: schema, []) + } + + func testAddingConflictingTypesToExistingSchema() throws { + let schema = try buildSchema(source: "type Foo") + let sdl = """ + scalar Foo + type Foo + interface Foo + union Foo + enum Foo + input Foo + """ + try assertValidationErrors( + sdl, + schema: schema, + [ + GraphQLError( + message: #"Type "Foo" already exists in the schema. It cannot also be defined in this type definition."#, + locations: [.init(line: 1, column: 8)] + ), + GraphQLError( + message: #"Type "Foo" already exists in the schema. It cannot also be defined in this type definition."#, + locations: [.init(line: 2, column: 6)] + ), + GraphQLError( + message: #"Type "Foo" already exists in the schema. It cannot also be defined in this type definition."#, + locations: [.init(line: 3, column: 11)] + ), + GraphQLError( + message: #"Type "Foo" already exists in the schema. It cannot also be defined in this type definition."#, + locations: [.init(line: 4, column: 7)] + ), + GraphQLError( + message: #"Type "Foo" already exists in the schema. It cannot also be defined in this type definition."#, + locations: [.init(line: 5, column: 6)] + ), + GraphQLError( + message: #"Type "Foo" already exists in the schema. It cannot also be defined in this type definition."#, + locations: [.init(line: 6, column: 7)] + ), + ] + ) + } +} diff --git a/Tests/GraphQLTests/ValidationTests/ValidationTests.swift b/Tests/GraphQLTests/ValidationTests/ValidationTests.swift index adb37eaf..91f26c6f 100644 --- a/Tests/GraphQLTests/ValidationTests/ValidationTests.swift +++ b/Tests/GraphQLTests/ValidationTests/ValidationTests.swift @@ -129,3 +129,30 @@ class ValidationTestCase: XCTestCase { XCTAssertEqual(errorPath, path, "Unexpected error path", file: testFile, line: testLine) } } + +class SDLValidationTestCase: XCTestCase { + typealias Rule = (SDLValidationContext) -> Visitor + + var rule: Rule! + + func assertValidationErrors( + _ sdlStr: String, + schema: GraphQLSchema? = nil, + _ errors: [GraphQLError], + testFile _: StaticString = #file, + testLine _: UInt = #line + ) throws { + let doc = try parse(source: sdlStr) + let validationErrors = validateSDL(documentAST: doc, schemaToExtend: schema, rules: [rule]) + + XCTAssertEqual( + validationErrors.map(\.message), + errors.map(\.message) + ) + + XCTAssertEqual( + validationErrors.map(\.locations), + errors.map(\.locations) + ) + } +}