Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Swift Concurrency #306

Merged
merged 4 commits into from
Oct 2, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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