diff --git a/Sources/AsyncHTTPClient/HTTPClient+HTTPCookie.swift b/Sources/AsyncHTTPClient/HTTPClient+HTTPCookie.swift index 9a1d786f4..5993fb0e3 100644 --- a/Sources/AsyncHTTPClient/HTTPClient+HTTPCookie.swift +++ b/Sources/AsyncHTTPClient/HTTPClient+HTTPCookie.swift @@ -16,18 +16,33 @@ import Foundation import NIOHTTP1 extension HTTPClient { + /// A representation of an HTTP cookie. public struct Cookie { + /// The name of the cookie. public var name: String + /// The cookie's string value. public var value: String + /// The cookie's path. public var path: String + /// The domain of the cookie. public var domain: String? + /// The cookie's expiration date. public var expires: Date? + /// The cookie's age in seconds. public var maxAge: Int? + /// Whether the cookie should only be sent to HTTP servers. public var httpOnly: Bool + /// Whether the cookie should only be sent over secure channels. public var secure: Bool - public init?(from string: String, defaultDomain: String) { - let components = string.components(separatedBy: ";").map { + /// Create a Cookie by parsing a `Set-Cookie` header. + /// + /// - parameters: + /// - header: String representation of the `Set-Cookie` response header. + /// - defaultDomain: Default domain to use if cookie was sent without one. + /// - returns: nil if the header is invalid. + public init?(header: String, defaultDomain: String) { + let components = header.components(separatedBy: ";").map { $0.trimmingCharacters(in: .whitespaces) } @@ -90,6 +105,17 @@ extension HTTPClient { } } + /// Create HTTP cookie. + /// + /// - parameters: + /// - name: The name of the cookie. + /// - value: The cookie's string value. + /// - path: The cookie's path. + /// - domain: The domain of the cookie, defaults to nil. + /// - expires: The cookie's expiration date, defaults to nil. + /// - maxAge: The cookie's age in seconds, defaults to nil. + /// - httpOnly: Whether this cookie should be used by HTTP servers only, defaults to false. + /// - secure: Whether this cookie should only be sent using secure channels, defaults to false. public init(name: String, value: String, path: String = "/", domain: String? = nil, expires: Date? = nil, maxAge: Int? = nil, httpOnly: Bool = false, secure: Bool = false) { self.name = name self.value = value @@ -112,12 +138,13 @@ extension HTTPClient { } } -public extension HTTPClient.Response { - internal var cookieHeaders: [HTTPHeaders.Element] { +extension HTTPClient.Response { + var cookieHeaders: [HTTPHeaders.Element] { return headers.filter { $0.name.lowercased() == "set-cookie" } } - var cookies: [HTTPClient.Cookie] { - return self.cookieHeaders.compactMap { HTTPClient.Cookie(from: $0.value, defaultDomain: self.host) } + /// List of HTTP cookies returned by the server. + public var cookies: [HTTPClient.Cookie] { + return self.cookieHeaders.compactMap { HTTPClient.Cookie(header: $0.value, defaultDomain: self.host) } } } diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index 8dfd55ab8..18114323b 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -18,12 +18,42 @@ import NIOConcurrencyHelpers import NIOHTTP1 import NIOSSL +/// HTTPClient class provides API for request execution. +/// +/// Example: +/// +/// ```swift +/// let client = HTTPClient(eventLoopGroupProvider = .createNew) +/// client.get(url: "https://swift.org", deadline: .now() + .seconds(1)).whenComplete { result in +/// switch result { +/// case .failure(let error): +/// // process error +/// case .success(let response): +/// if let response.status == .ok { +/// // handle response +/// } else { +/// // handle remote error +/// } +/// } +/// } +/// ``` +/// +/// It is important to close the client instance, for example in a defer statement, after use to cleanly shutdown the underlying NIO `EventLoopGroup`: +/// +/// ```swift +/// try client.syncShutdown() +/// ``` public class HTTPClient { public let eventLoopGroup: EventLoopGroup let eventLoopGroupProvider: EventLoopGroupProvider let configuration: Configuration let isShutdown = Atomic(value: false) + /// Create an `HTTPClient` with specified `EventLoopGroup` provider and configuration. + /// + /// - parameters: + /// - eventLoopGroupProvider: Specify how `EventLoopGroup` will be created. + /// - configuration: Client configuration. public init(eventLoopGroupProvider: EventLoopGroupProvider, configuration: Configuration = Configuration()) { self.eventLoopGroupProvider = eventLoopGroupProvider switch self.eventLoopGroupProvider { @@ -44,6 +74,7 @@ public class HTTPClient { } } + /// Shuts down the client and `EventLoopGroup` if it was created by the client. public func syncShutdown() throws { switch self.eventLoopGroupProvider { case .shared: @@ -58,6 +89,11 @@ public class HTTPClient { } } + /// Execute `GET` request using specified URL. + /// + /// - parameters: + /// - url: Remote URL. + /// - deadline: Point in time by which the request must complete. public func get(url: String, deadline: NIODeadline? = nil) -> EventLoopFuture { do { let request = try Request(url: url, method: .GET) @@ -67,6 +103,12 @@ public class HTTPClient { } } + /// Execute `POST` request using specified URL. + /// + /// - parameters: + /// - url: Remote URL. + /// - body: Request body. + /// - deadline: Point in time by which the request must complete. public func post(url: String, body: Body? = nil, deadline: NIODeadline? = nil) -> EventLoopFuture { do { let request = try HTTPClient.Request(url: url, method: .POST, body: body) @@ -76,6 +118,12 @@ public class HTTPClient { } } + /// Execute `PATCH` request using specified URL. + /// + /// - parameters: + /// - url: Remote URL. + /// - body: Request body. + /// - deadline: Point in time by which the request must complete. public func patch(url: String, body: Body? = nil, deadline: NIODeadline? = nil) -> EventLoopFuture { do { let request = try HTTPClient.Request(url: url, method: .PATCH, body: body) @@ -85,6 +133,12 @@ public class HTTPClient { } } + /// Execute `PUT` request using specified URL. + /// + /// - parameters: + /// - url: Remote URL. + /// - body: Request body. + /// - deadline: Point in time by which the request must complete. public func put(url: String, body: Body? = nil, deadline: NIODeadline? = nil) -> EventLoopFuture { do { let request = try HTTPClient.Request(url: url, method: .PUT, body: body) @@ -94,6 +148,11 @@ public class HTTPClient { } } + /// Execute `DELETE` request using specified URL. + /// + /// - parameters: + /// - url: Remote URL. + /// - deadline: The time when the request must have been completed by. public func delete(url: String, deadline: NIODeadline? = nil) -> EventLoopFuture { do { let request = try Request(url: url, method: .DELETE) @@ -103,11 +162,22 @@ public class HTTPClient { } } + /// Execute arbitrary HTTP request using specified URL. + /// + /// - parameters: + /// - request: HTTP request to execute. + /// - deadline: Point in time by which the request must complete. public func execute(request: Request, deadline: NIODeadline? = nil) -> EventLoopFuture { let accumulator = ResponseAccumulator(request: request) return self.execute(request: request, delegate: accumulator, deadline: deadline).futureResult } + /// Execute arbitrary HTTP request and handle response processing using provided delegate. + /// + /// - parameters: + /// - request: HTTP request to execute. + /// - delegate: Delegate to process response parts. + /// - deadline: Point in time by which the request must complete. public func execute(request: Request, delegate: T, deadline: NIODeadline? = nil) -> Task { let eventLoop = self.eventLoopGroup.next() @@ -187,10 +257,24 @@ public class HTTPClient { } } + /// `HTTPClient` configuration. public struct Configuration { + /// TLS configuration, defaults to `TLSConfiguration.forClient()`. public var tlsConfiguration: TLSConfiguration? + /// Enables following 3xx redirects automatically, defaults to `false`. + /// + /// Following redirects are supported: + /// - `301: Moved Permanently` + /// - `302: Found` + /// - `303: See Other` + /// - `304: Not Modified` + /// - `305: Use Proxy` + /// - `307: Temporary Redirect` + /// - `308: Permanent Redirect` public var followRedirects: Bool + /// Default client timeout, defaults to no timeouts. public var timeout: Timeout + /// Upstream proxy, defaults to no proxy. public var proxy: Proxy? public init(tlsConfiguration: TLSConfiguration? = nil, followRedirects: Bool = false, timeout: Timeout = Timeout(), proxy: Proxy? = nil) { @@ -208,15 +292,26 @@ public class HTTPClient { } } + /// Specifies how `EventLoopGroup` will be created and establishes lifecycle ownership. public enum EventLoopGroupProvider { + /// `EventLoopGroup` will be provided by the user. Owner of this group is responsible for its lifecycle. case shared(EventLoopGroup) + /// `EventLoopGroup` will be created by the client. When `syncShutdown` is called, created `EventLoopGroup` will be shut down as well. case createNew } + /// Timeout configuration public struct Timeout { + /// Specifies connect timeout. public var connect: TimeAmount? + /// Specifies read timeout. public var read: TimeAmount? + /// Create timeout. + /// + /// - parameters: + /// - connect: `connect` timeout. + /// - read: `read` timeout. public init(connect: TimeAmount? = nil, read: TimeAmount? = nil) { self.connect = connect self.read = read @@ -255,6 +350,7 @@ private extension ChannelPipeline { } } +/// Possible client errors. public struct HTTPClientError: Error, Equatable, CustomStringConvertible { private enum Code: Equatable { case invalidURL @@ -281,16 +377,28 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible { return "HTTPClientError.\(String(describing: self.code))" } + /// URL provided is invalid. public static let invalidURL = HTTPClientError(code: .invalidURL) + /// URL does not contain host. public static let emptyHost = HTTPClientError(code: .emptyHost) + /// Client is shutdown and cannot be used for new requests. public static let alreadyShutdown = HTTPClientError(code: .alreadyShutdown) + /// URL does not contain scheme. public static let emptyScheme = HTTPClientError(code: .emptyScheme) + /// Provided URL scheme is not supported, supported schemes are: `http` and `https` public static func unsupportedScheme(_ scheme: String) -> HTTPClientError { return HTTPClientError(code: .unsupportedScheme(scheme)) } + /// Request timed out. public static let readTimeout = HTTPClientError(code: .readTimeout) + /// Remote connection was closed unexpectedly. public static let remoteConnectionClosed = HTTPClientError(code: .remoteConnectionClosed) + /// Request was cancelled. public static let cancelled = HTTPClientError(code: .cancelled) + /// Request contains invalid identity encoding. public static let identityCodingIncorrectlyPresent = HTTPClientError(code: .identityCodingIncorrectlyPresent) + /// Request contains multiple chunks definitions. public static let chunkedSpecifiedMultipleTimes = HTTPClientError(code: .chunkedSpecifiedMultipleTimes) + /// Proxy response was invalid. public static let invalidProxyResponse = HTTPClientError(code: .invalidProxyResponse) + /// Request does not contain `Content-Length` header. public static let contentLengthMissing = HTTPClientError(code: .contentLengthMissing) } diff --git a/Sources/AsyncHTTPClient/HTTPHandler.swift b/Sources/AsyncHTTPClient/HTTPHandler.swift index 862c7a371..db25f2ca8 100644 --- a/Sources/AsyncHTTPClient/HTTPHandler.swift +++ b/Sources/AsyncHTTPClient/HTTPHandler.swift @@ -20,28 +20,51 @@ import NIOSSL extension HTTPClient { + /// Represent request body. public struct Body { + /// Chunk provider. public struct StreamWriter { let closure: (IOData) -> EventLoopFuture + /// Write data to server. + /// + /// - parameters: + /// - data: `IOData` to write. public func write(_ data: IOData) -> EventLoopFuture { return self.closure(data) } } + /// Body size. Request validation will be failed with `HTTPClientErrors.contentLengthMissing` if nil, + /// unless `Trasfer-Encoding: chunked` header is set. public var length: Int? + /// Body chunk provider. public var stream: (StreamWriter) -> EventLoopFuture + /// Create and stream body using `ByteBuffer`. + /// + /// - parameters: + /// - buffer: Body `ByteBuffer` representation. public static func byteBuffer(_ buffer: ByteBuffer) -> Body { return Body(length: buffer.readableBytes) { writer in writer.write(.byteBuffer(buffer)) } } + /// Create and stream body using `StreamWriter`. + /// + /// - parameters: + /// - length: Body size. Request validation will be failed with `HTTPClientErrors.contentLengthMissing` if nil, + /// unless `Trasfer-Encoding: chunked` header is set. + /// - stream: Body chunk provider. public static func stream(length: Int? = nil, _ stream: @escaping (StreamWriter) -> EventLoopFuture) -> Body { return Body(length: length, stream: stream) } + /// Create and stream body using `Data`. + /// + /// - parameters: + /// - data: Body `Data` representation. public static func data(_ data: Data) -> Body { return Body(length: data.count) { writer in var buffer = ByteBufferAllocator().buffer(capacity: data.count) @@ -50,6 +73,10 @@ extension HTTPClient { } } + /// Create and stream body using `String`. + /// + /// - parameters: + /// - string: Body `String` representation. public static func string(_ string: String) -> Body { return Body(length: string.utf8.count) { writer in var buffer = ByteBufferAllocator().buffer(capacity: string.utf8.count) @@ -59,15 +86,36 @@ extension HTTPClient { } } + /// Represent HTTP request. public struct Request { + /// Request HTTP version, defaults to `HTTP/1.1`. public var version: HTTPVersion + /// Request HTTP method, defaults to `GET`. public var method: HTTPMethod + /// Remote URL. public var url: URL + /// Remote HTTP scheme, resolved from `URL`. public var scheme: String + /// Remote host, resolved from `URL`. public var host: String + /// Request custom HTTP Headers, defaults to no headers. public var headers: HTTPHeaders + /// Request body, defaults to no body. public var body: Body? + /// Create HTTP request. + /// + /// - parameters: + /// - url: Remote `URL`. + /// - version: HTTP version. + /// - method: HTTP method. + /// - headers: Custom HTTP headers. + /// - body: Request body. + /// - throws: + /// - `invalidURL` if URL cannot be parsed. + /// - `emptyScheme` if URL does not contain HTTP scheme. + /// - `unsupportedScheme` if URL does contains unsupported HTTP scheme. + /// - `emptyHost` if URL does not contains a host. public init(url: String, version: HTTPVersion = HTTPVersion(major: 1, minor: 1), method: HTTPMethod = .GET, headers: HTTPHeaders = HTTPHeaders(), body: Body? = nil) throws { guard let url = URL(string: url) else { throw HTTPClientError.invalidURL @@ -76,6 +124,18 @@ extension HTTPClient { try self.init(url: url, version: version, method: method, headers: headers, body: body) } + /// Create an HTTP `Request`. + /// + /// - parameters: + /// - url: Remote `URL`. + /// - version: HTTP version. + /// - method: HTTP method. + /// - headers: Custom HTTP headers. + /// - body: Request body. + /// - throws: + /// - `emptyScheme` if URL does not contain HTTP scheme. + /// - `unsupportedScheme` if URL does contains unsupported HTTP scheme. + /// - `emptyHost` if URL does not contains a host. public init(url: URL, version: HTTPVersion = HTTPVersion(major: 1, minor: 1), method: HTTPMethod = .GET, headers: HTTPHeaders = HTTPHeaders(), body: Body? = nil) throws { guard let scheme = url.scheme else { throw HTTPClientError.emptyScheme @@ -98,10 +158,12 @@ extension HTTPClient { self.body = body } + /// Whether request will be executed using secure socket. public var useTLS: Bool { return self.url.scheme == "https" } + /// Resolved port. public var port: Int { return self.url.port ?? (self.useTLS ? 443 : 80) } @@ -111,10 +173,15 @@ extension HTTPClient { } } + /// Represent HTTP response. public struct Response { + /// Remote host of the request. public var host: String + /// Response HTTP status. public var status: HTTPResponseStatus + /// Reponse HTTP headers. public var headers: HTTPHeaders + /// Response body. public var body: ByteBuffer? } } @@ -191,24 +258,71 @@ internal class ResponseAccumulator: HTTPClientResponseDelegate { } } -/// This delegate is strongly held by the HTTPTaskHandler -/// for the duration of the HTTPRequest processing and will be -/// released together with the HTTPTaskHandler when channel is closed +/// `HTTPClientResponseDelegate` allows an implementation to receive notifications about request processing and to control how response parts are processed. +/// You can implement this protocol if you need fine-grained control over an HTTP request/response, for example, if you want to inspect the response +/// headers before deciding whether to accept a response body, or if you want to stream your request body. Pass an instance of your conforming +/// class to the `HTTPClient.execute()` method and this package will call each delegate method appropriately as the request takes place. +/// +/// - note: This delegate is strongly held by the `HTTPTaskHandler` +/// for the duration of the `Request` processing and will be +/// released together with the `HTTPTaskHandler` when channel is closed. +/// Users of the library are not required to keep a reference to the +/// object that implements this protocol, but may do so if needed. public protocol HTTPClientResponseDelegate: AnyObject { associatedtype Response + /// Called when the request head is sent. Will be called once. + /// + /// - parameters: + /// - task: Current request context. + /// - head: Request head. func didSendRequestHead(task: HTTPClient.Task, _ head: HTTPRequestHead) + /// Called when a part of the request body is sent. Could be called zero or more times. + /// + /// - parameters: + /// - task: Current request context. + /// - part: Request body `Part`. func didSendRequestPart(task: HTTPClient.Task, _ part: IOData) + /// Called when the request is fully sent. Will be called once. + /// + /// - parameters: + /// - task: Current request context. func didSendRequest(task: HTTPClient.Task) + /// Called when response head is received. Will be called once. + /// You must return an `EventLoopFuture` that you complete when you have finished processing the body part. + /// You can create an already succeeded future by calling `task.eventLoop.makeSucceededFuture(())`. + /// + /// - parameters: + /// - task: Current request context. + /// - head: Received reposonse head. + /// - returns: `EventLoopFuture` that will be used for backpressure. func didReceiveHead(task: HTTPClient.Task, _ head: HTTPResponseHead) -> EventLoopFuture + /// Called when part of a response body is received. Could be called zero or more times. + /// You must return an `EventLoopFuture` that you complete when you have finished processing the body part. + /// You can create an already succeeded future by calling `task.eventLoop.makeSucceededFuture(())`. + /// + /// - parameters: + /// - task: Current request context. + /// - buffer: Received body `Part`. + /// - returns: `EventLoopFuture` that will be used for backpressure. func didReceivePart(task: HTTPClient.Task, _ buffer: ByteBuffer) -> EventLoopFuture + /// Called when error was thrown during request execution. Will be called zero or one time only. Request processing will be stopped after that. + /// + /// - parameters: + /// - task: Current request context. + /// - error: Error that occured during response processing. func didReceiveError(task: HTTPClient.Task, _ error: Error) + /// Called when the complete HTTP request is finished. You must return an instance of your `Response` associated type. Will be called once, except if an error occurred. + /// + /// - parameters: + /// - task: Current request context. + /// - returns: Result of processing. func didFinishRequest(task: HTTPClient.Task) throws -> Response } @@ -238,7 +352,10 @@ internal extension URL { } extension HTTPClient { + /// Response execution context. Will be created by the library and could be used for obtaining + /// `EventLoopFuture` of the execution or cancellation of the execution. public final class Task { + /// `EventLoop` used to execute and process this request. public let eventLoop: EventLoop let promise: EventLoopPromise @@ -253,14 +370,20 @@ extension HTTPClient { self.lock = Lock() } + /// `EventLoopFuture` for the response returned by this request. public var futureResult: EventLoopFuture { return self.promise.futureResult } + /// Waits for execution of this request to complete. + /// + /// - returns: The value of the `EventLoopFuture` when it completes. + /// - throws: The error value of the `EventLoopFuture` if it errors. public func wait() throws -> Response { return try self.promise.futureResult.wait() } + /// Cancels the request execution. public func cancel() { self.lock.withLock { if !cancelled { diff --git a/Tests/AsyncHTTPClientTests/HTTPClientCookieTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientCookieTests.swift index fda30d8d7..787c64521 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientCookieTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientCookieTests.swift @@ -19,7 +19,7 @@ import XCTest class HTTPClientCookieTests: XCTestCase { func testCookie() { let v = "key=value; Path=/path; Domain=example.com; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Max-Age=42; Secure; HttpOnly" - let c = HTTPClient.Cookie(from: v, defaultDomain: "exampe.org")! + let c = HTTPClient.Cookie(header: v, defaultDomain: "exampe.org")! XCTAssertEqual("key", c.name) XCTAssertEqual("value", c.value) XCTAssertEqual("/path", c.path) @@ -32,7 +32,7 @@ class HTTPClientCookieTests: XCTestCase { func testCookieDefaults() { let v = "key=value" - let c = HTTPClient.Cookie(from: v, defaultDomain: "example.org")! + let c = HTTPClient.Cookie(header: v, defaultDomain: "example.org")! XCTAssertEqual("key", c.name) XCTAssertEqual("value", c.value) XCTAssertEqual("/", c.path)