Adding Advanced Features to your Network Stack in Swift

Peter Livesey
Device Blogs
Published in
7 min readAug 15, 2019

This is part 2 of a series in writing an elegant and extensible network stack in Swift. You can read part 1 here.

Writing your own networking stack is easy! One of the main advantages of owning your stack is that it’s simple to add additional features. You can pick and choose what you add depending on the specific needs of your application. You don’t need your stack to support every feature anyone could think of, but if you own your code, you can easily add in features when appropriate.

In this post, I’ll show a few examples of extending an existing stack. You may find some examples useful, while others may inspire you to add something else. Specifically, we’ll add:

  • A DataConvertible protocol which will make returning non-models possible
  • The ability to cancel requests
  • Support for downloading files to disk
  • Background tasks
Just keep adding features until you’ve got what you need. But hopefully, designed better than this. (Source: Buzzfeed)

What we have so far

In part 1, we built a simple networking stack which could handle GET and POST requests which also handled model parsing, status code errors and threading.

  1. It creates a URLRequestfrom a Requestable object
  2. It creates a task and starts it
  3. In the completion block, it checks for errors and then parses the Data into a model

You can view the full starter code here, but here’s a condensed version:

class Network {
static let shared = Network()
let session: URLSession = URLSession(configuration: .default) private let queue = DispatchQueue(label: "Network", qos: .userInitiated, attributes: .concurrent) func send<T: Model>(_ request: Requestable, completion: @escaping (Result<T, Error>)->Void) {
queue.async {
let urlRequest = request.urlRequest()
// Send the request
let task = self.session.dataTask(with: urlRequest) { data, response, error in
let result: Result<T, Error>
if let error = error {
// First, check if the network just returned an error
result = .failure(error)
} else if let error = self.error(from: response) {
// Next, check if the status code was valid
result = .failure(error)
} else if let data = data {
// Otherwise, let's try parsing the data
do {
let decoder = JSONDecoder()
result = .success(try decoder.decode(T.self, from: data))
} catch {
result = .failure(error)
}
} else {
Log.assertFailure("Missing both data and error")
result = .failure(NetworkError.noDataOrError)
}
DispatchQueue.main.async {
completion(result)
}
}
task.resume()
}
}
}

Data Convertible

One problem with the current stack is that network requests always return a Model. This is the main use-case, but what if the network request doesn’t return anything? Or it returns binary data such as an image?

You have a couple of options. You could write a separate send function for each use-case, but you’d end up with a lot of duplicated code. Instead, let’s try a protocol-oriented approach:

protocol DataConvertible {
static func convert(from data: Data?) throws -> Self
}
protocol RequiredDataConvertible: DataConvertible {
static func convert(from data: Data) throws -> Self
}
extension RequiredDataConvertible {
static func convert(from data: Data?) throws -> Self {
if let data = data {
return try convert(from: data)
} else {
throw DataConversionError("Missing data")
}
}
}

For most types, it’s useful to convert non-optional Data, so there’s a wrapper protocol, RequiredDataConvertible for convenience. We can change our send function to:

func send<T: DataConvertible>(_ request: Requestable, completion: @escaping (Result<T, Error>)->Void) {
// ...
} else {
do {
result = .success(try T.convert(from: data))
} catch {
result = .failure(error)
}

And now, we can make any type we want adhere to DataConvertible.

extension Data: RequiredDataConvertible {
static func convert(from data: Data) throws -> Data {
return data
}
}
// You can use this to return no data
struct Empty: DataConvertible {
static func convert(from data: Data?) throws -> Empty {
return Empty()
}
}

You could also easily add support for strings, images, dictionaries, arrays, or anything else your app may need.

Sadly, making Decodable is a little trickier as you can’t simply extend Decodable to be DataConvertible. Data is also Decodable, so you end up with ambiguous types. To get around this, we need to use type erasure:

struct DecodableConvertible<T: Model>: RequiredDataConvertible {
let model: T
init(_ model: T) {
self.model = model
}
static func convert(from data: Data) throws -> DecodableConvertible<T> {
let decoder = JSONDecoder()
let model = try decoder.decode(T.self, from: data)
return DecodableConvertible(model)
}
}

And then we need to add another send method to Network:

func send<T: Model>(_ request: Requestable, completion: @escaping (Result<T, Error>)->Void) {
return send(request) { (result: Result<DecodableConvertible<T>, Error>) in
// We want to return T, so we should map to the inner model
completion(result.map { $0.model })
}
}

Effectively, we’re creating a wrapper struct to hold the inner model. Then, we return this struct from the network stack and map out the inner model. The type erasure technique is common, and you can learn more about it here.

By the way, this type erasure is the reason why we need the Model protocol (remember how we used that instead of Decodable for the models?). Now, if you pass in a Model as a return type, it will use the DecodableConvertible struct, but if you pass in Data, it will use the extension on Data to do the conversion. That may seem like a lot to follow, but if you’re confused, try checking out the code and use Decodable instead of Model.

Cancelling Requests

NSURLSession allows cancelling of requests, but currently, our network stack does not. We can’t simply return a URLSessionTask because our stack is completely asynchronous (which is a good thing). There’s a solution though — we just need to write a class to handle canceling before the request is sent.

class NetworkTask {
private var task: URLSessionTask?
private var cancelled = false
private let queue = DispatchQueue(label: "com.peterlivesey.networkTask", qos: .utility) func cancel() {
queue.sync {
cancelled = true
// If we already have a task cancel it
if let task = task {
task.cancel()
}
}
}

func set(_ task: URLSessionTask) {
queue.sync {
self.task = task
// If we've cancelled the request before the task was set, let's cancel now
if cancelled {
task.cancel()
}
}
}
}

This class simply saves the cancel request and once the task is set, it will cancel it immediately. The only thing to remember is to use a DispatchQueue to keep everything thread-safe. Then, just update the send function:

func send<T: DataConvertible>(...) -> NetworkTask {
let networkTask = NetworkTask()
queue.async {
// ...
task.resume()
networkTask.set(task)
}
return networkTask
}

Though that session task is set asynchronously, we can immediately return our empty task since it can be cancelled at any time.

Adding Background Tasks

When your app goes to the background, usually all network tasks are cut off. This creates a really bad user experience. If users switch apps quickly, they’ll see error messages when they return to the app. Luckily, Apple provides a really easy API to solve this, and you can add it directly into the stack for every request. Just add to your send method:

func send(...) {
let backgroundTaskID = UIApplication.shared.beginBackgroundTask(expirationHandler: nil)
// ... after all the main logic, when you finish:
DispatchQueue.main.async {
completion(result)
UIApplication.shared.endBackgroundTask(backgroundTaskID)
}
}

File Download Support

File downloading is similar to downloading data, but it has a slightly different API. You could simply copy-paste your existing code and change the necessary lines, but I prefer to share the code.

This is a little tricky, but possible with some creative closures. First, we need to change our send method slightly and also make it private (because this API isn’t exactly user friendly):

private func send<DataType, ReturnType>
(_ request: Requestable,
taskCreator: @escaping ((URLRequest, @escaping (DataType?, URLResponse?, Error?)->Void)->URLSessionTask),
dataConvertor: @escaping (DataType?) throws -> ReturnType,
completion: @escaping (Result<ReturnType, Error>)->Void)
-> NetworkTask {

Let’s decode what’s happened here:

  • There’s an additional generic parameter: DataType
  • The first parameter is still just the request
  • The second parameter is a closure which converts a URLRequest and completion block into a data task
  • The third parameter is a closure which converts a DataType into a ReturnType
  • The final parameter is the completion block

Now, let’s convert our original send function to use this helper function.

func send<T: DataConvertible>(request, completion) -> NetworkTask {
return send(
request,
taskCreator: { session.dataTask(with: $0, completionHandler: $1) },
dataConvertor: { try T.convert(from: $0) },
completion: completion
)
}

The second parameter creates a data task from the session object. This is just a one-liner. The third parameter simply calls the DataConverible convert function.

We can write a similar function to wrap download requests:

func download
(_ request: Requestable,
destination: URL,
completion: @escaping (Result<Empty, Error>)->Void) -> NetworkTask
{
return send(
request,
taskCreator: { session.downloadTask(with: $0, completionHandler: $1) },
dataConvertor: { try self.moveFile(from: $0, to: destination) } },
completion: completion)
}

This function takes a request and a destination URL and downloads a file to that location. The second parameter creates a download task instead of a data task. The third parameter takes the temporary URL returned by the download task and moves the file from that location to the passed in location.

Now, you just need to update the send code to use these task creator and data convertor tasks. This is relatively simple, but if you’re interested in the details, you can check out the full code here.

Other Ideas

Remember, you don’t need to add any of these features if it’s not appropriate for your app. These are only examples of things you may want to add.

When building the Navigator app, I added even more features including redirect handling, unzip support for files, and parsing custom errors. (If you’re interested in working with a stack like that, they’re hiring).

The key takeaway here is that if you own your network stack, it’s yours. You can mold it into whatever your app needs.

This project and article will need to evolve as Swift and Foundation change. If you have any suggestions, comments, improvements, bugs, please let me know in the comments or submit a PR to the Github project.

Thanks to Alice Avery and Kamilah Taylor for reviewing this post.

--

--