diff --git a/APIKit.xcodeproj/project.pbxproj b/APIKit.xcodeproj/project.pbxproj index 16ded583..e460ab57 100644 --- a/APIKit.xcodeproj/project.pbxproj +++ b/APIKit.xcodeproj/project.pbxproj @@ -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 */; }; @@ -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 = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 7FA1690C1D9D8C80006C982B /* HTTPStub.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTTPStub.swift; sourceTree = ""; }; + C5725F4A28D8C36500810D7C /* Concurrency.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Concurrency.swift; sourceTree = ""; }; + C5B144D728D8D7DC00E30ECD /* ConcurrencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcurrencyTests.swift; sourceTree = ""; }; C5FF1DC028A80FFD0059573D /* test.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = test.json; sourceTree = ""; }; 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 = ""; }; @@ -240,6 +244,7 @@ 7F698E451D9D680C00F1561D /* RequestTests.swift */, 7F698E491D9D680C00F1561D /* SessionCallbackQueueTests.swift */, 7F698E4A1D9D680C00F1561D /* SessionTests.swift */, + C5B144D628D8D7D000E30ECD /* Concurrency */, 0973EE33259E2DD000879BA2 /* Combine */, 7F698E3B1D9D680C00F1561D /* BodyParametersType */, 7F698E401D9D680C00F1561D /* DataParserType */, @@ -310,6 +315,7 @@ 7F7048CA1D9D89BE003C99F6 /* Request.swift */, 7F7048CB1D9D89BE003C99F6 /* Session.swift */, 7F7048CC1D9D89BE003C99F6 /* Unavailable.swift */, + C5725F4928D8C36500810D7C /* Concurrency */, 0969AE0D259DEC3C00C498AF /* Combine */, 7F85FB8B1C9D317300CEE132 /* SessionAdapter */, 7F18BD0D1C972C38003A31DF /* BodyParameters */, @@ -364,6 +370,23 @@ path = APIKit/DataParser; sourceTree = ""; }; + C5725F4928D8C36500810D7C /* Concurrency */ = { + isa = PBXGroup; + children = ( + C5725F4A28D8C36500810D7C /* Concurrency.swift */, + ); + name = Concurrency; + path = APIKit/Concurrency; + sourceTree = ""; + }; + C5B144D628D8D7D000E30ECD /* Concurrency */ = { + isa = PBXGroup; + children = ( + C5B144D728D8D7DC00E30ECD /* ConcurrencyTests.swift */, + ); + path = Concurrency; + sourceTree = ""; + }; C5FF1DBF28A80FFD0059573D /* Resources */ = { isa = PBXGroup; children = ( @@ -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 */, @@ -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 */, diff --git a/Sources/APIKit/Combine/Combine.swift b/Sources/APIKit/Combine/Combine.swift index 0b4db3f4..6e3223c0 100644 --- a/Sources/APIKit/Combine/Combine.swift +++ b/Sources/APIKit/Combine/Combine.swift @@ -68,6 +68,7 @@ public struct SessionTaskPublisher: 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. diff --git a/Sources/APIKit/Concurrency/Concurrency.swift b/Sources/APIKit/Concurrency/Concurrency.swift new file mode 100644 index 00000000..c0518e35 --- /dev/null +++ b/Sources/APIKit/Concurrency/Concurrency.swift @@ -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(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(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 diff --git a/Sources/APIKit/Session.swift b/Sources/APIKit/Session.swift index 4406fca9..85fb9615 100644 --- a/Sources/APIKit/Session.swift +++ b/Sources/APIKit/Session.swift @@ -55,8 +55,30 @@ open class Session { /// - returns: The new session task. @discardableResult open func send(_ request: Request, callbackQueue: CallbackQueue? = nil, handler: @escaping (Result) -> 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(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: Request, callbackQueue: CallbackQueue?, handler: @escaping (Result) -> Void) -> SessionTask? { + let callbackQueue = callbackQueue ?? self.callbackQueue let urlRequest: URLRequest do { urlRequest = try request.buildURLRequest() @@ -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(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: Request, forTask task: SessionTask) { objc_setAssociatedObject(task, &taskRequestKey, request, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } diff --git a/Tests/APIKitTests/Concurrency/ConcurrencyTests.swift b/Tests/APIKitTests/Concurrency/ConcurrencyTests.swift new file mode 100644 index 00000000..30052335 --- /dev/null +++ b/Tests/APIKitTests/Concurrency/ConcurrencyTests.swift @@ -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