Transform callback approach to reactive with Combine - swift

This is what I am doing:
-> Login/Signup to Firebase using FirebaseAuthentification
-> Listining to AuthStateDidChangeListenerHandle
-> I store extra user information in Firestore, therefore I check if the user exists in Firestore
-> If the user does not exist I create an empty user
-> If everything was successful I return a Future Publisher via callback (I want to change that as well)
This is the checkLoginState function:
func checkLoginState(completion: #escaping (AnyPublisher<AccountDetails,Error>) -> Void) {
self.handler = Auth.auth().addStateDidChangeListener { [weak self] auth, user in
guard let safeSelf = self else { return }
completion(Future<AccountDetails,Error> { promise in
if let user = user {
print(user)
print(auth)
safeSelf.checkIfUserIsInDatabase(user: user.uid) { result in
switch result {
case .success(let isAvailable):
if isAvailable {
promise(.success(AccountDetails(userUID: user.uid,name: user.displayName, loggedIn: true, premiumUser: false)))
} else {
safeSelf.createEmptyUser(user: user.uid,email: user.email) { result in
switch result {
case .success(_):
promise(.success(AccountDetails(userUID: user.uid,name: user.displayName, loggedIn: true, premiumUser: false)))
case .failure(let error):
print(error)
}
}
}
case .failure(let error):
print(error)
}
}
} else {
promise(.success(AccountDetails(userUID: nil, loggedIn: false, premiumUser: false)))
}
}.eraseToAnyPublisher()
)
}
}
These are my current functions:
private func checkIfUserIsInDatabase(user id: String, completion: #escaping (Result<Bool,Error>) -> Void)
private func createEmptyUser(user id: String, email:String?, completion: #escaping (Result<Bool,Error>) -> Void)
Thats what I want to use:
private func checkIfUserIsInDatabase(user id: String) -> AnyPublisher<Bool,Error>
private func createEmptyUser(user id: String) -> AnyPublisher<Bool,Error>
func checkLoginState() -> AnyPublisher<AccountDetails,Error>
I had something like that, but it does not work, also looks confusing:
func checkLoginState(completion: #escaping (AnyPublisher<AccountDetails,Error>) -> Void) {
self.handler = Auth.auth().addStateDidChangeListener { [weak self] auth, user in
guard let safeSelf = self else { return }
completion(Future<AccountDetails,Error> { promise in
if let user = user {
print(user)
print(auth)
safeSelf.checkIfUserIsInDatabase(user: user.uid)
.sinkToResult { value in
switch value {
case .success(let isUserInDatabase):
if isUserInDatabase {
promise(.success(AccountDetails(userUID: user.uid,name: user.displayName, loggedIn: true, premiumUser: false)))
} else {
safeSelf.createEmptyUser(user: user.uid)
.sinkToResult { value in
switch value {
case .success( _):
promise(.success(AccountDetails(userUID: user.uid,name: user.displayName, loggedIn: true, premiumUser: false)))
case .failure(let error):
print(error)
}
}
}
case .failure(let error):
print(error)
}
}
} else {
promise(.success(AccountDetails(userUID: nil, loggedIn: false, premiumUser: false)))
}
}.eraseToAnyPublisher()
)
}
}

So you have some AccountDetails type:
import Combine
import FirebaseAuth
struct AccountDetails {
var userId: String
var name: String?
var isLoggedIn: Bool
var isPremiumUser: Bool
}
Let's extend it with an init that takes a User, because it will simplify things later:
extension AccountDetails {
init(user: User) {
self.userId = user.uid
self.name = user.displayName
self.isLoggedIn = true
self.isPremiumUser = false
}
}
I think your end goal is a Publisher that emits AccountDetails. But since there isn't always a logged-in user, it should really emit Optional<AccountDetails>, so that it can emit nil when the user logs out.
Let's start by wrapping the addStateDidChangeListener API in a Publisher. We can't use a Future for this, because a Future emits at most one output, but addStateDidChangeListener can emit multiple events. So we'll use a CurrentValueSubject instead. That means we need a place to store the subject and the AuthStateDidChangeListenerHandle. You could store them as globals, or in your AppDelegate, or wherever you feel is appropriate. For this answer, let's create a Demo class to hold them:
class Demo {
static let shared = Demo()
let userPublisher: AnyPublisher<User?, Error>
private let userSubject = CurrentValueSubject<User?, Error>(nil)
private var tickets: [AnyCancellable] = []
private init() {
userPublisher = userSubject.eraseToAnyPublisher()
let handle = Auth.auth().addStateDidChangeListener { [userSubject] (_, user) in
userSubject.send(user)
}
AnyCancellable { Auth.auth().removeStateDidChangeListener(handle) }
.store(in: &tickets)
}
}
So now you can get a Publisher of the logged-in user (or nil if no user is logged in) like this:
let loggedInUserPublisher: AnyPublisher<User?, Error> = Demo.shared.userPublisher
But you really want an AccountDetails? publisher, not a User? publisher, like this:
let accountDetailsPublisher: AnyPublisher<AccountDetails?, Error> = Demo.shared
.accountDetailsPublisher()
So we need to write an accountDetailsPublisher method that maps the User? to an AccountDetails?.
If the User? is nil, we just want to emit nil. But if the User? is .some(user), we need to do more asynchronous actions: we need to check whether the user is in the database, and add the user if not. The flatMap operator lets you chain asynchronous actions, but there's some complexity because we need to take different actions depending on the output of the upstream publisher.
We'd really like to hide the complexity away and just write this:
extension Demo {
func loggedInAccountDetailsPublisher() -> AnyPublisher<AccountDetails?, Error> {
return userPublisher
.flatMap(
ifSome: { $0.accountDetailsPublisher().map { Optional.some($0) } },
ifNone: { Just(nil).setFailureType(to: Error.self) })
.eraseToAnyPublisher()
}
}
But then we need to write flatMap(ifSome:ifNone:). Here it is:
extension Publisher {
func flatMap<Wrapped, Some: Publisher, None: Publisher>(
ifSome: #escaping (Wrapped) -> Some,
ifNone: #escaping () -> None
) -> AnyPublisher<Some.Output, Failure>
where Output == Optional<Wrapped>, Some.Output == None.Output, Some.Failure == Failure, None.Failure == Failure
{
return self
.flatMap { $0.map { ifSome($0).eraseToAnyPublisher() } ?? ifNone().eraseToAnyPublisher() }
.eraseToAnyPublisher()
}
}
Now we need to implement accountDetailsPublisher in a User extension. What does this method need to do? It needs to check whether the User is in the database (an asynchronous action) and, if not, add the User (another asynchronous action). Since we need to chain asynchronous actions, we again need flatMap. But we'd really like to just write this:
extension User {
func accountDetailsPublisher() -> AnyPublisher<AccountDetails, Error> {
return isInDatabasePublisher()
.flatMap(
ifTrue: { Just(AccountDetails(user: self)).setFailureType(to: Error.self) },
ifFalse: { self.addToDatabase().map { AccountDetails(user: self) } })
}
}
Here is flatMap(ifTrue:ifFalse:):
extension Publisher where Output == Bool {
func flatMap<True: Publisher, False: Publisher>(
ifTrue: #escaping () -> True,
ifFalse: #escaping () -> False
) -> AnyPublisher<True.Output, Failure>
where True.Output == False.Output, True.Failure == Failure, False.Failure == Failure
{
return self
.flatMap { return $0 ? ifTrue().eraseToAnyPublisher() : ifFalse().eraseToAnyPublisher() }
.eraseToAnyPublisher()
}
}
Now we need to write isInDatabasePublisher and addToDatabase methods on User. I don't have the source code to your checkIfUserIsInDatabase and createEmptyUser functions, so I can't convert them to publishers directly. But we can wrap them using Future:
extension User {
func isInDatabasePublisher() -> AnyPublisher<Bool, Error> {
return Future { promise in
checkIfUserIsInDatabase(user: self.uid, completion: promise)
}.eraseToAnyPublisher()
}
func addToDatabase() -> AnyPublisher<Void, Error> {
return Future { promise in
createEmptyUser(user: self.uid, email: self.email, completion: promise)
} //
.map { _ in } // convert Bool to Void
.eraseToAnyPublisher()
}
}
Note that, since your example code ignores the Bool output of createEmptyUser, I wrote addToDatabase to output Void instead.

Thats what I came up with:
Reference by matt:
http://www.apeth.com/UnderstandingCombine/operators/operatorsflatmap.html#SECSerializingAsynchronicity
https://stackoverflow.com/a/60418000/341994
var handler: AuthStateDidChangeListenerHandle?
var storage = Set<AnyCancellable>()
func checkLoginState(completion: #escaping (AnyPublisher<AccountDetails,Error>) -> Void) {
self.handler = Auth.auth().addStateDidChangeListener { [weak self] auth, user in
guard let safeSelf = self else { return }
completion(Future<AccountDetails,Error> { promise in
if let user = user {
safeSelf.handleUserInDatabase(user: user.uid)
.sink(receiveCompletion: { completion in
if let error = completion.error {
print(error.localizedDescription)
promise(.failure(error))
}
}, receiveValue: { result in
if result {
promise(.success(AccountDetails(userUID: user.uid,name: user.displayName, loggedIn: true, premiumUser: false)))
}
}).store(in: &safeSelf.storage)
} else {
promise(.success(AccountDetails(userUID: nil, loggedIn: false, premiumUser: false)))
}
}.eraseToAnyPublisher())
}
}
/// Checks if User exists in Firestore, if not creates an empty User and returns true
private func handleUserInDatabase(user: String) -> AnyPublisher<Bool,Error> {
return Future<Bool,Error>( { [weak self] promise in
guard let safeSelf = self else { return }
safeSelf.checkIfUserIsInDatabase(user: user)
.flatMap { result -> AnyPublisher<Bool,Error> in
if result == false {
return safeSelf.createEmptyUser(user: user).eraseToAnyPublisher()
} else {
promise(.success(true))
return Empty<Bool,Error>(completeImmediately: true).eraseToAnyPublisher()
}}
.sink(receiveCompletion: { completion in
if let error = completion.error {
promise(.failure(error))
}}, receiveValue: {promise(.success($0))})
.store(in:&safeSelf.storage)
}
).eraseToAnyPublisher()
}

Related

How to check if a user already exists in a Firestore collection after signing in with Firebase Auth in SwiftUI

#Published var isNewUser: Bool?
init() {
self.isNewUser = false
}
func checkIfTheUserExistsInDataBase(
userID: String?, completion: #escaping (_ isNewuser: Bool) -> Void
) {
let docRef = db.collection("users").whereField("user_id", isEqualTo: userID!).limit(to: 1)
docRef.getDocuments { querySnapshot, error in
if error != nil {
print(error?.localizedDescription)
} else {
if let doc = querySnapshot?.documents, doc.isEmpty {
completion(true)
} else {
completion(false)
}
}
}
}
func login(
email: String, password: String,
completion: #escaping (_ error: Error?, _ isEmailVerified: Bool) -> Void
) {
Auth.auth().signIn(withEmail: email, password: password) { authDataResult, error in
if error == nil {
if authDataResult!.user.isEmailVerified {
DispatchQueue.main.async {
self.checkIfTheUserExistsInDataBase(userID: authDataResult?.user.uid) { isNewUser in
self.isNewUser = isNewUser
}
}
UserDefaults.standard.set(authDataResult?.user.uid, forKey: CurrentUserDefaults.userID)
completion(error, true)
} else {
print("Email not verified")
completion(error, false)
}
} else {
completion(error, false)
}
}
}
I tried to use DispatchSemaphore to let a longer running function execute first which is checkIfTheUserExistsInDataBase, but it froze my app. Is there a better way to do this?
Firebase supports async/await (see this short, this video, and this blog post I created to explain this in detail.
To answer your question: you should use async/await to call signing in the user, waiting for the result, checking if the user exists in your Firestore collection, and the updating the UI.
The following code snippet (which is based on this sample app) uses the new COUNT feature in Firestore to count the number of documents in the users collection to determine if there is at least one user with the ID of the user that has just signed in.
func isNewUser(_ user: User) async -> Bool {
let userId = user.uid
let db = Firestore.firestore()
let collection = db.collection("users")
let query = collection.whereField("userId", isEqualTo: userId)
let countQuery = query.count
do {
let snapshot = try await countQuery.getAggregation(source: .server)
return snapshot.count.intValue >= 0
}
catch {
print(error)
return false
}
}
func signInWithEmailPassword() async -> Bool {
authenticationState = .authenticating
do {
let authResult = try await Auth.auth().signIn(withEmail: self.email, password: self.password)
if await isNewUser(authResult.user) {
}
return true
}
catch {
print(error)
errorMessage = error.localizedDescription
authenticationState = .unauthenticated
return false
}
}
See this video for more details about how to implement Firebase Authentication in SwiftUI apps.

Cannot convert Result<Bool, Error> to expected condition type 'Bool'. Swift

How to get the result from Result<Bool, Error> as a Boolean answer only in Swift?
I do the purchase from the AppStore from the ViewController:
Purchases.default.purchaseProduct(productId: "com.justdoit.buy_1week") { [weak self] res in
self?.hideSpinner()
if res == .success {
// Handle result
// IF OKAY - WE DO THE REQUEST AND CHANGE EVERYTHING FOR PREMIUM USER
} else {
self?.alert(alertMessage: "Error during the purchase") // my popUp view
}
}
Purchases.swift:
import StoreKit
typealias RequestProductsResult = Result<[SKProduct], Error>
typealias PurchaseProductResult = Result<Bool, Error>
typealias RequestProductsCompletion = (RequestProductsResult) -> Void
typealias PurchaseProductCompletion = (PurchaseProductResult) -> Void
// class
class Purchases: NSObject {
static let `default` = Purchases()
fileprivate var productPurchaseCallback: ((PurchaseProductResult) -> Void)?
private let productIdentifiers = Set<String>(
arrayLiteral:
"com.justdoit.buy_1week",
"com.justdoit.buy_1month",
"com.justdoit.buy_3months",
"com.justdoit.buy_1year"
)
private var products: [String: SKProduct]?
private var productRequest: SKProductsRequest?
func initialize(completion: #escaping RequestProductsCompletion) {
requestProducts(completion: completion)
}
private var productsRequestCallbacks = [RequestProductsCompletion]()
private func requestProducts(completion: #escaping RequestProductsCompletion) {
guard productsRequestCallbacks.isEmpty else {
productsRequestCallbacks.append(completion)
return
}
productsRequestCallbacks.append(completion)
let productRequest = SKProductsRequest(productIdentifiers: productIdentifiers)
productRequest.delegate = self
productRequest.start()
self.productRequest = productRequest
}
func purchaseProduct(productId: String, completion: #escaping (PurchaseProductResult) -> Void) {
guard productPurchaseCallback == nil else {
completion(.failure(PurchasesError.purchaseInProgress))
return
}
guard let product = products?[productId] else {
completion(.failure(PurchasesError.productNotFound))
return
}
productPurchaseCallback = completion
let payment = SKPayment(product: product)
SKPaymentQueue.default().add(payment)
}
public func restorePurchases(completion: #escaping (PurchaseProductResult) -> Void) {
guard productPurchaseCallback == nil else {
completion(.failure(PurchasesError.purchaseInProgress))
return
}
productPurchaseCallback = completion
// 4:
SKPaymentQueue.default().restoreCompletedTransactions()
}
}
// responses
extension Purchases: SKProductsRequestDelegate {
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
guard !response.products.isEmpty else {
print("Found 0 products")
productsRequestCallbacks.forEach { $0(.success(response.products)) }
productsRequestCallbacks.removeAll()
return
}
var products = [String: SKProduct]()
for skProduct in response.products {
print("Found product: \(skProduct.productIdentifier)")
products[skProduct.productIdentifier] = skProduct
}
self.products = products
productsRequestCallbacks.forEach { $0(.success(response.products)) }
productsRequestCallbacks.removeAll()
}
func request(_ request: SKRequest, didFailWithError error: Error) {
print("Failed to load products with error:\n \(error)")
productsRequestCallbacks.forEach { $0(.failure(error)) }
productsRequestCallbacks.removeAll()
}
}
extension Purchases: SKPaymentTransactionObserver {
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
// 1:
for transaction in transactions {
switch transaction.transactionState {
// 2:
case .purchased, .restored:
if finishTransaction(transaction) {
SKPaymentQueue.default().finishTransaction(transaction)
productPurchaseCallback?(.success(true))
} else {
productPurchaseCallback?(.failure(PurchasesError.unknown))
}
// 3:
case .failed:
productPurchaseCallback?(.failure(transaction.error ?? PurchasesError.unknown))
SKPaymentQueue.default().finishTransaction(transaction)
default:
break
}
}
productPurchaseCallback = nil
}
}
extension Purchases {
// 4:
func finishTransaction(_ transaction: SKPaymentTransaction) -> Bool {
let productId = transaction.payment.productIdentifier
print("Product \(productId) successfully purchased")
return true
}
}
enum PurchasesError: Error {
case purchaseInProgress
case productNotFound
case unknown
}
The first time I've been working with payments in Swift, I have no idea how to get the .success(true) result for successful payments only and use it later...
Is there any advice?
Use switch case. Here enum have Associated Values.
Purchases.default.purchaseProduct(productId: "com.justdoit.buy_1week") { [weak self] res in
switch res {
case .success(let value):
break
case .failure(let error):
print(error.lo)
}
}
You can also use if
if case Result.success(let value) = res {
print(value)
} else if case Result.failure(let error) = res {
print(error.localizedDescription)
}

Swift Combine - Async calls

I have the following functions:
func getUserProfile() -> AnyPublisher<UserProfileDTO?, Error> {
return Future { [unowned self] promise in
do {
if let data = KeychainWrapper.standard.data(forKey: profileKey) {
let profileDTO = try PropertyListDecoder().decode(UserProfileDTO.self, from: data)
setCurrentSession(profileDTO)
promise(.success(profileDTO))
}
else {
promise(.success(nil))
}
}
catch {
// Delete current UserProfile if cannot decode
let _ = KeychainWrapper.standard.removeAllKeys()
promise(.failure(error))
}
}.eraseToAnyPublisher()
}
func connect(userProfile: UserProfileDTO) -> AnyPublisher<UserProfileDTO, Error> {
return Future { promise in
SBDMain.connect(withUserId: userProfile.email) { (user, error) in
if let error = error {
promise(.failure(error))
}
else {
promise(.success(userProfile))
}
}
}.eraseToAnyPublisher()
}
What I want to do is to first call the getUserProfile() method and if the return value in not nil then call the connect() method. However, if the getUserProfile() has nil response it does not need to call the connect() and it should just return the nil response. Both these methods needs to be called from the autoLoginUser() method.
The problem I'm having right now is figuring out how to do this in a clean swift way without writing too much nested statements.
I tried to use flatMaps but it didn't workout the way I expected. Any help is much appreciated.
A solution I've been working on at the moment is this. But it doesn't quite work.
func autoLoginUser2() -> AnyPublisher<UserProfile?,Error> {
getUserProfile()
.tryMap { [unowned self] in
if let currentProfile = $0 {
return connect(userProfile: currentProfile)
.tryMap {
//Map from UserProfileDTO --> UserProfile
return UserProfileDTOMapper.map($0)
}
}
return nil
}.eraseToAnyPublisher()
}
With some adjustment for used types and error types this should work. First you ask for the profile, then you force unwrap the profile if it is nil you throw an error that will be sent to the sink as a failure.
If the profile is present you call connect.
getUserProfile()
.tryMap { userDTO -> UserProfileDTO in
if let id = userDTO {
return id
}
throw MyError.noProfileDT
}
.flatMap { id in
connect(id)
}
.sink {
//.....
}
If you change the signature of connect to return Optional profile:
func connect(userProfile: UserProfileDTO) -> AnyPublisher<UserProfileDTO?, Error>
You could do something like this:
getUserProfile()
.flatMap { userProfile -> AnyPublisher<UserProfileDTO?, Error> in
if let userProfile = userProfile {
return connect(userProfile: userProfile)
.eraseToAnyPublisher()
} else {
return Just<UserProfileDTO?>(nil)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
}
//.sink etc.
If you don't need the publisher to emit nil, you could leave the connect signature as is and use compactMap:
getUserProfile()
.compactMap { $0 }
.flatMap {
connect(userProfile: $0)
.eraseToAnyPublisher()
}

How do I return the result of my fetch call using combine?

I am new to combine and struggling to understand how I can return the result of my fetch pins.
I can set the result as #Published but I want to just be able to call the fetch method and await the result or error.
class PinService: NSObject, ObservableObject {
private var session: Session
private var subscriptions = Set<AnyCancellable>()
init(session: Session) {
self.session = session
super.init()
}
func fetchPins (categories: Set<CategoryModel>, coordinates: CLLocationCoordinate2D) {
_fetchPins(categories: categories, coordinates: coordinates)
.sink(receiveCompletion: { completion in
switch completion {
case .failure:
print("fetchPins() error")
case .finished:
print("fetchPins() complete")
}
}, receiveValue: { pins in
/*
What to do here?
I can add a #Published var pins: [Pin], and do
self.pins = pins
But if I want to be able to return the value or the error, how can I do that?
*/
})
.store(in: &self.subscriptions)
}
private func _fetchPins(categories: Set<CategoryModel>, coordinates: CLLocationCoordinate2D) -> Future<[Pin], Error> {
return Future<[Pin], Error> { promise in
let categoryIds = categories.map { return $0.id }.joined(separator: ",")
let radius = 15 //miles
self.session.request(baseUrl + "/api/v1/pinsRadius?latitude=\(coordinates.latitude)&longitude=\(coordinates.longitude)&radius=\(radius)&categories=\(categoryIds)")
.responseDecodable(of: [Pin].self) { (response: DataResponse) in
switch response.result {
case .success(let pins):
promise(.success((pins)))
case .failure(let error):
promise(.failure(error))
}
}
}
}
}
Sorry if its a dumb question, thanks.
One solution is to use a completion handler in your function and call it from receiveValue
func fetchPins (categories: Set<CategoryModel>,
coordinates: CLLocationCoordinate2D,
completion: #escaping (Result<[Pin], Error>) -> Void)) {
_fetchPins(categories: categories, coordinates: coordinates)
.sink(receiveCompletion: { completion in
switch completion {
case .failure(let error):
completion(.failure(error))
print("fetchPins() error")
case .finished:
print("fetchPins() complete")
}
}, receiveValue: { pins in
completion(.success(pins))
})
.store(in: &self.subscriptions)
}
Your call to fetchPins would then look something like
fetchPins(categories: someValue, coordinates: someOtherValue) { pins in
//do stuff with pins here
}

RxSwift to juggle local database and remote network?

Currently I have services that manages retrieving data from the local storage, but also checks the remote network for any modified data. It is using a completion handler with Result pattern and protocol type, but would like to convert this to an observable approach.
Here is the current logic:
struct AuthorWorker: AuthorWorkerType, Loggable {
private let store: AuthorStore
private let remote: AuthorRemote
init(store: AuthorStore, remote: AuthorRemote) {
self.store = store
self.remote = remote
}
}
extension AuthorWorker {
func fetch(id: Int, completion: #escaping (Result<AuthorType, DataError>) -> Void) {
store.fetch(id: id) {
// Immediately return local response
completion($0)
guard case .success(let cacheElement) = $0 else { return }
// Sync remote updates to cache if applicable
remote.fetch(id: id) {
// Validate if any updates occurred and return
guard case .success(let element) = $0,
element.modifiedAt > cacheElement.modifiedAt else {
return
}
// Update local storage with updated data
self.store.createOrUpdate(element) {
guard case .success = $0 else { return }
// Callback handler again if updated
completion($0)
}
}
}
}
}
I'm always instantly returning the local data to the UI so the user doesn't wait. In the background, it is checking the remote network for modified data and updates the UI again only if necessary. I use it like this:
authorWorker.fetch(1) { [weak self] in
guard case .success(let value) = $0 else {
// alert error
}
self?.myLabel.text = value.name
}
How can this be converted to RxSwift or an observable concept? This is what I got started, but I don't see the code on the walls like Neo yet when it comes to Rx, so I need help seeing the light.
extension AuthorWorker {
func fetch(id: Int) -> Observable<AuthorType> {
return Observable<AuthorType>.create { observer in
store.fetch(id: id) {
// Immediately return local response
observer.on(.next($0))
guard case .success(let cacheElement) = $0 else {
observer.on(.completed)
return
}
// Sync remote updates to cache if applicable
remote.fetch(id: id) {
// Validate if any updates occurred and return
guard case .success(let element) = $0,
element.modifiedAt > cacheElement.modifiedAt else {
observer.on(.completed)
return
}
// Update local storage with updated data
self.store.createOrUpdate(element) {
guard case .success = $0 else {
observer.on(.completed)
return
}
// Callback handler again if updated
observer.on(.next($0))
observer.on(.completed)
}
}
}
}
}
}
Then I would use it like this?
authorWorker.fetch(1).subscribe { [weak self] in
guard let element = $0.element else {
// Handle error how?
return
}
self?.myLabel.text = element.name
}
Is this the right approach or is there a more recommended way to do this? Is it also worth converting the underlying remote and local stores to observable as well, or does it make sense not to convert all things to observable all the time?
New Answer
Based on the comment, I see that you want something much more elaborate than my first answer, so here you go.
func worker<T: Equatable>(store: Observable<T>, remote: Observable<T>) -> (value: Observable<T>, store: Observable<T>) {
let sharedStore = store.share(replay: 1)
let sharedRemote = remote.share(replay: 1)
let value = Observable.merge(sharedStore, sharedRemote)
.distinctUntilChanged()
.takeUntil(sharedRemote.materialize().filter { $0.isStopEvent })
let store = Observable.zip(sharedStore, sharedRemote)
.filter { $0.0 != $0.1 }
.map { $0.1 }
return (value: value, store: store)
}
Here is the code above being used in your AuthorWorker class:
extension AuthorWorker {
func fetch(id: Int) -> Observable<AuthorType> {
let (_value, _store) = worker(store: store.fetch(id: id), remote: remote.fetch(id: id))
_ = _store
.subscribe(onNext: store.createOrUpdate)
return _value
}
}
And here is a test suite proving it works properly:
class Tests: XCTestCase {
var scheduler: TestScheduler!
var emission: TestableObserver<String>!
var storage: TestableObserver<String>!
var disposeBag: DisposeBag!
override func setUp() {
super.setUp()
scheduler = TestScheduler(initialClock: 0)
emission = scheduler.createObserver(String.self)
storage = scheduler.createObserver(String.self)
disposeBag = DisposeBag()
}
func testHappyPath() {
let storeProducer = scheduler.createColdObservable([.next(10, "store"), .completed(11)])
let remoteProducer = scheduler.createColdObservable([.next(20, "remote"), .completed(21)])
let (value, store) = worker(store: storeProducer.asObservable(), remote: remoteProducer.asObservable())
disposeBag.insert(
value.subscribe(emission),
store.subscribe(storage)
)
scheduler.start()
XCTAssertEqual(emission.events, [.next(10, "store"), .next(20, "remote"), .completed(21)])
XCTAssertEqual(storage.events, [.next(20, "remote"), .completed(21)])
}
func testSameValue() {
let storeProducer = scheduler.createColdObservable([.next(10, "store"), .completed(11)])
let remoteProducer = scheduler.createColdObservable([.next(20, "store"), .completed(21)])
let (value, store) = worker(store: storeProducer.asObservable(), remote: remoteProducer.asObservable())
disposeBag.insert(
value.subscribe(emission),
store.subscribe(storage)
)
scheduler.start()
XCTAssertEqual(emission.events, [.next(10, "store"), .completed(21)])
XCTAssertEqual(storage.events, [.completed(21)])
}
func testRemoteFirst() {
let storeProducer = scheduler.createColdObservable([.next(20, "store"), .completed(21)])
let remoteProducer = scheduler.createColdObservable([.next(10, "remote"), .completed(11)])
let (value, store) = worker(store: storeProducer.asObservable(), remote: remoteProducer.asObservable())
disposeBag.insert(
value.subscribe(emission),
store.subscribe(storage)
)
scheduler.start()
XCTAssertEqual(emission.events, [.next(10, "remote"), .completed(11)])
XCTAssertEqual(storage.events, [.next(20, "remote"), .completed(21)])
}
func testRemoteFirstSameValue() {
let storeProducer = scheduler.createColdObservable([.next(20, "store"), .completed(21)])
let remoteProducer = scheduler.createColdObservable([.next(10, "store"), .completed(11)])
let (value, store) = worker(store: storeProducer.asObservable(), remote: remoteProducer.asObservable())
disposeBag.insert(
value.subscribe(emission),
store.subscribe(storage)
)
scheduler.start()
XCTAssertEqual(emission.events, [.next(10, "store"), .completed(11)])
XCTAssertEqual(storage.events, [.completed(21)])
}
}
Previous Answer
I'd be inclined to aim for a usage like this:
let result = authorWorker.fetch(id: 1)
.share()
result
.map { $0.description }
.catchErrorJustReturn("")
.bind(to: myLabel.rx.text)
.disposed(by: disposeBag)
result
.subscribe(onError: { error in
// handle error here
})
.disposed(by: disposeBag)
The above can be accomplished if you have something like the below for example:
extension AuthorWorker {
func fetch(id: Int) -> Observable<AuthorType> {
return Observable.merge(store.fetch(id: id), remote.fetch(id: id))
.distinctUntilChanged()
}
}
extension AuthorStore {
func fetch(id: Int) -> Observable<AuthorType> {
return Observable.create { observer in
self.fetch(id: id, completion: { result in
switch result {
case .success(let value):
observer.onNext(value)
observer.onCompleted()
case .failure(let error):
observer.onError(error)
}
})
return Disposables.create()
}
}
}
extension AuthorRemote {
func fetch(id: Int) -> Observable<AuthorType> {
return Observable.create { observer in
self.fetch(id: id, completion: { result in
switch result {
case .success(let value):
observer.onNext(value)
observer.onCompleted()
case .failure(let error):
observer.onError(error)
}
})
return Disposables.create()
}
}
}