Write convenient APIs for networking by extending the URLSession
and Result
types
As most of you must know, in WWDC 2019, Apple released its functional reactive framework named Combine. I know the title says this article is about extending the URLSession
and Result
types built-in in Swift; I am bringing Combine to the table because I wanted to let you all know where the idea came up.
The first thing that caught my attention was "how easy" it is to read the code chained in Publishers and how pretty it looks. To give you a better idea of what I mean, let's see an example using the URLSession
API with Combine.
urlSession
.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: Response<User>.self, decoder: JSONDecoder())
.map(\.results)
.eraseToAnyPublisher()
That piece of code is pretty declarative. As soon as you read it, you can immediately catch what the code is doing.
- Performs a request with the given
URL
. - Maps the
\.data
(using key-paths). - Decodes the data into a
Decodable
model. - Maps and gets the
\.results
(using key-paths also). - Returns an erased Publisher.
Combine is available for the following deployment targets:
- iOS 13.0+
- macOS 10.15+
- Mac Catalyst 13.0+
- tvOS 13.0+
- watchOS 6.0+
If you are still supporting lower deployment targets in your app/framework or haven't had the chance to migrate your code to use Combine, it means, you are still using the closures in the URLSession
API.
What I am seeking to achieve here is to extend the Swift types so we can have a similar "look-and-feel" to using Combine. It won't be that astonishing, but I think we can make it better.
Extending URLSession
The URLSession
API provides a function with a closure that takes another function that receives a tuple of 3 optional types: (Data?, URLResponse?, Error?)
.
func dataTask(
with url: URL,
completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void
) -> URLSessionDataTask
I presume this is because of the interoperability between Swift and Obj-C.
- (NSURLSessionDataTask *)dataTaskWithURL:(NSURL *)
urlcompletionHandler:(void (^)(
NSData * _Nullable data,
NSURLResponse * _Nullable response,
NSError * _Nullable error)
)completionHandler;
Publishers are "functional data structures" that we can categorize as Functors and Monads, and this means that Publishers can do at least map
and flatMap
operations. And also, Publishers can deal with errors.
The closest and most convenient type I can think about for achieving the same thing - in terms of "mapping" operations - is the Result
type which is also a Functor and a Monad, and it is meant to handle errors.
The first thing we are going to do is get rid of the unnecessary optional parts in the callback, and in doing so, the Result
(Sum) type will replace the "tuple" (Product).
To have a better understanding of what Sum and Product are and the thinking behind the reasoning in this article, you can read Algebraic types in the Swift type system.
This being said, let's extend URLSession
to use Result
in the closure function.
extension URLSession {
func dataTask(
with url: URL,
completionHandler: @escaping (Result<(data: Data, response: URLResponse), Error>) -> Void
) -> URLSessionDataTask {
dataTask(with: url) { data, response, error in
if let error = error {
completionHandler(.failure(error))
} else if let data = data, let response = response {
completionHandler(.success((data: data, response: response)))
}
}
}
}
Technically speaking, now we can handle the response by looking at either the .success
or the .failure
.
urlSession
.dataTask(with: url) { result in
switch result {
case let .success((data, response)):
// success
case let .failure(error):
// failure
}
}
.resume()
Extending Result
The Combine extension in the built-in API that allows the mapping by using key-paths is defined as:
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
extension Publisher {
func map<T>(_ keyPath: KeyPath<Self.Output, T>) -> Publishers.MapKeyPath<Self, T>
}
In the context of the .dataTaskPublisher(for:)
function Self.Output
would be equivalent to:
(data: Data, response: URLResponse)
So, we can define a pretty similar API for the Result type in the following way:
extension Result where Success == (data: Data, response: URLResponse) {
func map<T>(
_ keyPath: KeyPath<Success, T>
) -> Result<T, Failure> {
switch self {
case let .success(dataResponse):
return .success(dataResponse[keyPath: keyPath])
case let .failure(error):
return .failure(error)
}
}
}
At this point, we should be able to at least map
the data response in a similar way that Combine does.
urlSession
.dataTask(with: url) { result in
_ = result
.map(\.data)
}
.resume()
Adding support for decoding
The next thing we are trying to achieve is enabling the .decode
transformation. If we look at the existing extension in Combine, we got the following:
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
extension Publisher {
func decode<Item, Coder>(
type: Item.Type,
decoder: Coder
) -> Publishers.Decode<Self, Item, Coder>
where Item: Decodable, Coder: TopLevelDecoder, Self.Output == Coder.Input
}
Who gets the work done in this built-in implementation is the value/instance that conforms to the TopLevelDecoder
protocol, which commonly is a JSONDecoder
. Let's see the TopLevelDecoder
definition:
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
public protocol TopLevelDecoder {
/// The type this decoder accepts.
associatedtype Input
/// Decodes an instance of the indicated type.
func decode<T>(
_ type: T.Type,
from: Self.Input
) throws -> T where T: Decodable
}
So, let's come up with a similar approach. Because we want to avoid conflict with the Combine definitions, let's create a different one for our non-Combine implementation.
protocol HigherLevelDecoder {
func decode<T: Decodable>(
_ type: T.Type,
from data: Data
) throws -> T
}
extension JSONDecoder: HigherLevelDecoder {}
Then, we can proceed with the definition of the Result
extension constrained to Data
values for decoding.
extension Result where Success == Data {
func decode<Item, Decoder>(
type: Item.Type,
decoder: Decoder
) -> Result<Item, Error>
where Item: Decodable, Decoder: HigherLevelDecoder {
do {
let data = try get()
let decoded = try decoder.decode(type, from: data)
return .success(decoded)
} catch {
return .failure(error)
}
}
}
With this extension, we can have a similar pipeline for a response "mapping" and "decoding" to the one with Combine.
urlSession
.dataTask(with: url) { result in
_ = result
.map(\.data)
.decode(type: Response<User>.self, decoder: JSONDecoder())
.map(\.results)
}
.resume()
Extending support for Concurrency
- async/await
With Xcode 13.2 and Swift 5.5, Apple made available the Concurrency API, allowing developers to write async code in a - kind of - sync manner by using an async/await syntax.
The existing built-in functions in the Concurrency API for URLSession
implement an async-throws
syntax. However, I like the possibilities I get as a developer when working with a monad. Because of this, I wanted to go beyond and extend the URLSession
to return an async-Result
instead.
extension URLSession {
func data(
from url: URL,
delegate: URLSessionTaskDelegate? = nil
) async -> Result<(data: Data, response: URLResponse), Error> {
await withCheckedContinuation { continuation in
dataTask(with: url) { result in
continuation
.resume(returning: result)
}
.resume()
}
}
}
And great!, That's all. We can now also replicate the same pipeline by using Concurrency.
Task {
_ = await urlSession
.data(from: url)
.map(\.data)
.decode(type: Response<User>.self, decoder: JSONDecoder())
.map(\.results)
}
TL;DR
- By writing convenient APIs, we can extend the functionality of our code and get a prettier and easier code to work with.
- For production apps/frameworks, even though you have to support older deployment targets, you still can provide a "similar" API to the one that Combine provides by simply writing Swift extensions.
- The
Result
type build-in in Swift, by being a Functor, perfectly knows how to domap
transformations and also knows how to flip between Success and Failure cases (it is a Sum type). As developers, we can always take advantage of it.