Combine - My Custom Publisher Is Not Publish - swift

I've been working on GraphQL for a while and I wanted to use custom publisher and Apollo together. But some kind of custom publisher didn't work, I've been dealing with it for days. I couldn't find what I missed
note: After running fetchPublisher(), no error or data is returned.
Custom Publisher
import Foundation
import Apollo
import Combine
struct ApolloPublisher<Query: GraphQLQuery>: Publisher {
typealias Output = GraphQLResult<Query.Data>
typealias Failure = Error
private let client: ApolloClient
private let query: Query
init(client:ApolloClient,
query: Query) {
self.client = client
self.query = query
}
func receive<S>(subscriber: S) where S : Subscriber, S.Failure == Error, S.Input == GraphQLResult<Query.Data> {
let subscription = ApolloSubscription(client: self.client,
query: self.query,
subscriber: subscriber)
subscriber.receive(subscription: subscription)
}
}
Custom Subscription
import Foundation
import Apollo
import Combine
final class ApolloSubscription<GraphQuery: GraphQLQuery, SubscriberType: Subscriber>: Subscription where SubscriberType.Input == GraphQLResult<GraphQuery.Data>, SubscriberType.Failure == Error {
private let subscriber: SubscriberType
private var cancelable: Apollo.Cancellable?
init(client: ApolloClient,
query: GraphQuery,
subscriber: SubscriberType) {
self.subscriber = subscriber
self.cancelable = client.fetch(query: query,
resultHandler: self.handle)
}
deinit {
cancelable?.cancel()
}
func request(_ demand: Subscribers.Demand) {}
func cancel() {
cancelable?.cancel()
cancelable = nil
}
func handle(result: Result<GraphQLResult<GraphQuery.Data>, Error>) {
switch result {
case .success(let resultSet):
_ = subscriber.receive(resultSet)
case .failure(let error):
subscriber.receive(completion: .failure(error))
}
subscriber.receive(completion: .finished)
}
}
extension for ApolloClient
extension ApolloClient {
func fetchPublisher<Query: GraphQLQuery>(query: Query) -> ApolloPublisher<Query> {
return ApolloPublisher(client: self,
query: query)
}
}
Client side.
let apollo = ApolloClient(url: URL(string: "https://api.spacex.land/graphql/")!)
func fetch() -> AnyPublisher<String, Error> {
return apollo.fetchPublisher(query: GetLaunchesQuery())
.mapError { error in
return error
}
.map { response in
return response.data?.launches?.description ?? ""
}
.eraseToAnyPublisher()
}

Related

"withLatestFrom" won't accept BehaviorRelay

I've got this simple view model to check a phone number's status before registering a user. But I've got this error:
Instance method 'withLatestFrom' requires that 'BehaviorRelay' conform to 'SharedSequenceConvertibleType'
Here's the code:
import Foundation
import RxSwift
import RxCocoa
protocol RegisterViewModelling {
var openRegistrationData: Signal<String> { get }
var showErrorMessage: Signal<String> { get }
var sendButtonActive: Driver<Bool> { get }
var phoneNumberText: BehaviorRelay<String> { get }
var tapSendButton: PublishRelay<Void> { get }
}
final class RegisterViewModel: RegisterViewModelling {
var openRegistrationData: Signal<String>
let showErrorMessage: Signal<String>
let sendButtonActive: Driver<Bool>
let phoneNumberText: BehaviorRelay<String> = BehaviorRelay<String>(value: "")
let tapSendButton: PublishRelay<Void> = PublishRelay<Void>()
init(getPhoneNumberStatus: GetPhoneNumberStatusUseCase) {
sendButtonActive = phoneNumberText
.asDriver(onErrorDriveWith: .empty())
.map(shouldButtonActive(number:))
let isRegistered = tapSendButton
.withLatestFrom(phoneNumberText)
.flatMap(getPhoneNumberStatus.get(number:))
.share()
showErrorMessage = isRegistered
.asSignal(onErrorSignalWith: .just(true))
.filter { $0 == true }
.map { _ in () }
.map(getErrorMessage)
openRegistrationData = isRegistered
.asSignal(onErrorSignalWith: .just(true))
.filter { $0 == false }
.withLatestFrom(phoneNumberText) // ERROR: Instance method 'withLatestFrom' requires that 'BehaviorRelay<String>' conform to 'SharedSequenceConvertibleType'
}
}
private func shouldButtonActive(number: String) -> Bool {
return !number.isEmpty && number.count <= 15
}
private func getErrorMessage() -> String {
return "Phone number has been registered."
}
protocol GetPhoneNumberStatusUseCase {
func get(number: String) -> Observable<Bool>
}
What went wrong here? Why won't withLatestFrom work at that line but it worked fine on the others? Thanks.
I think it because you have convert isRegistered to Signal before use withLatestFrom. You can try to move asSignal() to below withLatestFrom

Cannot map error after flatMap usage (Never result type)

I have RestManager class which is used for fetching data from Internet and is returning AnyPublisher
class RestManager {
func fetchData<T: Decodable>(url: URL) -> AnyPublisher<T, ErrorType> {
URLSession
.shared
.dataTaskPublisher(for: url)
.tryMap({ data, _ in
let value = try JSONDecoder().decode(T.self, from: data)
if let array = value as? Array<Any>, array.isEmpty {
throw ErrorType.empty
}
return value
})
.mapError { error -> ErrorType in
switch error {
case is ErrorType:
return ErrorType.empty
case let urlError as URLError:
switch urlError.code {
case .notConnectedToInternet, .networkConnectionLost, .timedOut:
return .noInternetConnection
case .cannotDecodeRawData, .cannotDecodeContentData:
return .empty
default:
return .general
}
default:
return .general
}
}
.eraseToAnyPublisher()
}
}
Repository has two functions (getWorldwideData and getCountryData returning AnyPublisher<(WorldwideResponse item or CountryResponse item), ErrorType>)
In viewModel, I made these functions.
private func getData() {
$useCaseSelection
.flatMap { value -> AnyPublisher<Covid19StatisticsDomainItem, ErrorType> in
self.loader = true
self.error = nil
switch value {
case let .country(name):
return self.countryPipeline(name: name)
case .worldwide:
return self.worldwidePipeline()
}
}
.mapError { error in
self.error = error
}
.assign(to: &$homeScreenDomainItem)
}
private func worldwidePipeline() -> AnyPublisher<Covid19StatisticsDomainItem, ErrorType> {
repository
.getWorldwideData()
.map { response -> Covid19StatisticsDomainItem in
self.error = nil
self.loader = false
return Covid19StatisticsDomainItem(worldwideResponseItem: response)
}
.eraseToAnyPublisher()
}
private func countryPipeline(name: String) -> AnyPublisher<Covid19StatisticsDomainItem, ErrorType> {
repository
.getCountryData(for: name)
.map { response -> Covid19StatisticsDomainItem in
self.error = nil
self.loader = false
return Covid19StatisticsDomainItem(countryDayOneStatsResponse: response)
}
.eraseToAnyPublisher()
}
I wanted to make clean code, so I split code into two separate function based on useCaseSelection.
useCaseSelection is enum with two types.
error is ErrorType? value wrapped with #Published, in which I want to save error type if there is any error.
homeScreenDomainItem is Covid19StatisticsDomainItem instance wrapped with #Published.
Problem is in getData function where in MapError pipeline I am getting:
Cannot convert value of type () to closure result type Never
I tried to use setFailureType(to: ErrorType.self) but that is not helping.

Published Object Not Working - Combine Swift

Anyone who understands please help me, I want to retrieve data from Firestore and it wants to be read in realtime whenever there is a change from the database, so I use addSnapshotListener to read it, it works every time there is a change from Firestore, but it's still in object form NewsResponse. Because the final result I want to change to a NewsDomainModel form, then I continue the results from NewsResponse to _mapper.transformerResponseToDomain to be converted into NewsDomainModel, but every latest data generated using addSnapshotListener is not updated to the _mapper.transformerResponseToDomain, _mapper only reads 1 time the data sent only.
GetNewsRepository
import SwiftUI
import Core
import Combine
public class GetNewsRepository<
NewsLocaleDataSource: LocaleDataSource,
RemoteDataSource: DataSource,
Transformer: Mapper>: ObservableObject, Repository
where
NewsLocaleDataSource.Request == String,
NewsLocaleDataSource.Response == NewsModuleEntity,
RemoteDataSource.Request == String,
RemoteDataSource.Response == [NewsResponse],
Transformer.Request == String,
Transformer.Response == [NewsResponse],
Transformer.Entity == [NewsModuleEntity],
Transformer.Domain == [NewsDomainModel] {
public typealias Request = String
#Published public var Response: [NewsDomainModel] = [NewsDomainModel]()
private let _localeDataSource: NewsLocaleDataSource
#Published public var _remoteDataSource: RemoteDataSource
#Published public var _mapper: Transformer
public init(
localeDataSource: NewsLocaleDataSource,
remoteDataSource: RemoteDataSource,
mapper: Transformer) {
_localeDataSource = localeDataSource
_remoteDataSource = remoteDataSource
_mapper = mapper
}
public func execute(request: String?) -> AnyPublisher<[NewsDomainModel], Error> {
return self._remoteDataSource.execute(request: request)
.map { self._mapper.transformerResponseToDomain(response: $0) }
.eraseToAnyPublisher()
}
}
GetNewsRemoteDataSource
import Core
import Combine
import FirebaseFirestore
import FirebaseFirestoreSwift
import Foundation
public class GetNewsRemoteDataSource: ObservableObject, DataSource {
public typealias Request = String
#Published public var Response: [NewsResponse] = [NewsResponse]()
private let _endPoint: String
public init(endPoint: String) {
_endPoint = endPoint
}
public func execute(request: String?) -> AnyPublisher<[NewsResponse], Error> {
return Future<[NewsResponse], Error> { completion in
let ref = Firestore.firestore()
ref.collection("news").addSnapshotListener { (querySnapshot, error) in
guard let documents = querySnapshot?.documents else {
print("Document not found")
return
}
let dataJson = documents.compactMap { queryDocumentSnapshot in
try? queryDocumentSnapshot.data(as: NewsResponse.self)
} // THIS DATA WILL BE AUTOMATIC UPDATE IF DATA FROM FIRESTORE UPDATED
completion(.success(dataJson))
}
}.eraseToAnyPublisher()
}
}
NewsTransformer
import Core
import Combine
public class NewsTransformer<NewsMapper: Mapper>: ObservableObject, Mapper
where
NewsMapper.Request == String,
NewsMapper.Response == NewsResponse,
NewsMapper.Entity == NewsModuleEntity,
NewsMapper.Domain == NewsDomainModel {
#Published public var Request = String()
#Published public var Response = [NewsResponse]()
#Published public var Entity = [NewsModuleEntity]()
#Published public var Domain = [NewsDomainModel]()
private let _newsMapper: NewsMapper
public init(newsMapper: NewsMapper) {
_newsMapper = newsMapper
}
public func transformerResponseToEntity(request: String?, response: [NewsResponse]) -> [NewsModuleEntity] {
return response.map { result in
_newsMapper.transformerResponseToEntity(request: request, response: result)
}
}
public func transformerResponseToDomain(response: [NewsResponse]) -> [NewsDomainModel] {
print("\(response) DATA RESPONSE") // NOT UPDATING
return response.map { results in
_newsMapper.transformerResponseToDomain(response: results)
}
}
public func transformerEntityToDomain(entity: [NewsModuleEntity]) -> [NewsDomainModel] {
return entity.map { result in
_newsMapper.transformerEntityToDomain(entity: result)
}
}
public func transformerDomainToEntities(domain: [NewsDomainModel]) -> [NewsModuleEntity] {
return domain.map { result in
_newsMapper.transformerDomainToEntities(domain: result)
}
}
}
The sequence of images above is GetNewsRepository (Repository), GetNewsRemoteDataSource (Get Data From Firebase), NewsTransformer (Transform from NewsResponse to NewsDomainModel)
Sorry if the title I ask is wrong.
Your Future in GetNewsRemoteDataSource completes. Once a publisher has completed it won't send any further values. Instead of using a Future, return a PassthroughSubject. And use the .send method to pass values through the publisher.
https://developer.apple.com/documentation/combine/passthroughsubject

How to synchronously refresh an access token using Alamofire + RxSwift

I have this generic fetchData() function in my NetworkManager class that is able to request make a authorised request to the network and if it fail (after a number of retries) emits an error that will restart my app (requesting a new login). I need that this retry token be called synchronously, I mean, if multiple requests failed, only one should be requesting the refresh token at once. And if that one fail, and the other one requests must be discarded. I already tried some approached using DispatchGroup / NSRecursiveLock / and also with calling the function cancelRequests describing bellow (in this case, the tasks count is always 0). How can I make this behaviour works in this scenario?
My NetworkManager class:
public func fetchData<Type: Decodable>(fromApi api: TargetType,
decodeFromKeyPath keyPath: String? = nil) -> Single<Response> {
let request = MultiTarget(api)
return provider.rx.request(request)
.asRetriableAuthenticated(target: request)
}
func cancelAllRequests(){
if #available(iOS 9.0, *) {
DefaultAlamofireManager
.sharedManager
.session
.getAllTasks { (tasks) in
tasks.forEach{ $0.cancel() }
}
} else {
DefaultAlamofireManager
.sharedManager
.session
.getTasksWithCompletionHandler { (sessionDataTask, uploadData, downloadData) in
sessionDataTask.forEach { $0.cancel() }
uploadData.forEach { $0.cancel() }
downloadData.forEach { $0.cancel() }
}
}
}
The Single extension that make the retry works:
public extension PrimitiveSequence where TraitType == SingleTrait, ElementType == Response {
private var refreshTokenParameters: TokenParameters {
TokenParameters(clientId: "pdappclient",
grantType: "refresh_token",
refreshToken: KeychainManager.shared.refreshToken)
}
func retryWithToken(target: MultiTarget) -> Single<E> {
self.catchError { error -> Single<Response> in
if case Moya.MoyaError.statusCode(let response) = error {
if self.isTokenExpiredError(error) {
return Single.error(error)
} else {
return self.parseError(response: response)
}
}
return Single.error(error)
}
.retryToken(target: target)
.catchError { error -> Single<Response> in
if case Moya.MoyaError.statusCode(let response) = error {
return self.parseError(response: response)
}
return Single.error(InvalidGrantException())
}
}
private func retryToken(target: MultiTarget) -> Single<E> {
let maxRetries = 1
return self.retryWhen({ error in
error
.enumerated()
.flatMap { (attempt, error) -> Observable<Int> in
if attempt >= maxRetries {
return Observable.error(error)
}
if self.isTokenExpiredError(error) {
return Observable<Int>.just(attempt + 1)
}
return Observable.error(error)
}
.flatMap { _ -> Single<TokenResponse> in
self.refreshTokenRequest()
}
.share()
.asObservable()
})
}
private func refreshTokenRequest() -> Single<TokenResponse> {
return NetworkManager.shared.fetchData(fromApi: IdentityServerAPI
.token(parameters: self.refreshTokenParameters)).do(onSuccess: { tokenResponse in
KeychainManager.shared.accessToken = tokenResponse.accessToken
KeychainManager.shared.refreshToken = tokenResponse.refreshToken
}, onError: { error in
NetworkManager.shared.cancelAllRequests()
})
}
func parseError<E>(response: Response) -> Single<E> {
if response.statusCode == 401 {
// TODO
}
let decoder = JSONDecoder()
if let errors = try? response.map([BaseResponseError].self, atKeyPath: "errors", using: decoder,
failsOnEmptyData: true) {
return Single.error(BaseAPIErrorResponse(errors: errors))
}
return Single.error(APIError2.unknown)
}
func isTokenExpiredError(_ error: Error) -> Bool {
if let moyaError = error as? MoyaError {
switch moyaError {
case .statusCode(let response):
if response.statusCode != 401 {
return false
} else if response.data.count == 0 {
return true
}
default:
break
}
}
return false
}
func filterUnauthorized() -> Single<E> {
flatMap { (response) -> Single<E> in
if 200...299 ~= response.statusCode {
return Single.just(response)
} else if response.statusCode == 404 {
return Single.just(response)
} else {
return Single.error(MoyaError.statusCode(response))
}
}
}
func asRetriableAuthenticated(target: MultiTarget) -> Single<Element> {
filterUnauthorized()
.retryWithToken(target: target)
.filterStatusCode()
}
func filterStatusCode() -> Single<E> {
flatMap { (response) -> Single<E> in
if 200...299 ~= response.statusCode {
return Single.just(response)
} else {
return self.parseError(response: response)
}
}
}
}
Here is an RxSwift solution: RxSwift and Handling Invalid Tokens
Just posting the link isn't the best, so I will post the core of the solution as well:
The key is to make a class that is much like the ActivityMonitor class but handles token refreshing...
public final class TokenAcquisitionService<T> {
/// responds with the current token immediatly and emits a new token whenver a new one is aquired. You can, for example, subscribe to it in order to save the token as it's updated.
public var token: Observable<T> {
return _token.asObservable()
}
public typealias GetToken = (T) -> Observable<(response: HTTPURLResponse, data: Data)>
/// Creates a `TokenAcquisitionService` object that will store the most recent authorization token acquired and will acquire new ones as needed.
///
/// - Parameters:
/// - initialToken: The token the service should start with. Provide a token from storage or an empty string (object represting a missing token) if one has not been aquired yet.
/// - getToken: A function responsable for aquiring new tokens when needed.
/// - extractToken: A function that can extract a token from the data returned by `getToken`.
public init(initialToken: T, getToken: #escaping GetToken, extractToken: #escaping (Data) throws -> T) {
relay
.flatMapFirst { getToken($0) }
.map { (urlResponse) -> T in
guard urlResponse.response.statusCode / 100 == 2 else { throw TokenAcquisitionError.refusedToken(response: urlResponse.response, data: urlResponse.data) }
return try extractToken(urlResponse.data)
}
.startWith(initialToken)
.subscribe(_token)
.disposed(by: disposeBag)
}
/// Allows the token to be set imperativly if necessary.
/// - Parameter token: The new token the service should use. It will immediatly be emitted to any subscribers to the service.
func setToken(_ token: T) {
lock.lock()
_token.onNext(token)
lock.unlock()
}
/// Monitors the source for `.unauthorized` error events and passes all other errors on. When an `.unauthorized` error is seen, `self` will get a new token and emit a signal that it's safe to retry the request.
///
/// - Parameter source: An `Observable` (or like type) that emits errors.
/// - Returns: A trigger that will emit when it's safe to retry the request.
func trackErrors<O: ObservableConvertibleType>(for source: O) -> Observable<Void> where O.Element == Error {
let lock = self.lock
let relay = self.relay
let error = source
.asObservable()
.map { error in
guard (error as? TokenAcquisitionError) == .unauthorized else { throw error }
}
.flatMap { [unowned self] in self.token }
.do(onNext: {
lock.lock()
relay.onNext($0)
lock.unlock()
})
.filter { _ in false }
.map { _ in }
return Observable.merge(token.skip(1).map { _ in }, error)
}
private let _token = ReplaySubject<T>.create(bufferSize: 1)
private let relay = PublishSubject<T>()
private let lock = NSRecursiveLock()
private let disposeBag = DisposeBag()
}
extension ObservableConvertibleType where Element == Error {
/// Monitors self for `.unauthorized` error events and passes all other errors on. When an `.unauthorized` error is seen, the `service` will get a new token and emit a signal that it's safe to retry the request.
///
/// - Parameter service: A `TokenAcquisitionService` object that is being used to store the auth token for the request.
/// - Returns: A trigger that will emit when it's safe to retry the request.
public func renewToken<T>(with service: TokenAcquisitionService<T>) -> Observable<Void> {
return service.trackErrors(for: self)
}
}
Once you put the above in your app, you can just add a .retryWhen { $0.renewToken(with: tokenAcquisitionService) } to the end of your request. Make sure your request emits a ResponseError.unauthorized if the token is unauthorized and the service will handle the retry.
I found a solution to my problem using DispatchWorkItem and controlling the entrance on my function with a boolean: isTokenRefreshing. Maybe that's not the most elegant solution, but it works.
So, in my NetworkManager class I added this two new properties:
public var savedRequests: [DispatchWorkItem] = []
public var isTokenRefreshing = false
Now in my SingleTrait extension, whenever I enter in the token refresh method I set the boolean isTokenRefreshing to true. So, if it's true, instead of starting another request, I simply throw a RefreshTokenProcessInProgressException and save the current request in my savedRequests array.
private func saveRequest(_ block: #escaping () -> Void) {
// Save request to DispatchWorkItem array
NetworkManager.shared.savedRequests.append( DispatchWorkItem {
block()
})
}
(Of course, that, if the token refresh succeeds you have to remember to continue all the savedRequests that are saved inside the array, it's not described inside the code down below yet).
Well, my SingleTrait extension is now something like this:
import Foundation
import Moya
import RxSwift
import Domain
public extension PrimitiveSequence where TraitType == SingleTrait, ElementType == Response {
private var refreshTokenParameters: TokenParameters {
TokenParameters(clientId: "pdappclient",
grantType: "refresh_token",
refreshToken: KeychainManager.shared.refreshToken)
}
func retryWithToken(target: MultiTarget) -> Single<E> {
return self.catchError { error -> Single<Response> in
if case Moya.MoyaError.statusCode(let response) = error {
if self.isTokenExpiredError(error) {
return Single.error(error)
} else {
return self.parseError(response: response)
}
}
return Single.error(error)
}
.retryToken(target: target)
.catchError { error -> Single<Response> in
if case Moya.MoyaError.statusCode(let response) = error {
return self.parseError(response: response)
}
return Single.error(error)
}
}
private func retryToken(target: MultiTarget) -> Single<E> {
let maxRetries = 1
return self.retryWhen({ error in
error
.enumerated()
.flatMap { (attempt, error) -> Observable<Int> in
if attempt >= maxRetries {
return Observable.error(error)
}
if self.isTokenExpiredError(error) {
return Observable<Int>.just(attempt + 1)
}
return Observable.error(error)
}
.flatMapFirst { _ -> Single<TokenResponse> in
if NetworkManager.shared.isTokenRefreshing {
self.saveRequest {
self.retryToken(target: target)
}
return Single.error(RefreshTokenProcessInProgressException())
} else {
return self.refreshTokenRequest()
}
}
.share()
.asObservable()
})
}
private func refreshTokenRequest() -> Single<TokenResponse> {
NetworkManager.shared.isTokenRefreshing = true
return NetworkManager.shared.fetchData(fromApi: IdentityServerAPI
.token(parameters: self.refreshTokenParameters))
.do(onSuccess: { tokenResponse in
KeychainManager.shared.accessToken = tokenResponse.accessToken
KeychainManager.shared.refreshToken = tokenResponse.refreshToken
}).catchError { error -> Single<TokenResponse> in
return Single.error(InvalidGrantException())
}
}
private func saveRequest(_ block: #escaping () -> Void) {
// Save request to DispatchWorkItem array
NetworkManager.shared.savedRequests.append( DispatchWorkItem {
block()
})
}
func parseError<E>(response: Response) -> Single<E> {
if response.statusCode == 401 {
// TODO
}
let decoder = JSONDecoder()
if let errors = try? response.map([BaseResponseError].self, atKeyPath: "errors", using: decoder,
failsOnEmptyData: true) {
return Single.error(BaseAPIErrorResponse(errors: errors))
}
return Single.error(APIError2.unknown)
}
func isTokenExpiredError(_ error: Error) -> Bool {
if let moyaError = error as? MoyaError {
switch moyaError {
case .statusCode(let response):
if response.statusCode != 401 {
return false
} else if response.data.count == 0 {
return true
}
default:
break
}
}
return false
}
func filterUnauthorized() -> Single<E> {
flatMap { (response) -> Single<E> in
if 200...299 ~= response.statusCode {
return Single.just(response)
} else if response.statusCode == 404 {
return Single.just(response)
} else {
return Single.error(MoyaError.statusCode(response))
}
}
}
func asRetriableAuthenticated(target: MultiTarget) -> Single<Element> {
filterUnauthorized()
.retryWithToken(target: target)
.filterStatusCode()
}
func filterStatusCode() -> Single<E> {
flatMap { (response) -> Single<E> in
if 200...299 ~= response.statusCode {
return Single.just(response)
} else {
return self.parseError(response: response)
}
}
}
}
In my case, if the token refresh fails, after a N number of retries, I restart the app. And so, whenever a restart the application I'm setting the isTokenRefreshing to false again.
This is the way I found to solve this problem. If you have another approach, please let me know.

Publisher emitting progress of operation and final value

Given I have an SDK which provides the functionality below
class SDK {
static func upload(completion: #escaping (Result<String, Error>) -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
completion(.success("my_value"))
}
}
}
I am able to a create a wrapper around to make its usage more functional
class CombineSDK {
func upload() -> AnyPublisher<String, Error> {
Future { promise in
SDK.upload { result in
switch result {
case .success(let key):
promise(.success(key))
case .failure(let error):
promise(.failure(error))
}
}
}.eraseToAnyPublisher()
}
}
Now I'm trying to understand how my CombineSDK.upload method should look like if the SDK upload method also provides a progress block like below:
class SDK {
static func upload(progress: #escaping (Double) -> Void, completion: #escaping (Result<String, Error>) -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
progress(0.5)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
progress(1)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
completion(.success("s3Key"))
}
}
}
We need an Output type for your publisher that represents either the progress, or the final value. So we should use an enum. Since the Foundation framework already defines a type named Progress, we'll name ours Progressable to avoid a name conflict. We might as well make it generic:
enum Progressable<Value> {
case progress(Double)
case value(Value)
}
Now we need to think about how the publisher should behave. A typical publisher, like URLSession.DataTaskPublisher, doesn't do anything until it gets a subscription, and it starts its work fresh for each subscription. The retry operator only works if the upstream publisher behaves like this.
So our publisher should behave that way, too:
extension SDK {
static func uploadPublisher() -> UploadPublisher {
return UploadPublisher()
}
struct UploadPublisher: Publisher {
typealias Output = Progressable<String>
typealias Failure = Error
func receive<S>(subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input {
<#code#>
}
}
}
Creating the publisher (by calling SDK.uploadPublisher()) doesn't start any work. We'll replace <#code#> with code to start the upload:
extension SDK {
static func uploadPublisher() -> UploadPublisher {
return UploadPublisher()
}
struct UploadPublisher: Publisher {
typealias Output = Progressable<String>
typealias Failure = Error
func receive<S>(subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input {
let subject = PassthroughSubject<Output, Failure>()
subject.receive(subscriber: subscriber)
upload(
progress: { subject.send(.progress($0)) },
completion: {
switch $0 {
case .success(let value):
subject.send(.value(value))
subject.send(completion: .finished)
case .failure(let error):
subject.send(completion: .failure(error))
}
}
)
}
}
}
Note that we call subject.receive(subscriber: subscriber) before we start the upload. This is important! What if upload calls one of its callbacks synchronously, before returning? By passing the subscriber to the subject before calling upload, we ensure that the subscriber has the chance to be notified even if upload calls its callbacks synchronously.
Note: started writing an answer that's has a largely similar intent to #robmayoff's answer, but using Deferred, so posting here for completeness.
Swift Combine only works with values and errors - there's no separate type for progress. But you can model the progress as part of the output, either as a tuple, as was suggested in another answer, or as a custom enum with both progress and result as cases, which would be my preferred approach.
class CombineSDK {
enum UploadProgress<T> {
case progress(Double)
case result(T)
}
func upload() -> AnyPublisher<UploadProgress<String>, Error> {
Deferred { () -> AnyPublisher<UploadProgress<String>, Error> in
let subject = PassthroughSubject<UploadProgress<String>, Error>()
SDK.upload(
progress: { subject.send(.progress($0)) },
completion: { r in
let _ = r.map(UploadProgress.result).publisher.subscribe(subject)
})
return subject.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
}
EDIT
Based on #robmayoff's comment, the above solution doesn't handle synchronous case where subject.send is called before subject is returned.
The solution is largely the same, but it does introduce a small complication of having to capture these values, just in case. This can be done with Record, which will provide a temporary sink to subject
func upload() -> AnyPublisher<UploadProgress<String>, Error> {
Deferred { () -> AnyPublisher<UploadProgress<String>, Error> in
let subject = PassthroughSubject<UploadProgress<String>, Error>()
var recording = Record<UploadProgress<String>, Error>.Recording()
subject.sink(
receiveCompletion: { recording.receive(completion: $0) },
receiveValue: { recording.receive($0) })
SDK.upload(
progress: { subject.send(.progress($0)) },
completion: { r in
let _ = r.map(UploadProgress.result).publisher.subscribe(subject)
})
return Record(recording: recording).append(subject).eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
Here is possible approach
extension CombineSDK {
func upload() -> AnyPublisher<(Double, String?), Error> {
let publisher = PassthroughSubject<(Double, String?), Error>()
SDK.upload(progress: { value in
publisher.send((value, nil))
}, completion: { result in
switch result {
case .success(let key):
publisher.send((1.0, key))
publisher.send(completion: .finished)
case .failure(let error):
publisher.send(completion: .failure(error))
}
})
return publisher.eraseToAnyPublisher()
}
}