Designing a lightweight HTTP framework: foundation
November 30, 2021"Nowadays no need for a framework like Alamofire just use URLSession". This is the sort of affirmation that I hear quite often at work.
While maybe a little bit presumptuous it does leave an opened question: what is missing to URLSession
that still require to use a framework? How much would we need to add on top of it to have a Alamofire equivalent library?
Let's try asking this question by going into a journey where we'll build a lightweight πͺΆ HTTP library to create and handle our requests to see how much effort it will require us.
From idea to the final result including all the questions/mistakes we can make along the way let's see step by step how to do it. First stop: paving the foundation!
You can find the library on Github
Shaping our design
To start our framework we first need to think WHY we need one. It might come for three reasons:
- Missing functionalities
- Functionalities are there but API could be improved
- Code repetition
Which one is it? To help us there's nothing better than using a sample code:
var request = URLRequest(url: URL(string: "https://myapi.com/users")!)
let decoder = JSONDecoder()
let encoder = JSONEncoder()
request.httpMethod = "POST"
request.body = try! encoder.encode(CreateUserBody(username: "swiftunwrap"))
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
URLSession
.shared
.dataTaskPublisher(for: request)
.decode(type: CreateUserResponse.self, decoder: decoder)
.eraseToAnyPublisher()
This code make a POST request by sending a CreateUserBody
then parsing the result as a CreateUserResponse
. Let's analyse it and find our "why".
Why: Improvements
Our example is short but missing cases that could make it longer:
- β Error handling (from encoding and decoding)
- β Response validation
- π€ Authenticating a request
- βΎοΈ Retrying a auth failed request
Thinking broader than just authentication we can rewrite these points as:
- β Error handling (from encoding and decoding)
- β Response validation
- π€ Modifying a request before sending it (mostly for authentication)
- βΎοΈ Retrying failing requests (mostly for authentication)
Code is also too long for reuse: there would be lots of duplicated code every time we write a request. Ideally we'd like to reduce it to the bare minimum: sending a request (and receiving the response).
session.dataTaskPublisher(for: URLRequest.post("users", CreateUserBody(username: "swiftunwrap")))
Now we know our framework will actually respond to two "why":
- Filling missing functionalities
- Providing a shorter (and safer) API
Basics: Our needs
But focusing on the "why" is actually only half of the job. We also need to fill the "basics": everything we do with vanilla code. Looking again to our example we need to:
- Create a
URLRequest
- Send an encoded body
- Decode a response
- Having a Combine compatible API (we may also consider async/await support now that it's been made backward compatible)
Less visible but it should also be very important to have small testable methods.
The "why" and needs are our ultimate objectives. We can use them as a to-do list to build our library step by step π£.
We can even start right away by improving Foundation API by:
- Validating a HTTP response
- Simplifying HTTP body creation
Foundation++
Validation
Let's add a URLResponse.validate
method:
extension HTTPURLResponse {
func validate() throws {
guard (200..<300).contains(statusCode) else {
/// You can find HttpError implementation here: https://github.com/pjechris/Http/blob/main/Sources/Http/HttpError.swift
throw HttpError(statusCode: statusCode)
}
}
}
extension URLSession.DataTaskPublisher {
func validate() -> some Publisher {
tryMap {
try ($0.response as? HTTPURLResponse)?.validate()
return $0
}
}
}
With just this method we can rewrite our example and validate our HTTP response π.
URLSession
.shared
.dataTaskPublisher(for: request)
.validate()
.decode(type: CreateUserResponse.self, decoder: decoder)
.eraseToAnyPublisher()
Encoding
Instead of manually encoding and setting a content type let's add an API that do that for us:
extension URLRequest {
func encodedBody<Body: Encodable>(_ body: Body, encoder: JSONEncoder) -> Self {
var request = self
return request.encodeBody(body, encoder: encoder)
}
mutating func encodeBody<Body: Encodable>(_ body: Body, encoder: JSONEncoder) {
httpBody = try encoder.encode(body)
setValue("application/json", forHTTPHeaderField: "Content-Type")
}
}
And let's rewrite our example:
let decoder = JSONDecoder()
let encoder = JSONEncoder()
var request = try! URLRequest(url: URL(string: "https://myapi.com/users")!)
.encodedBody(CreateUserBody(username: "swiftunwrap"), encoder: encoder)
request.httpMethod = "POST"
URLSession
.shared
.dataTaskPublisher(for: request)
.validate()
.decode(type: CreateUserResponse.self, decoder: decoder)
.eraseToAnyPublisher()
It's now easier than ever π.
We could also consider adding httpMethod in encodedBody. Both have different meaning and based on what we'll do next it wouldn't make sense.
Our implementation has some caveats thought: it only support JSONEncoder
. It might be OK as most API use JSON. But supporting any encoder would be nice.
To do that let's add a ContentType
protocol linking an encoder with its HTTP content type:
protocol ContentType {
/// the http content type
var contentType: String { get }
}
extension JSONEncoder: ContentType {
var contentType: String {Β "application/json" }
}
mutating func encodeBody<Body: Encodable, Encoder: TopLevelEncoder>(_ body: Body, encoder: Encoder) {
httpBody = try encoder.encode(body)
setValue(encoder.contentType, forHTTPHeaderField: "Content-Type")
}
Conclusion
By bringing new API to Foundation we only started to scratch our network framework. These API are small improvements but they will shape how we'll code: by bringing small increments/changes to form a lightweight but efficient HTTP library.
In next article we'll make a counterpart to URLRequest
.