diff --git a/APIKit.xcodeproj/project.pbxproj b/APIKit.xcodeproj/project.pbxproj index b538939e..8fc0f748 100644 --- a/APIKit.xcodeproj/project.pbxproj +++ b/APIKit.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 0969AE0F259DEC6D00C498AF /* Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0969AE0E259DEC6D00C498AF /* Combine.swift */; }; + 0973EE35259E2DDC00879BA2 /* CombineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0973EE34259E2DDC00879BA2 /* CombineTests.swift */; }; 7F698E501D9D680C00F1561D /* FormURLEncodedBodyParametersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F698E3C1D9D680C00F1561D /* FormURLEncodedBodyParametersTests.swift */; }; 7F698E511D9D680C00F1561D /* JSONBodyParametersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F698E3D1D9D680C00F1561D /* JSONBodyParametersTests.swift */; }; 7F698E521D9D680C00F1561D /* MultipartFormDataParametersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F698E3E1D9D680C00F1561D /* MultipartFormDataParametersTests.swift */; }; @@ -76,6 +78,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0969AE0E259DEC6D00C498AF /* Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Combine.swift; sourceTree = ""; }; + 0973EE34259E2DDC00879BA2 /* CombineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineTests.swift; sourceTree = ""; }; 141F120F1C1C96820026D415 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Base.xcconfig; path = Configurations/Base.xcconfig; sourceTree = ""; }; 141F12101C1C96820026D415 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Configurations/Debug.xcconfig; sourceTree = ""; }; 141F12111C1C96820026D415 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Configurations/Release.xcconfig; sourceTree = ""; }; @@ -149,6 +153,23 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0969AE0D259DEC3C00C498AF /* Combine */ = { + isa = PBXGroup; + children = ( + 0969AE0E259DEC6D00C498AF /* Combine.swift */, + ); + name = Combine; + path = APIKit/Combine; + sourceTree = ""; + }; + 0973EE33259E2DD000879BA2 /* Combine */ = { + isa = PBXGroup; + children = ( + 0973EE34259E2DDC00879BA2 /* CombineTests.swift */, + ); + path = Combine; + sourceTree = ""; + }; 141F120E1C1C96690026D415 /* Configurations */ = { isa = PBXGroup; children = ( @@ -219,6 +240,7 @@ 7F698E451D9D680C00F1561D /* RequestTests.swift */, 7F698E491D9D680C00F1561D /* SessionCallbackQueueTests.swift */, 7F698E4A1D9D680C00F1561D /* SessionTests.swift */, + 0973EE33259E2DD000879BA2 /* Combine */, 7F698E3B1D9D680C00F1561D /* BodyParametersType */, 7F698E401D9D680C00F1561D /* DataParserType */, 7F698E461D9D680C00F1561D /* SessionAdapterType */, @@ -288,6 +310,7 @@ 7F7048CA1D9D89BE003C99F6 /* Request.swift */, 7F7048CB1D9D89BE003C99F6 /* Session.swift */, 7F7048CC1D9D89BE003C99F6 /* Unavailable.swift */, + 0969AE0D259DEC3C00C498AF /* Combine */, 7F85FB8B1C9D317300CEE132 /* SessionAdapter */, 7F18BD0D1C972C38003A31DF /* BodyParameters */, 7FA19A441C9CC9A2005D25AE /* DataParser */, @@ -473,6 +496,7 @@ ECA831481DE4DDBF004EB1B5 /* ProtobufDataParser.swift in Sources */, 7F7048F31D9D8A1F003C99F6 /* URLEncodedSerialization.swift in Sources */, 7F7048D71D9D89F2003C99F6 /* URLSessionAdapter.swift in Sources */, + 0969AE0F259DEC6D00C498AF /* Combine.swift in Sources */, 7F7048EB1D9D8A08003C99F6 /* StringDataParser.swift in Sources */, 7F7048CF1D9D89BE003C99F6 /* HTTPMethod.swift in Sources */, ); @@ -490,6 +514,7 @@ ECA8314A1DE4DEBE004EB1B5 /* ProtobufDataParserTests.swift in Sources */, 7F698E5E1D9D680C00F1561D /* TestRequest.swift in Sources */, 7F698E601D9D680C00F1561D /* TestSessionTask.swift in Sources */, + 0973EE35259E2DDC00879BA2 /* CombineTests.swift in Sources */, 7FA1690D1D9D8C80006C982B /* HTTPStub.swift in Sources */, 7F698E5A1D9D680C00F1561D /* URLSessionAdapterTests.swift in Sources */, 7F698E561D9D680C00F1561D /* StringDataParserTests.swift in Sources */, diff --git a/Sources/APIKit/Combine/Combine.swift b/Sources/APIKit/Combine/Combine.swift new file mode 100644 index 00000000..0b4db3f4 --- /dev/null +++ b/Sources/APIKit/Combine/Combine.swift @@ -0,0 +1,89 @@ +#if canImport(Combine) + +import Foundation +import Combine + +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +public struct SessionTaskPublisher: Publisher { + /// The kind of values published by this publisher. + public typealias Output = Request.Response + + /// The kind of errors this publisher might publish. + public typealias Failure = SessionTaskError + + private let request: Request + private let session: Session + private let callbackQueue: CallbackQueue? + + public init(request: Request, session: Session, callbackQueue: CallbackQueue?) { + self.request = request + self.session = session + self.callbackQueue = callbackQueue + } + + public func receive(subscriber: S) where S: Subscriber, S.Failure == SessionTaskPublisher.Failure, S.Input == SessionTaskPublisher.Output { + subscriber.receive(subscription: SessionTaskSubscription(request: request, + session: session, + callbackQueue: callbackQueue, + downstream: subscriber)) + } + + private final class SessionTaskSubscription: Subscription where Request.Response == Downstream.Input, Downstream.Failure == Failure { + + private let request: Request + private let session: Session + private let callbackQueue: CallbackQueue? + private var downstream: Downstream? + private var task: SessionTask? + + init(request: Request, session: Session, callbackQueue: CallbackQueue?, downstream: Downstream) { + self.request = request + self.session = session + self.callbackQueue = callbackQueue + self.downstream = downstream + } + + func request(_ demand: Subscribers.Demand) { + assert(demand > 0) + guard let downstream = self.downstream else { return } + self.downstream = nil + task = session.send(request, callbackQueue: callbackQueue) { result in + switch result { + case .success(let response): + _ = downstream.receive(response) + downstream.receive(completion: .finished) + case .failure(let error): + downstream.receive(completion: .failure(error)) + } + } + } + + func cancel() { + task?.cancel() + downstream = nil + } + } +} + +@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. + static func sessionTaskPublisher(for request: Request, callbackQueue: CallbackQueue? = nil) -> SessionTaskPublisher { + return SessionTaskPublisher(request: request, session: .shared, callbackQueue: callbackQueue) + } + + /// Returns a publisher that wraps a session task for the request. + /// + /// The publisher publishes `Request.Response` when the task completes, or terminates if the task fails with an error. + /// - 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. + func sessionTaskPublisher(for request: Request, callbackQueue: CallbackQueue? = nil) -> SessionTaskPublisher { + return SessionTaskPublisher(request: request, session: self, callbackQueue: callbackQueue) + } +} + +#endif diff --git a/Sources/APIKit/Session.swift b/Sources/APIKit/Session.swift index c772247f..4406fca9 100644 --- a/Sources/APIKit/Session.swift +++ b/Sources/APIKit/Session.swift @@ -30,7 +30,7 @@ open class Session { return privateShared } - /// Calls `send(_:handler:)` of `sharedSession`. + /// Calls `send(_:callbackQueue:handler:)` 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. /// - parameter handler: The closure that receives result of the request. @@ -40,7 +40,7 @@ open class Session { return shared.send(request, callbackQueue: callbackQueue, handler: handler) } - /// Calls `cancelRequests(with:passingTest:)` of `sharedSession`. + /// Calls `cancelRequests(with:passingTest:)` of `Session.shared`. open class func cancelRequests(with requestType: Request.Type, passingTest test: @escaping (Request) -> Bool = { _ in true }) { shared.cancelRequests(with: requestType, passingTest: test) } diff --git a/Tests/APIKitTests/Combine/CombineTests.swift b/Tests/APIKitTests/Combine/CombineTests.swift new file mode 100644 index 00000000..a253067b --- /dev/null +++ b/Tests/APIKitTests/Combine/CombineTests.swift @@ -0,0 +1,124 @@ +#if canImport(Combine) + +import Foundation +import XCTest +import Combine +import APIKit + +@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) +final class CombineTests: XCTestCase { + + var adapter: TestSessionAdapter! + var session: Session! + var cancellables: Set = [] + + override func setUp() { + super.setUp() + adapter = TestSessionAdapter() + session = Session(adapter: adapter) + } + + override func tearDown() { + super.tearDown() + cancellables = [] + } + + func testSuccess() { + let dictionary = ["key": "value"] + adapter.data = try! JSONSerialization.data(withJSONObject: dictionary, options: []) + + let expectation = self.expectation(description: "wait for response") + let request = TestRequest() + + session.sessionTaskPublisher(for: request) + .sink(receiveCompletion: { completion in + switch completion { + case .failure: + XCTFail() + case .finished: + expectation.fulfill() + } + }, receiveValue: { response in + XCTAssertEqual((response as? [String: String])?["key"], "value") + }) + .store(in: &cancellables) + + waitForExpectations(timeout: 1.0, handler: nil) + } + + func testParseDataError() { + adapter.data = "{\"broken\": \"json}".data(using: .utf8, allowLossyConversion: false) + + let expectation = self.expectation(description: "wait for response") + let request = TestRequest() + + session.sessionTaskPublisher(for: request) + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion, case .responseError(let responseError as NSError) = error { + XCTAssertEqual(responseError.domain, NSCocoaErrorDomain) + XCTAssertEqual(responseError.code, 3840) + expectation.fulfill() + } else { + XCTFail() + } + }, receiveValue: { response in + XCTFail() + }) + .store(in: &cancellables) + + waitForExpectations(timeout: 1.0, handler: nil) + } + + func testBefore2020OSVersionsCancel() throws { + if #available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) { + throw XCTSkip("Skip on After 2020 OS versions, as Combine cancellation no longer emits a value.") + } + + let cancelExpectation = self.expectation(description: "wait for cancel") + let completionExpectation = self.expectation(description: "wait for response") + let request = TestRequest() + + let cancellable = session.sessionTaskPublisher(for: request) + .handleEvents(receiveCancel: { + cancelExpectation.fulfill() + }) + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion, case .connectionError(let connectionError as NSError) = error { + XCTAssertEqual(connectionError.code, 0) + completionExpectation.fulfill() + } else { + XCTFail() + } + }, receiveValue: { response in + XCTFail() + }) + cancellable.cancel() + + waitForExpectations(timeout: 1.0, handler: nil) + } + + func testAfter2020OSVersionsCancel() throws { + guard #available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) else { + throw XCTSkip("Skip on Before 2020 OS versions.") + } + + let expectation = self.expectation(description: "wait for cancel") + let request = TestRequest() + + let cancellable = session.sessionTaskPublisher(for: request) + .handleEvents(receiveCancel: { + expectation.fulfill() + }) + .sink(receiveCompletion: { completion in + XCTFail() + }, receiveValue: { response in + XCTFail() + }) + cancellable.cancel() + + waitForExpectations(timeout: 1.0, handler: nil) + } + +} + +#endif