Sitelet https://github.com/ishkawa/APIKit/pull/285/files
Skip to content
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 @@ -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 */; };
Expand Down Expand Up @@ -76,6 +78,8 @@
/* End PBXCopyFilesBuildPhase section */

/* Begin PBXFileReference section */
0969AE0E259DEC6D00C498AF /* Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Combine.swift; sourceTree = "<group>"; };
0973EE34259E2DDC00879BA2 /* CombineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineTests.swift; sourceTree = "<group>"; };
141F120F1C1C96820026D415 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Base.xcconfig; path = Configurations/Base.xcconfig; sourceTree = "<group>"; };
141F12101C1C96820026D415 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Configurations/Debug.xcconfig; sourceTree = "<group>"; };
141F12111C1C96820026D415 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Configurations/Release.xcconfig; sourceTree = "<group>"; };
Expand Down Expand Up @@ -149,6 +153,23 @@
/* End PBXFrameworksBuildPhase section */

/* Begin PBXGroup section */
0969AE0D259DEC3C00C498AF /* Combine */ = {
isa = PBXGroup;
children = (
0969AE0E259DEC6D00C498AF /* Combine.swift */,
);
name = Combine;
path = APIKit/Combine;
sourceTree = "<group>";
};
0973EE33259E2DD000879BA2 /* Combine */ = {
isa = PBXGroup;
children = (
0973EE34259E2DDC00879BA2 /* CombineTests.swift */,
);
path = Combine;
sourceTree = "<group>";
};
141F120E1C1C96690026D415 /* Configurations */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -219,6 +240,7 @@
7F698E451D9D680C00F1561D /* RequestTests.swift */,
7F698E491D9D680C00F1561D /* SessionCallbackQueueTests.swift */,
7F698E4A1D9D680C00F1561D /* SessionTests.swift */,
0973EE33259E2DD000879BA2 /* Combine */,
7F698E3B1D9D680C00F1561D /* BodyParametersType */,
7F698E401D9D680C00F1561D /* DataParserType */,
7F698E461D9D680C00F1561D /* SessionAdapterType */,
Expand Down Expand Up @@ -288,6 +310,7 @@
7F7048CA1D9D89BE003C99F6 /* Request.swift */,
7F7048CB1D9D89BE003C99F6 /* Session.swift */,
7F7048CC1D9D89BE003C99F6 /* Unavailable.swift */,
0969AE0D259DEC3C00C498AF /* Combine */,
7F85FB8B1C9D317300CEE132 /* SessionAdapter */,
7F18BD0D1C972C38003A31DF /* BodyParameters */,
7FA19A441C9CC9A2005D25AE /* DataParser */,
Expand Down Expand Up @@ -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 */,
);
Expand All @@ -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 */,
Expand Down
89 changes: 89 additions & 0 deletions Sources/APIKit/Combine/Combine.swift
Original file line number Diff line number Diff line change
@@ -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<Request: APIKit.Request>: 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<S>(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<Request: APIKit.Request, Downstream: Subscriber>: 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<Request: APIKit.Request>(for request: Request, callbackQueue: CallbackQueue? = nil) -> SessionTaskPublisher<Request> {
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<Request: APIKit.Request>(for request: Request, callbackQueue: CallbackQueue? = nil) -> SessionTaskPublisher<Request> {
return SessionTaskPublisher(request: request, session: self, callbackQueue: callbackQueue)
}
}

#endif
4 changes: 2 additions & 2 deletions Sources/APIKit/Session.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<Request: APIKit.Request>(with requestType: Request.Type, passingTest test: @escaping (Request) -> Bool = { _ in true }) {
shared.cancelRequests(with: requestType, passingTest: test)
}
Expand Down
124 changes: 124 additions & 0 deletions Tests/APIKitTests/Combine/CombineTests.swift
Original file line number Diff line number Diff line change
@@ -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<AnyCancellable> = []

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