Designing a lightweight HTTP framework: foundation

Tue, 30 Nov 2021 00:00:00 +0000

Bring URLSession to a whole next level

"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:

1. Missing functionalities

2. Functionalities are there but API could be improved

3. 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":

1. Filling missing functionalities

2. 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.