Async Networking
At some point as iOS engineers, we’ve all had to deal with some messy network code. Whilst the closure based syntax of async work in Swift can be managed, with more complex operations things can get out of hand pretty quickly.
Now we have async await, we can say goodbye to code that looks like this.
public func requestData<D: Decodable>(_ responseType: D.Type, from url: URL, resultClosure: @escaping (Result<D, Error>) -> ()) {
// Grab a token & setup our request
authenticationService.getToken { tokenResult in
switch tokenResult {
case .success(let token):
var request = URLRequest(url: url)
request.addValue(token, forHTTPHeaderField: "authentication")
// Make the request and validate the response
let dataTask = self.urlSession.dataTask(with: request) { data, response, error in
if let urlResponse = response as? HTTPURLResponse, !(200..<300 ~= urlResponse.statusCode) {
resultClosure(.failure(NetworkError(from: urlResponse.statusCode)))
return
}
guard let data = data else {
resultClosure(.failure(NetworkError.badResponse))
return
}
// Return the JSON if we can decode it properly
do {
let json = try self.jsonDecoder.decode(responseType, from: data)
resultClosure(.success(json))
} catch (let jsonError) {
resultClosure(.failure(jsonError))
}
}
dataTask.resume()
case .failure(let err):
resultClosure(.failure(err))
}
}
}
public func requestData<D: Decodable>(_ responseType: D.Type, from url: URL, resultClosure: @escaping (Result<D, Error>) -> ()) {
// Grab a token & setup our request
authenticationService.getToken { tokenResult in
switch tokenResult {
case .success(let token):
var request = URLRequest(url: url)
request.addValue(token, forHTTPHeaderField: "authentication")
// Make the request and validate the response
let dataTask = self.urlSession.dataTask(with: request) { data, response, error in
if let urlResponse = response as? HTTPURLResponse, !(200..<300 ~= urlResponse.statusCode) {
resultClosure(.failure(NetworkError(from: urlResponse.statusCode)))
return
}
guard let data = data else {
resultClosure(.failure(NetworkError.badResponse))
return
}
// Return the JSON if we can decode it properly
do {
let json = try self.jsonDecoder.decode(responseType, from: data)
resultClosure(.success(json))
} catch (let jsonError) {
resultClosure(.failure(jsonError))
}
}
dataTask.resume()
case .failure(let err):
resultClosure(.failure(err))
}
}
}
Whilst it might seem like ive created a purposefully hard to read example of a networking call here, I've found very similar looking code in many apps I've worked on - including my own ( I'm looking at you Bike Settings 1.0 ).
Making Requests
async networking is wonderfully simple to use. There’s some new methods on URLSession that will do the heavy lifting for us.
The basic new look for networking is this. We can await the new async data(from: URL)
which will return a tuple of the data and response. The response is a basic URLResponse, you’ll most likely want to cast that to an HTTPURLResponse instead to read status codes.
*If you’re not too familiar with async yet, don’t worry - its brand new. I'll be covering the basics of async in a later post.
let (data, response) = try await URLSession.shared.data(from: url)
let (data, response) = try await URLSession.shared.data(from: url)
We have to mark our calls to URLSession methods with try as they can throw errors just like usual. This means we should either wrap in a do catch, or mark the function that calls this with async throws
.
If you want to do more than just get something, you can also perform URLRequests with a similar method.
let (data, response) = try await URLSession.shared.data(for: urlRequest)
let (data, response) = try await URLSession.shared.data(for: urlRequest)
Lets look at a more complete example, that adds an authentication header, makes a request, then decodes the response into a Codable. This is exactly the same as the code we saw in the intro, but using our new async methods to clean things up.
func requestData<D: Decodable>(_ responseType: D.Type, from url: URL) async throws -> D {
// Grab a token & setup our request
let token = await authenticationService.getToken()
var request = URLRequest(url: url)
request.addValue(token, forHTTPHeaderField: "authentication")
// Make the request and validate the response
let (data, response) = try await urlSession.data(for: request)
if let urlResponse = response as? HTTPURLResponse, !(200..<300 ~= urlResponse.statusCode) {
throw NetworkError(from: urlResponse.statusCode)
}
// Return the JSON if we can decode it properly
return try jsonDecoder.decode(responseType, from: data)
}
func requestData<D: Decodable>(_ responseType: D.Type, from url: URL) async throws -> D {
// Grab a token & setup our request
let token = await authenticationService.getToken()
var request = URLRequest(url: url)
request.addValue(token, forHTTPHeaderField: "authentication")
// Make the request and validate the response
let (data, response) = try await urlSession.data(for: request)
if let urlResponse = response as? HTTPURLResponse, !(200..<300 ~= urlResponse.statusCode) {
throw NetworkError(from: urlResponse.statusCode)
}
// Return the JSON if we can decode it properly
return try jsonDecoder.decode(responseType, from: data)
}
If we break that down, its doing quite a lot, and its all very readable. It gets a token, which could itself perform a network request, checks the response code is valid, then returns a decoded JSON.\ This setup code will be very familiar to you because its exactly what you'd do now - the cool part is waiting for our result without using closures.
if you wanted to upload some data instead, its just a couple small changes. Set the method to post, and attatch the json data.
func postData<E: Encodable, D: Decodable>(_ responseType: D.Type, data: E, to url: URL) async throws -> D {
// Grab a token & setup our request
let token = await authenticationService.getToken()
var request = URLRequest(url: url)
request.addValue(token, forHTTPHeaderField: "authentication")
request.httpMethod = "POST"
// Encode the data
request.httpBody = try jsonEncoder.encode(data)
// Make the request and validate the response
let (data, response) = try await urlSession.data(for: request)
if let urlResponse = response as? HTTPURLResponse, !(200..<300 ~= urlResponse.statusCode) {
throw NetworkError(from: urlResponse.statusCode)
}
// Return the JSON if we can decode it properly
return try jsonDecoder.decode(responseType, from: data)
}
func postData<E: Encodable, D: Decodable>(_ responseType: D.Type, data: E, to url: URL) async throws -> D {
// Grab a token & setup our request
let token = await authenticationService.getToken()
var request = URLRequest(url: url)
request.addValue(token, forHTTPHeaderField: "authentication")
request.httpMethod = "POST"
// Encode the data
request.httpBody = try jsonEncoder.encode(data)
// Make the request and validate the response
let (data, response) = try await urlSession.data(for: request)
if let urlResponse = response as? HTTPURLResponse, !(200..<300 ~= urlResponse.statusCode) {
throw NetworkError(from: urlResponse.statusCode)
}
// Return the JSON if we can decode it properly
return try jsonDecoder.decode(responseType, from: data)
}
Downloading & Uploading
We're not always just posting or asking for JSON - sometimes we have more to do. Its quite common to have to deal with file uploads, such as profile pictures, and we're luckily going to get some help here too.
Uploading has a similar new method to data tasks, upload(for: URLRequest, from: Data)
and using it looks something like this.
func uploadData<R: Decodable>(data: Data, to url: URL, responseType: R.Type) async throws -> R {
// Grab a token & setup our request
let token = await authenticationService.getToken()
var request = URLRequest(url: url)
request.addValue(token, forHTTPHeaderField: "authentication")
request.httpMethod = "POST"
let (responseData, response) = try await self.urlSession.upload(for: request, from: data)
if let urlResponse = response as? HTTPURLResponse, !(200..<300 ~= urlResponse.statusCode) {
throw NetworkError(from: urlResponse.statusCode)
}
// Return the JSON if we can decode it properly
return try jsonDecoder.decode(responseType, from: responseData)
}
func uploadData<R: Decodable>(data: Data, to url: URL, responseType: R.Type) async throws -> R {
// Grab a token & setup our request
let token = await authenticationService.getToken()
var request = URLRequest(url: url)
request.addValue(token, forHTTPHeaderField: "authentication")
request.httpMethod = "POST"
let (responseData, response) = try await self.urlSession.upload(for: request, from: data)
if let urlResponse = response as? HTTPURLResponse, !(200..<300 ~= urlResponse.statusCode) {
throw NetworkError(from: urlResponse.statusCode)
}
// Return the JSON if we can decode it properly
return try jsonDecoder.decode(responseType, from: responseData)
}
Downloading is just as simple, with download(for: URLRequest)
. The setup code in all these examples is just about the same, with just some method changes.
func downloadData(from url: URL) async throws -> URL {
// Grab a token & setup our request
let token = await authenticationService.getToken()
var request = URLRequest(url: url)
request.addValue(token, forHTTPHeaderField: "authentication")
request.httpMethod = "GET"
let (url, response) = try await self.urlSession.download(for: request)
if let urlResponse = response as? HTTPURLResponse, !(200..<300 ~= urlResponse.statusCode) {
throw NetworkError(from: urlResponse.statusCode)
}
// Return the URL for this download
return url
}
func downloadData(from url: URL) async throws -> URL {
// Grab a token & setup our request
let token = await authenticationService.getToken()
var request = URLRequest(url: url)
request.addValue(token, forHTTPHeaderField: "authentication")
request.httpMethod = "GET"
let (url, response) = try await self.urlSession.download(for: request)
if let urlResponse = response as? HTTPURLResponse, !(200..<300 ~= urlResponse.statusCode) {
throw NetworkError(from: urlResponse.statusCode)
}
// Return the URL for this download
return url
}
The URL returned here will contain our downloaded file. If you use the sample code and open tyhat URL, you will find the file with a .tmp extension.
Conclusion
That was a quick intro to how you can use async/await in your codebase for networking. Its nice and simple, with scope to really clean up your code. All I've shown today is what its like at the network point - this cleanup continues as you go through the layers of your app.
Head to the repo and open 001-async-networking to run this code for yourself. You'll find fully working code samples ( with exceptions for where I don't have any mock endpoints - sorry! ) that you can lift and take into your own projects. For a challenge, why not try displaying a list of characters in a SwiftUI list?
If you'd like to take this even further, you could even build a full network stack using the new await apis based on the code i've provided. I would suggest going around and removing some duplicated code from my examples, and considering adding a nice typed system to represent endpoints.
If you fancy reading a little more, I’m @SwiftyAlex on twitter.