Skip to content

Commit

Permalink
Concurrency Stage 1: Introduces initial wrapper-style async/await sup…
Browse files Browse the repository at this point in the history
…port (#184)

* Introduces a wrapper-style async/await approach originally implemented by @elfenlaid

* Adds the original implementation + improved header docs with usage caveats

* Updates the API.swift based on recent changes that resolve Swift 6 errors (in future)

* Update Sources/SwiftGraphQLClient/Client/Core.swift
  • Loading branch information
shaps80 authored Oct 31, 2023
1 parent 5255993 commit 8e8eb89
Show file tree
Hide file tree
Showing 7 changed files with 272 additions and 18 deletions.
38 changes: 36 additions & 2 deletions Sources/SwiftGraphQLClient/Client/Core.swift
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,27 @@ public class Client: GraphQLClient, ObservableObject {

return self.execute(operation: operation)
}


/// Executes a query request with given execution parameters.
///
/// Note: While this behaves much the same as the published-based
/// APIs, async/await inherently does __not__ support multiple
/// return values. If you expect multiple values from an async/await
/// API, please use the corresponding publisher API instead.
///
/// Additionally, due to the differences between async/await and
/// Combine publishers, the async APIs will only return a single value,
/// even if the query is invalidated. Therefore if you currently
/// rely on invalidation behaviour provided by publishers we suggest
/// you continue to use the Combine APIs.
public func query(
_ args: ExecutionArgs,
request: URLRequest? = nil,
policy: Operation.Policy = .cacheFirst
) async -> OperationResult {
await self.query(args, request: request, policy: policy).first()
}

/// Executes a mutation request with given execution parameters.
public func mutate(
_ args: ExecutionArgs,
Expand All @@ -273,7 +293,21 @@ public class Client: GraphQLClient, ObservableObject {

return self.execute(operation: operation)
}


/// Executes a mutation request with given execution parameters.
///
/// Note: While this behaves much the same as the published-based
/// APIs, async/await inherently does __not__ support multiple
/// return values. If you expect multiple values from an async/await
/// API, please use the corresponding publisher API instead.
public func mutate(
_ args: ExecutionArgs,
request: URLRequest? = nil,
policy: Operation.Policy = .cacheFirst
) async -> OperationResult {
await self.mutate(args, request: request, policy: policy).first()
}

/// Executes a subscription request with given execution parameters.
public func subscribe(
_ args: ExecutionArgs,
Expand Down
26 changes: 22 additions & 4 deletions Sources/SwiftGraphQLClient/Client/Selection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,7 @@ extension GraphQLClient {
}

// MARK: - Decoders



/// Executes a query and returns a stream of decoded values.
public func query<T, TypeLock>(
_ selection: Selection<T, TypeLock>,
Expand All @@ -97,7 +96,17 @@ extension GraphQLClient {
}
.eraseToAnyPublisher()
}


/// Executes a query request with given execution parameters.
public func query<T, TypeLock>(
_ selection: Selection<T, TypeLock>,
as operationName: String? = nil,
request: URLRequest? = nil,
policy: Operation.Policy = .cacheFirst
) async throws -> DecodedOperationResult<T> where TypeLock: GraphQLHttpOperation {
try await self.query(selection, as: operationName, request: request, policy: policy).first()
}

/// Executes a mutation and returns a stream of decoded values.
public func mutate<T, TypeLock>(
_ selection: Selection<T, TypeLock>,
Expand All @@ -116,7 +125,16 @@ extension GraphQLClient {
}
.eraseToAnyPublisher()
}


public func mutate<T, TypeLock>(
_ selection: Selection<T, TypeLock>,
as operationName: String? = nil,
request: URLRequest? = nil,
policy: Operation.Policy = .cacheFirst
) async throws -> DecodedOperationResult<T> where TypeLock: GraphQLHttpOperation {
try await self.mutate(selection, as: operationName, request: request, policy: policy).first()
}

/// Creates a subscription stream of decoded values from the given query.
public func subscribe<T, TypeLock>(
to selection: Selection<T, TypeLock>,
Expand Down
41 changes: 41 additions & 0 deletions Sources/SwiftGraphQLClient/Extensions/Publishers+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,47 @@ extension Publisher {
public func takeUntil<Terminator: Publisher>(_ terminator: Terminator) -> Publishers.TakenUntilPublisher<Self, Terminator> {
Publishers.TakenUntilPublisher<Self, Terminator>(upstream: self, terminator: terminator)
}

/// Takes the first emitted value and completes or throws an error
func first() async throws -> Output {
try await withCheckedThrowingContinuation { continuation in
var cancellable: AnyCancellable?

cancellable = first()
.sink { result in
switch result {
case .finished:
break
case let .failure(error):
continuation.resume(throwing: error)
}
cancellable?.cancel()
} receiveValue: { value in
continuation.resume(with: .success(value))
}
}
}
}

extension Publisher where Failure == Never {
/// Takes the first emitted value and completes or throws an error
///
/// Note: While this behaves much the same as the published-based
/// APIs, async/await inherently does __not__ support multiple
/// return values. If you expect multiple values from an async/await
/// API, please use the corresponding publisher API instead.
func first() async -> Output {
await withCheckedContinuation { continuation in
var cancellable: AnyCancellable?

cancellable = first()
.sink { _ in
cancellable?.cancel()
} receiveValue: { value in
continuation.resume(with: .success(value))
}
}
}
}

extension Publishers {
Expand Down
125 changes: 125 additions & 0 deletions Tests/SwiftGraphQLClientTests/AsyncClientTests.swift.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import GraphQL
import SwiftGraphQLClient
import XCTest
import Combine
import SwiftGraphQL

final class AsyncInterfaceTests: XCTestCase {
func testAsyncSelectionQueryReturnsValue() async throws {
let selection = Selection<String, Objects.User> {
try $0.id()
}

let client = MockClient(customExecute: { operation in
let id = GraphQLField.leaf(field: "id", parent: "User", arguments: [])

let user = GraphQLField.composite(
field: "user",
parent: "Query",
type: "User",
arguments: [],
selection: selection.__selection()
)

let result = OperationResult(
operation: operation,
data: [
user.alias!: [
id.alias!: "123"
]
],
error: nil
)
return Just(result).eraseToAnyPublisher()
})


let result = try await client.query(Objects.Query.user(selection: selection))
XCTAssertEqual(result.data, "123")
}

func testAsyncSelectionQueryThrowsError() async throws {
let selection = Selection<String, Objects.User> {
try $0.id()
}

let client = MockClient(customExecute: { operation in
let result = OperationResult(
operation: operation,
data: ["unknown_field": "123"],
error: nil
)
return Just(result).eraseToAnyPublisher()
})

await XCTAssertThrowsError(of: ObjectDecodingError.self) {
try await client.query(Objects.Query.user(selection: selection))
}
}

func testAsyncSelectionMutationReturnsValue() async throws {
let selection = Selection.AuthPayload<String?> {
try $0.on(
authPayloadSuccess: Selection.AuthPayloadSuccess<String?> {
try $0.token()
},
authPayloadFailure: Selection.AuthPayloadFailure<String?> { _ in
nil
}
)
}

let client = MockClient(customExecute: { operation in
let token = GraphQLField.leaf(field: "token", parent: "AuthPayloadSuccess", arguments: [])

let auth = GraphQLField.composite(
field: "auth",
parent: "Mutation",
type: "AuthPayload",
arguments: [],
selection: selection.__selection()
)

let result = OperationResult(
operation: operation,
data: [
auth.alias!: [
"__typename": "AuthPayloadSuccess",
token.alias!: "123"
]
],
error: nil
)
return Just(result).eraseToAnyPublisher()
})

let result = try await client.mutate(Objects.Mutation.auth(selection: selection))
XCTAssertEqual(result.data, "123")
}

func testAsyncSelectionMutationThrowsError() async throws {
let selection = Selection.AuthPayload<String?> {
try $0.on(
authPayloadSuccess: Selection.AuthPayloadSuccess<String?> {
try $0.token()
},
authPayloadFailure: Selection.AuthPayloadFailure<String?> { _ in
nil
}
)
}

let client = MockClient(customExecute: { operation in
let result = OperationResult(
operation: operation,
data: ["unknown_field": "123"],
error: nil
)
return Just(result).eraseToAnyPublisher()
})

await XCTAssertThrowsError(of: ObjectDecodingError.self) {
try await client.mutate(Objects.Mutation.auth(selection: selection))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,24 @@ final class PublishersExtensionsTests: XCTestCase {

XCTAssertEqual(received, [1])
}

func testTakeTheFirstEmittedValueAsynchronously() async throws {
let value = await Just(1).first()
XCTAssertEqual(value, 1)
}

func testTakeTheFirstEmittedValueAsynchronouslyFromThrowingPublisher() async throws {
struct TestError: Error {}

let value = try await Just(1).setFailureType(to: TestError.self).first()
XCTAssertEqual(value, 1)
}

func testThrowEmittedErrorAsynchronously() async throws {
struct TestError: Error {}

await XCTAssertThrowsError(of: TestError.self) {
try await Fail<Int, TestError>(error: TestError()).first()
}
}
}
16 changes: 16 additions & 0 deletions Tests/SwiftGraphQLClientTests/XCTest+Helpers.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import XCTest

/// Checks whether the provided `body` throws an `error` of the given `error`'s type
func XCTAssertThrowsError<T: Swift.Error, Output>(
of: T.Type,
file: StaticString = #file,
line: UInt = #line,
_ body: () async throws -> Output
) async {
do {
_ = try await body()
XCTFail("body completed successfully", file: file, line: line)
} catch let error {
XCTAssertNotNil(error as? T, "Expected error of \(T.self), got \(type(of: error))")
}
}
Loading

0 comments on commit 8e8eb89

Please sign in to comment.