I'm using the next states to handle my API state
enum RequestState<T: Decodable> {
case loading
case loaded(T)
case error(Error)
}
and the following code to change these states:
let response = request
.flatMapLatest {
provider.rx.request($0)
.map(T.self).map { RequestState.loaded($0) }
.asDriver(onErrorRecover: { error in
return Driver.just(.error(error))
})
.startWith(.loading)
}
all works awesome but except one issue. case loading works only when all fine with network and request in progress.
Connection
Tap - Loading spinner - Response
No connection
Tap - network thining delay - No connection response
I would like to start loading on tap always. Maybe use the new state case startedLoading. And get this state when a new request pushed to the sequence (ex. after tap refresh button).
Currently, .loading is emitted only after the first request observable emits a next value, as startWith is applied to the inner observable.
Moving .startWith to the outer observable will ensure you always get a .loading event before any other.
let response = request
.flatMapLatest {
provider.rx.request($0)
.map(T.self).map { RequestState.loaded($0) }
.asDriver(onErrorRecover: { error in
return Driver.just(.error(error))
})
}
.startWith(.loading)
Related
I have a logic problem. The premise of what I would like to achieve is to combine two sources of data and in case of failure, I would like to run retry only on one observable. The logic flow goes like this: I try and fail to get local data from StorageManager class, afterwards I try to get the data from an API. In case that API call fails, I would like to retry only that API call a certain number of times. Is there a nice way of doing this? By just running retry like in the code below, the local data observable gets triggered.
class func loginUser(user: User, replaySubject: ReplaySubject<User>){
let savedUserObs = StorageManager.readCachedModelData(requestType: .LOGIN, modelType: User.self)
let apiUserObs = NetworkHelper.makeRequest(requestType: .LOGIN).map { response -> User in
guard let user = response.user?.first else { throw TempErrors.wasNotAbleToExtractUser }
return user
}
savedUserObs.concat(apiUserObs)
.retry { errorObs in
errorObs.scan(0) { attempt, error in
let max = 5
if attempt == max { throw TempErrors.tooManyRetries }
return attempt + 1
}
}
.subscribe { user in
replaySubject.onNext(user)
StorageManager.saveData(requestType: .LOGIN, data: user)
} onError: { error in
print("onerror error: \(error)")
} onCompleted: {
print("completed")
} onDisposed: {
print("disposed")
}.disposed(by: disposeBag)
}
I have a login use case which involves a remote service call and a pin.
In my view model I have a behaviour relay for pin like so
let pin = BehaviorRelay(value: "")
Then I have this service:
protocol LoginService {
func login(pin: String) -> Single<User>
}
Also in the view model I have a publish relay (to back a submit button) and also a state stream. State must initially set to .inactive and once the submit relay fires I need the state to go .loading and eventually .active.
var state: Observable<State> {
return Observable.merge(
.just(.inactive),
submit.flatMap { [service, pin] in
service.login(pin: pin.value).asObservable().flatMap { user -> Observable<State> in
.just(.active)
}.catch { error in
return .just(.inactive)
}.startWith(.loading)
})
}
The problem is that should the pin change after submit (and my use case involves clearing the pin once submit button clicked), the service is called a second time with the new pin value (in this case empty string).
I want this stream to just take the value for pin and run the service once only and ignore any new value for pin unless the submit was fired again.
Hmm... The code shown only triggers when submit emits a next event, not when pin emits so either you have other code that you aren't showing that is causing the problem, or you are sending a .next event into your publish relay inappropriately.
In short, only send a .next event when the user taps the submit button and the code you posted will work fine. Also, clearing out the pin text field will not change the pin behavior relay unless you are doing something odd elsewhere so that shouldn't be an issue.
This is essentially the same as what you have, but uses the withLatestFrom operator:
class ViewModel {
let submit = PublishRelay<Void>()
let pin = BehaviorRelay(value: "")
let state: Observable<State>
init(service: LoginService) {
self.state = submit
.withLatestFrom(pin)
.flatMapLatest { [service] in
service.login(pin: $0)
.map { _ in State.active }
.catch { _ in .just(.inactive) }
.asObservable()
.startWith(.loading)
}
.startWith(.inactive)
}
}
I'm not a fan of all the relays though and I don't like that you are throwing away the User object. I would likely do something more like this:
class ViewModel {
let service: LoginService
init(service: LoginService) {
self.service = service
}
func bind(pin: Observable<String>, submit: Observable<Void>) -> (state: Observable<State>, user: Observable<User?>) {
let user = submit
.withLatestFrom(pin)
.flatMapLatest { [service] in
service.login(pin: $0)
.map(Optional.some)
.catchAndReturn(nil)
}
.share()
let state = Observable.merge(
submit.map { .loading },
user.map { user in user == nil ? .inactive : .active }
)
.startWith(State.inactive)
return (state: state, user: user)
}
}
I think you are trying too hard to chain things together :-).
Let's take your problem apart and see if that helps.
The important event for you is the button push. When the user pushes the "submit" button you want to make an attempt to log in.
So attach a subject to your pin entry field and let it capture the result of the user's typing. You want this to be a stream that holds the latest value of the pin:
// bound to a text input field. Has the latest pin entered
var pin = BehaviorSubject(value: "")
Then you can have an infinite stream that just gets sent a value when the button is pushed. The actual value sent is not as important as the fact that it emits a value when the user pushes the button.
var buttonPushes = PublishSubject<Bool>()
From that, we're going to create a stream that emits a value each time the button is pushed. We'll represent a login attempt as a struct, LoginInfo that contains all the stuff you need to try and log in.
struct LoginInfo {
let pin : String
/* Maybe other stuff like a username is needed here */
}
var loginAttempts = buttonPushes.map { _ in
LoginInfo(pin: try pin.value())
}
loginAttempts sees a button push and maps in into an attempt to log in.
As part of that mapping, it captures the latest value from the pin stream, but loginAttempts is not directly tied to the pin stream. The pin stream can go on changing forever and loginAttempts won't care until the user pushes the submit button.
I'm testing out the Kentico Cloud Swift SDK to return some 'article' content types (I have created two of them and they are published).
I am using the Boilerplate code as described here:
The result I get is : [Kentico Cloud] Getting items action has succeeded. Received nil items.
My code:
let client = DeliveryClient.init(projectId: <project id>, previewApiKey: <preview key>, secureApiKey: <secure key>, enableDebugLogging: true)
func getArticles(){
// Note: Using "items" as custom query returns all content items,
// but to map them to a single model, a filter is needed.
let customQuery = "items?system.type=article"
// More about strongly-typed models https://github.com/Kentico/cloud-sdk-swift#using-strongly-typed-models
client.getItems(modelType: Article.self, customQuery: customQuery) { (isSuccess, itemsResponse, error) in
if isSuccess {
// We get here and itemsResponse != nil but items == nil
if let articles = itemsResponse?.items {
for article in articles {
}
}
} else {
if let error = error {
print(error)
}
}
}
}
I believe this error message would appear before ObjectMapper is triggered to convert the JSON into Article objects. I could be wrong though.
Anyone have any ideas?
UPDATE
Interestingly, if I request a single article object like so ...
client.getItem(modelType: Article.self, itemName: <codename>) { (isSuccess, itemResponse, error) in
if isSuccess {
if let article = itemResponse?.item {
// Use your item here
}
} else {
if let error = error {
print(error)
}
}
}
... then it works. I get the Article object. It's just asking for all of the articles that fails.
I'm going to investigate the issue later today, however, from your description, it might be caused by the Delivery API item readiness delay - the project was not fully synced with the delivery API yet. After the publishing/unpublishing item or creating/generating the project, there might be a small delay in processing messages by Delivery API which could cause unavailability of item. This delay might be variable - from my experience, it may vary from a couple of seconds to 2-3 minutes. Nevertheless, I'm going to check it just to be sure. I'll keep you updated.
Edit: I'm pretty sure the project was not synced and processed on the Delivery API at the time you were requested the items. The API returned 200, which caused isSuccess in the callback to be true, however, there might have been none or just a subset of items available - I've reproduced this behavior (screenshot below), although it's by design (the content/messages in Event Hub must be processed asynchronously).
I've also suggested the improvement for Kentico Cloud's documentation to mention/explain the possible delay caused by processing events queue messages from Event Hubs.
Just to be sure - could you try it again with your getArticles custom query?
Edit2: Back to your question about the ObjectMapper. This is not an error just a debug message, however, there shouldn't be probably nil but 0 (zero) in the debug message. This message came from:
private func sendGetItemsRequest<T>(url: String, completionHandler: #escaping (Bool, ItemsResponse<T>?, Error?) -> ()) where T: Mappable {
sessionManager.request(url, headers: self.headers).responseObject { (response: DataResponse<ItemsResponse<T>>) in
switch response.result {
case .success:
if let value = response.result.value {
let deliveryItems = value
if self.isDebugLoggingEnabled {
print("[Kentico Cloud] Getting items action has succeeded. Received \(String(describing: deliveryItems.items?.count)) items.")
}
completionHandler(true, deliveryItems, nil)
}
case .failure(let error):
if self.isDebugLoggingEnabled {
print("[Kentico Cloud] Getting items action has failed. Check requested URL: \(url)")
}
completionHandler(false, nil, error)
}
}
}
Ok. This is very weird. After checking the API by requesting an individual item (see the update in the post above), and getting a result (woot). It now seems the original code (unchanged) now works.
I'm wondering if it takes a while for the data to propagate and be available in the API?
Who knows. Weird.
I have a network request that can Succeed or Fail
I have encapsulated it in an observable.
I have 2 rules for the request
1) There can never be more then 1 request at the same time
-> there is a share operator i can use for this
2) When the request was Succeeded i don't want to repeat the same
request again and just return the latest value
-> I can use shareReplay(1) operator for this
The problem arises when the request fails, the shareReplay(1) will just replay the latest error and not restart the request again.
The request should start again at the next subscription.
Does anyone have an idea how i can turn this into a Observable chain?
// scenario 1
let obs: Observable<Int> = request().shareReplay(1)
// outputs a value
obs.subscribe()
// does not start a new request but outputs the same value as before
obs.subscribe()
// scenario 2 - in case of an error
let obs: Observable<Int> = request().shareReplay(1)
// outputs a error
obs.subscribe()
// does not start a new request but outputs the same value as before, but in this case i want it to start a new request
obs.subscribe()
This seems to be a exactly doing what i want, but it consists of keeping state outside the observable, anyone know how i can achieve this in a more Rx way?
enum Err: Swift.Error {
case x
}
enum Result<T> {
case value(val: T)
case error(err: Swift.Error)
}
func sample() {
var result: Result<Int>? = nil
var i = 0
let intSequence: Observable<Result<Int>> = Observable<Int>.create { observer in
if let result = result {
if case .value(let val) = result {
return Observable<Int>.just(val).subscribe(observer)
}
}
print("do work")
delay(1) {
if i == 0 {
observer.onError(Err.x)
} else {
observer.onNext(1)
observer.onCompleted()
}
i += 1
}
return Disposables.create {}
}
.map { value -> Result<Int> in Result.value(val: value) }
.catchError { error -> Observable<Result<Int>> in
return .just(.error(err: error))
}
.do(onNext: { result = $0 })
.share()
_ = intSequence
.debug()
.subscribe()
delay(2) {
_ = intSequence
.debug()
.subscribe()
_ = intSequence
.debug()
.subscribe()
}
delay(4) {
_ = intSequence
.debug()
.subscribe()
}
}
sample()
it only generates work when we don't have anything cached, but thing again we need to use side effects to achieve the desired output
As mentioned earlier, RxSwift errors need to be treated as fatal errors. They are errors your stream usually cannot recover from, and usually errors that would not even be user facing.
For that reason - a stream that emits an .error or .completed event, will immediately dispose and you won't receive any more events there.
There are two approaches to tackling this:
Using a Result type like you just did
Using .materialize() (and .dematerialize() if needed). These first operator will turn your Observable<Element> into a Observable<Event<Element>>, meaning instead of an error being emitted and the sequence terminated, you will get an element that tells you it was an error event, but without any termination.
You can read more about error handling in RxSwift in Adam Borek's great blog post about this: http://adamborek.com/how-to-handle-errors-in-rxswift/
If an Observable sequence emits an error, it can never emit another event. However, it is a fairly common practice to wrap an error-prone Observable inside of another Observable using flatMap and catch any errors before they are allowed to propagate through to the outer Observable. For example:
safeObservable
.flatMap {
Requestor
.makeUnsafeObservable()
.catchErrorJustReturn(0)
}
.shareReplay(1)
.subscribe()
I'm combining a viewDidAppear and filter Drivers with RxSwift. And they work great. But when I introduce a third Driver, it stops calling flatMapLatest on the latest combine.
In my View Controller, I have these Drivers:
let filter: Driver<String>
let viewDidAppear: Driver<Void>
let refresh: Driver<Void>
And in my view model:
// On viewDidAppear, I download a list of portfolios
let viewDidAppearPortfolios = viewDidAppear
.flatMapLatest({ (_) -> Driver<Result<[PortfolioModel]>> in
return networkService.request(Router.portfolios)!
.responseCollections()
.trackActivity(fetching)
.asDriver(onErrorJustReturn: .failure(NSError()))
})
.flatMapLatest({ (result: Result<[PortfolioModel]>) -> Driver<[PortfolioModel]> in
switch result {
case .success(let value): return Driver.just(value)
case .failure(_): return Driver.just([])
}
})
// Then I combine with a filter from my search bar.
self.portfolios = Driver.combineLatest(viewDidAppearPortfolios, filter)
.flatMapLatest { (portfolios: [PortfolioModel], filter: String) -> Driver<[PortfolioModel]> in
if filter.isEmpty {
return Driver.just(portfolios)
}
return Driver.just(portfolios.filter({ (portfolio) -> Bool in
portfolio.portfolio.localizedCaseInsensitiveContains(filter)
}))
}
The above works!
The network requests a list of portfolios, and I'm able to filter those results as I type, client side.
However, I'd like for the user to pull to refresh, and trigger the network request again! And so, I combine with my refresh driver.
And this:
Driver.combineLatest(viewDidAppearPortfolios, filter)
Becomes this:
Driver.combineLatest(viewDidAppearPortfolios, filter, refresh)
Problem!
After combining with refresh the flatMapLatest is no longer called on viewDidAppear! Only if I manually pullToRefresh.
Driver.combineLatest(viewDidAppearPortfolios, filter, refresh).flatMapLatest { _,_,_ in
// No longer get's called on viewDidAppear after combining with refresh
}
The viewDidAppearPortfolios still executes, so the network request
is getting called!
Only if I manually pull to refresh do I get the
list of portfolios that I previously requested...
Any idea why?
Thank you!
It looks like your refresh didn't emit a single event yet and so the combineLatest is not computed.
I tried this code to test:
let one = Driver.just(1)
let two = Driver.just(2)
let three: Driver<Int> = .just(3)
let result = Driver.combineLatest(one, two, three)
.flatMapLatest {
return Driver.just($0 + $1 + $2)
}
result.drive(onNext: {
print($0)
})
This prints 6 but if you use let three: Driver<Int> = .empty() this is not printing anything. So I guess you need a way to set an initial value to refresh stream.