From 7ec050b236824992501247c15357a5b73e310d29 Mon Sep 17 00:00:00 2001 From: Fabian Fett Date: Mon, 16 Aug 2021 09:51:49 +0200 Subject: [PATCH 1/7] Add async/await proposal --- docs/async-await.md | 168 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 docs/async-await.md diff --git a/docs/async-await.md b/docs/async-await.md new file mode 100644 index 000000000..1bd3cf057 --- /dev/null +++ b/docs/async-await.md @@ -0,0 +1,168 @@ +# Proposal: Async-await support + +## Introduction + +With the introduction of [async/await][SE-0296] in Swift 5.5, it is now possible to write asynchronous code without the need for callbacks. + +Language support for [`AsyncSequence`][SE-0298] also allows for writing functions that return values over time. + +We would like to explore how we could offer APIs that make use of these new language features to allow users to run HTTPRequest using these new idioms. + +This proposal describes what these APIs could look like and explores some of the potential usability concerns. + +## Proposed API additions + +### New `AsyncRequest` type + +The proposed new `AsyncRequest` shall be a simple swift structure. + +```swift +struct AsyncRequest { + /// The requests url. + var url: String + + /// The request's HTTPMethod + var method: HTTPMethod + + /// The request's headers + var headers: HTTPHeaders + + /// The request's body + var body: Body? + + init(url: String) { + self.url = url + self.method = .GET + self.headers = .init() + self.body = .none + } +} +``` + +A notable change from the current [`HTTPRequest`][HTTPRequest] is that the url is not of type `URL`. This makes the creation of a request non throwing. Existing issues regarding current API: + +- [HTTPClient.Request.url is a let constant][issue-395] +- [refactor to make request non-throwing](https://github.com/swift-server/async-http-client/pull/56) +- [improve request validation](https://github.com/swift-server/async-http-client/pull/67) + +The url validation will become part of the normal request validation that occurs when the request is scheduled on the `HTTPClient`. If the user supplies a request with an invalid url, the http client, will reject the request. + +In normal try/catch flows this should not change the control flow: + +```swift +do { + var request = AsyncRequest(url: "invalidurl") + try await httpClient.execute(request, deadline: .now() + .seconds(3)) +} catch { + print(error) +} +``` + +If the library code throws from the AsyncRequest creation or the request invocation the user will, in normal use cases, handle the error in the same catch block. + +#### Body streaming + +The new AsyncRequest has a new body type, that is wrapper around an internal enum. This allows us to evolve this type for use-cases that we are not aware of today. + +```swift +public struct Body { + static func bytes(_ sequence: S) -> Body where S.Element == UInt8 + + static func stream(_ sequence: S) -> Body where S.Element == ByteBuffer + + static func stream(_ sequence: S) -> Body where S.Element == UInt8 +} +``` + +The main difference to today's `Request.Body` type is the lack of a `StreamWriter` for streaming scenarios. The existing StreamWriter offered the user an API to write into (thus the user was in control of when writing happened). The new `AsyncRequest.Body` uses `AsyncSequence`s to stream requests. By iterating over the provided AsyncSequence, the HTTPClient is in control when writes happen, and can ask for more data efficiently. + +Using the `AsyncSequence` from the Swift standard library as our upload stream mechanism dramatically reduces the learning curve for new users. + +### New `AsyncResponse` type + +The `AsyncResponse` looks more similar to the existing `Response` type. The biggest difference is again the `body` property, which is now an `AsyncSequence` of `ByteBuffer`s instead of a single optional `ByteBuffer?`. This will make every response on AsyncHTTPClient streaming by default. + +```swift +public struct AsyncResponse { + /// the used http version + public var version: HTTPVersion + /// the http response status + public var status: HTTPResponseStatus + /// the response headers + public var headers: HTTPHeaders + /// the response payload as an AsyncSequence + public var body: Body +} + +extension AsyncResponse { + public struct Body: AsyncSequence { + public typealias Element = ByteBuffer + public typealias AsyncIterator = Iterator + + public struct Iterator: AsyncIteratorProtocol { + public typealias Element = ByteBuffer + + public func next() async throws -> ByteBuffer? + } + + public func makeAsyncIterator() -> Iterator + } +} +``` + +At a later point we could add trailers to the AsyncResponse as effectful properties. + +```swift + public var trailers: HTTPHeaders { async throws } +``` + +However we will need to make sure that the user has consumed the body stream completely before, calling the trailers, because otherwise we might run into a situation from which we can not progress forward: + +```swift +do { + var request = AsyncRequest(url: "https://swift.org/") + let response = try await httpClient.execute(request, deadline: .now() + .seconds(3)) + + var trailers = try await response.trailers // can not move forward since body must be consumed before. +} catch { + print(error) +} + +``` + +### New invocation + +The new way to invoke a request shall look like this: + +```swift +extension HTTPClient { + func execute(_ request: AsyncRequest, deadline: NIODeadline) async throws -> AsyncResponse +} +``` + +- **Why do we have a deadline in the function signature?** + Task deadlines are not part of the Swift 5.5 release. However we think that they are an important tool to not overload the http client accidentally. For this reason we will not default them. +- **What happened to the Logger?** We will use Task locals to propagate the logger metadata. @slashmo and @ktoso are currently working on this. +- **How does cancellation work?** Cancellation works by cancelling the surrounding task: + + ```swift + let task = Task { + let response = try await httpClient.execute(request, deadline: .distantFuture) + } + + Task.sleep(500 * 1000 * 1000) // wait half a second + task.cancel() // cancel the task after half a second + ``` + +- **What happens with all the other configuration options?** Currently users can configure a TLSConfiguration on a request. This API doesn't expose this option. We hope to create a three layer model in the future. For this reason, we currently don't want to add per request configuration on the request invocation. More info can be found in the issue: [RFC: design suggestion: Make this a "3-tier library"][issue-392] + + +[SE-0296]: https://github.com/apple/swift-evolution/blob/main/proposals/0296-async-await.md +[SE-0298]: https://github.com/apple/swift-evolution/blob/main/proposals/0298-asyncsequence.md +[SE-0310]: https://github.com/apple/swift-evolution/blob/main/proposals/0310-effectful-readonly-properties.md +[SE-0314]: https://github.com/apple/swift-evolution/blob/main/proposals/0314-async-stream.md + +[issue-392]: https://github.com/swift-server/async-http-client/issues/392 +[issue-395]: https://github.com/swift-server/async-http-client/issues/395 + +[HTTPRequest]: https://github.com/swift-server/async-http-client/blob/main/Sources/AsyncHTTPClient/HTTPHandler.swift#L96-L318 \ No newline at end of file From e2f078ea20107bc5e5b6c3a2162b85ce2066998b Mon Sep 17 00:00:00 2001 From: Fabian Fett Date: Mon, 16 Aug 2021 14:53:16 +0200 Subject: [PATCH 2/7] PR review --- docs/async-await.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/async-await.md b/docs/async-await.md index 1bd3cf057..3e71d5ba5 100644 --- a/docs/async-await.md +++ b/docs/async-await.md @@ -113,7 +113,7 @@ extension AsyncResponse { At a later point we could add trailers to the AsyncResponse as effectful properties. ```swift - public var trailers: HTTPHeaders { async throws } + public var trailers: HTTPHeaders? { async throws } ``` However we will need to make sure that the user has consumed the body stream completely before, calling the trailers, because otherwise we might run into a situation from which we can not progress forward: From f96567cd930f74602ed0772da743f464ce731895 Mon Sep 17 00:00:00 2001 From: Fabian Fett Date: Mon, 16 Aug 2021 15:52:21 +0200 Subject: [PATCH 3/7] Update docs/async-await.md Co-authored-by: Konrad `ktoso` Malawski --- docs/async-await.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/async-await.md b/docs/async-await.md index 3e71d5ba5..986d1e29b 100644 --- a/docs/async-await.md +++ b/docs/async-await.md @@ -150,7 +150,7 @@ extension HTTPClient { let response = try await httpClient.execute(request, deadline: .distantFuture) } - Task.sleep(500 * 1000 * 1000) // wait half a second + await Task.sleep(nanosecond: 500 * 1000 * 1000) // wait half a second task.cancel() // cancel the task after half a second ``` From 110ab5e9c6254d168aac57b7d6002b9401dc25af Mon Sep 17 00:00:00 2001 From: Fabian Fett Date: Mon, 16 Aug 2021 16:52:37 +0200 Subject: [PATCH 4/7] HTTPClientRequest & HTTPClientResponse --- docs/async-await.md | 42 ++++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/docs/async-await.md b/docs/async-await.md index 986d1e29b..aa808a8f1 100644 --- a/docs/async-await.md +++ b/docs/async-await.md @@ -12,12 +12,12 @@ This proposal describes what these APIs could look like and explores some of the ## Proposed API additions -### New `AsyncRequest` type +### New `HTTPClientRequest` type -The proposed new `AsyncRequest` shall be a simple swift structure. +The proposed new `HTTPClientRequest` shall be a simple swift structure. ```swift -struct AsyncRequest { +struct HTTPClientRequest { /// The requests url. var url: String @@ -39,7 +39,7 @@ struct AsyncRequest { } ``` -A notable change from the current [`HTTPRequest`][HTTPRequest] is that the url is not of type `URL`. This makes the creation of a request non throwing. Existing issues regarding current API: +A notable change from the current [`HTTPClient.Request`][HTTPClient.Request] is that the url is not of type `URL`. This makes the creation of a request non throwing. Existing issues regarding current API: - [HTTPClient.Request.url is a let constant][issue-395] - [refactor to make request non-throwing](https://github.com/swift-server/async-http-client/pull/56) @@ -51,18 +51,18 @@ In normal try/catch flows this should not change the control flow: ```swift do { - var request = AsyncRequest(url: "invalidurl") + var request = HTTPClientRequest(url: "invalidurl") try await httpClient.execute(request, deadline: .now() + .seconds(3)) } catch { print(error) } ``` -If the library code throws from the AsyncRequest creation or the request invocation the user will, in normal use cases, handle the error in the same catch block. +If the library code throws from the `HTTPClientRequest` creation or the request invocation the user will, in normal use cases, handle the error in the same catch block. -#### Body streaming +#### Request body streaming -The new AsyncRequest has a new body type, that is wrapper around an internal enum. This allows us to evolve this type for use-cases that we are not aware of today. +The new `HTTPClientRequest` has a new body type, that is wrapper around an internal enum. This allows us to evolve this type for use-cases that we are not aware of today. ```swift public struct Body { @@ -74,16 +74,16 @@ public struct Body { } ``` -The main difference to today's `Request.Body` type is the lack of a `StreamWriter` for streaming scenarios. The existing StreamWriter offered the user an API to write into (thus the user was in control of when writing happened). The new `AsyncRequest.Body` uses `AsyncSequence`s to stream requests. By iterating over the provided AsyncSequence, the HTTPClient is in control when writes happen, and can ask for more data efficiently. +The main difference to today's `Request.Body` type is the lack of a `StreamWriter` for streaming scenarios. The existing StreamWriter offered the user an API to write into (thus the user was in control of when writing happened). The new `HTTPClientRequest.Body` uses `AsyncSequence`s to stream requests. By iterating over the provided AsyncSequence, the HTTPClient is in control when writes happen, and can ask for more data efficiently. Using the `AsyncSequence` from the Swift standard library as our upload stream mechanism dramatically reduces the learning curve for new users. -### New `AsyncResponse` type +### New `HTTPClientResponse` type -The `AsyncResponse` looks more similar to the existing `Response` type. The biggest difference is again the `body` property, which is now an `AsyncSequence` of `ByteBuffer`s instead of a single optional `ByteBuffer?`. This will make every response on AsyncHTTPClient streaming by default. +The `HTTPClientResponse` looks more similar to the existing [`HTTPClient.Response`][HTTPClient.Response] type. The biggest difference is again the `body` property, which is now an `AsyncSequence` of `ByteBuffer`s instead of a single optional `ByteBuffer?`. This will make every response on AsyncHTTPClient streaming by default. As with `HTTPClientRequest`, we dropped the namespacing on `HTTPClient` to allow easier discovery with autocompletion. ```swift -public struct AsyncResponse { +public struct HTTPClientResponse { /// the used http version public var version: HTTPVersion /// the http response status @@ -94,7 +94,7 @@ public struct AsyncResponse { public var body: Body } -extension AsyncResponse { +extension HTTPClientResponse { public struct Body: AsyncSequence { public typealias Element = ByteBuffer public typealias AsyncIterator = Iterator @@ -110,7 +110,11 @@ extension AsyncResponse { } ``` -At a later point we could add trailers to the AsyncResponse as effectful properties. +Note: The user must consume the `Body` stream or drop the `HTTPClientResponse`, to ensure that the +internal HTTPClient connection can move forward. Dropping the `HTTPClientResponse` would lead to a +request cancellation which in turn would lead to a close of an exisiting HTTP/1.1 connection. + +At a later point we could add trailers to the `HTTPClientResponse` as effectful properties: ```swift public var trailers: HTTPHeaders? { async throws } @@ -120,23 +124,24 @@ However we will need to make sure that the user has consumed the body stream com ```swift do { - var request = AsyncRequest(url: "https://swift.org/") + var request = HTTPClientRequest(url: "https://swift.org/") let response = try await httpClient.execute(request, deadline: .now() + .seconds(3)) var trailers = try await response.trailers // can not move forward since body must be consumed before. } catch { print(error) } - ``` +In such a case we can either throw an error or crash. + ### New invocation The new way to invoke a request shall look like this: ```swift extension HTTPClient { - func execute(_ request: AsyncRequest, deadline: NIODeadline) async throws -> AsyncResponse + func execute(_ request: HTTPClientRequest, deadline: NIODeadline) async throws -> HTTPClientResponse } ``` @@ -165,4 +170,5 @@ extension HTTPClient { [issue-392]: https://github.com/swift-server/async-http-client/issues/392 [issue-395]: https://github.com/swift-server/async-http-client/issues/395 -[HTTPRequest]: https://github.com/swift-server/async-http-client/blob/main/Sources/AsyncHTTPClient/HTTPHandler.swift#L96-L318 \ No newline at end of file +[HTTPClient.Request]: https://github.com/swift-server/async-http-client/blob/main/Sources/AsyncHTTPClient/HTTPHandler.swift#L96-L318 +[HTTPClient.Response]: https://github.com/swift-server/async-http-client/blob/main/Sources/AsyncHTTPClient/HTTPHandler.swift#L320-L364 \ No newline at end of file From 992f006f82d37dcab2663e40af87f9221367ae3a Mon Sep 17 00:00:00 2001 From: Fabian Fett Date: Tue, 17 Aug 2021 13:34:11 +0200 Subject: [PATCH 5/7] Added a ful usage example and made clear that request Body is namespaced in HTTPClientRequest --- docs/async-await.md | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/docs/async-await.md b/docs/async-await.md index aa808a8f1..ff2bc5384 100644 --- a/docs/async-await.md +++ b/docs/async-await.md @@ -65,12 +65,14 @@ If the library code throws from the `HTTPClientRequest` creation or the request The new `HTTPClientRequest` has a new body type, that is wrapper around an internal enum. This allows us to evolve this type for use-cases that we are not aware of today. ```swift -public struct Body { - static func bytes(_ sequence: S) -> Body where S.Element == UInt8 - - static func stream(_ sequence: S) -> Body where S.Element == ByteBuffer - - static func stream(_ sequence: S) -> Body where S.Element == UInt8 +extension HTTPClientRequest { + public struct Body { + static func bytes(_ sequence: S) -> Body where S.Element == UInt8 + + static func stream(_ sequence: S) -> Body where S.Element == ByteBuffer + + static func stream(_ sequence: S) -> Body where S.Element == UInt8 + } } ``` @@ -145,6 +147,27 @@ extension HTTPClient { } ``` +Usage example: + +```swift +var request = HTTPClientRequest(url: "https://swift.org") +request.method = .POST +request.headers = [ + "content-type": "text/plain; charset=UTF-8" + "x-my-fancy-header": "super-awesome" +] +request.body = .sequence("Hello world!".utf8) + +var response = try await client.execute(request, deadline: .now() + .seconds(5)) + +switch response.status { +case .ok: + let body = try await response.body.collect(maxBytes: 1024 * 1024) +default: + throw MyUnexpectedHTTPStatusError +} +``` + - **Why do we have a deadline in the function signature?** Task deadlines are not part of the Swift 5.5 release. However we think that they are an important tool to not overload the http client accidentally. For this reason we will not default them. - **What happened to the Logger?** We will use Task locals to propagate the logger metadata. @slashmo and @ktoso are currently working on this. From 22d8b4594bba913ec6390ab284d5208ba4fe80c2 Mon Sep 17 00:00:00 2001 From: Fabian Fett Date: Tue, 17 Aug 2021 13:41:07 +0200 Subject: [PATCH 6/7] Add FAQ about convenience APIs --- docs/async-await.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/async-await.md b/docs/async-await.md index ff2bc5384..04e739938 100644 --- a/docs/async-await.md +++ b/docs/async-await.md @@ -156,7 +156,7 @@ request.headers = [ "content-type": "text/plain; charset=UTF-8" "x-my-fancy-header": "super-awesome" ] -request.body = .sequence("Hello world!".utf8) +request.body = .bytes("Hello world!".utf8) var response = try await client.execute(request, deadline: .now() + .seconds(5)) @@ -183,6 +183,7 @@ default: ``` - **What happens with all the other configuration options?** Currently users can configure a TLSConfiguration on a request. This API doesn't expose this option. We hope to create a three layer model in the future. For this reason, we currently don't want to add per request configuration on the request invocation. More info can be found in the issue: [RFC: design suggestion: Make this a "3-tier library"][issue-392] +- **What about convenience APIs?** This is our first proposal for an async/await API. We are hesitant at the moment to think about convenience APIs, since we would like to observe actual API usage. Further before we define convenience APIs, we would like to come up with a final API design for the mentioned ["3-tier library"][issue-392], to ensure those would be covered with the convenience API as well. [SE-0296]: https://github.com/apple/swift-evolution/blob/main/proposals/0296-async-await.md From 96872470475f18a5667fbf681e2e0f67ad8a0110 Mon Sep 17 00:00:00 2001 From: Fabian Fett Date: Tue, 17 Aug 2021 14:52:25 +0200 Subject: [PATCH 7/7] Added a FileHandle stream example --- docs/async-await.md | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/docs/async-await.md b/docs/async-await.md index 04e739938..b91c85a63 100644 --- a/docs/async-await.md +++ b/docs/async-await.md @@ -147,7 +147,7 @@ extension HTTPClient { } ``` -Usage example: +Simple usage example: ```swift var request = HTTPClientRequest(url: "https://swift.org") @@ -168,6 +168,40 @@ default: } ``` +Stream upload and download example using the new `FileHandle` api: + +```swift +let readHandle = FileHandle(forReadingFrom: URL(string: "file:///tmp/readfile")!)) +let writeHandle = FileHandle(forWritingTo: URL(string: "file:///tmp/writefile")!)) + +var request = HTTPClientRequest(url: "https://swift.org/echo") +request.method = .POST +request.headers = [ + "content-type": "text/plain; charset=UTF-8" + "x-my-fancy-header": "super-awesome" +] +request.body = .stream(readHandle.bytes) + +var response = try await client.execute(request, deadline: .now() + .seconds(5)) + +switch response.status { +case .ok: + Task { + var streamIterator = response.body.makeAsyncIterator() + writeHandle.writeabilityHandler = { handle in + switch try await streamIterator.next() { + case .some(let buffer): + handle.write(buffer.readData(buffer.readableBytes)!) + case .none: + handle.close() + } + } + } +default: + throw MyUnexpectedHTTPStatusError +} +``` + - **Why do we have a deadline in the function signature?** Task deadlines are not part of the Swift 5.5 release. However we think that they are an important tool to not overload the http client accidentally. For this reason we will not default them. - **What happened to the Logger?** We will use Task locals to propagate the logger metadata. @slashmo and @ktoso are currently working on this.