HTTP in Swift, Part 4: Loading Requests
Part 4 in a series on building a Swift HTTP framework:
- HTTP in Swift, Part 1: An Intro to HTTP
- HTTP in Swift, Part 2: Basic Structures
- HTTP in Swift, Part 3: Request Bodies
- HTTP in Swift, Part 4: Loading Requests
- HTTP in Swift, Part 5: Testing and Mocking
- HTTP in Swift, Part 6: Chaining Loaders
- HTTP in Swift, Part 7: Dynamically Modifying Requests
- HTTP in Swift, Part 8: Request Options
- HTTP in Swift, Part 9: Resetting
- HTTP in Swift, Part 10: Cancellation
- HTTP in Swift, Part 11: Throttling
- HTTP in Swift, Part 12: Retrying
- HTTP in Swift, Part 13: Basic Authentication
- HTTP in Swift, Part 14: OAuth Setup
- HTTP in Swift, Part 15: OAuth
- HTTP in Swift, Part 16: Composite Loaders
- HTTP in Swift, Part 17: Brain Dump
- HTTP in Swift, Part 18: Wrapping Up
Up until now, we’ve been looking at the structure and implementation of a simple request/response model. Now it’s time to talk about how we send requests and receive responses.
If we think back to the first post, we’ll recall that with HTTP, we send off a request and eventually get a response (ignoring errors for a moment). There’s nothing about “tasks” or delegates or anything. We send (or “load”) a request. We eventually get a response.
If we were to describe that functionality in terms of a function, it would look something like this:
func load(request: HTTPRequest, completion: @escaping (HTTPResponse) -> Void)
We send a request, and at some point in the future, our closure will be executed with whatever response we got back. Of course, a single function isn’t what we want; instead, we want to describe an interface, ie the “shape of a thing”. So, we’ll wrap this up in a protocol:
public protocol HTTPLoading {
func load(request: HTTPRequest, completion: @escaping (HTTPResponse) -> Void)
}
Of course, we might actually have errors (dropped connections, etc), so we’ll swap out that HTTPResponse
for our “result” typealias:
public protocol HTTPLoading {
func load(request: HTTPRequest, completion: @escaping (HTTPResult) -> Void)
}
Let’s stop and appreciate this for a moment. This single method is all we’re going to need to define the core functionality of our networking framework. That’s it: one method. That’s awesome.
Let’s implement this single method using URLSession
.
Conforming to HTTPLoading
URLSession
is where the “rubber meets the road”. It’s the last hurdle to clear before our requests go out over the air (or wires) to the servers we’ve indicated. So it makes sense that our implementation of HTTPLoading
on a URLSession
will be about translating the HTTPRequest
into the URLRequest
that the session needs:
extension URLSession: HTTPLoading {
public func load(request: HTTPRequest, completion: @escaping (HTTPResult) -> Void)
guard let url = request.url else {
// we couldn't construct a proper URL out of the request's URLComponents
completion(.failure(...))
return
}
// construct the URLRequest
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = request.method.rawValue
// copy over any custom HTTP headers
for (header, value) in request.headers {
urlRequest.addValue(value, forHTTPHeaderField: header)
}
if request.body.isEmpty == false {
// if our body defines additional headers, add them
for (header, value) in request.body.additionalHeaders {
urlRequest.addValue(value, forHTTPHeaderField: header)
}
// attempt to retrieve the body data
do {
urlRequest.httpBody = try request.body.encode()
} catch {
// something went wrong creating the body; stop and report back
completion(.failure(...))
return
}
}
let dataTask = session.dataTask(with: urlRequest) { (data, response, error) in
// construct a Result<HTTPResponse, HTTPError> out of the triplet of data, url response, and url error
let result = HTTPResult(request: request, responseData: data, response: response, error: error)
completion(result)
}
// off we go!
dataTask.resume()
}
}
This should be pretty easy to follow. We’re going through the steps to pull information out of our HTTPRequest
value and apply it to a URLRequest
. If at any point something goes wrong, then we’ll report back with an error. (You’d need to fill in the ...
parts yourselves to construct an appropriate HTTPError
value)
Assuming that construction all goes smoothly, we end up with a URLRequest
that we can turn into a URLSessionDataTask
and execute it. When it completes, we’ll take the response values, turn them into an HTTPResult
, and report back via the completion block.
Creating the Result
At any point during transmission, our request might fail. If we’re in Airplane Mode or otherwise “unconnected”, then the request might never get sent at all. If we’re in the middle of sending the request (before we’ve gotten a response), the network connection might drop. Or it might drop after we’ve sent, but before we’ve gotten a response. Or it might drop after we’ve started getting the response, but before the response can be fully received.
This is why we created that HTTPError
struct while defining the request and response types, and it means we need to be a little bit more diligent about constructing our result than simply checking to see “did I get some Data
back”.
At a high level, the logic to initialize an HTTPResult
looks roughly like this:
var httpResponse: HTTPResponse?
if let r = response as? HTTPURLResponse {
httpResponse = HTTPResponse(request: request, response: r, body: responseData ?? Data())
}
if let e = error as? URLError {
let code: HTTPError.Code
switch e.code {
case .badURL: code = .invalidRequest
case .unsupportedURL: code = ...
case .cannotFindHost: code = ...
...
default: code = .unknown
}
self = .failure(HTTPError(code: code, request: request, response: httpResponse, underlyingError: e))
} else if let someError = error {
// an error, but not a URL error
self = .failure(HTTPError(code: .unknown, request: request, response: httpResponse, underlyingError: someError))
} else if let r = httpResponse {
// not an error, and an HTTPURLResponse
self = .success(r)
} else {
// not an error, but also not an HTTPURLResponse
self = .failure(HTTPError(code: .invalidResponse, request: request, response: nil, underlyingError: error))
}
Usage
Using an HTTPLoading
value is simple:
public class StarWarsAPI {
private let loader: HTTPLoading = URLSession.shared
public func requestPeople(completion: @escaping (...) -> Void) {
var r = HTTPRequest()
r.host = "swapi.dev"
r.path = "/api/people"
loader.load(request: r) { result in
// TODO: interpret the result
completion(...)
}
}
}
I want to point out here that at no point are we interpreting the response’s status code. Getting a 500 Internal Server Error
or 404 Not Found
response is a successful response. At this layer, “successful” means “we got a response”, and not “the response indicates some sort of semantic error”. Interpreting a status code is app-specific logic. In future posts we will allow for customizable, app-specific behavior that is based on status codes (such as following redirects or retrying requests).
This single method that we’ve defined is deceptively simple, but it’s also not complete. We haven’t indicated any way to proactively cancel a request, and we’ll need to adjust our HTTPLoading
protocol to add a couple more pieces of functionality. We’ll also be transforming it from a protocol
to a class
, for reasons which I’ll explain in future posts.
Despite these small holes, the protocol is still beautiful in its simplicity and it goes to show how a good conceptualization of a problem can result in something powerful and beautiful.
Simplicity is the ultimate sophistication.
In the next post, we’ll look at using the HTTPLoading
protocol to simplify unit testing.