From 2dccc6a56a4ec935f7349df07f8d584d0208bc62 Mon Sep 17 00:00:00 2001 From: Dennis Parussini Date: Sun, 12 Oct 2025 01:00:54 +0200 Subject: [PATCH 1/5] Update all dependencies and refactor to async/await --- Package.resolved | 171 ++++++++++++------ Package.swift | 17 +- .../GraphQLKit/GraphQLError+Debuggabe.swift | 2 +- Sources/GraphQLKit/Graphiti+Fluent.swift | 77 ++++---- Sources/GraphQLKit/Graphiti+Request.swift | 59 +++--- Sources/GraphQLKit/Graphiti+Router.swift | 51 +++--- 6 files changed, 226 insertions(+), 151 deletions(-) diff --git a/Package.resolved b/Package.resolved index 5736895..d098230 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/swift-server/async-http-client.git", "state": { "branch": null, - "revision": "a22083713ee90808d527d0baa290c2fb13ca3096", - "version": "1.21.1" + "revision": "8430dd49d4e2b417f472141805c9691ec2923cb8", + "version": "1.29.0" } }, { @@ -15,8 +15,8 @@ "repositoryURL": "https://github.com/vapor/async-kit.git", "state": { "branch": null, - "revision": "7ece208cd401687641c88367a00e3ea2b04311f1", - "version": "1.19.0" + "revision": "6f3615ccf2ac3c2ae0c8087d527546e9544a43dd", + "version": "1.21.0" } }, { @@ -24,8 +24,8 @@ "repositoryURL": "https://github.com/vapor/console-kit.git", "state": { "branch": null, - "revision": "9f7932f22ab6f64aafadc14491e694179b7d0f6f", - "version": "4.14.3" + "revision": "742f624a998cba2a9e653d9b1e91ad3f3a5dff6b", + "version": "4.15.2" } }, { @@ -33,8 +33,8 @@ "repositoryURL": "https://github.com/vapor/fluent.git", "state": { "branch": null, - "revision": "dfcbeba27a576c20ff181d496f21ecd45d2c1a71", - "version": "4.11.0" + "revision": "2fe9e36daf4bdb5edcf193e0d0806ba2074d2864", + "version": "4.13.0" } }, { @@ -42,8 +42,8 @@ "repositoryURL": "https://github.com/vapor/fluent-kit.git", "state": { "branch": null, - "revision": "d69efce21242ad4dba6935cc1b8d5637281604d5", - "version": "1.48.5" + "revision": "8baacd7e8f7ebf68886c496b43bbe6cdcc5b57e0", + "version": "1.52.2" } }, { @@ -51,8 +51,8 @@ "repositoryURL": "https://github.com/GraphQLSwift/Graphiti.git", "state": { "branch": null, - "revision": "42623e2f7c717c54dfe2f9b595d87437aafbf9a3", - "version": "1.14.0" + "revision": "a23a3d232df202fc158ad2d698926325b470523c", + "version": "3.0.0" } }, { @@ -60,8 +60,8 @@ "repositoryURL": "https://github.com/GraphQLSwift/GraphQL.git", "state": { "branch": null, - "revision": "3cf2dbce764e7ccff8447d0b7d4634c0438449d3", - "version": "2.9.2" + "revision": "0fe18bc0bbbc9ab8929c285f419adea7c8fc7da2", + "version": "4.0.1" } }, { @@ -69,8 +69,8 @@ "repositoryURL": "https://github.com/vapor/multipart-kit.git", "state": { "branch": null, - "revision": "a31236f24bfd2ea2f520a74575881f6731d7ae68", - "version": "4.7.0" + "revision": "3498e60218e6003894ff95192d756e238c01f44e", + "version": "4.7.1" } }, { @@ -78,8 +78,8 @@ "repositoryURL": "https://github.com/vapor/routing-kit.git", "state": { "branch": null, - "revision": "8c9a227476555c55837e569be71944e02a056b72", - "version": "4.9.1" + "revision": "93f7222c8e195cbad39fafb5a0e4cc85a8def7ea", + "version": "4.9.2" } }, { @@ -87,8 +87,8 @@ "repositoryURL": "https://github.com/vapor/sql-kit.git", "state": { "branch": null, - "revision": "25d8170c31173c7db4ddfef473e257c3bde60783", - "version": "3.30.0" + "revision": "1a9ab0523fb742d9629558cede64290165c4285b", + "version": "3.33.2" } }, { @@ -96,8 +96,26 @@ "repositoryURL": "https://github.com/apple/swift-algorithms.git", "state": { "branch": null, - "revision": "f6919dfc309e7f1b56224378b11e28bab5bccc42", - "version": "1.2.0" + "revision": "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version": "1.2.1" + } + }, + { + "package": "swift-asn1", + "repositoryURL": "https://github.com/apple/swift-asn1.git", + "state": { + "branch": null, + "revision": "f70225981241859eb4aa1a18a75531d26637c8cc", + "version": "1.4.0" + } + }, + { + "package": "swift-async-algorithms", + "repositoryURL": "https://github.com/apple/swift-async-algorithms.git", + "state": { + "branch": null, + "revision": "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", + "version": "1.0.4" } }, { @@ -105,8 +123,17 @@ "repositoryURL": "https://github.com/apple/swift-atomics.git", "state": { "branch": null, - "revision": "cd142fd2f64be2100422d658e7411e39489da985", - "version": "1.2.0" + "revision": "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version": "1.3.0" + } + }, + { + "package": "swift-certificates", + "repositoryURL": "https://github.com/apple/swift-certificates.git", + "state": { + "branch": null, + "revision": "4b092f15164144c24554e0a75e080a960c5190a6", + "version": "1.14.0" } }, { @@ -114,8 +141,8 @@ "repositoryURL": "https://github.com/apple/swift-collections.git", "state": { "branch": null, - "revision": "94cf62b3ba8d4bed62680a282d4c25f9c63c2efb", - "version": "1.1.0" + "revision": "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version": "1.3.0" } }, { @@ -123,17 +150,35 @@ "repositoryURL": "https://github.com/apple/swift-crypto.git", "state": { "branch": null, - "revision": "bc1c29221f6dfeb0ebbfbc98eb95cd3d4967868e", - "version": "3.4.0" + "revision": "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version": "3.15.1" + } + }, + { + "package": "swift-distributed-tracing", + "repositoryURL": "https://github.com/apple/swift-distributed-tracing.git", + "state": { + "branch": null, + "revision": "baa932c1336f7894145cbaafcd34ce2dd0b77c97", + "version": "1.3.1" + } + }, + { + "package": "swift-http-structured-headers", + "repositoryURL": "https://github.com/apple/swift-http-structured-headers.git", + "state": { + "branch": null, + "revision": "1625f271afb04375bf48737a5572613248d0e7a0", + "version": "1.4.0" } }, { "package": "swift-http-types", - "repositoryURL": "https://github.com/apple/swift-http-types", + "repositoryURL": "https://github.com/apple/swift-http-types.git", "state": { "branch": null, - "revision": "9bee2fdb79cc740081abd8ebd80738063d632286", - "version": "1.1.0" + "revision": "a0a57e949a8903563aba4615869310c0ebf14c03", + "version": "1.4.0" } }, { @@ -141,8 +186,8 @@ "repositoryURL": "https://github.com/apple/swift-log.git", "state": { "branch": null, - "revision": "e97a6fcb1ab07462881ac165fdbb37f067e205d5", - "version": "1.5.4" + "revision": "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", + "version": "1.6.4" } }, { @@ -150,8 +195,8 @@ "repositoryURL": "https://github.com/apple/swift-metrics.git", "state": { "branch": null, - "revision": "ce594e71e92a1610015017f83f402894df540e51", - "version": "2.4.4" + "revision": "0743a9364382629da3bf5677b46a2c4b1ce5d2a6", + "version": "2.7.1" } }, { @@ -159,8 +204,8 @@ "repositoryURL": "https://github.com/apple/swift-nio.git", "state": { "branch": null, - "revision": "9428f62793696d9a0cc1f26a63f63bb31da0516d", - "version": "2.66.0" + "revision": "a18bddb0acf7a40d982b2f128ce73ce4ee31f352", + "version": "2.86.2" } }, { @@ -168,8 +213,8 @@ "repositoryURL": "https://github.com/apple/swift-nio-extras.git", "state": { "branch": null, - "revision": "a3b640d7dc567225db7c94386a6e71aded1bfa63", - "version": "1.22.0" + "revision": "a55c3dd3a81d035af8a20ce5718889c0dcab073d", + "version": "1.29.0" } }, { @@ -177,8 +222,8 @@ "repositoryURL": "https://github.com/apple/swift-nio-http2.git", "state": { "branch": null, - "revision": "8d8eb609929aee75336a0a3d2417280786265868", - "version": "1.32.0" + "revision": "5e9e99ec96c53bc2c18ddd10c1e25a3cd97c55e5", + "version": "1.38.0" } }, { @@ -186,8 +231,8 @@ "repositoryURL": "https://github.com/apple/swift-nio-ssl.git", "state": { "branch": null, - "revision": "2b09805797f21c380f7dc9bedaab3157c5508efb", - "version": "2.27.0" + "revision": "b2b043a8810ab6d51b3ff4df17f057d87ef1ec7c", + "version": "2.34.1" } }, { @@ -195,17 +240,35 @@ "repositoryURL": "https://github.com/apple/swift-nio-transport-services.git", "state": { "branch": null, - "revision": "38ac8221dd20674682148d6451367f89c2652980", - "version": "1.21.0" + "revision": "df6c28355051c72c884574a6c858bc54f7311ff9", + "version": "1.25.2" } }, { "package": "swift-numerics", - "repositoryURL": "https://github.com/apple/swift-numerics", + "repositoryURL": "https://github.com/apple/swift-numerics.git", "state": { "branch": null, - "revision": "0a5bc04095a675662cf24757cc0640aa2204253b", - "version": "1.0.2" + "revision": "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version": "1.1.1" + } + }, + { + "package": "swift-service-context", + "repositoryURL": "https://github.com/apple/swift-service-context.git", + "state": { + "branch": null, + "revision": "1983448fefc717a2bc2ebde5490fe99873c5b8a6", + "version": "1.2.1" + } + }, + { + "package": "swift-service-lifecycle", + "repositoryURL": "https://github.com/swift-server/swift-service-lifecycle.git", + "state": { + "branch": null, + "revision": "0fcc4c9c2d58dd98504c06f7308c86de775396ff", + "version": "2.9.0" } }, { @@ -213,8 +276,8 @@ "repositoryURL": "https://github.com/apple/swift-system.git", "state": { "branch": null, - "revision": "f9266c85189c2751589a50ea5aec72799797e471", - "version": "1.3.0" + "revision": "395a77f0aa927f0ff73941d7ac35f2b46d47c9db", + "version": "1.6.3" } }, { @@ -222,8 +285,8 @@ "repositoryURL": "https://github.com/vapor/vapor.git", "state": { "branch": null, - "revision": "12e9b41cc576165150cb236676fc94d997d3db5f", - "version": "4.101.1" + "revision": "773ea6a63595ae4f6bc46a366d78769d4cb8b08c", + "version": "4.117.0" } }, { @@ -231,8 +294,8 @@ "repositoryURL": "https://github.com/vapor/websocket-kit.git", "state": { "branch": null, - "revision": "4232d34efa49f633ba61afde365d3896fc7f8740", - "version": "2.15.0" + "revision": "8666c92dbbb3c8eefc8008c9c8dcf50bfd302167", + "version": "2.16.1" } } ] diff --git a/Package.swift b/Package.swift index 57a66fc..c873c35 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.2 +// swift-tools-version:6.0 import PackageDescription @@ -10,10 +10,11 @@ let package = Package( products: [ .library( name: "GraphQLKit", - targets: ["GraphQLKit"]), + targets: ["GraphQLKit"] + ), ], dependencies: [ - .package(url: "https://github.com/GraphQLSwift/Graphiti.git", from: "1.0.0"), + .package(url: "https://github.com/GraphQLSwift/Graphiti.git", from: "3.0.0"), .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"), .package(url: "https://github.com/vapor/fluent.git", from: "4.2.0"), ], @@ -22,14 +23,12 @@ let package = Package( dependencies: [ .product(name: "Vapor", package: "vapor"), .product(name: "Graphiti", package: "Graphiti"), - .product(name: "Fluent", package: "fluent") - ] - ), + .product(name: "Fluent", package: "fluent"), + ]), .testTarget(name: "GraphQLKitTests", dependencies: [ .target(name: "GraphQLKit"), - .product(name: "XCTVapor", package: "vapor") - ] - ), + .product(name: "XCTVapor", package: "vapor"), + ]), ] ) diff --git a/Sources/GraphQLKit/GraphQLError+Debuggabe.swift b/Sources/GraphQLKit/GraphQLError+Debuggabe.swift index 38ce0ba..ca39e94 100644 --- a/Sources/GraphQLKit/GraphQLError+Debuggabe.swift +++ b/Sources/GraphQLKit/GraphQLError+Debuggabe.swift @@ -1,7 +1,7 @@ import GraphQL import Vapor -extension GraphQLError: AbortError { +extension GraphQLError: @retroactive AbortError { public var status: HTTPResponseStatus { return .ok } diff --git a/Sources/GraphQLKit/Graphiti+Fluent.swift b/Sources/GraphQLKit/Graphiti+Fluent.swift index 6c0ab25..151c2ed 100644 --- a/Sources/GraphQLKit/Graphiti+Fluent.swift +++ b/Sources/GraphQLKit/Graphiti+Fluent.swift @@ -1,98 +1,93 @@ +import Fluent import Graphiti import Vapor -import Fluent // Child Relationship extension Graphiti.Field where Arguments == NoArguments, Context == Request, ObjectType: Model { - /// Creates a GraphQL field for a one-to-many relationship for Fluent /// - Parameters: /// - name: Filed name /// - keyPath: KeyPath to the @Children property - public convenience init( + convenience init( _ name: FieldKey, with keyPath: KeyPath> - ) where FieldType == [TypeReference] { - self.init(name.description, at: { (type) -> (Request, NoArguments, EventLoopGroup) throws -> EventLoopFuture<[ChildType]> in - return { (context: Request, arguments: NoArguments, eventLoop: EventLoopGroup) in - return type[keyPath: keyPath].query(on: context.db).all() // Get the desired property and make the Fluent database query on it. + ) where FieldType == [ChildType] { + self.init(name.description, at: { type -> (Request, NoArguments) async throws -> [ChildType] in + return { (context: Request, _: NoArguments) async throws in + try await type[keyPath: keyPath].query(on: context.db).all() } - }, as: [TypeReference].self) + }) } } // Parent Relationship -extension Graphiti.Field where Arguments == NoArguments, Context == Request, ObjectType: Model { - +public extension Graphiti.Field where Arguments == NoArguments, Context == Request, ObjectType: Model { /// Creates a GraphQL field for a one-to-many/one-to-one relationship for Fluent /// - Parameters: /// - name: Field name /// - keyPath: KeyPath to the @Parent property - public convenience init( + convenience init( _ name: FieldKey, - with keyPath: KeyPath> - ) where FieldType == TypeReference { - self.init(name.description, at: { (type) -> (Request, NoArguments, EventLoopGroup) throws -> EventLoopFuture in - return { (context: Request, arguments: NoArguments, eventLoop: EventLoopGroup) in - return type[keyPath: keyPath].get(on: context.db) // Get the desired property and make the Fluent database query on it. + with keyPath: KeyPath> + ) where FieldType: Model { + self.init(name.description, at: { type -> (Request, NoArguments) async throws -> FieldType in + return { (context: Request, _: NoArguments) async throws in + return try await type[keyPath: keyPath].get(on: context.db) } - }, as: TypeReference.self) + }) } } // Siblings Relationship -extension Graphiti.Field where Arguments == NoArguments, Context == Request, ObjectType: Model { - +public extension Graphiti.Field where Arguments == NoArguments, Context == Request, ObjectType: Model { /// Creates a GraphQL field for a many-to-many relationship for Fluent /// - Parameters: /// - name: Field name /// - keyPath: KeyPath to the @Siblings property - public convenience init( + convenience init( _ name: FieldKey, with keyPath: KeyPath> - ) where FieldType == [TypeReference] { - self.init(name.description, at: { (type) -> (Request, NoArguments, EventLoopGroup) throws -> EventLoopFuture<[ToType]> in - return { (context: Request, arguments: NoArguments, eventLoop: EventLoopGroup) in - return type[keyPath: keyPath].query(on: context.db).all() // Get the desired property and make the Fluent database query on it. + ) where FieldType == [ToType] { + self.init(name.description, at: { type -> (Request, NoArguments) async throws -> [ToType] in + return { (context: Request, _: NoArguments) async throws in + return try await type[keyPath: keyPath].query(on: context.db).all() } - }, as: [TypeReference].self) + }) } } // OptionalParent Relationship -extension Graphiti.Field where Arguments == NoArguments, Context == Request, ObjectType: Model { - +public extension Graphiti.Field where Arguments == NoArguments, Context == Request, ObjectType: Model { /// Creates a GraphQL field for an optional one-to-many/one-to-one relationship for Fluent /// - Parameters: /// - name: Field name /// - keyPath: KeyPath to the @OptionalParent property - public convenience init( + convenience init( _ name: FieldKey, with keyPath: KeyPath> - ) where FieldType == TypeReference? { - self.init(name.description, at: { (type) -> (Request, NoArguments, EventLoopGroup) throws -> EventLoopFuture> in - return { (context: Request, arguments: NoArguments, eventLoop: EventLoopGroup) throws -> EventLoopFuture> in - return type[keyPath: keyPath].get(on: context.db) // Get the desired property and make the Fluent database query on it. + ) where FieldType == ParentType? { + self.init(name.description, at: { type -> (Request, NoArguments) async throws -> ParentType? in + return { (context: Request, _: NoArguments) async throws -> ParentType? in + return try await type[keyPath: keyPath].get(on: context.db) } - }, as: TypeReference?.self) + }) } } // OptionalChild Relationship -extension Graphiti.Field where Arguments == NoArguments, Context == Request, ObjectType: Model { - +public extension Graphiti.Field where Arguments == NoArguments, Context == Request, ObjectType: Model { /// Creates a GraphQL field for an optional one-to-many/one-to-one relationship for Fluent /// - Parameters: /// - name: Field name /// - keyPath: KeyPath to the @OptionalParent property - public convenience init( + convenience init( _ name: FieldKey, with keyPath: KeyPath> - ) where FieldType == TypeReference? { - self.init(name.description, at: { (type) -> (Request, NoArguments, EventLoopGroup) throws -> EventLoopFuture> in - return { (context: Request, arguments: NoArguments, eventLoop: EventLoopGroup) throws -> EventLoopFuture> in - return type[keyPath: keyPath].get(on: context.db) // Get the desired property and make the Fluent database query on it. + ) where FieldType == ParentType? { + self.init(name.description, at: { type -> (Request, NoArguments) async throws -> ParentType? in + return { (context: Request, _: NoArguments) async throws -> ParentType? in + return try await type[keyPath: keyPath].get(on: context.db) } - }, as: TypeReference?.self) + }) } } diff --git a/Sources/GraphQLKit/Graphiti+Request.swift b/Sources/GraphQLKit/Graphiti+Request.swift index 84ded5c..f9af24d 100644 --- a/Sources/GraphQLKit/Graphiti+Request.swift +++ b/Sources/GraphQLKit/Graphiti+Request.swift @@ -1,38 +1,53 @@ -import Vapor import Graphiti import GraphQL +import Vapor extension Request { - func resolveByBody(graphQLSchema schema: Schema, with rootAPI: RootType) throws -> Future { - let queryRequest = try self.content - .decode(QueryRequest.self) - return self.resolve(byQueryRequest: queryRequest, graphQLSchema: schema, with: rootAPI) + func resolveByBody( + graphQLSchema schema: Schema, + with rootAPI: RootType + ) async throws -> GraphQLResult { + let queryRequest = try content.decode(QueryRequest.self) + + return try await resolve(byQueryRequest: queryRequest, graphQLSchema: schema, with: rootAPI) } - func resolveByQueryParameters(graphQLSchema schema: Schema, with rootAPI: RootType) throws -> Future { - guard let queryString = self.query[String.self, at: "query"] else { throw GraphQLError(GraphQLResolveError.noQueryFound) } - let variables = self.query[String.self, at: "variables"].flatMap { - $0.data(using: .utf8).flatMap { (data) -> [String: Map]? in - let decoder = JSONDecoder() - if #available(OSX 10.12, *) { - decoder.dateDecodingStrategy = .iso8601 - } - return try? decoder.decode([String: Map]?.self, from: data) - } + func resolveByQueryParameters( + graphQLSchema schema: Schema, + with rootAPI: RootType + ) async throws -> GraphQLResult { + guard let queryString = query[String.self, at: "query"] else { + throw GraphQLError(GraphQLResolveError.noQueryFound) } - let operationName = self.query[String.self, at: "operationName"] - let data = QueryRequest(query: queryString, operationName: operationName, variables: variables) - return resolve(byQueryRequest: data, graphQLSchema: schema, with: rootAPI) + let variables = query[String.self, at: "variables"] + let data = variables?.data(using: .utf8) + let decoder = JSONDecoder() + + if #available(macOS 10.12, *) { + decoder.dateDecodingStrategy = .iso8601 + } + + let map = try decoder.decode([String: Map]?.self, from: data!) + + let operationName = query[String.self, at: "operationName"] + + let request = QueryRequest(query: queryString, operationName: operationName, variables: map) + + return try await resolve(byQueryRequest: request, graphQLSchema: schema, with: rootAPI) } - private func resolve(byQueryRequest data: QueryRequest, graphQLSchema schema: Schema, with rootAPI: RootType) -> Future { - schema.execute( + private func resolve( + byQueryRequest data: QueryRequest, + graphQLSchema schema: Schema, + with rootAPI: RootType + ) async throws -> GraphQLResult { + try await schema.execute( request: data.query, resolver: rootAPI, context: self, - eventLoopGroup: self.eventLoop, variables: data.variables ?? [:], - operationName: data.operationName) + operationName: data.operationName + ) } } diff --git a/Sources/GraphQLKit/Graphiti+Router.swift b/Sources/GraphQLKit/Graphiti+Router.swift index b6a4007..35ae48d 100644 --- a/Sources/GraphQLKit/Graphiti+Router.swift +++ b/Sources/GraphQLKit/Graphiti+Router.swift @@ -1,20 +1,25 @@ -import Vapor import Graphiti import GraphQL +import Vapor + +extension Schema: @retroactive @unchecked Sendable {} -extension RoutesBuilder { - public func register(graphQLSchema schema: Schema, withResolver rootAPI: RootType, at path: PathComponent="graphql", postBodyStreamStrategy: HTTPBodyStreamStrategy = .collect) { - self.on(.POST, path, body: postBodyStreamStrategy) { (request) -> EventLoopFuture in - try request.resolveByBody(graphQLSchema: schema, with: rootAPI) - .flatMap({ - $0.encodeResponse(status: .ok, for: request) - }) +public extension RoutesBuilder { + func register( + graphQLSchema schema: Schema, + withResolver rootAPI: RootType, + at path: PathComponent = "graphql", + postBodyStreamStrategy: HTTPBodyStreamStrategy = .collect + ) { + on(.POST, path, body: postBodyStreamStrategy) { request async throws -> Response in + let result = try await request.resolveByBody(graphQLSchema: schema, with: rootAPI) + + return try await result.encodeResponse(status: .ok, for: request) } - self.get(path) { (request) -> EventLoopFuture in - try request.resolveByQueryParameters(graphQLSchema: schema, with: rootAPI) - .flatMap({ - $0.encodeResponse(status: .ok, for: request) - }) + get(path) { request async throws -> Response in + let result = try await request.resolveByQueryParameters(graphQLSchema: schema, with: rootAPI) + + return try await result.encodeResponse(status: .ok, for: request) } } } @@ -23,16 +28,14 @@ enum GraphQLResolveError: Swift.Error { case noQueryFound } -extension GraphQLResult: Content { - public func encodeResponse(for request: Request) -> EventLoopFuture { - return request.eventLoop.submit { - Response( - status: .ok, - headers: [ - "Content-Type": "application/json" - ], - body: .init(data: try GraphQLJSONEncoder().encode(self)) - ) - } +extension GraphQLResult: @retroactive Content { + public func encodeResponse(for _: Request) async throws -> Response { + try Response( + status: .ok, + headers: [ + "Content-Type": "application/json", + ], + body: .init(data: GraphQLJSONEncoder().encode(self)) + ) } } From 9b734e4dcb0040036b4f5a1ce958ceacc6165014 Mon Sep 17 00:00:00 2001 From: Dennis Parussini Date: Thu, 18 Dec 2025 11:15:19 +0100 Subject: [PATCH 2/5] Remove EventLoopFutures in favor of async/await. Update README to reflect update to Swift 6 and structured concurrency --- Package.resolved | 603 ++++++++++---------- Package.swift | 36 +- README.md | 41 +- Sources/GraphQLKit/Graphiti+Fluent.swift | 28 +- Sources/GraphQLKit/Graphiti+Request.swift | 35 +- Sources/GraphQLKit/Graphiti+Router.swift | 16 +- Tests/GraphQLKitTests/GraphQLKitTests.swift | 477 ++++++++-------- 7 files changed, 639 insertions(+), 597 deletions(-) diff --git a/Package.resolved b/Package.resolved index d098230..f7fc14a 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,304 +1,303 @@ { - "object": { - "pins": [ - { - "package": "async-http-client", - "repositoryURL": "https://github.com/swift-server/async-http-client.git", - "state": { - "branch": null, - "revision": "8430dd49d4e2b417f472141805c9691ec2923cb8", - "version": "1.29.0" - } - }, - { - "package": "async-kit", - "repositoryURL": "https://github.com/vapor/async-kit.git", - "state": { - "branch": null, - "revision": "6f3615ccf2ac3c2ae0c8087d527546e9544a43dd", - "version": "1.21.0" - } - }, - { - "package": "console-kit", - "repositoryURL": "https://github.com/vapor/console-kit.git", - "state": { - "branch": null, - "revision": "742f624a998cba2a9e653d9b1e91ad3f3a5dff6b", - "version": "4.15.2" - } - }, - { - "package": "fluent", - "repositoryURL": "https://github.com/vapor/fluent.git", - "state": { - "branch": null, - "revision": "2fe9e36daf4bdb5edcf193e0d0806ba2074d2864", - "version": "4.13.0" - } - }, - { - "package": "fluent-kit", - "repositoryURL": "https://github.com/vapor/fluent-kit.git", - "state": { - "branch": null, - "revision": "8baacd7e8f7ebf68886c496b43bbe6cdcc5b57e0", - "version": "1.52.2" - } - }, - { - "package": "Graphiti", - "repositoryURL": "https://github.com/GraphQLSwift/Graphiti.git", - "state": { - "branch": null, - "revision": "a23a3d232df202fc158ad2d698926325b470523c", - "version": "3.0.0" - } - }, - { - "package": "GraphQL", - "repositoryURL": "https://github.com/GraphQLSwift/GraphQL.git", - "state": { - "branch": null, - "revision": "0fe18bc0bbbc9ab8929c285f419adea7c8fc7da2", - "version": "4.0.1" - } - }, - { - "package": "multipart-kit", - "repositoryURL": "https://github.com/vapor/multipart-kit.git", - "state": { - "branch": null, - "revision": "3498e60218e6003894ff95192d756e238c01f44e", - "version": "4.7.1" - } - }, - { - "package": "routing-kit", - "repositoryURL": "https://github.com/vapor/routing-kit.git", - "state": { - "branch": null, - "revision": "93f7222c8e195cbad39fafb5a0e4cc85a8def7ea", - "version": "4.9.2" - } - }, - { - "package": "sql-kit", - "repositoryURL": "https://github.com/vapor/sql-kit.git", - "state": { - "branch": null, - "revision": "1a9ab0523fb742d9629558cede64290165c4285b", - "version": "3.33.2" - } - }, - { - "package": "swift-algorithms", - "repositoryURL": "https://github.com/apple/swift-algorithms.git", - "state": { - "branch": null, - "revision": "87e50f483c54e6efd60e885f7f5aa946cee68023", - "version": "1.2.1" - } - }, - { - "package": "swift-asn1", - "repositoryURL": "https://github.com/apple/swift-asn1.git", - "state": { - "branch": null, - "revision": "f70225981241859eb4aa1a18a75531d26637c8cc", - "version": "1.4.0" - } - }, - { - "package": "swift-async-algorithms", - "repositoryURL": "https://github.com/apple/swift-async-algorithms.git", - "state": { - "branch": null, - "revision": "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", - "version": "1.0.4" - } - }, - { - "package": "swift-atomics", - "repositoryURL": "https://github.com/apple/swift-atomics.git", - "state": { - "branch": null, - "revision": "b601256eab081c0f92f059e12818ac1d4f178ff7", - "version": "1.3.0" - } - }, - { - "package": "swift-certificates", - "repositoryURL": "https://github.com/apple/swift-certificates.git", - "state": { - "branch": null, - "revision": "4b092f15164144c24554e0a75e080a960c5190a6", - "version": "1.14.0" - } - }, - { - "package": "swift-collections", - "repositoryURL": "https://github.com/apple/swift-collections.git", - "state": { - "branch": null, - "revision": "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", - "version": "1.3.0" - } - }, - { - "package": "swift-crypto", - "repositoryURL": "https://github.com/apple/swift-crypto.git", - "state": { - "branch": null, - "revision": "95ba0316a9b733e92bb6b071255ff46263bbe7dc", - "version": "3.15.1" - } - }, - { - "package": "swift-distributed-tracing", - "repositoryURL": "https://github.com/apple/swift-distributed-tracing.git", - "state": { - "branch": null, - "revision": "baa932c1336f7894145cbaafcd34ce2dd0b77c97", - "version": "1.3.1" - } - }, - { - "package": "swift-http-structured-headers", - "repositoryURL": "https://github.com/apple/swift-http-structured-headers.git", - "state": { - "branch": null, - "revision": "1625f271afb04375bf48737a5572613248d0e7a0", - "version": "1.4.0" - } - }, - { - "package": "swift-http-types", - "repositoryURL": "https://github.com/apple/swift-http-types.git", - "state": { - "branch": null, - "revision": "a0a57e949a8903563aba4615869310c0ebf14c03", - "version": "1.4.0" - } - }, - { - "package": "swift-log", - "repositoryURL": "https://github.com/apple/swift-log.git", - "state": { - "branch": null, - "revision": "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", - "version": "1.6.4" - } - }, - { - "package": "swift-metrics", - "repositoryURL": "https://github.com/apple/swift-metrics.git", - "state": { - "branch": null, - "revision": "0743a9364382629da3bf5677b46a2c4b1ce5d2a6", - "version": "2.7.1" - } - }, - { - "package": "swift-nio", - "repositoryURL": "https://github.com/apple/swift-nio.git", - "state": { - "branch": null, - "revision": "a18bddb0acf7a40d982b2f128ce73ce4ee31f352", - "version": "2.86.2" - } - }, - { - "package": "swift-nio-extras", - "repositoryURL": "https://github.com/apple/swift-nio-extras.git", - "state": { - "branch": null, - "revision": "a55c3dd3a81d035af8a20ce5718889c0dcab073d", - "version": "1.29.0" - } - }, - { - "package": "swift-nio-http2", - "repositoryURL": "https://github.com/apple/swift-nio-http2.git", - "state": { - "branch": null, - "revision": "5e9e99ec96c53bc2c18ddd10c1e25a3cd97c55e5", - "version": "1.38.0" - } - }, - { - "package": "swift-nio-ssl", - "repositoryURL": "https://github.com/apple/swift-nio-ssl.git", - "state": { - "branch": null, - "revision": "b2b043a8810ab6d51b3ff4df17f057d87ef1ec7c", - "version": "2.34.1" - } - }, - { - "package": "swift-nio-transport-services", - "repositoryURL": "https://github.com/apple/swift-nio-transport-services.git", - "state": { - "branch": null, - "revision": "df6c28355051c72c884574a6c858bc54f7311ff9", - "version": "1.25.2" - } - }, - { - "package": "swift-numerics", - "repositoryURL": "https://github.com/apple/swift-numerics.git", - "state": { - "branch": null, - "revision": "0c0290ff6b24942dadb83a929ffaaa1481df04a2", - "version": "1.1.1" - } - }, - { - "package": "swift-service-context", - "repositoryURL": "https://github.com/apple/swift-service-context.git", - "state": { - "branch": null, - "revision": "1983448fefc717a2bc2ebde5490fe99873c5b8a6", - "version": "1.2.1" - } - }, - { - "package": "swift-service-lifecycle", - "repositoryURL": "https://github.com/swift-server/swift-service-lifecycle.git", - "state": { - "branch": null, - "revision": "0fcc4c9c2d58dd98504c06f7308c86de775396ff", - "version": "2.9.0" - } - }, - { - "package": "swift-system", - "repositoryURL": "https://github.com/apple/swift-system.git", - "state": { - "branch": null, - "revision": "395a77f0aa927f0ff73941d7ac35f2b46d47c9db", - "version": "1.6.3" - } - }, - { - "package": "vapor", - "repositoryURL": "https://github.com/vapor/vapor.git", - "state": { - "branch": null, - "revision": "773ea6a63595ae4f6bc46a366d78769d4cb8b08c", - "version": "4.117.0" - } - }, - { - "package": "websocket-kit", - "repositoryURL": "https://github.com/vapor/websocket-kit.git", - "state": { - "branch": null, - "revision": "8666c92dbbb3c8eefc8008c9c8dcf50bfd302167", - "version": "2.16.1" - } - } - ] - }, - "version": 1 + "originHash" : "cd599227a2dffec1c44991e5f36d4404f2745cb20c3e61a8e0c428953edc2891", + "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "5dd84c7bb48b348751d7bbe7ba94a17bafdcef37", + "version" : "1.30.2" + } + }, + { + "identity" : "async-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/async-kit.git", + "state" : { + "revision" : "6f3615ccf2ac3c2ae0c8087d527546e9544a43dd", + "version" : "1.21.0" + } + }, + { + "identity" : "console-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/console-kit.git", + "state" : { + "revision" : "742f624a998cba2a9e653d9b1e91ad3f3a5dff6b", + "version" : "4.15.2" + } + }, + { + "identity" : "fluent", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/fluent.git", + "state" : { + "revision" : "2fe9e36daf4bdb5edcf193e0d0806ba2074d2864", + "version" : "4.13.0" + } + }, + { + "identity" : "fluent-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/fluent-kit.git", + "state" : { + "revision" : "0272fdaf7cf6f482c2799026c0695f5fe40e3e8c", + "version" : "1.53.0" + } + }, + { + "identity" : "graphiti", + "kind" : "remoteSourceControl", + "location" : "https://github.com/GraphQLSwift/Graphiti.git", + "state" : { + "revision" : "a23a3d232df202fc158ad2d698926325b470523c", + "version" : "3.0.0" + } + }, + { + "identity" : "graphql", + "kind" : "remoteSourceControl", + "location" : "https://github.com/GraphQLSwift/GraphQL.git", + "state" : { + "revision" : "6e483aec1a8f86f7fee95b1f72e678bdf0e537a4", + "version" : "4.0.3" + } + }, + { + "identity" : "multipart-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/multipart-kit.git", + "state" : { + "revision" : "3498e60218e6003894ff95192d756e238c01f44e", + "version" : "4.7.1" + } + }, + { + "identity" : "routing-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/routing-kit.git", + "state" : { + "revision" : "1a10ccea61e4248effd23b6e814999ce7bdf0ee0", + "version" : "4.9.3" + } + }, + { + "identity" : "sql-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/sql-kit.git", + "state" : { + "revision" : "c0ea243ffeb8b5ff9e20a281e44003c6abb8896f", + "version" : "3.34.0" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "133a347911b6ad0fc8fe3bf46ca90c66cff97130", + "version" : "1.17.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095", + "version" : "4.2.0" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "baa932c1336f7894145cbaafcd34ce2dd0b77c97", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "76d7627bd88b47bf5a0f8497dd244885960dde0b", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca", + "version" : "1.8.0" + } + }, + { + "identity" : "swift-metrics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-metrics.git", + "state" : { + "revision" : "0743a9364382629da3bf5677b46a2c4b1ce5d2a6", + "version" : "2.7.1" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "a1605a3303a28e14d822dec8aaa53da8a9490461", + "version" : "2.92.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "1c90641b02b6ab47c6d0db2063a12198b04e83e2", + "version" : "1.31.2" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "c2ba4cfbb83f307c66f5a6df6bb43e3c88dfbf80", + "version" : "1.39.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "173cc69a058623525a58ae6710e2f5727c663793", + "version" : "2.36.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "60c3e187154421171721c1a38e800b390680fb5d", + "version" : "1.26.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "1983448fefc717a2bc2ebde5490fe99873c5b8a6", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348", + "version" : "2.9.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db", + "version" : "1.6.3" + } + }, + { + "identity" : "vapor", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/vapor.git", + "state" : { + "revision" : "f7090db27390ebc4cadbff06d76fe8ce79d6ece6", + "version" : "4.120.0" + } + }, + { + "identity" : "websocket-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/websocket-kit.git", + "state" : { + "revision" : "8666c92dbbb3c8eefc8008c9c8dcf50bfd302167", + "version" : "2.16.1" + } + } + ], + "version" : 3 } diff --git a/Package.swift b/Package.swift index c873c35..c9f13fe 100644 --- a/Package.swift +++ b/Package.swift @@ -19,16 +19,30 @@ let package = Package( .package(url: "https://github.com/vapor/fluent.git", from: "4.2.0"), ], targets: [ - .target(name: "GraphQLKit", - dependencies: [ - .product(name: "Vapor", package: "vapor"), - .product(name: "Graphiti", package: "Graphiti"), - .product(name: "Fluent", package: "fluent"), - ]), - .testTarget(name: "GraphQLKitTests", - dependencies: [ - .target(name: "GraphQLKit"), - .product(name: "XCTVapor", package: "vapor"), - ]), + .target( + name: "GraphQLKit", + dependencies: [ + .product(name: "Vapor", package: "vapor"), + .product(name: "Graphiti", package: "Graphiti"), + .product(name: "Fluent", package: "fluent"), + ] + ), + .testTarget( + name: "GraphQLKitTests", + dependencies: [ + .target(name: "GraphQLKit"), + .product(name: "VaporTesting", package: "vapor"), + ] + ), ] ) + +let swiftSettings: [SwiftSetting] = [ + .enableUpcomingFeature("NonIsolatedNonSendingByDefault"), +] + +for target in package.targets { + var settings = target.swiftSettings ?? [] + settings.append(contentsOf: swiftSettings) + target.swiftSettings = settings +} diff --git a/README.md b/README.md index aa170af..e15a4d0 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # GraphQLKit -[![Language](https://img.shields.io/badge/Swift-5.2-brightgreen.svg)](http://swift.org) + +[![Language](https://img.shields.io/badge/Swift-6.0-brightgreen.svg)](http://swift.org) [![Vapor Version](https://img.shields.io/badge/Vapor-4-F6CBCA.svg)](http://vapor.codes) [![build](https://github.com/alexsteinerde/graphql-kit/workflows/build/badge.svg)](https://github.com/alexsteinerde/graphql-kit/actions) - Easy setup of a GraphQL server with Vapor. It uses the GraphQL implementation of [Graphiti](https://github.com/GraphQLSwift/Graphiti). ## Features + - [x] Arguments, operation name and query support - [x] Normal access to the `Request` object as in normal Vapor request handlers - [x] Accept JSON in the body of a POST request as the GraphQL query @@ -16,6 +17,7 @@ Easy setup of a GraphQL server with Vapor. It uses the GraphQL implementation of - [ ] Multi-Resolver support ## Installation + ```Swift import PackageDescription @@ -31,28 +33,27 @@ let package = Package( ``` ## Getting Started + ### Define your schema + This package is setup to accept only `Request` objects as the context object for the schema. This gives the opportunity to access all functionality that Vapor provides, for example authentication, service management and database access. To see an example implementation please have a look at the [`vapor-graphql-template`](https://github.com/alexsteinerde/vapor-graphql-template) repository. This package only provides the needed functions to register an existing GraphQL schema on a Vapor application. To define your schema please refer to the [Graphiti](https://github.com/GraphQLSwift/Graphiti) documentations. But by including this package some other helper functions are exposed: #### Async Resolver -An `EventLoopGroup` parameter is no longer required for async resolvers as the `Request` context object already provides access to it's `EventLoopGroup` attribute `eventLoop`. -```Swift -// Instead of adding an unnecessary parameter -func getAllTodos(store: Request, arguments: NoArguments, _: EventLoopGroup) throws -> EventLoopFuture<[Todo]> { - Todo.query(on: store).all() -} +Asynchronously resolve any requests using async/await -// You don't need to provide the eventLoopGroup parameter even when resolving a future. -func getAllTodos(store: Request, arguments: NoArguments) throws -> EventLoopFuture<[Todo]> { - Todo.query(on: store).all() +```Swift +func getAllTodos(store: Request, arguments: NoArguments) async throws -> [Todo] { + try await Todo.query(on: store).all() } ``` #### Enums -It automatically resolves all cases of an enum if the type conforms to `CaseIterable`. + +It automatically resolves all cases of an enum if the type conforms to `CaseIterable`. + ```swift enum TodoState: String, Codable, CaseIterable { case open @@ -63,7 +64,8 @@ enum TodoState: String, Codable, CaseIterable { Enum(TodoState.self), ``` -#### `Parent`, `Children` and `Siblings` +#### `Parent`, `Children` and `Siblings` + Vapor has the functionality to fetch an objects parent, children or siblings automatically with `@Parent`, `@Children` and `@Siblings` types. To integrate this into GraphQL, GraphQLKit provides extensions to the `Field` type that lets you use the parent, children or siblings property as a keypath. Fetching of those related objects is then done automatically. > :warning: Loading related objects in GraphQL has the [**N+1** problem](https://itnext.io/what-is-the-n-1-problem-in-graphql-dd4921cb3c1a). A solution would be to build a DataLoader package for Swift. But this hasn't been done yet. @@ -71,22 +73,22 @@ Vapor has the functionality to fetch an objects parent, children or siblings aut ```swift final class User: Model { ... - + @Children(for: \.$user) var todos: [Todo] - + ... } final class Todo: Model { ... - + @Parent(key: "user_id") var user: User - + @Siblings(through: TodoTag.self, from: \.$todo, to: \.$tag) public var tags: [Tag] - + ... } ``` @@ -104,6 +106,7 @@ Type(Todo.self) { ``` ### Register the schema on the application + In your `configure.swift` file call the `register(graphQLSchema: Schema, withResolver: YourResolver)` on your `Application` instance. By default this registers the GET and POST endpoints at `/graphql`. But you can also pass the optional parameter `at:` and override the default value. ```Swift @@ -112,7 +115,9 @@ app.register(graphQLSchema: todoSchema, withResolver: TodoAPI()) ``` ## License + This project is released under the MIT license. See [LICENSE](LICENSE) for details. ## Contribution + You can contribute to this project by submitting a detailed issue or by forking this project and sending a pull request. Contributions of any kind are very welcome :) diff --git a/Sources/GraphQLKit/Graphiti+Fluent.swift b/Sources/GraphQLKit/Graphiti+Fluent.swift index 151c2ed..aab8bef 100644 --- a/Sources/GraphQLKit/Graphiti+Fluent.swift +++ b/Sources/GraphQLKit/Graphiti+Fluent.swift @@ -12,11 +12,11 @@ extension Graphiti.Field where Arguments == NoArguments, Context == Request, Obj _ name: FieldKey, with keyPath: KeyPath> ) where FieldType == [ChildType] { - self.init(name.description, at: { type -> (Request, NoArguments) async throws -> [ChildType] in + self.init(name.description) { type -> (Request, NoArguments) async throws -> [ChildType] in return { (context: Request, _: NoArguments) async throws in - try await type[keyPath: keyPath].query(on: context.db).all() + try await type[keyPath: keyPath].query(on: context.db).all() // Get the desired property and make the Fluent database query on it } - }) + } } } @@ -30,11 +30,11 @@ public extension Graphiti.Field where Arguments == NoArguments, Context == Reque _ name: FieldKey, with keyPath: KeyPath> ) where FieldType: Model { - self.init(name.description, at: { type -> (Request, NoArguments) async throws -> FieldType in + self.init(name.description) { type -> (Request, NoArguments) async throws -> FieldType in return { (context: Request, _: NoArguments) async throws in - return try await type[keyPath: keyPath].get(on: context.db) + return try await type[keyPath: keyPath].get(on: context.db) // Get the desired property and make the Fluent database query on it } - }) + } } } @@ -48,11 +48,11 @@ public extension Graphiti.Field where Arguments == NoArguments, Context == Reque _ name: FieldKey, with keyPath: KeyPath> ) where FieldType == [ToType] { - self.init(name.description, at: { type -> (Request, NoArguments) async throws -> [ToType] in + self.init(name.description) { type -> (Request, NoArguments) async throws -> [ToType] in return { (context: Request, _: NoArguments) async throws in - return try await type[keyPath: keyPath].query(on: context.db).all() + return try await type[keyPath: keyPath].query(on: context.db).all() // Get the desired property and make the Fluent database query on it } - }) + } } } @@ -66,11 +66,11 @@ public extension Graphiti.Field where Arguments == NoArguments, Context == Reque _ name: FieldKey, with keyPath: KeyPath> ) where FieldType == ParentType? { - self.init(name.description, at: { type -> (Request, NoArguments) async throws -> ParentType? in + self.init(name.description) { type -> (Request, NoArguments) async throws -> ParentType? in return { (context: Request, _: NoArguments) async throws -> ParentType? in - return try await type[keyPath: keyPath].get(on: context.db) + return try await type[keyPath: keyPath].get(on: context.db) // Get the desired property and make the Fluent database query on it } - }) + } } } @@ -84,10 +84,10 @@ public extension Graphiti.Field where Arguments == NoArguments, Context == Reque _ name: FieldKey, with keyPath: KeyPath> ) where FieldType == ParentType? { - self.init(name.description, at: { type -> (Request, NoArguments) async throws -> ParentType? in + self.init(name.description) { type -> (Request, NoArguments) async throws -> ParentType? in return { (context: Request, _: NoArguments) async throws -> ParentType? in return try await type[keyPath: keyPath].get(on: context.db) } - }) + } } } diff --git a/Sources/GraphQLKit/Graphiti+Request.swift b/Sources/GraphQLKit/Graphiti+Request.swift index f9af24d..0d1156a 100644 --- a/Sources/GraphQLKit/Graphiti+Request.swift +++ b/Sources/GraphQLKit/Graphiti+Request.swift @@ -9,7 +9,11 @@ extension Request { ) async throws -> GraphQLResult { let queryRequest = try content.decode(QueryRequest.self) - return try await resolve(byQueryRequest: queryRequest, graphQLSchema: schema, with: rootAPI) + return try await resolve( + byQueryRequest: queryRequest, + graphQLSchema: schema, + with: rootAPI + ) } func resolveByQueryParameters( @@ -20,21 +24,28 @@ extension Request { throw GraphQLError(GraphQLResolveError.noQueryFound) } - let variables = query[String.self, at: "variables"] - let data = variables?.data(using: .utf8) - let decoder = JSONDecoder() + let variables = query[String.self, at: "variables"].flatMap { + $0.data(using: .utf8).flatMap { data -> [String: Map]? in + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 - if #available(macOS 10.12, *) { - decoder.dateDecodingStrategy = .iso8601 + return try? decoder.decode([String: Map]?.self, from: data) + } } - let map = try decoder.decode([String: Map]?.self, from: data!) - let operationName = query[String.self, at: "operationName"] - let request = QueryRequest(query: queryString, operationName: operationName, variables: map) + let data = QueryRequest( + query: queryString, + operationName: operationName, + variables: variables + ) - return try await resolve(byQueryRequest: request, graphQLSchema: schema, with: rootAPI) + return try await resolve( + byQueryRequest: data, + graphQLSchema: schema, + with: rootAPI + ) } private func resolve( @@ -42,7 +53,9 @@ extension Request { graphQLSchema schema: Schema, with rootAPI: RootType ) async throws -> GraphQLResult { - try await schema.execute( + nonisolated(unsafe) let schema = schema + + return try await schema.execute( request: data.query, resolver: rootAPI, context: self, diff --git a/Sources/GraphQLKit/Graphiti+Router.swift b/Sources/GraphQLKit/Graphiti+Router.swift index 35ae48d..40cf6df 100644 --- a/Sources/GraphQLKit/Graphiti+Router.swift +++ b/Sources/GraphQLKit/Graphiti+Router.swift @@ -2,8 +2,6 @@ import Graphiti import GraphQL import Vapor -extension Schema: @retroactive @unchecked Sendable {} - public extension RoutesBuilder { func register( graphQLSchema schema: Schema, @@ -11,15 +9,15 @@ public extension RoutesBuilder { at path: PathComponent = "graphql", postBodyStreamStrategy: HTTPBodyStreamStrategy = .collect ) { - on(.POST, path, body: postBodyStreamStrategy) { request async throws -> Response in - let result = try await request.resolveByBody(graphQLSchema: schema, with: rootAPI) + nonisolated(unsafe) let schema = schema - return try await result.encodeResponse(status: .ok, for: request) + on(.POST, path, body: postBodyStreamStrategy) { request async throws -> Response in + try await request.resolveByBody(graphQLSchema: schema, with: rootAPI) + .encodeResponse(status: .ok, for: request) } get(path) { request async throws -> Response in - let result = try await request.resolveByQueryParameters(graphQLSchema: schema, with: rootAPI) - - return try await result.encodeResponse(status: .ok, for: request) + try await request.resolveByQueryParameters(graphQLSchema: schema, with: rootAPI) + .encodeResponse(status: .ok, for: request) } } } @@ -29,7 +27,7 @@ enum GraphQLResolveError: Swift.Error { } extension GraphQLResult: @retroactive Content { - public func encodeResponse(for _: Request) async throws -> Response { + public func encodeResponse(for _: Request) throws -> Response { try Response( status: .ok, headers: [ diff --git a/Tests/GraphQLKitTests/GraphQLKitTests.swift b/Tests/GraphQLKitTests/GraphQLKitTests.swift index cb6ee4d..92b3905 100644 --- a/Tests/GraphQLKitTests/GraphQLKitTests.swift +++ b/Tests/GraphQLKitTests/GraphQLKitTests.swift @@ -1,29 +1,34 @@ -import XCTest +import Testing +import VaporTesting import Vapor -import XCTVapor @testable import GraphQLKit -final class GraphQLKitTests: XCTestCase { - struct SomeBearerAuthenticator: BearerAuthenticator { +@Suite +struct GraphQLKitTests { + private func withApp(_ test: (Application) async throws -> ()) async throws { + let app = try await Application.make(.testing) + try await test(app) + try await app.asyncShutdown() + } + + struct SomeBearerAuthenticator: AsyncBearerAuthenticator { struct User: Authenticatable {} - func authenticate(bearer: BearerAuthorization, for request: Request) -> EventLoopFuture<()> { + func authenticate(bearer: BearerAuthorization, for request: Request) async throws { // Bearer token should be equal to `token` to pass the auth if bearer.token == "token" { request.auth.login(User()) - return request.eventLoop.makeSucceededFuture(()) } else { - return request.eventLoop.makeFailedFuture(Abort(.unauthorized)) + throw Abort(.unauthorized) } } - func authenticate(request: Request) -> EventLoopFuture<()> { + func authenticate(request: Request) async throws { // Bearer token should be equal to `token` to pass the auth if request.headers.bearerAuthorization?.token == "token" { request.auth.login(User()) - return request.eventLoop.makeSucceededFuture(()) } else { - return request.eventLoop.makeFailedFuture(Abort(.unauthorized)) + throw Abort(.unauthorized) } } } @@ -44,12 +49,13 @@ final class GraphQLKitTests: XCTestCase { public var address: Address } + struct ProtectedResolver { func test(store: Request, _: NoArguments) throws -> String { _ = try store.auth.require(SomeBearerAuthenticator.User.self) return "Hello World" } - + func number(store: Request, _: NoArguments) throws -> Int { _ = try store.auth.require(SomeBearerAuthenticator.User.self) return 42 @@ -60,19 +66,24 @@ final class GraphQLKitTests: XCTestCase { func test(store: Request, _: NoArguments) -> String { "Hello World" } - + func number(store: Request, _: NoArguments) -> Int { 42 } func person(store: Request, _: NoArguments) throws -> Person { - return Person(firstName: "John", lastName: "Appleseed", age: 42, address: Address( - number: 767, - streetName: "Fifth Avenue", - city: "New York", - postalCode: "NY 10153", - country: "United States" - )) + return Person( + firstName: "John", + lastName: "Appleseed", + age: 42, + address: Address( + number: 767, + streetName: "Fifth Avenue", + city: "New York", + postalCode: "NY 10153", + country: "United States" + ) + ) } } @@ -82,7 +93,7 @@ final class GraphQLKitTests: XCTestCase { Field("number", at: ProtectedResolver.number) } } - + let schema = try! Schema { Scalar(UInt.self) @@ -110,258 +121,261 @@ final class GraphQLKitTests: XCTestCase { } let query = """ - query { - test - } - """ - - func testPostEndpoint() throws { + query { + test + } + """ + + @Test + func testPostEndpoint() async throws { let queryRequest = QueryRequest(query: query, operationName: nil, variables: nil) let data = String(data: try! JSONEncoder().encode(queryRequest), encoding: .utf8)! - - let app = Application(.testing) - defer { app.shutdown() } - - app.register(graphQLSchema: schema, withResolver: Resolver()) - - var body = ByteBufferAllocator().buffer(capacity: 0) - body.writeString(data) - var headers = HTTPHeaders() - headers.replaceOrAdd(name: .contentLength, value: body.readableBytes.description) - headers.contentType = .json - - try app.testable().test(.POST, "/graphql", headers: headers, body: body) { res in - XCTAssertEqual(res.status, .ok) - var res = res - let expected = #"{"data":{"test":"Hello World"}}"# - XCTAssertEqual(res.body.readString(length: expected.count), expected) + + try await withApp { app in + app.register(graphQLSchema: schema, withResolver: Resolver()) + + var body = ByteBufferAllocator().buffer(capacity: 0) + body.writeString(data) + var headers = HTTPHeaders() + headers.replaceOrAdd(name: .contentLength, value: body.readableBytes.description) + headers.contentType = .json + + try await app.testing().test(.POST, "/graphql", headers: headers, body: body) { res in + #expect(res.status == .ok) + var res = res + let expected = #"{"data":{"test":"Hello World"}}"# + #expect(res.body.readString(length: expected.count) == expected) + } } } - - func testGetEndpoint() throws { - let app = Application(.testing) - defer { app.shutdown() } - - app.register(graphQLSchema: schema, withResolver: Resolver()) - try app.testable().test(.GET, "/graphql?query=\(query.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)") { res in - XCTAssertEqual(res.status, .ok) - var body = res.body - let expected = #"{"data":{"test":"Hello World"}}"# - XCTAssertEqual(body.readString(length: expected.count), expected) + + @Test + func testGetEndpoint() async throws { + try await withApp { app in + app.register(graphQLSchema: schema, withResolver: Resolver()) + try await app.testing().test(.GET, "/graphql?query=\(query.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)") { res in + #expect(res.status == .ok) + var body = res.body + let expected = #"{"data":{"test":"Hello World"}}"# + #expect(body.readString(length: expected.count) == expected) + } } } - func testPostOperatinName() throws { + @Test + func testPostOperationName() async throws { let multiQuery = """ - query World { - test - } - - query Number { - number - } - """ + query World { + test + } + + query Number { + number + } + """ + let queryRequest = QueryRequest(query: multiQuery, operationName: "Number", variables: nil) let data = String(data: try! JSONEncoder().encode(queryRequest), encoding: .utf8)! - - let app = Application(.testing) - defer { app.shutdown() } - - app.register(graphQLSchema: schema, withResolver: Resolver()) - - var body = ByteBufferAllocator().buffer(capacity: 0) - body.writeString(data) - var headers = HTTPHeaders() - headers.replaceOrAdd(name: .contentLength, value: body.readableBytes.description) - headers.contentType = .json - - try app.testable().test(.POST, "/graphql", headers: headers, body: body) { res in - XCTAssertEqual(res.status, .ok) - var res = res - let expected = #"{"data":{"number":42}}"# - XCTAssertEqual(res.body.readString(length: expected.count), expected) + + try await withApp { app in + app.register(graphQLSchema: schema, withResolver: Resolver()) + + var body = ByteBufferAllocator().buffer(capacity: 0) + body.writeString(data) + var headers = HTTPHeaders() + headers.replaceOrAdd(name: .contentLength, value: body.readableBytes.description) + headers.contentType = .json + + try await app.testing().test(.POST, "/graphql", headers: headers, body: body) { res in + #expect(res.status == .ok) + var res = res + let expected = #"{"data":{"number":42}}"# + #expect(res.body.readString(length: expected.count) == expected) + } } } - func testProtectedPostEndpoint() throws { + @Test + func testProtectedPostEndpoint() async throws { let queryRequest = QueryRequest(query: query, operationName: nil, variables: nil) let data = String(data: try! JSONEncoder().encode(queryRequest), encoding: .utf8)! - - let app = Application(.testing) - defer { app.shutdown() } - - let protected = app.grouped(SomeBearerAuthenticator()) - protected.register(graphQLSchema: protectedSchema, withResolver: ProtectedResolver()) - - var body = ByteBufferAllocator().buffer(capacity: 0) - body.writeString(data) - var headers = HTTPHeaders() - headers.replaceOrAdd(name: .contentLength, value: body.readableBytes.description) - headers.contentType = .json - - var protectedHeaders = headers - protectedHeaders.replaceOrAdd(name: .authorization, value: "Bearer token") - try app.testable().test(.POST, "/graphql", headers: headers, body: body) { res in - XCTAssertEqual(res.status, .unauthorized) - } - - try app.testable().test(.POST, "/graphql", headers: protectedHeaders, body: body) { res in - XCTAssertEqual(res.status, .ok) - var res = res - let expected = #"{"data":{"test":"Hello World"}}"# - XCTAssertEqual(res.body.readString(length: expected.count), expected) + try await withApp { app in + let protected = app.grouped(SomeBearerAuthenticator()) + protected.register(graphQLSchema: protectedSchema, withResolver: ProtectedResolver()) + + var body = ByteBufferAllocator().buffer(capacity: 0) + body.writeString(data) + var headers = HTTPHeaders() + headers.replaceOrAdd(name: .contentLength, value: body.readableBytes.description) + headers.contentType = .json + + var protectedHeaders = headers + protectedHeaders.replaceOrAdd(name: .authorization, value: "Bearer token") + + try await app.testing().test(.POST, "/graphql", headers: headers, body: body) { res in + #expect(res.status == .unauthorized) + } + + try await app.testing().test(.POST, "/graphql", headers: protectedHeaders, body: body) { res in + #expect(res.status == .ok) + var res = res + let expected = #"{"data":{"test":"Hello World"}}"# + #expect(res.body.readString(length: expected.count) == expected) + } } } - func testProtectedGetEndpoint() throws { - let app = Application(.testing) - defer { app.shutdown() } - - let protected = app.grouped(SomeBearerAuthenticator()) - protected.register(graphQLSchema: protectedSchema, withResolver: ProtectedResolver()) - - var headers = HTTPHeaders() - headers.replaceOrAdd(name: .authorization, value: "Bearer token") - - try app.testable().test(.GET, "/graphql?query=\(query.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)") { res in - XCTAssertEqual(res.status, .unauthorized) - } - - try app.testable().test(.GET, "/graphql?query=\(query.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)", headers: headers) { res in - XCTAssertEqual(res.status, .ok) - var body = res.body - let expected = #"{"data":{"test":"Hello World"}}"# - XCTAssertEqual(body.readString(length: expected.count), expected) + @Test + func testProtectedGetEndpoint() async throws { + try await withApp { app in + let protected = app.grouped(SomeBearerAuthenticator()) + protected.register(graphQLSchema: protectedSchema, withResolver: ProtectedResolver()) + + var headers = HTTPHeaders() + headers.replaceOrAdd(name: .authorization, value: "Bearer token") + + try await app.testing().test(.GET, "/graphql?query=\(query.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)") { res in + #expect(res.status == .unauthorized) + } + + try await app.testing().test(.GET, "/graphql?query=\(query.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)", headers: headers) { res in + #expect(res.status == .ok) + var body = res.body + let expected = #"{"data":{"test":"Hello World"}}"# + #expect(body.readString(length: expected.count) == expected) + } } } - func testProtectedPostOperatinName() throws { + @Test + func testProtectedPostOperatinName() async throws { let multiQuery = """ - query World { - test - } - - query Number { - number - } - """ - let queryRequest = QueryRequest(query: multiQuery, operationName: "Number", variables: nil) - let data = String(data: try! JSONEncoder().encode(queryRequest), encoding: .utf8)! - - let app = Application(.testing) - defer { app.shutdown() } - - let protected = app.grouped(SomeBearerAuthenticator()) - protected.register(graphQLSchema: protectedSchema, withResolver: ProtectedResolver()) - - var body = ByteBufferAllocator().buffer(capacity: 0) - body.writeString(data) + query World { + test + } - var headers = HTTPHeaders() - headers.replaceOrAdd(name: .contentLength, value: body.readableBytes.description) - headers.contentType = .json + query Number { + number + } + """ - var protectedHeaders = headers - protectedHeaders.replaceOrAdd(name: .authorization, value: "Bearer token") + let queryRequest = QueryRequest(query: multiQuery, operationName: "Number", variables: nil) + let data = String(data: try! JSONEncoder().encode(queryRequest), encoding: .utf8)! - try app.testable().test(.POST, "/graphql", headers: headers, body: body) { res in - XCTAssertEqual(res.status, .unauthorized) - } - - try app.testable().test(.POST, "/graphql", headers: protectedHeaders, body: body) { res in - XCTAssertEqual(res.status, .ok) - var res = res - let expected = #"{"data":{"number":42}}"# - XCTAssertEqual(res.body.readString(length: expected.count), expected) + try await withApp { app in + let protected = app.grouped(SomeBearerAuthenticator()) + protected.register(graphQLSchema: protectedSchema, withResolver: ProtectedResolver()) + + var body = ByteBufferAllocator().buffer(capacity: 0) + body.writeString(data) + + var headers = HTTPHeaders() + headers.replaceOrAdd(name: .contentLength, value: body.readableBytes.description) + headers.contentType = .json + + var protectedHeaders = headers + protectedHeaders.replaceOrAdd(name: .authorization, value: "Bearer token") + + try await app.testing().test(.POST, "/graphql", headers: headers, body: body) { res in + #expect(res.status == .unauthorized) + } + + try await app.testing().test(.POST, "/graphql", headers: protectedHeaders, body: body) { res in + #expect(res.status == .ok) + var res = res + let expected = #"{"data":{"number":42}}"# + #expect(res.body.readString(length: expected.count) == expected) + } } } - func testFieldsOrder() throws { + @Test + func testFieldsOrder() async throws { let query1Request = QueryRequest(query: // this query returns fields in arbitrary order - """ - query { - person { - firstName - lastName - age - address { - number - streetName - city - postalCode - country - } + """ + query { + person { + firstName + lastName + age + address { + number + streetName + city + postalCode + country } } - """, operationName: nil, variables: nil) + } + """, operationName: nil, variables: nil) let query2Request = QueryRequest(query: // this query will return all fields in alphabetical order - """ - query { - person { - address { - city - country - number - postalCode - streetName - } - age - firstName - lastName + """ + query { + person { + address { + city + country + number + postalCode + streetName } + age + firstName + lastName } - """, operationName: nil, variables: nil) + } + """, operationName: nil, variables: nil) let data1 = String(data: try! JSONEncoder().encode(query1Request), encoding: .utf8)! let data2 = String(data: try! JSONEncoder().encode(query2Request), encoding: .utf8)! - - let app = Application(.testing) - defer { app.shutdown() } - - app.register(graphQLSchema: schema, withResolver: Resolver()) - - var body1 = ByteBufferAllocator().buffer(capacity: 0) - body1.writeString(data1) - var headers1 = HTTPHeaders() - headers1.replaceOrAdd(name: .contentLength, value: body1.readableBytes.description) - headers1.contentType = .json - - var body2 = ByteBufferAllocator().buffer(capacity: 0) - body2.writeString(data2) - var headers2 = HTTPHeaders() - headers2.replaceOrAdd(name: .contentLength, value: body2.readableBytes.description) - headers2.contentType = .json - - try app.testable().test(.POST, "/graphql", headers: headers1, body: body1) { res in - XCTAssertEqual(res.status, .ok) - var res = res - let expected = #"{"data":{"person":{"firstName":"John","lastName":"Appleseed","age":42,"address":{"number":767,"streetName":"Fifth Avenue","city":"New York","postalCode":"NY 10153","country":"United States"}}}}"# - XCTAssertEqual(res.body.readString(length: expected.count), expected) - } - try app.testable().test(.POST, "/graphql", headers: headers2, body: body2) { res in - XCTAssertEqual(res.status, .ok) - var res = res - let expected = #"{"data":{"person":{"address":{"city":"New York","country":"United States","number":767,"postalCode":"NY 10153","streetName":"Fifth Avenue"},"age":42,"firstName":"John","lastName":"Appleseed"}}}"# - XCTAssertEqual(res.body.readString(length: expected.count), expected) + try await withApp { app in + app.register(graphQLSchema: schema, withResolver: Resolver()) + + var body1 = ByteBufferAllocator().buffer(capacity: 0) + body1.writeString(data1) + var headers1 = HTTPHeaders() + headers1.replaceOrAdd(name: .contentLength, value: body1.readableBytes.description) + headers1.contentType = .json + + var body2 = ByteBufferAllocator().buffer(capacity: 0) + body2.writeString(data2) + var headers2 = HTTPHeaders() + headers2.replaceOrAdd(name: .contentLength, value: body2.readableBytes.description) + headers2.contentType = .json + + try await app.testing().test(.POST, "/graphql", headers: headers1, body: body1) { res in + #expect(res.status == .ok) + var res = res + let expected = #"{"data":{"person":{"firstName":"John","lastName":"Appleseed","age":42,"address":{"number":767,"streetName":"Fifth Avenue","city":"New York","postalCode":"NY 10153","country":"United States"}}}}"# + #expect(res.body.readString(length: expected.count) == expected) + } + + try await app.testing().test(.POST, "/graphql", headers: headers2, body: body2) { res in + #expect(res.status == .ok) + var res = res + let expected = #"{"data":{"person":{"address":{"city":"New York","country":"United States","number":767,"postalCode":"NY 10153","streetName":"Fifth Avenue"},"age":42,"firstName":"John","lastName":"Appleseed"}}}"# + #expect(res.body.readString(length: expected.count) == expected) + } } } - func testEnum() throws { + @Test + func testEnum() async throws { enum TodoState: String, Codable, CaseIterable { case open case done case forLater } - class TestResolver { + final class TestResolver: Sendable { init() {} func test(store: Request, _: NoArguments) -> TodoState { .open } } - + let schema = try Schema { Enum(TodoState.self) Query { @@ -370,20 +384,19 @@ final class GraphQLKitTests: XCTestCase { } let query = """ - query { - test - } - """ + query { + test + } + """ - let app = Application(.testing) - defer { app.shutdown() } - - app.register(graphQLSchema: schema, withResolver: TestResolver()) - try app.testable().test(.GET, "/graphql?query=\(query.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)") { res in - XCTAssertEqual(res.status, .ok) - var body = res.body - let expected = #"{"data":{"test":"open"}}"# - XCTAssertEqual(body.readString(length: expected.count), expected) + try await withApp { app in + app.register(graphQLSchema: schema, withResolver: TestResolver()) + try await app.testing().test(.GET, "/graphql?query=\(query.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)") { res in + #expect(res.status == .ok) + var body = res.body + let expected = #"{"data":{"test":"open"}}"# + #expect(body.readString(length: expected.count) == expected) + } } } } From de077e17a4e06645dd6b3542689bfde40195e143 Mon Sep 17 00:00:00 2001 From: Dennis Parussini Date: Thu, 18 Dec 2025 15:14:44 +0100 Subject: [PATCH 3/5] Fix Parent Relationship --- Sources/GraphQLKit/Graphiti+Fluent.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/GraphQLKit/Graphiti+Fluent.swift b/Sources/GraphQLKit/Graphiti+Fluent.swift index aab8bef..eb468ee 100644 --- a/Sources/GraphQLKit/Graphiti+Fluent.swift +++ b/Sources/GraphQLKit/Graphiti+Fluent.swift @@ -26,13 +26,13 @@ public extension Graphiti.Field where Arguments == NoArguments, Context == Reque /// - Parameters: /// - name: Field name /// - keyPath: KeyPath to the @Parent property - convenience init( + convenience init( _ name: FieldKey, - with keyPath: KeyPath> - ) where FieldType: Model { + with keyPath: KeyPath> + ) where ParentType: Model { self.init(name.description) { type -> (Request, NoArguments) async throws -> FieldType in return { (context: Request, _: NoArguments) async throws in - return try await type[keyPath: keyPath].get(on: context.db) // Get the desired property and make the Fluent database query on it + return try await type[keyPath: keyPath].get(on: context.db) as! FieldType } } } From 3f11024faa060aa6c27833c17ede19fcda2b98ef Mon Sep 17 00:00:00 2001 From: Dennis Parussini Date: Tue, 23 Dec 2025 20:51:02 +0100 Subject: [PATCH 4/5] Added tests for querying relationships in GraphQL --- Package.resolved | 31 ++- Package.swift | 2 + Sources/GraphQLKit/Graphiti+Fluent.swift | 28 +- Tests/GraphQLKitTests/GraphQLKitTests.swift | 292 +++++++++++++++----- Tests/GraphQLKitTests/Utilities.swift | 80 ++++++ 5 files changed, 355 insertions(+), 78 deletions(-) create mode 100644 Tests/GraphQLKitTests/Utilities.swift diff --git a/Package.resolved b/Package.resolved index f7fc14a..4c48541 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "cd599227a2dffec1c44991e5f36d4404f2745cb20c3e61a8e0c428953edc2891", + "originHash" : "e9df32a7ed0111800f0194bf8b2eed26c6e997fad7b2219ec5f724a29bdb3844", "pins" : [ { "identity" : "async-http-client", @@ -46,6 +46,15 @@ "version" : "1.53.0" } }, + { + "identity" : "fluent-sqlite-driver", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/fluent-sqlite-driver.git", + "state" : { + "revision" : "73529a63ab11c7fe87da17b5a67a1b1f58c020f8", + "version" : "4.8.1" + } + }, { "identity" : "graphiti", "kind" : "remoteSourceControl", @@ -91,6 +100,24 @@ "version" : "3.34.0" } }, + { + "identity" : "sqlite-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/sqlite-kit.git", + "state" : { + "revision" : "f35a863ecc2da5d563b836a9a696b148b0f4169f", + "version" : "4.5.2" + } + }, + { + "identity" : "sqlite-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/sqlite-nio.git", + "state" : { + "revision" : "2ab61385b70da8ed74958ce62fa9ebf0359cb08b", + "version" : "1.12.2" + } + }, { "identity" : "swift-algorithms", "kind" : "remoteSourceControl", @@ -139,7 +166,7 @@ { "identity" : "swift-collections", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections", + "location" : "https://github.com/apple/swift-collections.git", "state" : { "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", "version" : "1.3.0" diff --git a/Package.swift b/Package.swift index c9f13fe..f423dea 100644 --- a/Package.swift +++ b/Package.swift @@ -17,6 +17,7 @@ let package = Package( .package(url: "https://github.com/GraphQLSwift/Graphiti.git", from: "3.0.0"), .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"), .package(url: "https://github.com/vapor/fluent.git", from: "4.2.0"), + .package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.0.0"), ], targets: [ .target( @@ -32,6 +33,7 @@ let package = Package( dependencies: [ .target(name: "GraphQLKit"), .product(name: "VaporTesting", package: "vapor"), + .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"), ] ), ] diff --git a/Sources/GraphQLKit/Graphiti+Fluent.swift b/Sources/GraphQLKit/Graphiti+Fluent.swift index eb468ee..af145ac 100644 --- a/Sources/GraphQLKit/Graphiti+Fluent.swift +++ b/Sources/GraphQLKit/Graphiti+Fluent.swift @@ -12,8 +12,8 @@ extension Graphiti.Field where Arguments == NoArguments, Context == Request, Obj _ name: FieldKey, with keyPath: KeyPath> ) where FieldType == [ChildType] { - self.init(name.description) { type -> (Request, NoArguments) async throws -> [ChildType] in - return { (context: Request, _: NoArguments) async throws in + self.init(name.description) { type in + { (context: Request, _: NoArguments) async throws in try await type[keyPath: keyPath].query(on: context.db).all() // Get the desired property and make the Fluent database query on it } } @@ -26,13 +26,13 @@ public extension Graphiti.Field where Arguments == NoArguments, Context == Reque /// - Parameters: /// - name: Field name /// - keyPath: KeyPath to the @Parent property - convenience init( + convenience init( _ name: FieldKey, - with keyPath: KeyPath> - ) where ParentType: Model { - self.init(name.description) { type -> (Request, NoArguments) async throws -> FieldType in - return { (context: Request, _: NoArguments) async throws in - return try await type[keyPath: keyPath].get(on: context.db) as! FieldType + with keyPath: KeyPath> + ) where FieldType: Model { + self.init(name.description) { type in + { (context: Request, _: NoArguments) async throws in + return try await type[keyPath: keyPath].get(on: context.db) } } } @@ -48,8 +48,8 @@ public extension Graphiti.Field where Arguments == NoArguments, Context == Reque _ name: FieldKey, with keyPath: KeyPath> ) where FieldType == [ToType] { - self.init(name.description) { type -> (Request, NoArguments) async throws -> [ToType] in - return { (context: Request, _: NoArguments) async throws in + self.init(name.description) { type in + { (context: Request, _: NoArguments) async throws in return try await type[keyPath: keyPath].query(on: context.db).all() // Get the desired property and make the Fluent database query on it } } @@ -66,8 +66,8 @@ public extension Graphiti.Field where Arguments == NoArguments, Context == Reque _ name: FieldKey, with keyPath: KeyPath> ) where FieldType == ParentType? { - self.init(name.description) { type -> (Request, NoArguments) async throws -> ParentType? in - return { (context: Request, _: NoArguments) async throws -> ParentType? in + self.init(name.description) { type in + { (context: Request, _: NoArguments) async throws -> ParentType? in return try await type[keyPath: keyPath].get(on: context.db) // Get the desired property and make the Fluent database query on it } } @@ -84,8 +84,8 @@ public extension Graphiti.Field where Arguments == NoArguments, Context == Reque _ name: FieldKey, with keyPath: KeyPath> ) where FieldType == ParentType? { - self.init(name.description) { type -> (Request, NoArguments) async throws -> ParentType? in - return { (context: Request, _: NoArguments) async throws -> ParentType? in + self.init(name.description) { type in + { (context: Request, _: NoArguments) async throws -> ParentType? in return try await type[keyPath: keyPath].get(on: context.db) } } diff --git a/Tests/GraphQLKitTests/GraphQLKitTests.swift b/Tests/GraphQLKitTests/GraphQLKitTests.swift index 92b3905..ca8ea24 100644 --- a/Tests/GraphQLKitTests/GraphQLKitTests.swift +++ b/Tests/GraphQLKitTests/GraphQLKitTests.swift @@ -1,12 +1,16 @@ +@testable import GraphQLKit import Testing -import VaporTesting import Vapor -@testable import GraphQLKit +import VaporTesting @Suite struct GraphQLKitTests { - private func withApp(_ test: (Application) async throws -> ()) async throws { + private func withApp(_ test: (Application) async throws -> Void) async throws { let app = try await Application.make(.testing) + app.databases.use(.sqlite(.memory), as: .sqlite) + app.migrations.add(CreateUser()) + app.migrations.add(CreateArticle()) + try await app.autoMigrate() try await test(app) try await app.asyncShutdown() } @@ -34,22 +38,21 @@ struct GraphQLKitTests { } struct Address: Content { - public var number: Int - public var streetName: String - public var additionalStreetName: String? - public var city: String - public var postalCode: String - public var country: String + var number: Int + var streetName: String + var additionalStreetName: String? + var city: String + var postalCode: String + var country: String } struct Person: Content { - public var firstName: String - public var lastName: String - public var age: UInt - public var address: Address + var firstName: String + var lastName: String + var age: UInt + var address: Address } - struct ProtectedResolver { func test(store: Request, _: NoArguments) throws -> String { _ = try store.auth.require(SomeBearerAuthenticator.User.self) @@ -63,15 +66,15 @@ struct GraphQLKitTests { } struct Resolver { - func test(store: Request, _: NoArguments) -> String { + func test(store _: Request, _: NoArguments) -> String { "Hello World" } - func number(store: Request, _: NoArguments) -> Int { + func number(store _: Request, _: NoArguments) -> Int { 42 } - func person(store: Request, _: NoArguments) throws -> Person { + func person(store _: Request, _: NoArguments) throws -> Person { return Person( firstName: "John", lastName: "Appleseed", @@ -121,13 +124,13 @@ struct GraphQLKitTests { } let query = """ - query { - test - } - """ + query { + test + } + """ @Test - func testPostEndpoint() async throws { + func postEndpoint() async throws { let queryRequest = QueryRequest(query: query, operationName: nil, variables: nil) let data = String(data: try! JSONEncoder().encode(queryRequest), encoding: .utf8)! @@ -150,7 +153,7 @@ struct GraphQLKitTests { } @Test - func testGetEndpoint() async throws { + func getEndpoint() async throws { try await withApp { app in app.register(graphQLSchema: schema, withResolver: Resolver()) try await app.testing().test(.GET, "/graphql?query=\(query.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)") { res in @@ -163,7 +166,7 @@ struct GraphQLKitTests { } @Test - func testPostOperationName() async throws { + func postOperationName() async throws { let multiQuery = """ query World { test @@ -196,7 +199,7 @@ struct GraphQLKitTests { } @Test - func testProtectedPostEndpoint() async throws { + func protectedPostEndpoint() async throws { let queryRequest = QueryRequest(query: query, operationName: nil, variables: nil) let data = String(data: try! JSONEncoder().encode(queryRequest), encoding: .utf8)! @@ -227,7 +230,7 @@ struct GraphQLKitTests { } @Test - func testProtectedGetEndpoint() async throws { + func protectedGetEndpoint() async throws { try await withApp { app in let protected = app.grouped(SomeBearerAuthenticator()) protected.register(graphQLSchema: protectedSchema, withResolver: ProtectedResolver()) @@ -249,7 +252,7 @@ struct GraphQLKitTests { } @Test - func testProtectedPostOperatinName() async throws { + func protectedPostOperationName() async throws { let multiQuery = """ query World { test @@ -291,41 +294,41 @@ struct GraphQLKitTests { } @Test - func testFieldsOrder() async throws { + func fieldsOrder() async throws { let query1Request = QueryRequest(query: // this query returns fields in arbitrary order - """ - query { - person { - firstName - lastName - age - address { - number - streetName - city - postalCode - country - } - } - } - """, operationName: nil, variables: nil) + """ + query { + person { + firstName + lastName + age + address { + number + streetName + city + postalCode + country + } + } + } + """, operationName: nil, variables: nil) let query2Request = QueryRequest(query: // this query will return all fields in alphabetical order - """ - query { - person { - address { - city - country - number - postalCode - streetName - } - age - firstName - lastName - } - } - """, operationName: nil, variables: nil) + """ + query { + person { + address { + city + country + number + postalCode + streetName + } + age + firstName + lastName + } + } + """, operationName: nil, variables: nil) let data1 = String(data: try! JSONEncoder().encode(query1Request), encoding: .utf8)! let data2 = String(data: try! JSONEncoder().encode(query2Request), encoding: .utf8)! @@ -370,12 +373,11 @@ struct GraphQLKitTests { final class TestResolver: Sendable { init() {} - func test(store: Request, _: NoArguments) -> TodoState { + func test(store _: Request, _: NoArguments) -> TodoState { .open } } - let schema = try Schema { Enum(TodoState.self) Query { @@ -399,4 +401,170 @@ struct GraphQLKitTests { } } } + + @Test + func createTestArticles() async throws { + let user = User(username: "tester") + + try await withApp { app in + try await user.save(on: app.db) + + let articles = [ + Article(title: "Hello", userID: user.id!), + Article(title: "There", userID: user.id!), + ] + + for article in articles { + try await article.save(on: app.db) + } + + let savedArticles = try await Article.query(on: app.db).all() + + #expect(savedArticles.count == 2) + #expect(savedArticles[1].title == "There") + } + } + + @Test + func createTestUser() async throws { + let user = User(username: "vaporTester") + + try await withApp { app in + try await user.save(on: app.db) + + let users = try await User.query(on: app.db).all() + + #expect(users.count == 1) + #expect(users[0].username == "vaporTester") + } + } + + struct FluentResolver { + func articles( + _ request: Request, + _: NoArguments + ) async throws -> [Article] { + try await Article.query(on: request.db).all() + } + + func users( + _ request: Request, + _: NoArguments + ) async throws -> [User] { + try await User.query(on: request.db).all() + } + } + + let fluentSchema = try! Schema { + Scalar(UUID.self) + + Type(Article.self) { + Field("id", at: \.id) + Field("title", at: \.title) + Field("user", with: \.$user) + } + + Type(User.self) { + Field("id", at: \.id) + Field("username", at: \.username) + Field("articles", with: \.$articles) + } + + Query { + Field("articles", at: FluentResolver.articles) + Field("users", at: FluentResolver.users) + } + } + + @Test + func getArticles() async throws { + let user = User(username: "tester") + + let request = QueryRequest(query: """ + query { + articles { + title + user { + username + } + } + } + """, operationName: nil, variables: nil).query + + let data = try! JSONEncoder().encode(request) + var body = ByteBufferAllocator().buffer(capacity: 0) + body.writeData(data) + + try await withApp { app in + try await user.save(on: app.db) + + let articles = [ + Article(title: "Hello", userID: user.id!), + Article(title: "There", userID: user.id!), + ] + + for article in articles { + try await article.save(on: app.db) + } + + app.register(graphQLSchema: fluentSchema, withResolver: FluentResolver()) + + try await app.testing().test( + .GET, + "/graphql?query=\(request.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)" + ) { response in + #expect(response.status == .ok) + let expected = #"{"data":{"articles":[{"title":"Hello","user":{"username":"tester"}},{"title":"There","user":{"username":"tester"}}]}}"# + #expect(response.body.string == expected) + } + } + } + + @Test + func getUsers() async throws { + let users = [ + User(username: "tester"), + ] + + let request = QueryRequest(query: """ + query { + users { + username + articles { + title + } + } + } + """, operationName: nil, variables: nil).query + + let data = try! JSONEncoder().encode(request) + var body = ByteBufferAllocator().buffer(capacity: 0) + body.writeData(data) + + try await withApp { app in + for user in users { + try await user.save(on: app.db) + } + + let articles = [ + Article(title: "Hello", userID: users[0].id!), + Article(title: "There", userID: users[0].id!), + ] + + for article in articles { + try await article.save(on: app.db) + } + + app.register(graphQLSchema: fluentSchema, withResolver: FluentResolver()) + + try await app.testing().test( + .GET, + "/graphql?query=\(request.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)" + ) { response in + #expect(response.status == .ok) + let expected = #"{"data":{"users":[{"username":"tester","articles":[{"title":"Hello"},{"title":"There"}]}]}}"# + #expect(response.body.string == expected) + } + } + } } diff --git a/Tests/GraphQLKitTests/Utilities.swift b/Tests/GraphQLKitTests/Utilities.swift new file mode 100644 index 0000000..e1d03d6 --- /dev/null +++ b/Tests/GraphQLKitTests/Utilities.swift @@ -0,0 +1,80 @@ +import FluentSQLiteDriver +import Vapor +import VaporTesting + +final class Article: Model, Content, @unchecked Sendable { + static let schema = "articles" + + @ID + var id: UUID? + + @Field(key: "title") + var title: String + + @Parent(key: "user_id") + var user: User + + init() {} + + init( + id: UUID? = nil, + title: String, + userID: User.IDValue + ) { + self.id = id + self.title = title + $user.id = userID + } +} + +struct CreateArticle: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema(Article.schema) + .id() + .field("title", .string, .required) + .field("user_id", .uuid, .required, .references("users", "id")) + .unique(on: "id") + .create() + } + + func revert(on database: any Database) async throws { + try await database.schema(Article.schema).delete() + } +} + +final class User: Model, Content, @unchecked Sendable { + static let schema = "users" + + @ID + var id: UUID? + + @Field(key: "username") + var username: String + + @Children(for: \.$user) + var articles: [Article] + + init() {} + + init( + id: UUID? = nil, + username: String + ) { + self.id = id + self.username = username + } +} + +struct CreateUser: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema(User.schema) + .id() + .field("username", .string, .required) + .unique(on: "id") + .create() + } + + func revert(on database: any Database) async throws { + try await database.schema(User.schema).delete() + } +} From de37142f9ad4ba9f6572ad87796c438abfc61617 Mon Sep 17 00:00:00 2001 From: Dennis Parussini Date: Wed, 7 Jan 2026 13:07:40 +0100 Subject: [PATCH 5/5] Re-added public access to Child Relationship extension --- Sources/GraphQLKit/Graphiti+Fluent.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/GraphQLKit/Graphiti+Fluent.swift b/Sources/GraphQLKit/Graphiti+Fluent.swift index af145ac..2615388 100644 --- a/Sources/GraphQLKit/Graphiti+Fluent.swift +++ b/Sources/GraphQLKit/Graphiti+Fluent.swift @@ -3,8 +3,8 @@ import Graphiti import Vapor // Child Relationship -extension Graphiti.Field where Arguments == NoArguments, Context == Request, ObjectType: Model { - /// Creates a GraphQL field for a one-to-many relationship for Fluent +public extension Graphiti.Field where Arguments == NoArguments, Context == Request, ObjectType: Model { + /// Creates a GraphQL field for a one-to-many relationship for Fluent /// - Parameters: /// - name: Filed name /// - keyPath: KeyPath to the @Children property