December 06, 2019
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,
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:
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.
.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.
.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.
.mapError { .error($0) }
to the next operator. URLError
to ValidationError
which looks like below,enum ValidationError: Error {
case error(Error)
case jsonError(Data)
}
.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.
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()
}}
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)
Hi, Iโm Ritesh Gupta, iOS Engineer from India ๐ฎ๐ณ. Here, I mainly write about Swift and iOS app development.
twitter โ @_riteshhh.
More about me.