diff --git a/Package.resolved b/Package.resolved index 660d2dd1..fd90ad1a 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/GraphQLSwift/GraphQL.git", "state": { "branch": null, - "revision": "283cc4de56b994a00b2724328221b7a1bc846ddc", - "version": "2.2.1" + "revision": "ebd2ea40676f8bcbdfd6088c408f3ed321c1a905", + "version": "2.4.0" } }, { @@ -24,8 +24,8 @@ "repositoryURL": "https://github.com/apple/swift-nio.git", "state": { "branch": null, - "revision": "d6e3762e0a5f7ede652559f53623baf11006e17c", - "version": "2.39.0" + "revision": "124119f0bb12384cef35aa041d7c3a686108722d", + "version": "2.40.0" } } ] diff --git a/Package.swift b/Package.swift index f2730261..99114007 100644 --- a/Package.swift +++ b/Package.swift @@ -7,7 +7,7 @@ let package = Package( .library(name: "Graphiti", targets: ["Graphiti"]), ], dependencies: [ - .package(url: "https://github.com/GraphQLSwift/GraphQL.git", .upToNextMajor(from: "2.0.0")) + .package(url: "https://github.com/GraphQLSwift/GraphQL.git", from: "2.4.0") ], targets: [ .target(name: "Graphiti", dependencies: ["GraphQL"]), diff --git a/Sources/Graphiti/API/API.swift b/Sources/Graphiti/API/API.swift index 9264b83b..efb5514b 100644 --- a/Sources/Graphiti/API/API.swift +++ b/Sources/Graphiti/API/API.swift @@ -43,3 +43,46 @@ extension API { ) } } + + +#if compiler(>=5.5) && canImport(_Concurrency) + +extension API { + @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) + public func execute( + request: String, + context: ContextType, + on eventLoopGroup: EventLoopGroup, + variables: [String: Map] = [:], + operationName: String? = nil + ) async throws -> GraphQLResult { + return try await schema.execute( + request: request, + resolver: resolver, + context: context, + eventLoopGroup: eventLoopGroup, + variables: variables, + operationName: operationName + ).get() + } + + @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) + public func subscribe( + request: String, + context: ContextType, + on eventLoopGroup: EventLoopGroup, + variables: [String: Map] = [:], + operationName: String? = nil + ) async throws -> SubscriptionResult { + return try await schema.subscribe( + request: request, + resolver: resolver, + context: context, + eventLoopGroup: eventLoopGroup, + variables: variables, + operationName: operationName + ).get() + } +} + +#endif diff --git a/Sources/Graphiti/Field/Field/Field.swift b/Sources/Graphiti/Field/Field/Field.swift index 8745af00..c9d249da 100644 --- a/Sources/Graphiti/Field/Field/Field.swift +++ b/Sources/Graphiti/Field/Field/Field.swift @@ -247,3 +247,72 @@ public extension Field where Arguments == NoArguments { self.init(name: name, arguments: [], syncResolve: syncResolve) } } + +#if compiler(>=5.5) && canImport(_Concurrency) + +public extension Field { + + @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) + convenience init( + name: String, + arguments: [ArgumentComponent], + concurrentResolve: @escaping ConcurrentResolve + ) { + let asyncResolve: AsyncResolve = { type in + { context, arguments, eventLoopGroup in + let promise = eventLoopGroup.next().makePromise(of: ResolveType.self) + promise.completeWithTask { + try await concurrentResolve(type)(context, arguments) + } + return promise.futureResult + } + } + self.init(name: name, arguments: arguments, asyncResolve: asyncResolve) + } +} + +// MARK: ConcurrentResolve Initializers + +public extension Field where FieldType : Encodable { + @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) + convenience init( + _ name: String, + at function: @escaping ConcurrentResolve, + @ArgumentComponentBuilder _ argument: () -> ArgumentComponent + ) { + self.init(name: name, arguments: [argument()], concurrentResolve: function) + } + + @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) + convenience init( + _ name: String, + at function: @escaping ConcurrentResolve, + @ArgumentComponentBuilder _ arguments: () -> [ArgumentComponent] = {[]} + ) { + self.init(name: name, arguments: arguments(), concurrentResolve: function) + } +} + +public extension Field { + @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) + convenience init( + _ name: String, + at function: @escaping ConcurrentResolve, + as: FieldType.Type, + @ArgumentComponentBuilder _ argument: () -> ArgumentComponent + ) { + self.init(name: name, arguments: [argument()], concurrentResolve: function) + } + + @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) + convenience init( + _ name: String, + at function: @escaping ConcurrentResolve, + as: FieldType.Type, + @ArgumentComponentBuilder _ arguments: () -> [ArgumentComponent] = {[]} + ) { + self.init(name: name, arguments: arguments(), concurrentResolve: function) + } +} + +#endif diff --git a/Sources/Graphiti/Field/Resolve/ConcurrentResolve.swift b/Sources/Graphiti/Field/Resolve/ConcurrentResolve.swift new file mode 100644 index 00000000..91138593 --- /dev/null +++ b/Sources/Graphiti/Field/Resolve/ConcurrentResolve.swift @@ -0,0 +1,13 @@ +import NIO + +#if compiler(>=5.5) && canImport(_Concurrency) + +@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) +public typealias ConcurrentResolve = ( + _ object: ObjectType +) -> ( + _ context: Context, + _ arguments: Arguments +) async throws -> ResolveType + +#endif diff --git a/Sources/Graphiti/Subscription/SubscribeField.swift b/Sources/Graphiti/Subscription/SubscribeField.swift index 48a7ddea..5c6fe86a 100644 --- a/Sources/Graphiti/Subscription/SubscribeField.swift +++ b/Sources/Graphiti/Subscription/SubscribeField.swift @@ -357,6 +357,109 @@ public extension SubscriptionField { } } +#if compiler(>=5.5) && canImport(_Concurrency) + +public extension SubscriptionField { + + @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) + convenience init( + name: String, + arguments: [ArgumentComponent], + concurrentResolve: @escaping ConcurrentResolve, + concurrentSubscribe: @escaping ConcurrentResolve> + ) { + let asyncResolve: AsyncResolve = { type in + { context, arguments, eventLoopGroup in + let promise = eventLoopGroup.next().makePromise(of: ResolveType.self) + promise.completeWithTask { + try await concurrentResolve(type)(context, arguments) + } + return promise.futureResult + } + } + let asyncSubscribe: AsyncResolve> = { type in + { context, arguments, eventLoopGroup in + let promise = eventLoopGroup.next().makePromise(of: EventStream.self) + promise.completeWithTask { + try await concurrentSubscribe(type)(context, arguments) + } + return promise.futureResult + } + } + self.init(name: name, arguments: arguments, asyncResolve: asyncResolve, asyncSubscribe: asyncSubscribe) + } + + @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) + convenience init( + name: String, + arguments: [ArgumentComponent], + as: FieldType.Type, + concurrentSubscribe: @escaping ConcurrentResolve> + ) { + let asyncSubscribe: AsyncResolve> = { type in + { context, arguments, eventLoopGroup in + let promise = eventLoopGroup.next().makePromise(of: EventStream.self) + promise.completeWithTask { + try await concurrentSubscribe(type)(context, arguments) + } + return promise.futureResult + } + } + self.init(name: name, arguments: arguments, as: `as`, asyncSubscribe: asyncSubscribe) + } +} + +// MARK: ConcurrentResolve Initializers + +public extension SubscriptionField where FieldType : Encodable { + @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) + convenience init( + _ name: String, + at function: @escaping ConcurrentResolve, + atSub subFunc: @escaping ConcurrentResolve>, + @ArgumentComponentBuilder _ argument: () -> ArgumentComponent + ) { + self.init(name: name, arguments: [argument()], concurrentResolve: function, concurrentSubscribe: subFunc) + } + + @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) + convenience init( + _ name: String, + at function: @escaping ConcurrentResolve, + atSub subFunc: @escaping ConcurrentResolve>, + @ArgumentComponentBuilder _ arguments: () -> [ArgumentComponent] = {[]} + ) { + self.init(name: name, arguments: arguments(), concurrentResolve: function, concurrentSubscribe: subFunc) + } +} + +public extension SubscriptionField { + @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) + convenience init( + _ name: String, + at function: @escaping ConcurrentResolve, + as: FieldType.Type, + atSub subFunc: @escaping ConcurrentResolve>, + @ArgumentComponentBuilder _ argument: () -> ArgumentComponent + ) { + self.init(name: name, arguments: [argument()], concurrentResolve: function, concurrentSubscribe: subFunc) + } + + @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) + convenience init( + _ name: String, + at function: @escaping ConcurrentResolve, + as: FieldType.Type, + atSub subFunc: @escaping ConcurrentResolve>, + @ArgumentComponentBuilder _ arguments: () -> [ArgumentComponent] = {[]} + ) { + self.init(name: name, arguments: arguments(), concurrentResolve: function, concurrentSubscribe: subFunc) + } +} + +#endif + + // TODO Determine if we can use keypaths to initialize // MARK: Keypath Initializers diff --git a/Tests/GraphitiTests/HelloWorldTests/HelloWorldAsyncTests.swift b/Tests/GraphitiTests/HelloWorldTests/HelloWorldAsyncTests.swift new file mode 100644 index 00000000..175e28ff --- /dev/null +++ b/Tests/GraphitiTests/HelloWorldTests/HelloWorldAsyncTests.swift @@ -0,0 +1,341 @@ +import XCTest +import GraphQL +import NIO +@testable import Graphiti + +#if compiler(>=5.5) && canImport(_Concurrency) + +@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) +let pubsub = SimplePubSub() + +@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) +extension HelloResolver { + func asyncHello( + context: HelloContext, + arguments: NoArguments + ) async -> String { + return await Task { + context.hello() + }.value + } + + func subscribeUser(context: HelloContext, arguments: NoArguments) -> EventStream { + pubsub.subscribe() + } + + func futureSubscribeUser(context: HelloContext, arguments: NoArguments, group: EventLoopGroup) -> EventLoopFuture> { + group.next().makeSucceededFuture(pubsub.subscribe()) + } + + func asyncSubscribeUser(context: HelloContext, arguments: NoArguments) async -> EventStream { + return await Task { + pubsub.subscribe() + }.value + } +} + +@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) +// Same as the HelloAPI, except with an async query and a few subscription fields +struct HelloAsyncAPI : API { + let resolver = HelloResolver() + let context = HelloContext() + + let schema = try! Schema { + Scalar(Float.self) + .description("The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point).") + + Scalar(ID.self) + .description("The `ID` scalar type represents a unique identifier.") + + Type(User.self) { + Field("id", at: \.id) + Field("name", at: \.name) + Field("friends", at: \.friends, as: [TypeReference]?.self) + } + + Input(UserInput.self) { + InputField("id", at: \.id) + InputField("name", at: \.name) + InputField("friends", at: \.friends, as: [TypeReference]?.self) + } + + Type(UserEvent.self) { + Field("user", at: \.user) + } + + Query { + Field("hello", at: HelloResolver.hello) + Field("futureHello", at: HelloResolver.futureHello) + Field("asyncHello", at: HelloResolver.asyncHello) + + + Field("float", at: HelloResolver.getFloat) { + Argument("float", at: \.float) + } + + Field("id", at: HelloResolver.getId) { + Argument("id", at: \.id) + } + + Field("user", at: HelloResolver.getUser) + } + + Mutation { + Field("addUser", at: HelloResolver.addUser) { + Argument("user", at: \.user) + } + } + + Subscription { + SubscriptionField("subscribeUser", as: User.self, atSub: HelloResolver.subscribeUser) + SubscriptionField("subscribeUserEvent", at: User.toEvent, atSub: HelloResolver.subscribeUser) + + SubscriptionField("futureSubscribeUser", as: User.self, atSub: HelloResolver.subscribeUser) + SubscriptionField("asyncSubscribeUser", as: User.self, atSub: HelloResolver.subscribeUser) + } + } +} + +@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) +class HelloWorldAsyncTests : XCTestCase { + private let api = HelloAsyncAPI() + private var group = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) + + /// Tests that async version of API.execute works as expected + func testAsyncExecute() async throws { + let query = "{ hello }" + let result = try await api.execute( + request: query, + context: api.context, + on: group + ) + XCTAssertEqual( + result, + GraphQLResult(data: ["hello": "world"]) + ) + } + + /// Tests that async fields (via ConcurrentResolve) are resolved successfully + func testAsyncHello() async throws { + let query = "{ asyncHello }" + let result = try await api.execute( + request: query, + context: api.context, + on: group + ) + XCTAssertEqual( + result, + GraphQLResult(data: ["asyncHello": "world"]) + ) + } + + /// Tests subscription when the sourceEventStream type matches the resolved type (i.e. the normal resolution function should just short-circuit to the sourceEventStream object) + func testSubscriptionSelf() async throws { + let request = """ + subscription { + subscribeUser { + id + name + } + } + """ + + let subscriptionResult = try api.subscribe( + request: request, + context: api.context, + on: group + ).wait() + guard let subscription = subscriptionResult.stream else { + XCTFail(subscriptionResult.errors.description) + return + } + guard let stream = subscription as? ConcurrentEventStream else { + XCTFail("stream isn't ConcurrentEventStream") + return + } + var iterator = stream.stream.makeAsyncIterator() + + pubsub.publish(event: User(id: "124", name: "Jerry", friends: nil)) + + let result = try await iterator.next()?.get() + XCTAssertEqual( + result, + GraphQLResult(data: [ + "subscribeUser": [ + "id": "124", + "name": "Jerry" + ] + ]) + ) + } + + /// Tests subscription when the sourceEventStream type does not match the resolved type (i.e. there is a non-trivial resolution function that transforms the sourceEventStream object) + func testSubscriptionEvent() async throws { + let request = """ + subscription { + subscribeUserEvent { + user { + id + name + } + } + } + """ + + let subscriptionResult = try await api.subscribe( + request: request, + context: api.context, + on: group + ) + guard let subscription = subscriptionResult.stream else { + XCTFail(subscriptionResult.errors.description) + return + } + guard let stream = subscription as? ConcurrentEventStream else { + XCTFail("stream isn't ConcurrentEventStream") + return + } + var iterator = stream.stream.makeAsyncIterator() + + pubsub.publish(event: User(id: "124", name: "Jerry", friends: nil)) + + let result = try await iterator.next()?.get() + XCTAssertEqual( + result, + GraphQLResult(data: [ + "subscribeUserEvent": [ + "user": [ + "id": "124", + "name": "Jerry" + ] + ] + ]) + ) + } + + /// Tests that subscription resolvers that return futures work + func testFutureSubscription() async throws { + let request = """ + subscription { + futureSubscribeUser { + id + name + } + } + """ + + let subscriptionResult = try await api.subscribe( + request: request, + context: api.context, + on: group + ) + guard let subscription = subscriptionResult.stream else { + XCTFail(subscriptionResult.errors.description) + return + } + guard let stream = subscription as? ConcurrentEventStream else { + XCTFail("stream isn't ConcurrentEventStream") + return + } + var iterator = stream.stream.makeAsyncIterator() + + pubsub.publish(event: User(id: "124", name: "Jerry", friends: nil)) + + let result = try await iterator.next()?.get() + XCTAssertEqual( + result, + GraphQLResult(data: [ + "futureSubscribeUser": [ + "id": "124", + "name": "Jerry" + ] + ]) + ) + } + + /// Tests that subscription resolvers that are async work + func testAsyncSubscription() async throws { + let request = """ + subscription { + asyncSubscribeUser { + id + name + } + } + """ + + let subscriptionResult = try await api.subscribe( + request: request, + context: api.context, + on: group + ) + guard let subscription = subscriptionResult.stream else { + XCTFail(subscriptionResult.errors.description) + return + } + guard let stream = subscription as? ConcurrentEventStream else { + XCTFail("stream isn't ConcurrentEventStream") + return + } + var iterator = stream.stream.makeAsyncIterator() + + pubsub.publish(event: User(id: "124", name: "Jerry", friends: nil)) + + let result = try await iterator.next()?.get() + XCTAssertEqual( + result, + GraphQLResult(data: [ + "asyncSubscribeUser": [ + "id": "124", + "name": "Jerry" + ] + ]) + ) + } + +} + +@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) +/// A very simple publish/subscriber used for testing +class SimplePubSub { + private var subscribers: [Subscriber] + + init() { + subscribers = [] + } + + func publish(event: T) { + for subscriber in subscribers { + subscriber.callback(event) + } + } + + func cancel() { + for subscriber in subscribers { + subscriber.cancel() + } + } + + func subscribe() -> ConcurrentEventStream { + let asyncStream = AsyncThrowingStream { continuation in + let subscriber = Subscriber( + callback: { newValue in + continuation.yield(newValue) + }, + cancel: { + continuation.finish() + } + ) + subscribers.append(subscriber) + return + } + return ConcurrentEventStream.init(asyncStream) + } +} + +struct Subscriber { + let callback: (T) -> Void + let cancel: () -> Void +} + +#endif diff --git a/Tests/GraphitiTests/HelloWorldTests/HelloWorldTests.swift b/Tests/GraphitiTests/HelloWorldTests/HelloWorldTests.swift index 87c7172c..193d88b4 100644 --- a/Tests/GraphitiTests/HelloWorldTests/HelloWorldTests.swift +++ b/Tests/GraphitiTests/HelloWorldTests/HelloWorldTests.swift @@ -68,7 +68,7 @@ struct HelloResolver { context.hello() } - func asyncHello( + func futureHello( context: HelloContext, arguments: NoArguments, group: EventLoopGroup @@ -134,7 +134,7 @@ struct HelloAPI : API { Query { Field("hello", at: HelloResolver.hello) - Field("asyncHello", at: HelloResolver.asyncHello) + Field("futureHello", at: HelloResolver.futureHello) Field("float", at: HelloResolver.getFloat) { Argument("float", at: \.float) @@ -181,9 +181,9 @@ class HelloWorldTests : XCTestCase { wait(for: [expectation], timeout: 10) } - func testHelloAsync() throws { - let query = "{ asyncHello }" - let expected = GraphQLResult(data: ["asyncHello": "world"]) + func testFutureHello() throws { + let query = "{ futureHello }" + let expected = GraphQLResult(data: ["futureHello": "world"]) let expectation = XCTestExpectation() @@ -381,7 +381,7 @@ extension HelloWorldTests { static var allTests: [(String, (HelloWorldTests) -> () throws -> Void)] { return [ ("testHello", testHello), - ("testHelloAsync", testHelloAsync), + ("testFutureHello", testFutureHello), ("testBoyhowdy", testBoyhowdy), ("testScalar", testScalar), ("testInput", testInput),