[Feedback] NIO-based HTTP Client

SSWG HTTP Client Library

Introduction

Number of projects implemented their own HTTP client libraries, like:

This shows that there is a need for generic, multi-purpose, non-blocking, asynchronous HTTP client library built on top of SwiftNIO. SSWG aims to provide a number of packages that could be shared between different projects, and I think proposed HTTP client library would be a good fit for those projects and other use-cases.

Motivation

Having one, community-driver project that can be shared between different projects will hopefully solve the need to implement this functionality in every project from scratch.

Proposed solution

Proposed solution is to have a HTTPClient class, that support a number of often-used methods, as well as an ability to pass a delegate for more precise control over the HTTP data transmission:

class HTTPClient {

    public func get(url: String, timeout: Timeout? = nil) -> EventLoopFuture<Response> {
    }

    public func post(url: String, body: Body? = nil, timeout: Timeout? = nil) -> EventLoopFuture<Response> {
    }

    public func put(url: String, body: Body? = nil, timeout: Timeout? = nil) -> EventLoopFuture<Response> {
    }

    public func delete(url: String, timeout: Timeout? = nil) -> EventLoopFuture<Response> {
    }

    public func execute(request: Request, timeout: Timeout? = nil) -> EventLoopFuture<Response> {
    }

    public func execute<T: HTTPClientResponseDelegate>(request: Request, delegate: T, timeout: Timeout? = nil) -> Task<T.Response> {
    }
}

For the first release we have the following features implemented:

  1. Simple follow-redirects (cookie headers are dropped)
  2. Streaming body download/upload
  3. TLS support
  4. Cookie parsing (but not storage)

Detailed design

Lifecycle

Creating a client is strait-forward:

let client = HTTPClient(eventLoopGroupProvider: .createNew)

This initializer will create new EventLoopGroup, so every client instance will have it's own separate EventLoopGroup. Alternatively, users can supply shared EventLoopGroup:

let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
let client = HTTPClient(eventLoopGroupProvider: .shared(group))

It is important to shutdown your client instance after it is no longer needed, in order to shutdown internal SwiftNIO machinery:

try client.syncShutdown()

In case EventLoopGroup was provided to the client instance, there is no need to shutdown the client, we expect that lifecycle of that group will be controlled by its owner.

Request

In case helper methods do not provide required functionality (for example, if user needs to set headers, or use specific HTTP method), clients of the library can use HTTPClient.Request:

extension HTTPClient {
    struct Body {
        public struct StreamWriter {
            let closure: (IOData) -> EventLoopFuture<Void>

            public func write(_ data: IOData) -> EventLoopFuture<Void> {
                return self.closure(data)
            }
        }

        public var length: Int?
        public var stream: (StreamWriter) -> EventLoopFuture<Void>

        public static func byteBuffer(_ buffer: ByteBuffer) -> Body
        public static func stream(length: Int? = nil, _ stream: @escaping (StreamWriter) -> EventLoopFuture<Void>) -> Body
        public static func data(_ data: Data) -> Body
        public static func string(_ string: String) -> Body
    }

    struct Request {
        public var version: HTTPVersion
        public var method: HTTPMethod
        public var url: URL
        public var headers: HTTPHeaders
        public var body: Body?

        public init(url: String,
                    version: HTTPVersion = HTTPVersion(major: 1, minor: 1),
                    method: HTTPMethod = .GET,
                    headers: HTTPHeaders = HTTPHeaders(),
                    body: Body? = nil) throws {}
    }
}

Example:

var request = try HTTPClient.Request(url: "http://swift.org")
request.headers.add(name: "User-Agent", value: "nio-http-client")

let future = client.execute(request: request)

Response

HTTPClient's methods return an EventLoopFuture<HTTPClient.Reponse. This struct is defined as follows:

extension HTTPClient {
    struct Response {
        public var host: String
        public var status: HTTPResponseStatus
        public var headers: HTTPHeaders
        public var body: ByteBuffer?
    }
}

where HTTPResponseStatus is an enum that describes HTTP codes that could be returned by the server:

client.get(url: "http://swift.org").whenSuccess { response in
    switch response.status {
        case .ok: print("server return 200 OK")
        case .notFound: print("server return 404 Not Found")
        case .internalServerError: print("server return 500 Internal Server Error")
        ...
    }
}

HTTPClientResponseDelegate

In addition to helper/request methods, library also provides the following delegate, which provides greater control over how HTTP response is processed:

public protocol HTTPClientResponseDelegate: class {
    associatedtype Response

    // this is executed right after request head was sent, called once
    func didSendRequestHead(task: HTTPClient.Task<Response>, _ head: HTTPRequestHead) {}

    // this is executed when request body part is sent, could be called zero or more times
    func didSendRequestPart(task: HTTPClient.Task<Response>, _ part: IOData) {}

    // this is executed when request is fully sent, called once
    func didSendRequest(task: HTTPClient.Task<Response>) {}

    // this method will be called when we receive Head response, with headers and status code
    // in case backpressure is needed, all reads will be paused until returned future is resolved
    func didReceiveHead(task: HTTPClient.Task<Response>, _ head: HTTPResponseHead) -> EventLoopFuture<Void> {
        return task.eventLoop.makeSucceededFuture(())
    }

    // this method will be called multiple times with chunks of the HTTP response body (if there is a body)
    // in case backpressure is needed, all reads will be paused until returned future is resolved
    func didReceivePart(task: HTTPClient.Task<Response>, _ buffer: ByteBuffer) -> EventLoopFuture<Void> {
        return task.eventLoop.makeSucceededFuture(())
    }

    // this method will be called if an error occurs during request processing
    func didReceiveError(task: HTTPClient.Task<Response>, _ error: Error)

    // this will be called when HTTP response is read fully
    func didFinishRequest(task: HTTPClient.Task<Response>) throws -> Response
}

This delegate will be especially helpful when you need to process HTTP response body in a streaming fashion, for example:

class CountingDelegate: HTTPClientResponseDelegate {
    typealias Response = Int

    var count = 0

    func didReceivePart(_ buffer: ByteBuffer) -> EventLoopFuture<Void> {
        count += buffer.readableBytes
        return task.eventLoop.makeSucceededFuture(())
    }

    func didFinishRequest() throws -> Int {
        return count
    }
}

let request = try HTTPRequest(url: "https://swift.org")
let delegate = CountingDelegate()

try client.execute(request: request, delegate: delegate).futureResult.whenSuccess { count in
    print(count) // count is of type Int
}
9 Likes

Thanks for posting this. I think this package is shaping up really nicely.

One thing I was wondering about... should we use deadlines instead of timeouts? They can compose better when you have multiple requests that you want to chain within a certain total time.

3 Likes

Just like for Redis and Postgres (both of which are now done), I propose for this package to change its name. First of all, there is another package using the exact same (project & module) name which is kind of a no-go. And secondly, I think the module name NIOHTTPClient is way too close to NIO's existing NIOHTTP1Client module name.

What about

  • AsyncHTTPClient (async-http-client)
  • AsyncRequests (async-requests)
  • HTTPClientNIO (http-client-nio)

or something else that's not clashing with existing packages nor sitting in the same prefix as other existing software.

I feel pretty strongly that we should aim for some consistency in package and module naming. The NIO team want to reserve the NIO prefix, which is fine, but for the rest we should define some conventions. This will help with discoverability, marketing of our overall effort, and developer sanity.

Currently for package naming we have:

  • swift-nio-http-client
  • postgres-nio
  • swift-redis-nio-client

So basically, a mess.

I propose we go with swift-http-client-nio. In other words:

  • swift - common prefix on everything, makes it clear to people driving by on GitHub that this is a Swift package
  • http-client - obvious, that's what it is. Enforce uniqueness of this for SSWG packages.
  • nio - common suffix for NIO-based packages going through SSWG. Avoids collisions with non NIO-based HTTP clients and helps with the marketing of NIO as the go-to foundation for server-side Swift.

Getting consistency would involve some more renaming:

  • nio-postgres becomes swift-postgres-client-nio
  • swift-redis-nio-client becomes swift-redis-client-nio

For module names we can drop the "swift" and go with import HTTPClientNIO, import PostgresClientNIO, import RedisClientNIO.

Thoughts? @Mordil @tanner0101

3 Likes

I know we all like consistency. The problem with consistency is that it also facilitates clashes :slight_smile:. So I'd like to argue against too much consistency...

For everybody's benefit: We don't want to reserve the NIO prefix for our own sake but to prevent clashes with NIO stuff. NIO does add top-level types/modules in SemVer minor versions but we will always prefix those with NIO. That gives other package authors some help in how to prevent clashes with NIO things. Obviously, clashes are also possible with other packages but the NIO team thought we better start somewhere and have others possibly follow later also putting their new types into certain namespaces.

What's the benefit of the swift- prefix? I don't think it adds much value and it makes clashes more likely. I'm not arguing against anybody using it if they feel like it but I'm not sure if we should mandate it.

Given how easily we can have clashes in Swift today, I think there's some actual benefit in using more 'cute' names such as MongoKitten, Stencil, Kitura, Vapor, Alamofire, Kingfisher, ...

"makes it clear to people driving by on GitHub that this is a Swift package". And the precedent is there with swift-nio, swift-log etc.

2 Likes

Hmm, could be mentioned in the readme first thing, right?

Heh yes :slight_smile:. I think that's because both log and nio are words that have been used before for different things in different context.

Hi, first I would like to say thanks for the amazing library, have been using it now for about a month and it's just great! The only downside to it is the missing release/tag so I hope you guys won't mind me presenting my opinion over something I haven't even in a slightest bit helped create.

I am not a big fan of having these long names as they are hard to remember, I think any github library should be managed partially as a business, of which a brand is a big part of, if it's to gain any traction. I believe that a part of the massive success of Alamofire or SnapKit on iOS/mac/etc is the name which is to the point, punchy and easy to remember.

Should you go for swift-http-client-nio or similar, soon this will become a part of suite of products that have extremely similar name and will be almost indistinguishable from each other. So although a "cute" name is not as descriptive, you will be still able to ude the short perex for the repo and tags on github for it which will suffice as a helper to make the library visible before it becomes more popular.

So to end this with something that I believe could possible work, check the name HyperTTP which, if you search on github has 0 results https://github.com/search?q=hyperttp

Either way, give a tag soon pretty, pretty please :)

1 Like

Given that the first commit was only 21 days ago, I'd say the "first past the post" is this package we're reviewing.

It's interesting how we read it differently. I see swift-redis-nio-client as swift, redis, nio client.

To your particular suggestion, I think swift can be optional, especially if the project is listed under swift-server in GitHub.

I personally find specific names of modules to be more helpful when reading code that imports it, as cute names always force me to retain more words so I can recognize them in other uses, and the first time I encounter it - I have to stop and go look up what the package does (or spend time trying to infer what it does from usage, but I still might not have enough context to know what types and code is from the module).

For example, I had no idea that the AWS SDK for Python was Boto3.

However, I'm not going to ever say that's a hard requirement for me. CryptoKit and MongoKitten are "cute" names that also provide some context within their name - "crypto" and "mongo".

However, while working though and thinking about this I thought of RESPClient for RedisNIO, and it looks like it's available from Johannes' GitHub search for Swift, and fits a precedence for other ecosystems such as Java and C#...

what about dropping NIO from the name altogether? Just HTTPClient (swift-http-client)? Is it really that important to advertise what technology we use underneath? As a second choice I think AsyncHTTPClient (async-http-client) is also not bad, but HTTPClientNIO is just strange imho...

Thank you for using it and kind words :) Users are as important as developers, so your opinion definitely matters!

I personally don't like "brand" names that much, I much prefer something descriptive, at least for core libraries (like logging, http, database layers), that minimises the amount of names I have to remember. It also kind of a pattern that we have with "server-oriented" libraries, I think.

1 Like

Well, prior to the one listed, there was this one, again with the same name. The point is that the name swift-nio-http-client just seems to invite clashes...
We can't make anyone (who isn't proposing theirs to the SSWG) else to rename their package so I think we should remain clear of existing packages.

I think those are perfectly good names.

HTTPClient is very taken already , miraculously, swift-http-client seems free but I think that's another candidate where we will just get clashes. and so it swift-http-client.

AsyncHTTPClient seems free on github.

I like HyperTTP :slight_smile:. Pretty descriptive and clashes unlikely.

2 Likes

You and I are 100% on the same page.

Honestly, I'm not entirely opposed to HyperTTP as it's "cute" in that it only expands the first part of the acronym, but is still descriptive of the package. (I originally supported just claiming HTTP)

1 Like

Buut, since other nio-based projects went with SomethingNIO, we should go for HTTPClientNIO...

I'm open/warm to the idea of switching to RESPClient for RedisNIO. Until Tanner speaks up about postgres-nio he's the only "holdout".

I think we just fundamentally need to answer the question:

Do we feel like we need to have consistency in naming for these foundational packages?

But is it really that important that all share the same naming scheme? I think with PostgresNIO, RedisNIO, and HyperTTP (for example) we have three names that are all pretty descriptive.

The other issue with naming everything SomethingNIO is what if in a few months time there's a better package to do Something. They would then be the only odd one out. So why not just name packages somewhat more creatively to start with?

This is a great idea, but I think there are two issues:

  • how will it work with configuration timeouts? Configuration timeouts are super useful and I think we should keep them, but then question is - do we add deadlines to execute or replace timeout?
  • what I'm not yet sure how to implement: how to set connection timeout using deadline? Ie what to do if we timeout before we have a Channel?

You could offer both like NIO does (we have schedule(in:) for 'timeouts' and schedule(deadline:) for deadlines).

Not sure I 100% understand but it could be client.get("https://foo.bar", deadline: .now() + 5). Or maybe you were thinking about configuring a 'default connection timeout'? In that case, yes, you'd need something like client.defaultConnectionTimeout = .seconds(5).

Mind expanding on what makes timeouts easier to work with for this compared to deadlines? I'd think deadlines are easier there because they would say 'the whole operation including everything that's necessary must be completed by 5 seconds from now.'

I'm talking about bootstrap.connectionTimeout, how to set it using a deadline?