Skip to content

Commit

Permalink
Merge pull request #306 from ishkawa/feature/async-await
Browse files Browse the repository at this point in the history
Support Swift Concurrency
  • Loading branch information
Econa77 authored Oct 2, 2022
2 parents 078b936 + 1695c58 commit 0d0d79c
Show file tree
Hide file tree
Showing 5 changed files with 177 additions and 19 deletions.
25 changes: 25 additions & 0 deletions APIKit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
7F7048F11D9D8A12003C99F6 /* SessionTaskError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7048EE1D9D8A12003C99F6 /* SessionTaskError.swift */; };
7F7048F31D9D8A1F003C99F6 /* URLEncodedSerialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7048F21D9D8A1F003C99F6 /* URLEncodedSerialization.swift */; };
7FA1690D1D9D8C80006C982B /* HTTPStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FA1690C1D9D8C80006C982B /* HTTPStub.swift */; };
C5725F4B28D8C36500810D7C /* Concurrency.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5725F4A28D8C36500810D7C /* Concurrency.swift */; };
C5B144D828D8D7DC00E30ECD /* ConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5B144D728D8D7DC00E30ECD /* ConcurrencyTests.swift */; };
C5FF1DC128A80FFD0059573D /* test.json in Resources */ = {isa = PBXBuildFile; fileRef = C5FF1DC028A80FFD0059573D /* test.json */; };
ECA831481DE4DDBF004EB1B5 /* ProtobufDataParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA831471DE4DDBF004EB1B5 /* ProtobufDataParser.swift */; };
ECA8314A1DE4DEBE004EB1B5 /* ProtobufDataParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA831491DE4DEBE004EB1B5 /* ProtobufDataParserTests.swift */; };
Expand Down Expand Up @@ -128,6 +130,8 @@
7F7048F21D9D8A1F003C99F6 /* URLEncodedSerialization.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = URLEncodedSerialization.swift; path = Sources/APIKit/Serializations/URLEncodedSerialization.swift; sourceTree = SOURCE_ROOT; };
7F8ECDFD1B6A799E00234E04 /* Demo.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = Demo.playground; sourceTree = "<group>"; xcLanguageSpecificationIdentifier = xcode.lang.swift; };
7FA1690C1D9D8C80006C982B /* HTTPStub.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTTPStub.swift; sourceTree = "<group>"; };
C5725F4A28D8C36500810D7C /* Concurrency.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Concurrency.swift; sourceTree = "<group>"; };
C5B144D728D8D7DC00E30ECD /* ConcurrencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcurrencyTests.swift; sourceTree = "<group>"; };
C5FF1DC028A80FFD0059573D /* test.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = test.json; sourceTree = "<group>"; };
ECA831471DE4DDBF004EB1B5 /* ProtobufDataParser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ProtobufDataParser.swift; path = Sources/APIKit/DataParser/ProtobufDataParser.swift; sourceTree = SOURCE_ROOT; };
ECA831491DE4DEBE004EB1B5 /* ProtobufDataParserTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProtobufDataParserTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -240,6 +244,7 @@
7F698E451D9D680C00F1561D /* RequestTests.swift */,
7F698E491D9D680C00F1561D /* SessionCallbackQueueTests.swift */,
7F698E4A1D9D680C00F1561D /* SessionTests.swift */,
C5B144D628D8D7D000E30ECD /* Concurrency */,
0973EE33259E2DD000879BA2 /* Combine */,
7F698E3B1D9D680C00F1561D /* BodyParametersType */,
7F698E401D9D680C00F1561D /* DataParserType */,
Expand Down Expand Up @@ -310,6 +315,7 @@
7F7048CA1D9D89BE003C99F6 /* Request.swift */,
7F7048CB1D9D89BE003C99F6 /* Session.swift */,
7F7048CC1D9D89BE003C99F6 /* Unavailable.swift */,
C5725F4928D8C36500810D7C /* Concurrency */,
0969AE0D259DEC3C00C498AF /* Combine */,
7F85FB8B1C9D317300CEE132 /* SessionAdapter */,
7F18BD0D1C972C38003A31DF /* BodyParameters */,
Expand Down Expand Up @@ -364,6 +370,23 @@
path = APIKit/DataParser;
sourceTree = "<group>";
};
C5725F4928D8C36500810D7C /* Concurrency */ = {
isa = PBXGroup;
children = (
C5725F4A28D8C36500810D7C /* Concurrency.swift */,
);
name = Concurrency;
path = APIKit/Concurrency;
sourceTree = "<group>";
};
C5B144D628D8D7D000E30ECD /* Concurrency */ = {
isa = PBXGroup;
children = (
C5B144D728D8D7DC00E30ECD /* ConcurrencyTests.swift */,
);
path = Concurrency;
sourceTree = "<group>";
};
C5FF1DBF28A80FFD0059573D /* Resources */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -495,6 +518,7 @@
7F7048E01D9D89FB003C99F6 /* Data+InputStream.swift in Sources */,
7F7048DF1D9D89FB003C99F6 /* BodyParameters.swift in Sources */,
7F7048E21D9D89FB003C99F6 /* JSONBodyParameters.swift in Sources */,
C5725F4B28D8C36500810D7C /* Concurrency.swift in Sources */,
7F7048D61D9D89F2003C99F6 /* SessionAdapter.swift in Sources */,
7F7048EF1D9D8A12003C99F6 /* RequestError.swift in Sources */,
7F7048E91D9D8A08003C99F6 /* FormURLEncodedDataParser.swift in Sources */,
Expand All @@ -521,6 +545,7 @@
7F698E581D9D680C00F1561D /* RequestTests.swift in Sources */,
ECA8314A1DE4DEBE004EB1B5 /* ProtobufDataParserTests.swift in Sources */,
7F698E5E1D9D680C00F1561D /* TestRequest.swift in Sources */,
C5B144D828D8D7DC00E30ECD /* ConcurrencyTests.swift in Sources */,
7F698E601D9D680C00F1561D /* TestSessionTask.swift in Sources */,
0973EE35259E2DDC00879BA2 /* CombineTests.swift in Sources */,
7FA1690D1D9D8C80006C982B /* HTTPStub.swift in Sources */,
Expand Down
1 change: 1 addition & 0 deletions Sources/APIKit/Combine/Combine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ public struct SessionTaskPublisher<Request: APIKit.Request>: Publisher {
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public extension Session {
/// Calls `sessionTaskPublisher(for:callbackQueue:)` of `Session.shared`.
///
/// - parameter request: The request to be sent.
/// - parameter callbackQueue: The queue where the handler runs. If this parameters is `nil`, default `callbackQueue` of `Session` will be used.
/// - returns: A publisher that wraps a session task for the request.
Expand Down
60 changes: 60 additions & 0 deletions Sources/APIKit/Concurrency/Concurrency.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#if compiler(>=5.5.2) && canImport(_Concurrency)

import Foundation

@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public extension Session {
/// Calls `response(for:callbackQueue:)` of `Session.shared`.
///
/// - parameter request: The request to be sent.
/// - parameter callbackQueue: The queue where the handler runs. If this parameters is `nil`, default `callbackQueue` of `Session` will be used.
/// - returns: `Request.Response`
static func response<Request: APIKit.Request>(for request: Request, callbackQueue: CallbackQueue? = nil) async throws -> Request.Response {
return try await shared.response(for: request, callbackQueue: callbackQueue)
}

/// Convenience method to load `Request.Response` using an `Request`, creates and resumes an `SessionTask` internally.
///
/// - parameter request: The request to be sent.
/// - parameter callbackQueue: The queue where the handler runs. If this parameters is `nil`, default `callbackQueue` of `Session` will be used.
/// - returns: `Request.Response`
func response<Request: APIKit.Request>(for request: Request, callbackQueue: CallbackQueue? = nil) async throws -> Request.Response {
let cancellationHandler = SessionTaskCancellationHandler()
return try await withTaskCancellationHandler(operation: {
return try await withCheckedThrowingContinuation { continuation in
Task {
let sessionTask = createSessionTask(request, callbackQueue: callbackQueue) { result in
continuation.resume(with: result)
}
await cancellationHandler.register(with: sessionTask)
if await cancellationHandler.isTaskCancelled {
sessionTask?.cancel()
} else {
sessionTask?.resume()
}
}
}
}, onCancel: {
Task { await cancellationHandler.cancel() }
})
}
}

@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
private actor SessionTaskCancellationHandler {
private var sessionTask: SessionTask?
private(set) var isTaskCancelled = false

func register(with task: SessionTask?) {
guard !isTaskCancelled else { return }
guard sessionTask == nil else { return }
sessionTask = task
}

func cancel() {
isTaskCancelled = true
sessionTask?.cancel()
}
}

#endif
42 changes: 23 additions & 19 deletions Sources/APIKit/Session.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,30 @@ open class Session {
/// - returns: The new session task.
@discardableResult
open func send<Request: APIKit.Request>(_ request: Request, callbackQueue: CallbackQueue? = nil, handler: @escaping (Result<Request.Response, SessionTaskError>) -> Void = { _ in }) -> SessionTask? {
let callbackQueue = callbackQueue ?? self.callbackQueue
let task = createSessionTask(request, callbackQueue: callbackQueue, handler: handler)
task?.resume()
return task
}

/// Cancels requests that passes the test.
/// - parameter requestType: The request type to cancel.
/// - parameter test: The test closure that determines if a request should be cancelled or not.
open func cancelRequests<Request: APIKit.Request>(with requestType: Request.Type, passingTest test: @escaping (Request) -> Bool = { _ in true }) {
adapter.getTasks { [weak self] tasks in
tasks
.filter { task in
if let request = self?.requestForTask(task) as Request? {
return test(request)
} else {
return false
}
}
.forEach { $0.cancel() }
}
}

internal func createSessionTask<Request: APIKit.Request>(_ request: Request, callbackQueue: CallbackQueue?, handler: @escaping (Result<Request.Response, SessionTaskError>) -> Void) -> SessionTask? {
let callbackQueue = callbackQueue ?? self.callbackQueue
let urlRequest: URLRequest
do {
urlRequest = try request.buildURLRequest()
Expand Down Expand Up @@ -91,28 +113,10 @@ open class Session {
}

setRequest(request, forTask: task)
task.resume()

return task
}

/// Cancels requests that passes the test.
/// - parameter requestType: The request type to cancel.
/// - parameter test: The test closure that determines if a request should be cancelled or not.
open func cancelRequests<Request: APIKit.Request>(with requestType: Request.Type, passingTest test: @escaping (Request) -> Bool = { _ in true }) {
adapter.getTasks { [weak self] tasks in
tasks
.filter { task in
if let request = self?.requestForTask(task) as Request? {
return test(request)
} else {
return false
}
}
.forEach { $0.cancel() }
}
}

private func setRequest<Request: APIKit.Request>(_ request: Request, forTask task: SessionTask) {
objc_setAssociatedObject(task, &taskRequestKey, request, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
Expand Down
68 changes: 68 additions & 0 deletions Tests/APIKitTests/Concurrency/ConcurrencyTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#if compiler(>=5.6.0) && canImport(_Concurrency)

import XCTest
import APIKit

@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
final class ConcurrencyTests: XCTestCase {
var adapter: TestSessionAdapter!
var session: Session!

override func setUp() {
super.setUp()
adapter = TestSessionAdapter()
session = Session(adapter: adapter)
}

func testSuccess() async throws {
let dictionary = ["key": "value"]
adapter.data = try XCTUnwrap(JSONSerialization.data(withJSONObject: dictionary, options: []))

let request = TestRequest()
let value = try await session.response(for: request)
XCTAssertEqual((value as? [String: String])?["key"], "value")
}

func testParseDataError() async throws {
adapter.data = "{\"broken\": \"json}".data(using: .utf8, allowLossyConversion: false)

let request = TestRequest()
do {
_ = try await session.response(for: request)
XCTFail()
} catch {
let sessionError = try XCTUnwrap(error as? SessionTaskError)
if case .responseError(let responseError as NSError) = sessionError {
XCTAssertEqual(responseError.domain, NSCocoaErrorDomain)
XCTAssertEqual(responseError.code, 3840)
} else {
XCTFail()
}
}
}

func testCancel() async throws {
let request = TestRequest()

let task = Task {
do {
_ = try await session.response(for: request)
XCTFail()
} catch {
let sessionError = try XCTUnwrap(error as? SessionTaskError)
if case .connectionError(let connectionError as NSError) = sessionError {
XCTAssertEqual(connectionError.code, 0)
XCTAssertTrue(Task.isCancelled)
} else {
XCTFail()
}
}
}
task.cancel()
_ = try await task.value

XCTAssertTrue(task.isCancelled)
}
}

#endif

0 comments on commit 0d0d79c

Please sign in to comment.