Samuel Kith
Samuel Kith

Reputation: 119

How to handle error from api request properly with RxSwift in MVVM?

So, I have a button and will make an API request upon tapping it. When the API request returns an error, if my understanding is correct, the sequence will be terminated and no subsequent action will be recorded. How do I handle this properly so that I can still make another API request when tapping the button.

My thoughts are to have two observables that I can subscribe to in ViewController and on button pressed, one of it will print the success response and one of it will print the error. Just not quite sure how I can achieve that.

PS: In Post.swift, I have purposely set id as String type to fail the response. It should have be an Int type.

Post.swift

import Foundation
struct Post: Codable {
    let id: String
    let title: String
    let body: String
    let userId: Int
}

APIClient.swift

class APIClient {

    static func request<T: Codable> (_ urlConvertible: URLRequestConvertible, decoder: JSONDecoder = JSONDecoder()) -> Observable<T> {

        return Observable<T>.create { observer in

            URLCache.shared.removeAllCachedResponses()

            let request = AF.request(urlConvertible)
                .responseDecodable (decoder: decoder) { (response: DataResponse<T>) in

                switch response.result {
                case .success(let value):
                    observer.onNext(value)
                    observer.onCompleted()
                case .failure(let error):
                    switch response.response?.statusCode {
                    default:
                        observer.onError(error)
                    }
                }
            }

            return Disposables.create {
                request.cancel()
            }
        }
    }

}

PostService.swift

class PostService {
    static func getPosts(userId: Int) -> Observable<[Post]> {
        return APIClient.request(PostRouter.getPosts(userId: userId))
    } 
}

ViewModel.swift

class LoginLandingViewModel {

    struct Input {
        let username: AnyObserver<String>
        let nextButtonDidTap: AnyObserver<Void>
    }

    struct Output {
        let apiOutput: Observable<Post>
        let invalidUsername: Observable<String>
    }

    // MARK: - Public properties

    let input: Input
    let output: Output

    // Inputs

    private let usernameSubject = BehaviorSubject(value: "")
    private let nextButtonDidTapSubject = PublishSubject<Void>()

    // MARK: - Init

    init() {

        let minUsernameLength = 4

        let usernameEntered = nextButtonDidTapSubject
            .withLatestFrom(usernameSubject.asObservable())

        let apiOutput = usernameEntered
            .filter { text in
                text.count >= minUsernameLength
            }
            .flatMapLatest { _ -> Observable<Post> in
                PostService.getPosts(userId: 1)
                    .map({ posts -> Post in
                        return posts[0]
                    })

            }

        let invalidUsername = usernameEntered
            .filter { text in
                text.count < minUsernameLength
            }
            .map { _ in "Please enter a valid username" }


        input = Input(username: usernameSubject.asObserver(),
                      nextButtonDidTap: nextButtonDidTapSubject.asObserver())

        output = Output(apiOutput: apiOutput,
                        invalidUsername: invalidUsername)

    }

    deinit {
        print("\(self) dellocated")
    }


}

ViewController

private func configureBinding() {

        loginLandingView.usernameTextField.rx.text.orEmpty
            .bind(to: viewModel.input.username)
            .disposed(by: disposeBag)

        loginLandingView.nextButton.rx.tap
            .debounce(0.3, scheduler: MainScheduler.instance)
            .bind(to: viewModel.input.nextButtonDidTap)
            .disposed(by: disposeBag)

        viewModel.output.apiOutput
            .subscribe(onNext: { [unowned self] post in
                print("Valid username - Navigate with post: \(post)")
                })
            .disposed(by: disposeBag)


        viewModel.output.invalidUsername
            .subscribe(onNext: { [unowned self] message in
                self.showAlert(with: message)
            })
            .disposed(by: disposeBag)

    }

Upvotes: 0

Views: 2724

Answers (2)

Samuel Kith
Samuel Kith

Reputation: 119

So I have also found the way to achieve what I wanted, which is assigning the success output and error output into two different observable respectively. By using RxSwiftExt, there are two additional operators, elements() and errors() which can be used on an observable that is materialized to get the element.

Here is how I did it,

ViewModel.swift

let apiOutput = usernameEntered
            .filter { text in
                text.count >= minUsernameLength
            }
            .flatMapLatest { _ in
                PostService.getPosts(userId: 1)
                    .materialize()
            }
            .share()

let apiSuccess = apiOutput
    .elements()

let apiError = apiOutput
    .errors()
    .map { "\($0)" }

Then, just subscribe to each of these observables in the ViewController.

As reference: http://adamborek.com/how-to-handle-errors-in-rxswift/

Upvotes: 1

K.Wu
K.Wu

Reputation: 3633

You can do that by materializing the even sequence:

First step: Make use of .rx extension on URLSession.shared in your network call

func networkCall(...) -> Observable<[Post]> {
    var request: URLRequest = URLRequest(url: ...)
    request.httpMethod = "..."
    request.httpBody = ...

    URLSession.shared.rx.response(request)
        .map { (response, data) -> [Post] in
            guard let json = try? JSONSerialization.jsonObject(with: data, options: []),
                let jsonDictionary = json as? [[String: Any]]
                else { throw ... }    // Throw some error here

            // Decode this dictionary and initialize your array of posts here
            ...
            return posts
        }
}

Second step, materializing your observable sequence

viewModel.networkCall(...)
    .materialize()
    .subscribe(onNext: { event in
        switch event {
            case .error(let error):
                // Do something with error
                break
            case .next(let posts):
                // Do something with posts
                break
            default: break
        }
    })
    .disposed(by: disposeBag)

This way, your observable sequence will never be terminated even when you throw an error inside your network call, because .error events get transformed into .next events but with a state of .error.

Upvotes: 2

Related Questions