loading...
Cover image for Declarative Networking with Combine

Declarative Networking with Combine

ritesh profile image Ritesh Gupta ・5 min read

Originally posted on my personal blog at riteshhh.com 🙇 🙈

In this post, we will explore how we can form a declarative networking layer using Combine for iOS applications. The idea here is not to re-create URLSession.DataTaskPublisher operator but rather to understand and take advantage of the existing declarative nature of Combine (and its operators) to manage success and error values declaratively. I know I have already over used the term declarative, so please bear with me 😅

The Combine framework has a built-in operator named DataTaskPublisher along with a convenience method on URLSession which can be used to hit a network request i.e.

func dataTaskPublisher(for request: URLRequest) -> URLSession.DataTaskPublisher

Though before we go ahead and parse server's response we need to validate it so that we can segregate the error values from the success values. Once we have established this clear separation between the two paths, we can then parse them correctly and pass on the values in their respective streams. Also, the Combine framework doesn't know how to understand custom error cases that a server might throw (4xx/5xx) or some hidden key/value pair in headers. So we need to handle two things to move forward,

  1. How to add validation rules to segregate success and error values?
  2. How to map the server's error response to a custom type and forward its value via error stream only?

Let's take a look at the final API and the various reactive operators used to achieve it,

extension URLSession {
    func dataTaskPublisher<Output: Decodable>(for request: URLRequest) -> AnyPublisher<Output, Error> {
        return self
            .dataTaskPublisher(for: request)
            .validateStatusCode({ (200..<300).contains($0) })
            .mapJsonError(to: ApiErrorResponse.self, decoder: JSONDecoder())
            .mapJsonValue(to: Output.self, decoder: JSONDecoder())
            .eraseToAnyPublisher()
    }
}

Now let's break it line by line to understand what we are trying to achieve here:

1. func dataTaskPublisher<>(...)

func dataTaskPublisher<Output: Decodable>(for request: URLRequest) -> AnyPublisher<Output, Error> { ... }

First thing first, the function name and its arguments is similar to Combine's extension function i.e. func dataTaskPublisher(for request: URLRequest). Though the output type is a bit different i.e our function returns a generic output type <Output: Decodable> which is a type-safe version of what we are expecting from the server. Since Output is not the type itself but represents any type which conforms to Decodable, it makes it quite flexible.

2. dataTaskPublisher(for :)

.dataTaskPublisher(for: request)

We are building on top of the existing operator by Combine i.e. dataTaskPublisher(for: request) hence we are calling it the first thing which returns a stream of (Data, URLResponse) on success and URLError on error. Under the hood, it takes care of making a data task and uses it to hit a network request.

3. validateStatusCode(...)

.validateStatusCode({ (200..<300).contains($0) })

After we have recieved a response from the server (i.e. json/xml or maybe error), we would like to validate the upstream value into separate success and error streams based on our business logic. Here's the internal implementation on the validation process,

typealias DataTaskResult = (data: Data, response: URLResponse)

extension Publisher where Output == DataTaskResult {
    func validateStatusCode(_ isValid: @escaping (Int) -> Bool) -> AnyPublisher<Output, ValidationError> {
        return validateResponse { (data, response) in
            let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
            return isValid(statusCode)
        }
    }

    func validateResponse(_ isValid: @escaping (DataTaskResult) -> Bool) -> AnyPublisher<Output, ValidationError> {
        return self
            .mapError { .error($0) }
            .flatMap { (result) -> AnyPublisher<DataTaskResult, ValidationError> in
                let (data, _) = result
                if isValid(result) {
                    return Just(result)
                        .setFailureType(to: ValidationError.self)
                        .eraseToAnyPublisher()
                } else {
                    return Fail(outputType: Output.self, failure: .jsonError(data))
                        .eraseToAnyPublisher()
                }}
            .eraseToAnyPublisher()
    }
}

validateResponse accepts a closure that provides the status code of the response and returns a boolean based on the business logic. In this case, if the status code is between 200..<300 then it's a success otherwise it's an error. We could have our custom logic as well where we might want to use some other things like header values or to check whether the data is empty or not. One could also write a generic version i.e. func validateResponse(_ isValid: (DataTaskResult) -> Bool) for more customisations.

  • A thing to note here is that it only checks for validation if the upstream sends a success value. In case of a default/generic failure, it simply bypasses the validation logic and passes the error value via .mapError { .error($0) } to the next operator.

  • Another thing to note here is that we don't modify the incoming success values but only lift the incoming error value from URLError to ValidationError which looks like below,

enum ValidationError: Error {
    case error(Error)
    case jsonError(Data)
}

4. mapJsonError(to :)

.mapJsonError(to: APIErrorResponse.self)

Now at this point, we have lifted the incoming error value to ValidationError so that we can parse JSON error response into a custom type. Here's the inner implementation,

extension Publisher where Failure == ValidationError {
    func mapJsonError<E: Error & Decodable>(to errorType: E.Type, decoder: JSONDecoder) -> AnyPublisher<Output, Error> {
        return self
            .catch { (error: ValidationError) -> AnyPublisher<Output, Error> in
                switch error {
                case .error(let e):
                    return Fail(outputType: Output.self, failure: e)
                        .eraseToAnyPublisher()
                case .jsonError(let d):
                    return Just(d)
                        .decode(type: E.self, decoder: decoder)
                        .flatMap { Fail(outputType: Output.self, failure: $0) }
                        .eraseToAnyPublisher() } }
            .eraseToAnyPublisher()
    }
}

It takes a custom error type which we want to model against the server's error response/message. A thing to note here is that it only works for an error path i.e. in case of success Combine framework won't even call this operator which is what we desire as well.

5. mapJsonValue(to :)

mapJsonValue(to: Output.self)

So far we have handled the validation of the server's response and formation of custom error object. Now we can safely parse the success JSON value into a custom type that can be passed onto the UI layer (or data layer depending on your architecture but we are not here to discuss that 😉).

extension Publisher where Output == DataTaskResult {
    func mapJsonValue<Output: Decodable>(to outputType: Output.Type, decoder: JSONDecoder) -> AnyPublisher<Output, Error> {
        return self
            .map(\.data)
            .decode(type: outputType, decoder: decoder)
            .eraseToAnyPublisher()
    }}

6. eraseToAnyPublisher()

eraseToAnyPublisher()

Last but not least, we have used eraseToAnyPublisher() so that we can hide the implementation detail and provide a simple API for consumption.

Thus by hooking all the pieces together, we get this nice declarative API which can be used to parse success and error values declaratively along with some validation 🤩 Here's the summary of the stream value types as they move along the reactive chain,

extension URLSession {
    func dataTaskPublisher<Output: Decodable>(for request: URLRequest) -> AnyPublisher<Output, Error> {
        return self
            .dataTaskPublisher(for:) // (DataTaskResult, URLError)
            .validateStatusCode(...) // (DataTaskResult, ValidationError)
            .mapJsonError(to:)       // (DataTaskResult, ResponseError)
            .mapJsonValue(to:)       // (ResponseValue, ResponseError)
            .eraseToAnyPublisher()   // (ResponseValue, ResponseError)
    }
}

Thanks for reading 🙌 You can reach out to me at @_riteshhh on twitter 😄

(Some images are copyright Icons8 LLC © 2019)

Posted on by:

ritesh profile

Ritesh Gupta

@ritesh

📱 iOS Engineer (mostly Swift) & occasionally Kotlin 📡 currently remote @Fueled, previously @Over 🗣️ occasional speaker 🍝 food lover 👫 @ragdroid

Discussion

markdown guide
 

Thank you for the great article, however, if I change the URL of a request to something like: blahblah.com (or any fake url) this won't let the ApiErrorResponse report the error because there is a missing pipeline operator which is .mapError, the main pipeline would become:

 extension URLSession {
       func dataTaskPublisher<Output: Decodable>(for request: URLRequest) -> AnyPublisher<Output, Error> {
           dataTaskPublisher(for: request)
            .mapError { ApiErrorResponse(message: $0.localizedDescription) }
            .validateStatusCode{ (200..<300).contains($0) }
            .mapJsonError(to: ApiErrorResponse.self, decoder: JSONDecoder())
            .mapJsonValue(to: Output.self, decoder: JSONDecoder())
            .eraseToAnyPublisher()
       }
   }

I've created a mock ApiErrorResponse type as follows:

struct ApiErrorResponse: Error, Decodable {
    var message: String

    enum CodingKeys: String, CodingKey {
        case message = "message"
    }

    init(from decoder: Decoder) throws {
        let container = try decoder .container(keyedBy: CodingKeys.self)
        let message: String = try container.decode(String.self, forKey: .message)
        self.message = message
    }

    init(message: String) {
        self.message = message
    }
}