SwiftUnwrap

Discover about Swift, iOS and architecture patterns

Designing a lightweight HTTP framework: foundation

November 30, 2021

Today let's start a journey where we'll build a lightweight πŸͺΆ HTTP library to create and handle our requests.

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.

This is the first article of Designing a lightweight HTTP framework serie. Have a look to the project on Github

Shaping our design

To build 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. Fun. But we'll ditch that one ;)

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 reusing: 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 wrap URLSession to bring it to a new level.