diff --git a/Examples/Basic/ViewController.swift b/Examples/Basic/ViewController.swift index 37b9a5d1d..a4cb68edd 100644 --- a/Examples/Basic/ViewController.swift +++ b/Examples/Basic/ViewController.swift @@ -1,9 +1,11 @@ import UIKit import Moya +import Combine class ViewController: UITableViewController { var progressView = UIView() var repos = NSArray() + var cancellable: AnyCancellable? override func viewDidLoad() { super.viewDidLoad() @@ -39,16 +41,11 @@ class ViewController: UITableViewController { } func downloadZen() { - gitHubProvider.request(.zen) { result in - var message = "Couldn't access API" - - if case let .success(response) = result { - let jsonString = try? response.mapString() - message = jsonString ?? message - } - - self.showAlert("Zen", message: message) - } + cancellable = gitHubProvider.requestPublisher(.zen) + .mapString() + .sink(receiveCompletion: { _ in }, receiveValue: { message in + self.showAlert("Zen", message: message) + }) } func uploadGiphy() { diff --git a/Moya.podspec b/Moya.podspec index 09164fd51..2e62e9198 100644 --- a/Moya.podspec +++ b/Moya.podspec @@ -21,9 +21,10 @@ Pod::Spec.new do |s| s.default_subspec = "Core" s.swift_version = '5.1' s.cocoapods_version = '>= 1.4.0' + s.pod_target_xcconfig = { 'OTHER_LDFLAGS' => '-weak_framework Combine' } s.subspec "Core" do |ss| - ss.source_files = "Sources/Moya/", "Sources/Moya/Plugins/" + ss.source_files = "Sources/Moya/", "Sources/Moya/Combine", "Sources/Moya/Plugins/" ss.dependency "Alamofire", "~> 5.0" ss.framework = "Foundation" end diff --git a/Moya.xcodeproj/project.pbxproj b/Moya.xcodeproj/project.pbxproj index b0c3de57a..61150cd83 100644 --- a/Moya.xcodeproj/project.pbxproj +++ b/Moya.xcodeproj/project.pbxproj @@ -63,6 +63,10 @@ 83B0E400A7562256224CB7FF /* AccessTokenPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC115388D44D0DB7A753E9BB /* AccessTokenPlugin.swift */; }; 85850A4122CF5AB50089E731 /* NimbleHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85850A4022CF5AB50089E731 /* NimbleHelpers.swift */; }; 85B2DBBC221C69620098F59A /* PropertyListEncoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85B2DBBB221C69620098F59A /* PropertyListEncoding.swift */; }; + 85C98000231D2D4000AAAFB2 /* MoyaProvider+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85C97FFF231D2D4000AAAFB2 /* MoyaProvider+Combine.swift */; }; + 85C98002231D3EC700AAAFB2 /* MoyaProvider+CombineSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85C98001231D3EC700AAAFB2 /* MoyaProvider+CombineSpec.swift */; }; + 85C98006231D41E400AAAFB2 /* MoyaPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85C98005231D41E400AAAFB2 /* MoyaPublisher.swift */; }; + 85C98008231D4BE900AAAFB2 /* AnyPublisher+Response.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85C98007231D4BE900AAAFB2 /* AnyPublisher+Response.swift */; }; 85D5277A2302BEF30093E9C1 /* TestImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85D527792302BEF30093E9C1 /* TestImage.swift */; }; 85F6042E22A018BB00063320 /* RequestTypeWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85F6042D22A018BB00063320 /* RequestTypeWrapper.swift */; }; 8995DF740A59721AA79F5B43 /* MoyaProvider+ReactiveSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = C841AA621AEC61FAEA0CA019 /* MoyaProvider+ReactiveSpec.swift */; }; @@ -194,6 +198,10 @@ 851FFE6DC58E873408D0E8E7 /* MoyaProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MoyaProvider.swift; sourceTree = ""; }; 85850A4022CF5AB50089E731 /* NimbleHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NimbleHelpers.swift; sourceTree = ""; }; 85B2DBBB221C69620098F59A /* PropertyListEncoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertyListEncoding.swift; sourceTree = ""; }; + 85C97FFF231D2D4000AAAFB2 /* MoyaProvider+Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MoyaProvider+Combine.swift"; sourceTree = ""; }; + 85C98001231D3EC700AAAFB2 /* MoyaProvider+CombineSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MoyaProvider+CombineSpec.swift"; sourceTree = ""; }; + 85C98005231D41E400AAAFB2 /* MoyaPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoyaPublisher.swift; sourceTree = ""; }; + 85C98007231D4BE900AAAFB2 /* AnyPublisher+Response.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AnyPublisher+Response.swift"; sourceTree = ""; }; 85D527792302BEF30093E9C1 /* TestImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestImage.swift; sourceTree = ""; }; 85F6042D22A018BB00063320 /* RequestTypeWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestTypeWrapper.swift; sourceTree = ""; }; 86F3C0EA472C23E786E23CE9 /* Image.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Image.swift; sourceTree = ""; }; @@ -310,6 +318,7 @@ 331A6D3090D9773091435406 /* Moya */ = { isa = PBXGroup; children = ( + 85C97FFE231D2D2D00AAAFB2 /* Combine */, B2CD7F4E23A6F1BA007F67AC /* Atomic.swift */, 149749421F8923EC00FA4900 /* AnyEncodable.swift */, 5C2B20158E599EDBE51D7AB2 /* Cancellable.swift */, @@ -402,6 +411,16 @@ path = Plugins; sourceTree = ""; }; + 85C97FFE231D2D2D00AAAFB2 /* Combine */ = { + isa = PBXGroup; + children = ( + 85C98007231D4BE900AAAFB2 /* AnyPublisher+Response.swift */, + 85C98005231D41E400AAAFB2 /* MoyaPublisher.swift */, + 85C97FFF231D2D4000AAAFB2 /* MoyaProvider+Combine.swift */, + ); + path = Combine; + sourceTree = ""; + }; 85D527782302B4CC0093E9C1 /* MoyaTests */ = { isa = PBXGroup; children = ( @@ -411,6 +430,7 @@ C79C5F376B4A2C3F70051980 /* Error+MoyaSpec.swift */, 7F112022D8F42844A7D5CB5C /* ErrorTests.swift */, 819334FE5D32EFB82599D482 /* MethodSpec.swift */, + 85C98001231D3EC700AAAFB2 /* MoyaProvider+CombineSpec.swift */, C841AA621AEC61FAEA0CA019 /* MoyaProvider+ReactiveSpec.swift */, 4C7A20CC38EB5A0221E6EA34 /* MoyaProvider+RxSpec.swift */, 82E52DC541FD052ABA625D2A /* MoyaProviderIntegrationTests.swift */, @@ -838,18 +858,21 @@ B2CD7F4F23A6F1BA007F67AC /* Atomic.swift in Sources */, FB223B5C3B7D4AA5261E25EA /* Image.swift in Sources */, EDBA10DB0D0E35C1474AAB4D /* Moya+Alamofire.swift in Sources */, + 85C98000231D2D4000AAAFB2 /* MoyaProvider+Combine.swift in Sources */, 15D3A2BD1223B59E2BBE09F0 /* MoyaError.swift in Sources */, 59BC4B9B9D0D6F9C060E5620 /* MoyaProvider+Defaults.swift in Sources */, 149749431F8923EC00FA4900 /* AnyEncodable.swift in Sources */, 9854C1DBF14818CCA76AEC7F /* MoyaProvider+Internal.swift in Sources */, 147E26EC1F5B14B300C1F513 /* Task.swift in Sources */, 149749451F892E2F00FA4900 /* URLRequest+Encoding.swift in Sources */, + 85C98008231D4BE900AAAFB2 /* AnyPublisher+Response.swift in Sources */, 5BCAACCA268FEA0DAB07F998 /* MoyaProvider.swift in Sources */, 61633F6E85FB39A4707A6167 /* MultipartFormData.swift in Sources */, 5054F87AFF61EAC0097F88BD /* MultiTarget.swift in Sources */, BEAA605B30BE7651BA5C61A4 /* Plugin.swift in Sources */, 83B0E400A7562256224CB7FF /* AccessTokenPlugin.swift in Sources */, B5A7124CF285D8EC7F89C1AD /* CredentialsPlugin.swift in Sources */, + 85C98006231D41E400AAAFB2 /* MoyaPublisher.swift in Sources */, 1446FBB31F214C5200C1EFF2 /* URL+Moya.swift in Sources */, 4A609CED953E8A6C59AD01A1 /* NetworkActivityPlugin.swift in Sources */, 1FD44D9E21CEA6B6221807EF /* NetworkLoggerPlugin.swift in Sources */, @@ -907,6 +930,7 @@ files = ( A6304A7E74FA3B04C9B10B63 /* AccessTokenPluginSpec.swift in Sources */, 2C7132B56A7B129E12BAACAC /* EndpointSpec.swift in Sources */, + 85C98002231D3EC700AAAFB2 /* MoyaProvider+CombineSpec.swift in Sources */, 78A2D3991F44BACD00C9E122 /* RxTestHelpers.swift in Sources */, 53376123902C6A22A44DB88E /* Error+MoyaSpec.swift in Sources */, 78A2D39B1F44D45100C9E122 /* Single+MoyaSpec.swift in Sources */, @@ -1141,6 +1165,9 @@ GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 10.0; MACOSX_DEPLOYMENT_TARGET = 10.12; + OTHER_LDFLAGS = $OTHER_LDFLAGS_XCODE11; + OTHER_LDFLAGS_XCODE10 = ""; + OTHER_LDFLAGS_XCODE11 = "-weak_framework Combine"; SDKROOT = macosx; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; SWIFT_VERSION = 5.0; @@ -1462,6 +1489,9 @@ IPHONEOS_DEPLOYMENT_TARGET = 10.0; MACOSX_DEPLOYMENT_TARGET = 10.12; ONLY_ACTIVE_ARCH = YES; + OTHER_LDFLAGS = $OTHER_LDFLAGS_XCODE11; + OTHER_LDFLAGS_XCODE10 = ""; + OTHER_LDFLAGS_XCODE11 = "-weak_framework Combine"; SDKROOT = macosx; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; SWIFT_VERSION = 5.0; diff --git a/Sources/Moya/Combine/AnyPublisher+Response.swift b/Sources/Moya/Combine/AnyPublisher+Response.swift new file mode 100644 index 000000000..b10704fa1 --- /dev/null +++ b/Sources/Moya/Combine/AnyPublisher+Response.swift @@ -0,0 +1,120 @@ +#if canImport(Combine) + +import Foundation +import Combine + +#if canImport(UIKit) + import UIKit.UIImage +#elseif canImport(AppKit) + import AppKit.NSImage +#endif + +/// Extension for processing raw NSData generated by network access. +@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public extension AnyPublisher where Output == Response, Failure == MoyaError { + + /// Filters out responses that don't fall within the given range, generating errors when others are encountered. + func filter(statusCodes: R) -> AnyPublisher where R.Bound == Int { + return unwrapThrowable { response in + try response.filter(statusCodes: statusCodes) + } + } + + /// Filters out responses that has the specified `statusCode`. + func filter(statusCode: Int) -> AnyPublisher { + return unwrapThrowable { response in + try response.filter(statusCode: statusCode) + } + } + + /// Filters out responses where `statusCode` falls within the range 200 - 299. + func filterSuccessfulStatusCodes() -> AnyPublisher { + return unwrapThrowable { response in + try response.filterSuccessfulStatusCodes() + } + } + + /// Filters out responses where `statusCode` falls within the range 200 - 399 + func filterSuccessfulStatusAndRedirectCodes() -> AnyPublisher { + return unwrapThrowable { response in + try response.filterSuccessfulStatusAndRedirectCodes() + } + } + + /// Maps data received from the signal into an Image. If the conversion fails, the signal errors. + func mapImage() -> AnyPublisher { + return unwrapThrowable { response in + try response.mapImage() + } + } + + /// Maps data received from the signal into a JSON object. If the conversion fails, the signal errors. + func mapJSON(failsOnEmptyData: Bool = true) -> AnyPublisher { + return unwrapThrowable { response in + try response.mapJSON(failsOnEmptyData: failsOnEmptyData) + } + } + + /// Maps received data at key path into a String. If the conversion fails, the signal errors. + func mapString(atKeyPath keyPath: String? = nil) -> AnyPublisher { + return unwrapThrowable { response in + try response.mapString(atKeyPath: keyPath) + } + } + + /// Maps received data at key path into a Decodable object. If the conversion fails, the signal errors. + func map(_ type: D.Type, atKeyPath keyPath: String? = nil, using decoder: JSONDecoder = JSONDecoder(), failsOnEmptyData: Bool = true) -> AnyPublisher { + return unwrapThrowable { response in + try response.map(type, atKeyPath: keyPath, using: decoder, failsOnEmptyData: failsOnEmptyData) + } + } +} + +@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public extension AnyPublisher where Output == ProgressResponse, Failure == MoyaError { + + /** + Filter completed progress response and maps to actual response + + - returns: response associated with ProgressResponse object + */ + func filterCompleted() -> AnyPublisher { + return self + .compactMap { $0.response } + .eraseToAnyPublisher() + } + + /** + Filter progress events of current ProgressResponse + + - returns: observable of progress events + */ + func filterProgress() -> AnyPublisher { + return self + .filter { !$0.completed } + .map { $0.progress } + .eraseToAnyPublisher() + } +} + +@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +extension AnyPublisher where Failure == MoyaError { + + // Workaround for a lot of things, actually. We don't have Publishers.Once, flatMap + // that can throw and a lot more. So this monster was created because of that. Sorry. + private func unwrapThrowable(throwable: @escaping (Output) throws -> T) -> AnyPublisher { + self.tryMap { element in + try throwable(element) + } + .mapError { error -> MoyaError in + if let moyaError = error as? MoyaError { + return moyaError + } else { + return .underlying(error, nil) + } + } + .eraseToAnyPublisher() + } +} + +#endif diff --git a/Sources/Moya/Combine/MoyaProvider+Combine.swift b/Sources/Moya/Combine/MoyaProvider+Combine.swift new file mode 100644 index 000000000..0b66bba35 --- /dev/null +++ b/Sources/Moya/Combine/MoyaProvider+Combine.swift @@ -0,0 +1,62 @@ +#if canImport(Combine) + +import Foundation +import Combine + +@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public extension MoyaProvider { + + /// Designated request-making method. + /// + /// - Parameters: + /// - target: Entity, which provides specifications necessary for a `MoyaProvider`. + /// - callbackQueue: Callback queue. If nil - queue from provider initializer will be used. + /// - Returns: `AnyPublisher AnyPublisher { + return MoyaPublisher { [weak self] subscriber in + return self?.request(target, callbackQueue: callbackQueue, progress: nil) { result in + switch result { + case let .success(response): + _ = subscriber.receive(response) + subscriber.receive(completion: .finished) + case let .failure(error): + subscriber.receive(completion: .failure(error)) + } + } + } + .eraseToAnyPublisher() + } + + /// Designated request-making method with progress. + func requestWithProgressPublisher(_ target: Target, callbackQueue: DispatchQueue? = nil) -> AnyPublisher { + let progressBlock: (AnySubscriber) -> (ProgressResponse) -> Void = { subscriber in + return { progress in + _ = subscriber.receive(progress) + } + } + + let response = MoyaPublisher { [weak self] subscriber in + let cancellableToken = self?.request(target, callbackQueue: callbackQueue, progress: progressBlock(subscriber)) { result in + switch result { + case .success: + subscriber.receive(completion: .finished) + case let .failure(error): + subscriber.receive(completion: .failure(error)) + } + } + + return cancellableToken + } + + // Accumulate all progress and combine them when the result comes + return response + .scan(ProgressResponse()) { last, progress in + let progressObject = progress.progressObject ?? last.progressObject + let response = progress.response ?? last.response + return ProgressResponse(progress: progressObject, response: response) + } + .eraseToAnyPublisher() + } +} + +#endif diff --git a/Sources/Moya/Combine/MoyaPublisher.swift b/Sources/Moya/Combine/MoyaPublisher.swift new file mode 100644 index 000000000..139d477a1 --- /dev/null +++ b/Sources/Moya/Combine/MoyaPublisher.swift @@ -0,0 +1,42 @@ +#if canImport(Combine) + +import Combine + +// This should be already provided in Combine, but it's not. +// Ideally we would like to remove it, in favor of a framework-provided solution, ASAP. + +@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +internal class MoyaPublisher: Publisher { + + internal typealias Failure = MoyaError + + private class Subscription: Combine.Subscription { + + private let cancellable: Cancellable? + + init(subscriber: AnySubscriber, callback: @escaping (AnySubscriber) -> Cancellable?) { + self.cancellable = callback(subscriber) + } + + func request(_ demand: Subscribers.Demand) { + // We don't care for the demand right now + } + + func cancel() { + cancellable?.cancel() + } + } + + private let callback: (AnySubscriber) -> Cancellable? + + init(callback: @escaping (AnySubscriber) -> Cancellable?) { + self.callback = callback + } + + internal func receive(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input { + let subscription = Subscription(subscriber: AnySubscriber(subscriber), callback: callback) + subscriber.receive(subscription: subscription) + } +} + +#endif diff --git a/Tests/MoyaTests/MoyaProvider+CombineSpec.swift b/Tests/MoyaTests/MoyaProvider+CombineSpec.swift new file mode 100644 index 000000000..cb630e956 --- /dev/null +++ b/Tests/MoyaTests/MoyaProvider+CombineSpec.swift @@ -0,0 +1,342 @@ +#if canImport(Combine) +import Quick +import Nimble +import Combine + +#if canImport(OHHTTPStubs) + import OHHTTPStubs +#elseif canImport(OHHTTPStubsSwift) + import OHHTTPStubsCore + import OHHTTPStubsSwift +#endif + +@testable import Moya + +final class MoyaProviderCombineSpec: QuickSpec { + + override func spec() { + if #available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) { + describe("provider") { + var provider: MoyaProvider! + + beforeEach { + provider = MoyaProvider(stubClosure: MoyaProvider.immediatelyStub) + } + + it("emits one Response object") { + var calls = 0 + + _ = provider.requestPublisher(.zen) + .sink(receiveCompletion: { completion in + switch completion { + case let .failure(error): + fail("errored: \(error)") + default: + () + } + }, receiveValue: { _ in + calls += 1 + }) + + expect(calls).to(equal(1)) + } + + it("emits stubbed data for zen request") { + var responseData: Data? + + let target: GitHub = .zen + _ = provider.requestPublisher(target) + .sink(receiveCompletion: { completion in + switch completion { + case let .failure(error): + fail("errored: \(error)") + default: + () + } + }, receiveValue: { response in + responseData = response.data + }) + + expect(responseData).to(equal(target.sampleData)) + } + + it("maps JSON data correctly for user profile request") { + var receivedResponse: [String: Any]? + + let target: GitHub = .userProfile("ashfurrow") + _ = provider.requestPublisher(target) + .mapJSON() + .sink(receiveCompletion: { completion in + switch completion { + case let .failure(error): + fail("errored: \(error)") + default: + () + } + }, receiveValue: { response in + receivedResponse = response as? [String: Any] + }) + + expect(receivedResponse).toNot(beNil()) + } + } + + describe("failing") { + var provider: MoyaProvider! + + beforeEach { + provider = MoyaProvider(endpointClosure: failureEndpointClosure, stubClosure: MoyaProvider.immediatelyStub) + } + + it("emits the correct error message") { + var receivedError: MoyaError? + + _ = provider.requestPublisher(.zen) + .sink(receiveCompletion: { completion in + switch completion { + case let .failure(error): + receivedError = error + case .finished: + () + } + }, receiveValue: { _ in + fail("should have errored") + }) + + switch receivedError { + case .some(.underlying(let error, _)): + expect(error.localizedDescription) == "Houston, we have a problem" + default: + fail("expected an Underlying error that Houston has a problem") + } + } + + it("emits an error") { + var errored = false + + let target: GitHub = .zen + _ = provider.requestPublisher(target) + .sink(receiveCompletion: { completion in + switch completion { + case .failure: + errored = true + case .finished: + () + } + }, receiveValue: { _ in + fail("should have errored") + }) + + expect(errored).to(beTrue()) + } + } + + describe("a reactive provider") { + var provider: MoyaProvider! + + beforeEach { + OHHTTPStubs.stubRequests(passingTest: {$0.url!.path == "/zen"}, withStubResponse: { _ in + return OHHTTPStubsResponse(data: GitHub.zen.sampleData, statusCode: 200, headers: nil) + }) + provider = MoyaProvider(trackInflights: true) + } + + it("emits identical response for inflight requests") { + let target: GitHub = .zen + let signalProducer1 = provider.requestPublisher(target) + let signalProducer2 = provider.requestPublisher(target) + + expect(provider.inflightRequests.keys.count).to(equal(0)) + + var receivedResponse: Moya.Response! + + // If we do not name the variable, Combine's Cancellable will cancel itself + let cancellable1 = signalProducer1.sink(receiveCompletion: { completion in + switch completion { + case let .failure(error): + fail("errored: \(error)") + default: + () + } + }, receiveValue: { response in + receivedResponse = response + expect(provider.inflightRequests.count).to(equal(1)) + }) + + // If we do not name the variable, Combine's Cancellable will cancel itself + let cancellable2 = signalProducer2.sink(receiveCompletion: { completion in + switch completion { + case let .failure(error): + fail("errored: \(error)") + default: + () + } + }, receiveValue: { response in + expect(receivedResponse).toNot(beNil()) + expect(receivedResponse).to(beIdenticalToResponse(response)) + expect(provider.inflightRequests.count).to(equal(1)) + }) + + // This is to silence the warning about unused variables + _ = cancellable1 + _ = cancellable2 + + // Allow for network request to complete + expect(provider.inflightRequests.count).toEventually(equal(0)) + } + } + + describe("a provider with progress tracking") { + var provider: MoyaProvider! + + beforeEach { + //delete downloaded filed before each test + let directoryURLs = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) + let file = directoryURLs.first!.appendingPathComponent("logo_github.png") + try? FileManager.default.removeItem(at: file) + + //`responseTime(-4)` equals to 1000 bytes at a time. The sample data is 4000 bytes. + OHHTTPStubs.stubRequests(passingTest: {$0.url!.path.hasSuffix("logo_github.png")}, withStubResponse: { _ in + return OHHTTPStubsResponse(data: GitHubUserContent.downloadMoyaWebContent("logo_github.png").sampleData, statusCode: 200, headers: nil).responseTime(-4) + }) + provider = MoyaProvider() + } + + it("tracks progress of request") { + let target: GitHubUserContent = .downloadMoyaWebContent("logo_github.png") + + let expectedNextProgressValues = [0.25, 0.5, 0.75, 1.0, 1.0] + let expectedNextResponseCount = 1 + let expectedErrorEventsCount = 0 + let expectedCompletedEventsCount = 1 + let timeout = 5.0 + + var nextProgressValues: [Double] = [] + var nextResponseCount = 0 + var errorEventsCount = 0 + var completedEventsCount = 0 + + let cancellable = provider.requestWithProgressPublisher(target) + .sink(receiveCompletion: { completion in + switch completion { + case .failure: + errorEventsCount += 1 + case .finished: + completedEventsCount += 1 + } + }, receiveValue: { response in + nextProgressValues.append(response.progress) + + if response.response != nil { nextResponseCount += 1 } + }) + + // This is to silence the warning about unused variables + _ = cancellable + + expect(completedEventsCount).toEventually(equal(expectedCompletedEventsCount), timeout: timeout) + expect(errorEventsCount).toEventually(equal(expectedErrorEventsCount), timeout: timeout) + expect(nextResponseCount).toEventually(equal(expectedNextResponseCount), timeout: timeout) + expect(nextProgressValues).toEventually(equal(expectedNextProgressValues), timeout: timeout) + } + + describe("a custom callback queue") { + var stubDescriptor: OHHTTPStubsDescriptor! + + beforeEach { + stubDescriptor = OHHTTPStubs.stubRequests(passingTest: {$0.url!.path == "/zen"}, withStubResponse: { _ in + return OHHTTPStubsResponse(data: GitHub.zen.sampleData, statusCode: 200, headers: nil) + }) + } + + afterEach { + OHHTTPStubs.removeStub(stubDescriptor) + } + + describe("a provider with a predefined callback queue") { + var provider: MoyaProvider! + var callbackQueue: DispatchQueue! + + beforeEach { + callbackQueue = DispatchQueue(label: UUID().uuidString) + provider = MoyaProvider(callbackQueue: callbackQueue) + } + + context("the callback queue is provided with the request") { + it("invokes the callback on the request queue") { + let requestQueue = DispatchQueue(label: UUID().uuidString) + var callbackQueueLabel: String? + + let cancellable = provider.requestPublisher(.zen, callbackQueue: requestQueue) + .sink(receiveCompletion: { _ in }, receiveValue: { _ in + callbackQueueLabel = DispatchQueue.currentLabel + }) + + // This is to silence the warning about unused variables + _ = cancellable + + expect(callbackQueueLabel).toEventually(equal(requestQueue.label)) + } + } + + context("the queueless request method is invoked") { + it("invokes the callback on the provider queue") { + var callbackQueueLabel: String? + + let cancellable = provider.requestPublisher(.zen) + .sink(receiveCompletion: { _ in }, receiveValue: { _ in + callbackQueueLabel = DispatchQueue.currentLabel + }) + // This is to silence the warning about unused variables + _ = cancellable + + expect(callbackQueueLabel).toEventually(equal(callbackQueue.label)) + } + } + } + + describe("a provider without a predefined queue") { + var provider: MoyaProvider! + + beforeEach { + provider = MoyaProvider() + } + + context("the queue is provided with the request") { + it("invokes the callback on the specified queue") { + let requestQueue = DispatchQueue(label: UUID().uuidString) + var callbackQueueLabel: String? + + let cancellable = provider.requestPublisher(.zen, callbackQueue: requestQueue) + .sink(receiveCompletion: { _ in }, receiveValue: { _ in + callbackQueueLabel = DispatchQueue.currentLabel + }) + + // This is to silence the warning about unused variables + _ = cancellable + + expect(callbackQueueLabel).toEventually(equal(requestQueue.label)) + } + } + + context("the queue is not provided with the request") { + it("invokes the callback on the main queue") { + var callbackQueueLabel: String? + + let cancellable = provider.requestPublisher(.zen) + .sink(receiveCompletion: { _ in }, receiveValue: { _ in + callbackQueueLabel = DispatchQueue.currentLabel + }) + + // This is to silence the warning about unused variables + _ = cancellable + + expect(callbackQueueLabel).toEventually(equal(DispatchQueue.main.label)) + } + } + } + } + } + } + } +} +#endif