How to be friends with URLRequest.

Native solution to live without Alamofire, Moya and etc in 2024.

This year, Swift celebrates its 10th anniversary. Alongside performance and safety, one of the language's main strengths throughout these years has been its simplicity.

"It is fastmodernsafe, and a joy to write" proclaims the headline on the official website.

Over the years, the language has evolved, enriching its API and introducing simple and elegant solutions to a wide range of problems. For instance, at one point in the past, we got Codable, which greatly simplified data parsing.

struct Binomial: Codable {
    var genus: String
    var species: String
    var subspecies: String?
}

let tree = Binomial(genus: "Pin", species: "oak")
let jsonData = try JSONEncoder().encode(tree)


Moving forward, we have SwiftUI, StructuredConcurency, SwiftData, RegexpBuilder, Observation, Macro, and now we're on the verge of Swift6.
It's truly exciting.

However, despite the language's capabilities and infrastructure, how often do you encounter projects where standard URLRequest and URLSession are used without any wrappers around them? Chances are, you've used something from this list:

Among all networking libraries, Alamofire probably stands out as one that genuinely brings some new value. Released in 2015, a year after Swift's launch, it filled gaps in the API and offered a simpler interface for network operations. When you needed something more complex than a GET request, Alamofire likely had a solution. But how justified is its use in 2024?

If you're developing a large-scale project with many users, you probably wouldn't want to drag in 16,000 lines of third-party library code. And if your project is small, are you sure you can't manage with the standard URLRequest? Perhaps you've grown so accustomed to Alamofire and Rx that you can't imagine working without them.

In this article, we'll explore what exactly is wrong with networking frameworks and the problems they can cause. We'll also try to organize networking using standard URLRequest and URLSession, enhancing them into a more flexible and user-friendly solution. But first, let's delve into the issues with various approaches to implementing networking in an application.
Here, I want to focus on just two problems.

Loosing Locality of Behavior

Most solutions for the network layer are based on introducing additional objects that hide parts of the logic for creating, executing, and processing requests. This approach takes us further away from meaningful code, requiring an understanding of how these additional objects work, their lifecycle, and how they ultimately map to native primitives. When everything works as intended, this might not cause issues, but as soon as we need to troubleshoot a problem, we end up jumping between various files trying to piece together the logic scattered across different files, methods, and classes.

Such code reduces the locality of behavior.

Imagine that a network call is made in the following manner:

provider = MoyaProvider<GitHub>()
provider.request(.userProfile("ashfurrow")) { result in
    // do something with the result
}

Can you explain what the MoyaProvider is responsible for?
What is its lifecycle?
Since it's a class, should you retain an instance of this class for subsequent calls, or can you create a new one for each request?
What will the request ultimately look like?

If you look at how requests are declared in the GitHub enum, you'll see something like this:

extension GitHub: TargetType {
    public var baseURL: URL { URL(string: "https://api.github.com")! }
    public var path: String {
        switch self {
        case .zen:
            return "/zen"
        case .userProfile(let name):
            return "/users/\(name.urlEscaped)"
        case .userRepositories(let name):
            return "/users/\(name.urlEscaped)/repos"
        }
    }
    public var method: Moya.Method {
        return .get
    }

    public var task: Task {
        switch self {
        case .userRepositories:
            return .requestParameters(parameters: ["sort": "pushed"], encoding: URLEncoding.default)
        default:
            return .requestPlain
        }
    }
    public var validationType: ValidationType {
        switch self {
        case .zen:
            return .successCodes
        default:
            return .none
        }
    }

    public var headers: [String: String]? { nil }
}

All the parts necessary to assemble a URLRequest are spread across different methods and properties. It becomes challenging to get the complete picture. Moreover, in what sequence will these methods and properties be called? Another difficulty you will encounter is finding all the places from which the request of interest is made. This task will require several steps to solve.

Here, we make a discovery: any additional class or service that wraps around URLRequest and the network call logic essentially offers nothing new. A wrapper might make sense if we are working with low-level code or specific frameworks that require additional knowledge. For instance, if you've worked with KeyChain, you might prefer having a wrapper because the API for KeyChain isn't very high-level.

However, when we talk about URLRequest/URLResponse/URLSession, they have sufficiently rich APIs and do not require additional wrappers.

Next, I'll show an approach that simplifies working with them. But for now, let's move on to the second problem.

Over-Abstracted

A too-high level of abstraction leads to a loss of flexibility and the ability for fine-tuning.

let network = NetworkingClient(baseURL: "https://jsonplaceholder.typicode.com")
let user: User = try await network.post("/users", params: ["firstname" : "Alan", "lastname" : "Turing"])

https://github.com/freshOS/Networking

This is a very common way of implementing networking, where everything boils down to one or two lines of code. In one line, the logic for creating, executing, and processing the request is combined (a violation of the Single Responsibility Principle).

Let's touch on just one problem with this solution: we can't check the HTTP status before parsing the response into a model. We also can't specify under which conditions we should attempt to decode the response into a model. We lose this flexibility. Obviously, when the response returns a 400 status code, we shouldn't try to parse the response into a User model.

Another framework offers a more flexible solution:

let networking = Networking(baseURL: "http://httpbin.org")
let result = try await networking.get("/get")
switch result {
case .success(let response):
    let headers = response.allHeaderFields
    // Do something with headers
case .failure(let response):
    // Handle error
}

https://github.com/3lvis/Networking

This method returns an enum where we can handle different cases. But in this case, we are forced to write these additional lines of code every time and add the logic for decoding the response. This is also not an ideal solution. Without access to standard networking primitives, we lose a lot of flexibility.

And these are just the simplest cases for ordinary requests. When we need to manage caching for a request or implement retry logic for specific cases, the problem becomes even more acute. Despite the apparent simplicity of all such solutions, when you refuse to work directly with URLRequest, you complicate code understanding and lose flexibility.

Now, recognizing the problem, let's design a better solution. As mentioned above, we will base our solution on standard primitives like URLRequest and URLSession. We'll start with a simple way to perform HTTP requests and enhance it by adding new requirements. We'll define extension points and leave room for changes in behavior.

Let's start.

A simple way to perform an HTTP request looks as follows:

/// Create request
var urlRequest = URLRequest(url: URL(string: "https://dogapi.dog/api/v2/breeds")!)
urlRequest.httpMethod = "GET"

/// Make call
let (data, _) = try await URLSession.shared.data(for: urlRequest)

/// Handle result
let decoder = JSONDecoder()
let user = try decoder.decode(Breeds.self, from: data)

It consists of three main stages:

  1. Create the Request (URLRequest)
  2. Execute it (URLSession)
  3. Process the Result (URLResponse, JSON Decoder)

We will not hide these stages behind common abstractions as the frameworks mentioned earlier did. Our goal is to make each of the three stages flexible and simple enough so that we can directly use URLRequest where we need data (e.g., in a Repository). Let's start with the first stage:

Creating the Request

The creation of an HTTP request is managed by the simple URLRequest structure. Although it is flexible, there are some issues with working with it:

  • Creating this structure from scratch and specifying all necessary parameters would be a poor solution as it would require duplicating many parameters during creation.
  • Some properties and methods of URLRequest are not sufficiently user-friendly. We have only strictly defined HTTP methods we can use, but URLRequest allows us to specify any string as the httpMethod.

We will solve these problems by adding the ability to create URLRequest through an additional configuration structure. This URLRequest Configurator will:

  • Provide a clear and concise way to define requests without repetitive code.
  • Include default settings for common use cases while allowing for customization when needed.
  • Facilitate the specification of HTTP methods by offering predefined options and ensuring correct usage.

This approach not only makes the code cleaner and easier to manage but also enhances the flexibility and readability of the networking layer, allowing developers to maintain and extend it with ease.

Now let’s look into how this configurator might be structured and how it simplifies the process of creating requests.

import Foundation

extension URLRequest {

  enum Method: String {
    case get
  }

  struct Config {
    public var host: String = "https://dogapi.dog"
    public var path: String = ""
    public var urlParams: [String: String] = [:]
    public var method: Method = .get
    public var headers: [String: String] = [:]
  }

  static func with(
    _ config: Config = .init(),
    _ configurate: (inout Config) -> Void
  ) -> URLRequest {
    var config = config
    configurate(&config)
    return with(config: config)
  }

  /// Build URLRequest with config
  private static func with(config: Config) -> URLRequest {

    guard var components = URLComponents(string: config.host + config.path) else {
      fatalError("You provide incorrect URL components host+path: \(config.host)\(config.path)")
    }

    if !config.urlParams.isEmpty {
      components.queryItems = config.urlParams.map { URLQueryItem(name: $0.key, value: $0.value) }
    }

    guard let url = components.url else {
      fatalError("You provide incorrect URL components host:\(config.host) path: \(config.path)")
    }

    var request = URLRequest(url: url)
    request.httpMethod = config.method.rawValue
    request.allHTTPHeaderFields = config.headers

    return request
  }
}

Here’s what we’ve done:

  • First, we added an enum Method to limit the possible httpMethod values.
  • We declared a Config structure, which we use as a basis for creating requests, with parameters declared in a more user-friendly way.
  • The with(config:, configurate:) -> URLRequest function allows us to create a URLRequest from the passed configurator while modifying any of its properties.
  • A private with(config) -> URLRequest function creates a URLRequest object based on the Config.

Now, creating a request looks like this:

let request = URLRequest.with {
  $0.method = .get
  $0.path = "/api/v2/breeds"
}

By default, we use a configurator with "https://dogapi.dog" as the host. But what if we want to call another host and also need an additional authorization header?
Let's add new configurator instances to handle different scenarios.

extension URLRequest.Config {
  static var dogapi = Self(host: "https://dogapi.dog")

  static var github = Self(host: "https://github.com/swiftnative/NativeNetwork")

  static var finance: Self {
    var config = Self()
    config.host = "https://financialmodelingprep.com/"
    config.header["authorize"] = "Bearer kSWTdrNFTnVkZNQL03GB73etn5xpZ8TP"
    return config
  }
}

And then we can create requests as follows:

 let search = URLRequest.with(.finance) {
      $0.path = "/api/v3/search-ticker"
      $0.urlParams["query"] = "APL"
 }

 let request2 = URLRequest.with(.github) {
      $0.path = "/swiftnative/NativeURL"
 }

Such an approach simplifies the use of URLRequest for us, making the code simpler and more pleasant, while not depriving us of any benefits of its use. We have just one function and one additional structure that do all the work.

Of course, we need to add support for POST and other request types, the ability to pass a request body, and various other parameters. But importantly, adding new features will only expand our API without introducing breaking changes. We can do this as needed, keeping the code as simple and functional as necessary.

We have centralized request creation in one place. With one click, we can navigate to the specific configurator and see everything it adds by default. With one click, we can navigate to the function that creates the URLRequest. The code has become more consistent with the principle of Locality of Behavior.

If you're curious about what real request creation looks like, here's an example from the OpenAI project:

  /// - Response: `ChatCompletion`
  static func completion(_ body: ChatCompletionRequest) -> URLRequest {
    URLRequest.with(.OpenAI.base) {
      $0.method = .post
      $0.path = "/v1/chat/completions"
      $0.bodyModel = body
    }
  }

Everything remains just as concise. Next, I'll show what else can be improved here, but for now, it's time to move on to the next stage.

Execute Request

To execute requests, we have URLSession. By default, we use the instance URLSession.shared, but nothing prevents us from adding our own URLSession instance, such as URLSession.mysession. Therefore, we won't dwell on this too much.

    let request = URLRequest.with {
      $0.method = .get
      $0.path = "/api/v2/breeds"
    }

    let (data, response) = try await URLSession.shared.data(for: request)

As you might have guessed, we will take a similar approach here by providing our URLSession with a configurator that will manage the behavior of request execution. But we'll do something more: we will combine our data and response into a single structure.

public extension URLSession {

  struct Config {
    public init() {}
  }

  func response(
    for request: URLRequest,
    config: Config = .init(),
    file: String = #file,
    function: String = #function,
    _ configurate: (inout Config) -> Void = { _ in }
  ) async throws -> DataResponse {

    var request = request
    var config = config
    configurate(&config)

    
    let (data, urlResponse) = try await data(for: request)
    let response = urlResponse as! HTTPURLResponse

    let dataResponse = DataResponse(request: request,
                                    response: response,
                                    data: data,
                                    status: .init(code: response.statusCode),
                                    fields: (response.allHeaderFields as? [String: String]) ?? [:])

    return dataResponse
  }
}

What has changed in the call code?

    let request = URLRequest.with {
      $0.method = .get
      $0.path = "/api/v2/breeds"
    }

    let response = try await URLSession.shared.response(for: request)

Not too much, but what does this give us?
And what should we add to our configurator?
Actually, anything you want—it depends on your project. As an example, I'll show you the following configuration from a real project with multiple microservices.

  struct Config {
    public var authorize: Bool = true

    public typealias IsUnauthorized = (HTTPResponse) -> Bool
    public var isUnauthorized: IsUnauthorized = { response.status == .unauthorized }

    public static var `default` = Config()
  }

And the function making the call

private static func response(
    for request: URLRequest,
    base: Config,
    _ configurate: (inout Config) -> Void,
    file: String = #file,
    function: String = #function,
  ) async throws -> DataResponse {

    let session = Container.shared.urlSession()

    var request = request
    var config = base
    configurate(&config)

    /// Check if the request need authorization
    if config.authorize {
      /// Get actual access token
      let tokens = try await Container.shared.tokenStorage.getTokens()
      request.setValue("Bearer \(tokens.access.token)", forHTTPHeaderField: .authorization)
    }

    do {
      let (data, urlResponse) = try await session.data(for: request)

      let dataResponse = DataResponse(request: request,
                                      status: response.status,
                                      headerFields: response.headerFields,
                                      data: data)

      /// CloudFare blocked request
      if response.status == .forbidden, response.contentTypeIsTextHTML {
        /// Notify to handle this event
        Notification.ClaudfareForbidden.post()
        /// Just return this error, because we can't do more
        throw ClaudfareForbiddenError(dataResponse: dataResponse)
      }

      /// check if request was unauthorized despite we had not expirable token
      if config.withAuth && config.isUnauthorized(response) {
          /// Logout 
          await Container.shared.authSession().logout()
          throw UnathorizedError(dataResponse: dataResponse)
      }

      return dataResponse

    } catch { 
      ... /// Some logging logic
      throw error
    }
  }

In this project, a mobile application interacts with numerous microservices, using JWT authorization. Here are the key points:

  1. Not all requests require authorization.
  2. Before making a call, we need to obtain a valid access token if the request requires authorization.
  3. The storage always returns a current, non-expired token. It can refresh the token using a refresh token if necessary.
  4. Sometimes the token might be revoked, in which case the backend will return an error, and we must log the user out.
  5. Some microservices return a 403 status in case of unsuccessful authorization instead of the default 401.
  6. In some cases, Cloudflare blocks requests. We should send a notification to be handled in another part of the application (an appropriate view will be shown).

In this implementation, URLSession is returned via Dependency Injection (DI), and the response function is made static. For simplicity, all logging logic, error handling, and request retry mechanisms are omitted.

Usage examples:

let response = try await URLSession.shared.response(for: request) {
    $0.authorize = false 
}
let response = try await URLSession.shared.response(for: request) {
    $0.isUnauthorized = { response.status == .forbidden }
}

All this complex and project-specific logic is concentrated in one place, tailored to the project's needs. This logic is easily extendable thanks to the configurator approach. Usage remains simple and comprehensible.

Logging

Let's look at another implementation example that includes logging.

public extension URLSession {

  struct Config {
    public var taskDelegate: URLSessionTaskDelegate? = defaultTaskDelegate
    public var logger: Logger? = defaultLogger

    public static var defaultTaskDelegate: URLSessionTaskDelegate?
    public static var defaultLogger: Logger? = Logger.networking

    public init() {}
  }

  typealias Configurate = (inout Config) -> Void

  @discardableResult
  func response(
    for request: URLRequest,
    config: Config = .init(),
    file: String = #file,
    function: String = #function,
    _ configurate: Configurate? = nil
  ) async throws -> DataResponse {

    let request = request
    var config = config
    configurate?(&config)

    config.logger?.debug("🛫 \(request.urlString)\n\(request.bodyString)\n📄 \(file.lastPathComponent)")
    
    do {
      let (data, urlResponse) = try await data(for: request, delegate: config.taskDelegate)

      let respones = try urlResponse.httpResponse()

      let dataResponse = DataResponse(request: request,
                                      response: respones,
                                      data: data)

      config.logger?.debug("🛬 \(dataResponse.request.urlString) \(dataResponse.status)\n\(dataResponse.bodyString)\n📄 \(file.lastPathComponent)")

      return dataResponse
    } catch {
      config.logger?.error("🛬 \(request.urlString)\n\(error)")
      throw error
    }
  }
}

In this enhanced implementation, we add logging for requests, responses, and errors. The response function uses file and function in its parameters to capture the context of the call. One of the useful advantages of this implementation is that we can log the request along with the code that triggered it, making it easy to trace the source of the call in the console.

🛫 GET https://dogapi.dog/api/v2/breeds

📄 Dogs+Repository.swift

Another approach used here involves declaring default values through static variables, which will be used for all default configurations. This way, we can define a default URLSessionTaskDelegate:

URLSession.Config.defaultTaskDelegate = myCusomDelegate

We can also set different delegates for each specific configuration or even replace an instance at the moment of a call. The same applies to loggers; for instance, to disable logging, we can do:

URLSession.Config.defaultLogger = nil 

This allows us to flexibly control the behavior of request execution through URLSession. In complex cases, we are not tied to this solution, as our request creation is separated from execution. We can switch to using other native URLSession methods such as download and upload.

But one question remains: Why do we need the DataResponse structure?

Response Handling

Handling HTTP responses usually involves checking the response status or decoding data into a model. The native return methods of URLSession return a tuple, which has certain limitations. For example, we cannot write extensions for tuples.

DataResponse unifies the request, response, and data into one structure, allowing us to extend this structure with additional behavior. The simplest version of DataResponse looks like this:

public struct DataResponse {
  let request: URLRequest
  let response: HTTPURLResponse
  let data: Data
  let status: URLResponse.Status
  let fields: [String: String]

  func decode<T: Decodable>(decoder: JSONDecoder = JSONDecoder(),
                            _ type: T.Type = T.self) throws -> T {
    do {
      try decoder.decode(type, from: data)
    } catch {
      // Log DecodingError with request and data
      throw error
    }
  }
}

Here we have everything we might need for data handling. We've also added a decode method with a default decoder.
Let's look at an example of how this is used:

  func breed(by id: String) async throws -> Breed {
    let request = URLRequest.with {
      $0.method = .get
      $0.path = "/api/v2/breeds/\(id)"
    }

    let response = try await URLSession.shared.response(for: request)

    if response.status == .notFound {
       throw BreedNotFoundError(id: id)
    }

    return try response.decode()
  }

Here, we perform decoding only after handling other cases, in this example, ensuring that the status is not .notFound. Thanks to the decoding implementation in DataResponse, we can link a decoding error to a specific request, which is often useful. Our DataResponse structure allows extending behavior by adding additional data, such as request duration, response size, etc.

If we need more customization for DataResponse methods, not just specifying a custom decoder for parsing:

 try response.decode(decoder: .myDecoder)

We can add a configurator as we did earlier:

public struct DataResponse {
  public let request: URLRequest
  public let status: HTTPResponse.Status
  public let headerFields: HTTPFields
  public let data: Data

  public struct Config {
    public var decoder: JSONDecoder = defaultDecoder
    public static var defaultDecoder = JSONDecoder()

    public init () {}
  }

  public func decode<T: Decodable>(
    _ config: Config = .init(),
    _ type: T.Type = T.self) throws -> T {
      try config.decoder.decode(type, from: data)
  }

  public func decode<T: Decodable>(
    decoder: JSONDecoder,
    _ type: T.Type = T.self
  ) throws -> T {
    try decoder.decode(type, from: data)
  }
}

We can now create configurators for request handling similarly. Again, Config in this case allows us to extend request handling functionality as needed. Thus, we've upgraded each stage of networking: request creation and execution, response handling. We've added the ability to customize behavior and made our API more user-friendly. What else could we improve?

Well...

When forming a request, you often need to pass certain headers. The keys of these headers have to be manually written. We would prefer to have something like:

config.headers[.accept] = "application/json"

The same applies to response statuses. In the examples above, you saw how we compare the status with a static variable:

 if response.status == .notFound {
   throw BreedNotFoundError(id: id)
 }

However, HTTPURLRequest only has a numeric status code.
To solve this problem, we will use swift-http-types, a small framework from Apple that declares various HTTP primitives.
This framework contains exactly what we need - static values for all HTTP methods, statuses, and headers. Through HTTPTypes, you can also make requests using its own types, HTTPRequest and HTTPResponse.

Note that HTTPRequest is somewhat of an alternative to URLRequest. We could use it in our solution. Unfortunately, this type currently has some limitations (e.g., we cannot set the body directly on HTTPRequest). Perhaps in the future, we might migrate for some reasons, but for now, URLRequest remains the most preferred for our solution.

After integration, our networking solution now looks like this (an imaginary request):

var request = URLRequest.with(.myApi) {
    $0.method = .post
    $0.path = "/users/\(userID)/article"
    $0.headers[.acceptLanguage] = ["en-US", "zh-Hans-CN"]
    $0.body["title"] = "some-title"
    $0.body["text"] = "some-text"
}

let response = try await URLSession.shared.response(for: request)

guard response.status == .created else {
    // Handle error
}

let article: Article = response.decode()

The code is maximally simple and clear; we use standard URLRequest and URLSession, while equipping them with configurators that eliminate duplication and make the API more user-friendly. We maintained all three stages of networking—request formation, execution, and result handling—separately, which allows us to retain flexibility. We can easily extend and enrich the capabilities of our API as needed.

You can find the code with examples at https://github.com/swiftnative/URLConfig. You can use URLConfig in your projects to implement a flexible and simple networking solution.

The OpenAI API has been implemented based on URLConfig, available at https://github.com/swiftnative/OpenAI.

Subscribe to SwiftNative

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe