Designing an HTTP Client API for Swift

HTTP is the internet’s foundational application-layer protocol. In fact, you probably just used it to view this post. Today we want to kick off the effort to design a new unified HTTP Client API to improve the experience for developers targeting multiple platforms, adopting modern Swift features, and supporting new use cases. While the API will be consistent, the underlying implementation may vary by platform today, retaining the potential over the longer term for a common source base. As a part of the ongoing work in the proposed Swift networking vision, we invite your participation as we work together to make Swift the best language for all networking use cases — starting with HTTP.

Goals

These are the goals that we have in mind when designing the API. We’d love to hear your thoughts on the goals and the overall approach to help shape the final design.

Designed for Swift

The new API should be Swift-first, adopting modern language features and best practices. And it must fully embrace Swift’s concurrency model and structured concurrency.

Progressive disclosure with full-featured HTTP support

The API should be simple for common use cases, such as HTTP redirection, metrics, authentication, cookies, and caching. Yet it should also support more advanced use cases, such as trailers, bidirectional streaming, and resumable uploads. Moving from simple to advanced use cases should not require major rewrites.

More than just for app developers

The API should serve more than just app developers. Library developers need the flexibility to avoid depending on a specific concrete client implementation, while middleware developers want to extend existing HTTP client functionality.

Cross-platform

The API should work on all platforms that Swift supports, while allowing platform-specific behaviors.

Performant

The API should allow high-performance implementations expected of a low-level system language.

Current design

The current design is still at its early stages. You can find the prototype implementation here, with more detailed proposals being available in the near future.

The proposal consists of 3 parts: the abstract interface, convenience methods to use the interface, and a concrete platform default implementation.

Abstract interface

Similar to the HTTP server API, the HTTP client API is based on an abstract protocol with a single method perform. Library authors who do not want to depend on a specific HTTP implementation can accept the abstract HTTPClient protocol. The dependency injection pattern allows libraries and apps to be more modular and testable.

public protocol HTTPClient<RequestOptions>: ~Copyable {
    associatedtype RequestOptions: HTTPClientCapability.RequestOptions
    associatedtype RequestWriter: AsyncWriter
    associatedtype ResponseConcludingReader: ConcludingAsyncReader

    var defaultRequestOptions: RequestOptions { get }

    func perform<Return: ~Copyable>(
        request: HTTPRequest,
        body: consuming HTTPClientRequestBody<RequestWriter>?,
        options: RequestOptions,
        responseHandler: (HTTPResponse, consuming ResponseConcludingReader) async throws -> Return
    ) async throws -> Return
}
  • request uses HTTPRequest which comes from the Swift HTTP Types package.
  • body is the request body to upload along with the trailer fields. It supports streaming, trailers, and restarting.
  • options represents the configuration options for performing the HTTP request, including configuration properties and event handlers.
  • responseHandler is a closure where the response can be accessed, including the header, the streamed body, and the trailer.

Request and response body types are shared between the client and the server APIs. More details about the streaming interface can be found here. They are currently based on Span<UInt8> and we are still evaluating the right types to represent the body.

Request body

The request body needs to be restartable since the client retransmits the request upon redirections or authentication challenges, and can optionally be seekable to support resumable uploads. It is represented as a struct with convenience methods to initialize from common body sources such as Data.

public struct HTTPClientRequestBody<Writer>: Sendable
{
    public static func restartable(
        knownLength: Int64? = nil,
        _ body: @escaping @Sendable (consuming Writer) async throws -> HTTPFields?
    ) -> Self

    public static func seekable(
        knownLength: Int64? = nil,
        _ body: @escaping @Sendable (Int64, consuming Writer) async throws -> HTTPFields?
    ) -> Self
}

extension HTTPClientRequestBody {
    public static func data(_ data: Data) -> Self
}

Request options

RequestOptions is a protocol that defines configuration options for an HTTP client. The specifics are still under active design.

public enum HTTPClientCapability {
    public protocol RequestOptions {
    }
}

Additional protocols are defined to refine HTTPRequestOptions to add capabilities to the client, so client backend implementations can model their specific capabilities by conforming to a certain set of protocols.

extension HTTPClientCapability {
    public protocol TLSVersionSelection: RequestOptions {
        var minimumTLSVersion: TLSVersion { get set }
        var maximumTLSVersion: TLSVersion { get set }
    }
}

Abstract usage

Users of the abstract client interface can depend on the specific capabilities of the client implementation by putting additional constrains on the request options.

func performMySecureQuery(on client: some HTTPClient<some HTTPClientCapability.TLSVersionSelection>)

Convenience methods

We provide convenience methods on top of the core perform method to simplify its usage, e.g. a convenience get method for sending a GET request.

extension HTTPClient {
    public func get(
        url: URL,
        headerFields: HTTPFields = [:],
        options: RequestOptions? = nil,
        collectUpTo limit: Int,
    ) async throws -> (response: HTTPResponse, bodyData: Data)
}

To use the convenience get method:

let (response, data) = try await client.get(url, body: bodyData, collectUpTo: .max)

Concrete implementation

The HTTPClient module exposes a platform-default implementation as a static constant. Currently the default implementation on Apple platforms is based on URLSession, and the default implementation on other platforms is based on AsyncHTTPClient.

enum HTTP {
    public static func perform<Client: HTTPClient, Return>(
        request: HTTPRequest,
        body: consuming HTTPClientRequestBody<Client.RequestWriter>? = nil,
        options: Client.RequestOptions? = nil,
        on client: Client = DefaultHTTPClient.shared,
        responseHandler: (HTTPResponse, consuming Client.ResponseConcludingReader) async throws -> Return,
    ) async throws -> Return
}

DefaultHTTPClient hosts the concrete HTTP client implementation, allowing control of the connection pooling behavior and storage isolation across requests.

public struct DefaultHTTPClient: HTTPClient, Sendable, ~Copyable {
    public static var shared: DefaultHTTPClient { get }

    public static func withClient<Return: ~Copyable, E: Error>(
        poolConfiguration: HTTPConnectionPoolConfiguration,
        body: (DefaultHTTPClient) async throws(E) -> Return
    ) async throws(E) -> Return
}

To use the platform default implementation, simply call perform or other convenience methods on HTTP:

import HTTPClient

// Using the `perform` method
try await HTTP.perform(request: request) { response, body in
    guard response.status == .ok else {
        throw MyNetworkingError.badResponse(response)
    }
    // Process body
}

// Using a proposed convenience `get` method
let (response, data) = try await HTTP.get(url, collectUpTo: .max)

Non-goals

These are features we have chosen not to include in the initial scope, though some may be addressed differently in the future.

Support of non-HTTP URL schemes

URLSession supports non-HTTP URL schemes such as file:// and data:// as well as custom schemes. By contrast, HTTPClient focuses exclusively on https:// and http://.

Rather than expanding HTTPClient to support other schemes, we believe a better approach would be to define a separate URLClient API built on top of HTTPClient. This allows HTTPClient to retain its focus and simplicity.

Replacement for background URLSession

Background URLSession supports file uploads, downloads, as well as AVAsset media downloads, with the system scheduling individual background URLSessionTasks to start at optimal times. Even though it shares some of the API surface with the default URLSession, the behaviors are often different in subtle ways.

The HTTPClient API is designed for bidirectional streaming which is not suitable for file-based background transfers. A potential future direction is to design a manifest-based bulk transfer API that can manage uploads and downloads both in-process and out-of-process.

WebSocket

WebSocket is not planned for the initial version of the HTTPClient API. An alternative for WebSocket on Apple platforms is Network Framework.

Why not URLSession or AsyncHTTPClient?

Both APIs were designed before Swift concurrency, with patterns no longer recommended in latest Swift. URLSession has a delegate queue and a deep object hierarchy, while AsyncHTTPClient relies on NIO EventLoop. We can leave the baggage behind when designing a brand-new API.

Feedback

Please use replies for your feedback on the goals and the overall approach. For detailed API discussion, feel free to open issues and pull requests in the swift-http-api-proposal repo, and please tag any related forum posts with http.

Links

:package: Repository

:crystal_ball: Networking Vision

:globe_with_meridians: HTTPServer API

16 Likes

Why do we return a tuple here with the response and data separate as opposed to the data being a property on the response?

Is Data the right type to use here or can be configure that?

5 Likes

Why do we return a tuple here with the response and data separate as opposed to the data being a property on the response?

The HTTPResponse type is used for both the streaming API where the body is delivered as a stream and the convenience API where the body is collected, so it does not contain the body as a property. Though it is possible to define a new struct that contains the response and the data instead of using a tuple.

Is Data the right type to use here or can be configure that?

Data is the currently the best type for the convenience APIs but we are working with the standard library team to explore new bag-of-bytes abstractions that are more efficient.

3 Likes

Thanks for the prototype implementation, I’ll definitely play around with it! Quick question: have you considered types throws for the peform method on the client protocol?

Quick question: have you considered types throws for the peform method on the client protocol?

Yes that has been brought up for embedded Swift. It is not trivial since the error can come from 1. HTTPClient itself when performing the load, 2. the request closure, 3. the response closure, and 4. any handlers in the request options (e.g. redirection handler). We don't want to place unnecessary restrictions on the types of errors that can be thrown.

3 Likes

From the design documentation this stood out to me:

APIs should offer "careful hole-punching": well-defined customization points that don't expose unnecessary complexity. Simple operations stay simple with intuitive defaults and minimal boilerplate, while advanced capabilities become available as needed without forcing developers off a cliff when requirements grow.

High-level APIs handle common tasks at the usage site; complex specialization happens at configuration time. We prefer compile-time enforcement over runtime checks, and minimize implicit interactions that the compiler cannot validate.

Abstractions should not completely obscure the underlying reality though. Developers must be able to understand, debug, and reason about network behavior at a lower level when necessary.

I really love this notion, and while this may be true to some extent for past HTTP APIs, it is especially true for what app developers usually build on top of these system networking APIs.

In my experience developers intuitively build abstractions around the standard HTTP APIs to share common functionality like authentication, custom error handling or retry behavior, encoding and decoding to JSON or protobuf, adhering to more high-level standards like JSON-RPC and so on. So in reality I often do not find myself fighting the standard APIs, but the abstractions that developers (me included) build on top of them. While these abstractions usually serve a well-intentioned purpose, they often become inflexible and cumbersome to deal with as soon as exceptions to the rules come up, like unexpected status code handling, special-purpose HTTP headers or other calling conventions that the abstraction was not designed for.

So I wonder if we have the opportunity here to not only modernize the network stack, but also rethink how clients can derive HTTP clients tailor-made for their backend systems and calling conventions, that are built from primitive types and protocols that make the described abstractions unnecessary in the first place and that automatically adhere to the design considerations quoted above.

I would call this the problem of "sharing call patterns and request handling across a code base".

Typically we solve these problems of sharing functionality while keeping flexibility via some form of composable API, which I would like to see here as well.

One option would be to allow registration of middleware code client-side as well. In this case developers could just share pre-configured HTTPClient types with already registered middleware across the codebase and in special circumstances developers can add new middleware ad-hoc.

Another option would be to try to make HTTP clients or individual HTTP calls composable types by themselves, via a system that encourages writing new types that conform to a primitive protocol that can be used to perform HTTP calls – similar to how SwiftUI Views are composed from other views (Even though I would not propose to build something on top of ResultBuilders, but I think it is a good demonstration of the power of composability via a commonly shared protocol like View nevertheless)

In addition I would like to see ideas that include decoding and encoding of data via middleware or type composition as well, as this is usually part of every HTTP pipeline.

Long story short, independent of these rough ideas, I would like to see the use case of sharing call patterns and request handling across a code base reflected in the design considerations, because sub-optimal abstractions are way too common and I would like to make them unnecessary.

A protocoled HTTPClient is a huge win for testability. It doesn’t seem particularly onerous for devs to inject HTTPClient fixtures that produce representative API responses without needing to mock their whole service infrastructure. Big +1 on protocolizing HTTPClient.

9 Likes

Thanks for the thoughtful reply. Yes, middleware is a topic we should definitely dig into more. It's possible with the abstract protocol today (e.g. implementing a retry middleware by wrapping the HTTPClient with RetryableHTTPClient that calls perform multiple times). However, it was brought up that a formal Middleware abstraction might work better in the long run.

In addition I would like to see ideas that include decoding and encoding of data via middleware or type composition as well, as this is usually part of every HTTP pipeline.

This is more tricky since the current API is based on byte streaming, but encoding / decoding changes the shape of the data. It's possible to add conveniences to encode / decode data at the top though that is not composable.

Long story short, independent of these rough ideas, I would like to see the use case of sharing call patterns and request handling across a code base reflected in the design considerations, because sub-optimal abstractions are way too common and I would like to make them unnecessary.

What about "eliminating the need for unnecessary wrappers"?

I think this would be preferable, the current API feels very much like it’s just a wrapper around Objective-C and not following the Swift API guidelines

Since this is just for reading (i.e. we’re not writing to the response body), shouldn’t this instead be Span, which is what the ecosystem seems to be converging on. That provides both performant APIs and removes the need to pull in Foundation

2 Likes

However, it was brought up that a formal Middleware abstraction might work better in the long run.

IMO there are a lot of advantages in looking at this model and the SSWG has looked at this previously - the ability to add a middleware to something that looks like a HTTPClient (conforming to the protocol) and get back something that also looks like a HTTPClient (conforming to the protocol) which can be passed into consumers without the consumer being aware that middleware has been applied.

The second point is that a formal Middleware abstraction allows for “generic” middleware - tracing, logging, backoff retries etc - to be written once and then applied ecosystem-wide.

There is a private repository in the swift-server GitHub repository that has some of the original investigation.

3 Likes

I agree that middleware are very important and we need to explore this more. The repo already contains a potential approach for middleware here swift-http-api-proposal/Sources/Middleware at main · apple/swift-http-api-proposal · GitHub. I also have an open PR that implements a client and a server that use middleware including an example middleware that shows how they can introspect streaming requests.

Suggestion.

Implement a central point for a web service access, handling configuration, environments and inspect every request / response. Very common in apps that depend on web services.

public protocol HTTPClient: AnyObject {
    var urlSession: URLSession { get }
    var baseURL: URL { get }
    
    var playgroundMode: ((HTTPRequest) -> HTTPResponse)? { get set }
    
    func request(_ request: HTTPRequest) async throws -> HTTPResponse
    
    func willSendRequest(_ request: HTTPRequest) async throws -> HTTPRequest
    func didReceiveResponse(_ response: HTTPResponse) async throws -> HTTPResponse
    func shouldRetryWithError(_ error: HTTPError) async -> Bool
}
class MyShoppingClient: HTTPClient {
    static let shared = MyShoppingClient()
    
    let urlSession: URLSession = .jsonAPISession
        
#if DEVELOPMENT
    let baseURL = URL(string: "https://api.dev.myshopping.com")!
#else
    let baseURL = URL(string: "https://api.myshopping.com")!
#endif
    
    var playgroundMode: ((HTTPRequest) -> HTTPResponse)? = nil
    
    func willSendRequest(_ request: HTTPRequest) async throws -> HTTPRequest {
        // Handle authorization token and other header things
    }
    
    func didReceiveResponse(_ response: HTTPResponse) async throws -> HTTPResponse {
        // Modify response
    }
    
    func shouldRetryWithError(_ error: HTTPError) async -> Bool {
        // Handle refresh token and other errors
    }
}

URLSession can’t be part of the protocol, and it definitely shouldn’t be AnyObject

And you can do all of the willReceive/didReceive/shouldRetry in middleware, that’s what it’s designed for

I too am pleased about testability win, but I am concerned that the current design would result in a proliferation of generics or some HTTPClient properties splattered throughout the codebase to control the type of HTTPClient used.

I wonder if task locals could be used - similar to how swift-dependencies uses them - for live and test clients, as well as configuring the middleware layer.

Now that I think about it a bit more, there is a negligible testability win here.

To avoid the generics or client properties throughout the code, and given current design’s missing affordance for developers to express their own APIs elegantly out of the box, what I expect to happen is that most developers will make their own “API Client” type that abstracts over some HTTPClient (or a concrete one) and its middleware (e.g.: getting and refreshing JWTs) and models the endpoints for the particular backend so that other code reads elegantly and expressively (e.g. expressively using this public API: let fact = try await client.getRandomNumberFact() rather than let request = HTTPRequest(…); let (data, response) = try await HTTP.perform(request, …); let fact = JSONDecoder()….;).

In my experience, this is not essentially different to today, where most codebases I’ve seen wrap a type around URLSession to the same effect. I acknowledge this post above. In these cases, the burden for testability will be back onto the developer, where they can use task locals, closures or whatever to avoid URLSession (or the HTTPClient) and achieve stubbing.

We have an issue tracking the inability to use the type as an existential:

Ideally we could spell it like any HTTPClient<some HTTPClientCapability.CookieOptions> and call perform on it.

Today you can already define your own capabilities and extend existing clients to conform to it.

I agree that'd be good, please add that to the GitHub issue, it's not mentioned there. And probably should file this on the Swift repo itself as well.

We have a set of open design questions that we would like your perspectives. Please give us your thoughts on these issues directly on GitHub:

  • What are the required and optional features (#67)
  • How do we perform feature detection (#141)
  • Whether we should allow non-restartable request bodies (#42)
  • Ability to expose live properties (#20 #23)