SSWG HTTP Client Library
- Proposal: SSWG-0005
- Authors: Artem Redkin, Tomer Doron, Tanner Nelson, Ian Partridge
- Sponsors: Swift Server Working Group
- Status: Implemented
- Pitch: Generic HTTP Client Library
- Discussion: Generic 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:
- Simple follow-redirects (cookie headers are dropped)
- Streaming body download/upload
- TLS support
- 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
}