Writing a retryIf operator with Swift's Combine framework - swift

I'm getting to know Swift + Swift's Combine framework and wanted to check that my attempt at implementing a retryIf(retries:, shouldRetry:) operator makes sense. In particular, I'm curious if all the .eraseToAnyPublishers are expected/idiomatic.
extension Publisher {
func retryIf(retries: Int, shouldRetry: #escaping (Self.Failure) -> Bool) -> AnyPublisher<Self.Output, Self.Failure> {
self.catch { error -> AnyPublisher<Self.Output, Self.Failure> in
guard shouldRetry(error) && retries > 0 else {
return Fail(error: error).eraseToAnyPublisher()
}
return self.retryIf(retries: retries - 1, shouldRetry: shouldRetry).eraseToAnyPublisher()
}.eraseToAnyPublisher()
}
}
Assuming that all the AnyPublishers are ok, when do you want to make your own Publisher struct? For example, the regular Combine operator retry returns a Retry<Upstream> struct rather than an AnyPublisher, but I imagine you could implement it along the same lines as the code above, something like:
extension Publisher {
func doOver(tries: Int) -> AnyPublisher<Self.Output, Self.Failure> {
self.catch { error -> AnyPublisher<Self.Output, Self.Failure> in
guard tries > 0 else { return Fail(error: error).eraseToAnyPublisher() }
return self.doOver(tries: tries - 1).eraseToAnyPublisher()
}.eraseToAnyPublisher()
}
}

You can eliminate the final eraseToAnyPublisher, and thus the heap allocation it requires, by defining your own Publisher. For example:
extension Publisher {
func retry(_ retries: Int, if shouldRetry: #escaping (Failure) -> Bool) -> MyPublishers.RetryIf<Self> {
return .init(upstream: self, triesLeft: retries, shouldRetry: shouldRetry)
}
}
enum MyPublishers { }
extension MyPublishers {
struct RetryIf<Upstream: Publisher>: Publisher {
typealias Output = Upstream.Output
typealias Failure = Upstream.Failure
init(upstream: Upstream, triesLeft: Int, shouldRetry: #escaping (Failure) -> Bool) {
self.upstream = upstream
self.triesLeft = triesLeft
self.shouldRetry = shouldRetry
}
var upstream: Upstream
var triesLeft: Int
var shouldRetry: (Failure) -> Bool
func receive<Downstream: Subscriber>(subscriber: Downstream) where Failure == Downstream.Failure, Output == Downstream.Input {
upstream
.catch {
triesLeft > 0 && shouldRetry($0)
? Self(upstream: upstream, triesLeft: triesLeft - 1, shouldRetry: shouldRetry).eraseToAnyPublisher()
: Fail(error: $0).eraseToAnyPublisher()
}
.receive(subscriber: subscriber)
}
}
}
If you want to eliminate the two eraseToAnyPublisher calls inside the catch body, you will have to give up using catch. Instead you will have to implement your own Subscription. Implementing Subscription is much more complicated, because it has to be thread-safe. However, those calls inside the catch body can only happen in the case of an upstream failure, and only one of the calls happens per failure. So if upstream failures are rare, it's probably not worth the effort.

Related

Swift combine repeat if (with delay) custom publisher

I need to implement repeat if with delay custom publisher for Swift Combine. The purpose of it is to repeatedly poll backend endpoint with delay set from previous response. It could be long polling (max 5min. with 3 - 6sec time period). I tried to use recursive approach, but it is not working consistelty. It makes from 20 to 200 repeats randomly, and then there is fired finished on the former/first subsciption and rest of the subscrptions are finished also. Count of repeats probably depends on memory situation etc.. Any coments or hints how to implement described functionality in reactive way are welcome.
Playground: git#github.com:BenBella/repeatif-publisher-playground.git
enum CustomPublishers { }
extension CustomPublishers {
struct RepeatIf<Upstream: Publisher>: Publisher {
typealias Output = Upstream.Output
typealias Failure = Upstream.Failure
init(
upstream: Upstream, shouldRepeat: #escaping (Upstream.Output) -> Bool,
withDelay: #escaping (Upstream.Output) -> Int
) {
self.upstream = upstream
self.shouldRepeat = shouldRepeat
self.withDelay = withDelay
}
var upstream: Upstream
var shouldRepeat: (Upstream.Output) -> Bool
var withDelay: (Upstream.Output) -> Int
func receive<Downstream: Subscriber>(subscriber: Downstream) where Failure == Downstream.Failure, Output == Downstream.Input {
upstream
.print("CustomPublishers.RepeatIf(1)>")
.flatMap { output in
Just((output)).setFailureType(to: Downstream.Failure.self)
.delay(for: .seconds(withDelay(output)),
scheduler: DispatchQueue.global())
}
.flatMap { output in
shouldRepeat(output)
? Self(upstream: upstream, shouldRepeat: shouldRepeat, withDelay: self.withDelay)
.eraseToAnyPublisher()
: Just((output)).setFailureType(to: Downstream.Failure.self)
.eraseToAnyPublisher()
}
.catch { (error: Upstream.Failure) -> AnyPublisher<Output, Failure> in
return Fail(error: error).eraseToAnyPublisher()
}
.print("CustomPublishers.RepeatIf(2)>")
.receive(subscriber: subscriber)
}
}
}
I was able to implement desired functionality with different approach, but I am still interested in any comments regarding to why recursive flatMap doesn't work. Thank you
Working solution:
func retryRequestWithDelay(url: URL) -> AnyPublisher<Response>, AppError> {
let pollPublisher = CurrentValueSubject<Int, AppError>(0)
return pollPublisher.compactMap { [weak self] delay in
return self?.networkingService.request(url)
.delay(for: .seconds(delay), scheduler: DispatchQueue.global())
}
.switchToLatest()
.receive(on: DispatchQueue.main)
.handleEvents(receiveOutput: { [weak self] (response: Response) in
guard let self = self else { return }
if response.status == .pending {
self.pollPublisher.send(response.pollPeriod)
} else {
self.pollPublisher.send(completion: .finished)
}
})
.filter { (response: Response) in
guard response.data.status == .pending else { return true }
return false
}
.eraseToAnyPublisher()
}
You can achieve this via two flatMap() calls:
extension CustomPublishers {
struct RepeatIf<Upstream: Publisher>: Publisher {
typealias Output = Upstream.Output
typealias Failure = Upstream.Failure
init(upstream: Upstream,
shouldRepeat: #escaping (Upstream.Output) -> Bool,
withDelay delay: #escaping (Upstream.Output) -> Int) {
self.upstream = upstream
self.shouldRepeat = shouldRepeat
self.delay = delay
}
private var upstream: Upstream
private var shouldRepeat: (Upstream.Output) -> Bool
private var delay: (Upstream.Output) -> Int
func receive<Downstream: Subscriber>(subscriber: Downstream) where Failure == Downstream.Failure, Output == Downstream.Input {
upstream
.flatMap { (output: Output) -> AnyPublisher<Output, Failure> in
guard shouldRepeat(output) else {
return Just(output)
.setFailureType(to: Failure.self)
.eraseToAnyPublisher()
}
return Just(())
.delay(for: .seconds(delay(output)), scheduler: DispatchQueue.main)
.flatMap { () -> RepeatIf<Upstream> in
return RepeatIf(upstream: upstream, shouldRepeat: shouldRepeat, withDelay: delay)
}
.eraseToAnyPublisher()
}
.receive(subscriber: subscriber)
}
}
}
extension Publisher {
func repeatIf(shouldRepeat: #escaping (Output) -> Bool,
withDelay delay: #escaping (Output) -> Int) -> CustomPublishers.RepeatIf<Self> {
.init(upstream: self, shouldRepeat: shouldRepeat, withDelay: delay)
}
}
The outer flatMap will either return the value, if no retry should be made, or it flatMap's over a dummy published whose scope is just to provide a starting point for the delay operator.
Example usage:
// this is a simple publisher that provides an incremented value each time
// a subscription occurs
var cnt = 0
let future = Deferred {
Future<Int, Never> { promise in
cnt += 1
promise(.success(cnt))
}
}
// let's test this baby
let subscription = future
.handleEvents(receiveOutput: { print("\(Date()): original value: \($0)") })
.repeatIf(shouldRepeat: { $0 < 5 }, withDelay: { $0 })
.sink(receiveValue: { print("\(Date()): final value: \($0)") })
// making sure we wait for all events to be received
// and makins sure the subscription doesn't deallocate while we wait
withExtendedLifetime(subscription) {
RunLoop.main.run()
}
The above code prints something like this:
2022-08-23 17:10:10 +0000: original value: 1
2022-08-23 17:10:11 +0000: original value: 2
2022-08-23 17:10:13 +0000: original value: 3
2022-08-23 17:10:16 +0000: original value: 4
2022-08-23 17:10:20 +0000: original value: 5
2022-08-23 17:10:20 +0000: final value: 5

How do I forward Publisher output to a downstream Subscriber through a custom operator?

I have a potential use for Combine, but I am having a lot of trouble with the implementation details. The goal is to provide an Publisher that will do the following:
Search for a cached value, and emit that value, or:
Refer the subscriber to an upstream publisher that will emit a value, storing it in the appropriate cache location
I understand that this could be done using existing operators, but I would like to learn how to make a custom Operator/Publisher/Subscription pattern, if possible.
I'd like the usage to be similar to the following bit of pseduocode:
URLSession.shared.dataTaskPublisher(for: url)
.cache(with: { someSortOfCachingPolicy })
.sink()
In order to implement this, I am guessing at what Apple does for things like map and flatMap.
I have created a CachePublisher to try to capture the Upstream Publisher:
struct CachePublisher<Upstream: Publisher>: Publisher {
typealias Output = Upstream.Output
typealias Failure = Upstream.Failure
var upstream: Upstream
var getCache: ()->Output?
var setCache: (Output)->Void
func receive<S>(subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input {
let subscription = CachePublisherSubscription(subscriber: subscriber, upstream: upstream, getCache: getCache, setCache: setCache)
subscriber.receive(subscription: subscription)
}
init(_ upstream: Upstream, getCache: #escaping ()->Output?, setCache: #escaping (Output)->Void) {
self.upstream = upstream
self.getCache = getCache
self.setCache = setCache
}
}
This is followed up with a Subscription:
extension CachePublisher {
class CachePublisherSubscription<S: Subscriber>: Subscription where S.Input == Upstream.Output, S.Failure == Upstream.Failure {
var subscriber: S
var upstream: Upstream
var setCache: (Output)->Void
var getCache: ()->Output?
init(subscriber: S, upstream: Upstream, getCache: #escaping ()->Output?, setCache: #escaping (Output)->Void) {
self.subscriber = subscriber
self.upstream = upstream
self.getCache = getCache
self.setCache = setCache
}
func request(_ demand: Subscribers.Demand) {
///check the cache for a value that satisfies the type
///return a value from the upstream publisher if not
if let output = self.getCache() {
subscriber.receive(output)
} else {
//forward an upstream value?
//how? an entire publisher/subscriber chain?
}
}
func cancel() {
}
}
}
And finally, a function so you can pass the upstream publisher to the CachePublisher
extension Publisher {
func cache() -> CachePublisher<Self> {
return CachePublisher(self, getCache: { nil }, setCache: { _ in })
}
}
I have no idea what to put in the required methods, or how to pass the subscriber up the chain to the upstream publisher. Or how to capture values from the upstream publisher.
The idea that came into my head is that downstream subscribers sort of create a nesting doll type structure, but I just don't know how to connect them.
You don't need the whole Publisher/Publishers/Subscription dance, you can customize the subscribe method without needing a custom class. Existing Combine operators to the rescue here :).
extension Publisher {
func cache(read: #escaping Publishers.Cache<Self>.Read,
write: #escaping Publishers.Cache<Self>.Write) -> Publishers.Cache<Self> {
Publishers.Cache(upstream: self, read: read, write: write)
}
}
extension Publishers {
struct Cache<P: Publisher>: Publisher {
typealias Output = P.Output
typealias Failure = P.Failure
typealias Read = () -> Output?
typealias Write = (Output) -> Void
private let upstream: P
private let read: Read
private let write: Write
init(upstream: P, read: #escaping Read, write: #escaping Write) {
self.upstream = upstream
self.read = read
self.write = write
}
func receive<S>(subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input {
if let cachedValue = read() {
Just(cachedValue).setFailureType(to: Failure.self).receive(subscriber: subscriber)
} else {
upstream.handleEvents(receiveOutput: write).receive(subscriber: subscriber)
}
}
}
}
handleEvents kinda breaks the "pure functions" paradigm that is recommended to be followed when writing custom operators pipelines, however as you anyway need to write to the cache, and that's already a side effect, the added impact of calling handleEvents is not that big.
Making the custom Subscription also a Subscriber allows it to connect both directions. When the cache getter produces a result, it is sent to the downstream subscriber. However, when the getter does not, the demand is forwarded to the upstream publisher, which emits a value.
That value is then captured by the Subscriber methods of the custom Subscription and forwarded to the downstream subscriber.
extension CachePublisher {
class CachePublisherSubscription<Downstream: Subscriber>: Subscription, Subscriber where Downstream.Input == Upstream.Output, Downstream.Failure == Upstream.Failure {
typealias Input = Upstream.Output
typealias Failure = Upstream.Failure
var downstream: Downstream
var upstream: Upstream
var upstreamSubscription: Subscription?
var read: Read
var write: Write
init(downstream: Downstream, upstream: Upstream, read: #escaping Read, write: #escaping Write) {
self.downstream = downstream
self.upstream = upstream
self.read = read
self.write = write
upstream.subscribe(self)
}
func request(_ demand: Subscribers.Demand) {
if let cachedValue = read() {
downstream.receive(cachedValue)
} else {
upstreamSubscription?.request(demand)
}
}
// keep a reference to the upstream subscription
func receive(subscription: Subscription) {
self.upstreamSubscription = subscription
}
// pass input downstream
func receive(_ input: Input) -> Subscribers.Demand {
self.write(input)
return downstream.receive(input)
}
// pass completion downstream
func receive(completion: Subscribers.Completion<Failure>) {
downstream.receive(completion: completion)
}
func cancel() {
//TO-DO: Finish cancellation
}
}
}

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()
}
}

Swift Combine alternative to Rx Observable.create

I have some code that is built using RxSwift, and I'm playing around with converting it to use Apple's Combine framework.
One pattern which is very common is the use of Observable.create for one-shot observables (usually network requests). Something like this:
func loadWidgets() -> Observable<[Widget]> {
return Observable.create { observer in
// start the request when someone subscribes
let loadTask = WidgetLoader.request("allWidgets", completion: { widgets in
// publish result on success
observer.onNext(widgets)
observer.onComplete()
}, error: { error in
// publish error on failure
observer.onError()
})
// allow cancellation
return Disposable {
loadTask.cancel()
}
}
}
I'm trying to map that across to Combine and I haven't been able to quite figure it out. The closest I've been able to get is using Future for something like this:
func loadWidgets() -> AnyPublisher<[Widget], Error> {
return Future<[Widget], Error> { resolve in
// start the request when someone subscribes
let loadTask = WidgetLoader.request("allWidgets", completion: { widgets in
// publish result on success
resolve(.success(widgets))
}, error: { error in
// publish error on failure
resolve(.failure(error))
})
// allow cancellation ???
}
}
As you can see, it does most of it, but there's no ability to cancel.
Secondarily, future doesn't allow multiple results.
Is there any way to do something like the Rx Observable.create pattern which allows cancellation and optionally multiple results?
I think I found a way to mimic Observable.create using a PassthroughSubject in Combine. Here is the helper I made:
struct AnyObserver<Output, Failure: Error> {
let onNext: ((Output) -> Void)
let onError: ((Failure) -> Void)
let onComplete: (() -> Void)
}
struct Disposable {
let dispose: () -> Void
}
extension AnyPublisher {
static func create(subscribe: #escaping (AnyObserver<Output, Failure>) -> Disposable) -> Self {
let subject = PassthroughSubject<Output, Failure>()
var disposable: Disposable?
return subject
.handleEvents(receiveSubscription: { subscription in
disposable = subscribe(AnyObserver(
onNext: { output in subject.send(output) },
onError: { failure in subject.send(completion: .failure(failure)) },
onComplete: { subject.send(completion: .finished) }
))
}, receiveCancel: { disposable?.dispose() })
.eraseToAnyPublisher()
}
}
And here is how it looks in usage:
func loadWidgets() -> AnyPublisher<[Widget], Error> {
AnyPublisher.create { observer in
let loadTask = WidgetLoader.request("allWidgets", completion: { widgets in
observer.onNext(widgets)
observer.onComplete()
}, error: { error in
observer.onError(error)
})
return Disposable {
loadTask.cancel()
}
}
}
From what I've learned, the support for initializing an AnyPublisher with a closure has been dropped in Xcode 11 beta 3. This would be a corresponding solution for Rx's Observable.create in this case, but for now I believe that the Future is a goto solution if you only need to propagate single value. In other cases I would go for returning a PassthroughSubject and propagating multiple values this way, but it will not allow you to start a task when the observation starts and I believe it's far from ideal compared to Observable.create.
In terms of cancellation, it does not have an isDisposed property similar to a Disposable, so it's not possible to directly check the state of it and stop your own tasks from executing. The only way that I can think of right now would be to observe for a cancel event, but it's surely not as comfortable as a Disposable.
Also, I'd assume that cancel might in fact stop tasks like network requests from URLSession based on the docs here: https://developer.apple.com/documentation/combine/cancellable
Add an isCancelled operation outside the closure and check it in the future's closure. isCancelled can be toggled with the handleEvent() operator.
var isCancelled = false
func loadWidgets() -> AnyPublisher<[Widget], Error> {
return HandleEvents<Future<Any, Error>> { resolve in
// start the request when someone subscribes
let loadTask = WidgetLoader.request("allWidgets", completion: { widgets in
// publish result on success
resolve(.success(widgets))
}, error: { error in
// publish error on failure
resolve(.failure(error))
}
if isCancelled {
loadTask.cancel()
}
).handleEvents(receiveCancel: {
isCancelled = true
})
}
}
and somewhere in the app you do this to cancel the event
loadWidgets().cancel()
Also check this article
Thanks to ccwasden for the inspiration. This replicates Observable.create semantics with a pure Combine implementation without any superfluous entities.
AnyPublisher.create() Swift 5.6 Extension
public extension AnyPublisher {
static func create<Output, Failure>(_ subscribe: #escaping (AnySubscriber<Output, Failure>) -> AnyCancellable) -> AnyPublisher<Output, Failure> {
let passthroughSubject = PassthroughSubject<Output, Failure>()
var cancellable: AnyCancellable?
return passthroughSubject
.handleEvents(receiveSubscription: { subscription in
let subscriber = AnySubscriber<Output, Failure> { subscription in
} receiveValue: { input in
passthroughSubject.send(input)
return .unlimited
} receiveCompletion: { completion in
passthroughSubject.send(completion: completion)
}
cancellable = subscribe(subscriber)
}, receiveCompletion: { completion in
}, receiveCancel: {
cancellable?.cancel()
})
.eraseToAnyPublisher()
}
}
Usage
func doSomething() -> AnyPublisher<Int, Error> {
return AnyPublisher<Int, Error>.create { subscriber in
// Imperative implementation of doing something can call subscriber as follows
_ = subscriber.receive(1)
subscriber.receive(completion: .finished)
// subscriber.receive(completion: .failure(myError))
return AnyCancellable {
// Imperative cancellation implementation
}
}
}

Observable with an a decision tree

I need a function that encapsulates a complicated IAP purchase tree into a simple attemptPurchase function that returns a boolean observable (true -> success, false -> cancelled, error -> any error)
But I am stumped at how to create that function, mainly because the start of the decision is async.
Decision tree and code below.
// fails -> missing return function
// but I cannot return the credit check, since the execution is different depending on the result
func attemptPurchase(amount: Int) -> Observable<Bool>{
let creditCheck = creditCheck(amount)
creditCheck.filter{$0}.subscribeNext{ _ in
return Observable.just(true)
}
creditCheck.filter{$0}.subscribeNext{ _ in
return confirmIAP().processIAP()
}
}
func creditCheck(amount: Int) -> Observable<Bool>{
return API.creditCheck.map{$0 > amount}
}
func confirmIAP() -> Observable<Bool> {
// UI for confirming IAP
}
func processIAP() -> Observable<Bool> {
// UI for uploading IAP on my server
}
This is how you could do it:
func attemptPurchase(amount: Int) -> Observable<Bool> {
return creditCheck(amount)
.flatMapLatest { (enoughCredit: Bool) -> Observable<Bool> in
if enoughCredit {
return Observable.just(true)
} else {
return confirmIAP()
.flatMapLatest { (isConfirmed: Bool) -> Observable<Bool> in
if isConfirmed {
return processIAP()
} else {
return Observable.just(false)
}
}
}
}
}
From iankeen's answer in the RxSwift slack group:
func attemptPurchase(amount: Int) -> Observable<Bool>{
return creditCheck(amount)
.flatMap { enough in
return (enough ? .just(true) : confirmIAP())
}
.flatMap { ready in
guard ready else { /* failure */ }
return processIAP()
}
}