diff --git a/Sources/swift-openapi-generator/Documentation.docc/Proposals/Proposals.md b/Sources/swift-openapi-generator/Documentation.docc/Proposals/Proposals.md index 8111e5a9..6fa8574f 100644 --- a/Sources/swift-openapi-generator/Documentation.docc/Proposals/Proposals.md +++ b/Sources/swift-openapi-generator/Documentation.docc/Proposals/Proposals.md @@ -46,3 +46,4 @@ If you have any questions, tag [Honza Dvorsky](https://github.com/czechboy0) or - - - +- diff --git a/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0004.md b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0004.md new file mode 100644 index 00000000..db42d68b --- /dev/null +++ b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0004.md @@ -0,0 +1,832 @@ +# SOAR-0004: Streaming request and response bodies + +Represent HTTP request and response bodies as a stream of bytes. + +## Overview + +- Proposal: SOAR-0004 +- Author(s): [Honza Dvorsky](https://github.com/czechboy0) +- Status: **Ready for Implementation** +- Issue: [apple/swift-openapi-generator#9](https://github.com/apple/swift-openapi-generator/issues/9) +- Implementation: + - [apple/swift-openapi-generator#245](https://github.com/apple/swift-openapi-generator/pull/245) + - [apple/swift-openapi-runtime#47](https://github.com/apple/swift-openapi-runtime/pull/47) + - [apple/swift-openapi-urlsession#15](https://github.com/apple/swift-openapi-urlsession/pull/15) + - [swift-server/swift-openapi-async-http-client#16](https://github.com/swift-server/swift-openapi-async-http-client/pull/16) +- Feature flag: none, lands as a breaking change to main, gets released as 0.3.0 +- Affected components: + - generator + - runtime + - client transports + - server transports +- Versions: + - v1 (2023-09-08): Initial version + - v1.1: Make HTTPBody.Iterator.next() mutating + - v1.2 (2023-09-12): Added more Sendable requirements on all the async sequences. + - v1.3: Removed initializers for sync sequences-of-chunks, removed labels for the first parameter, switched from `collect` methods to convenience initializers. + - v1.4 (2023-09-13): Use opaque parameter types wherever possible. + +### Introduction + +Represent HTTP request and response bodies as an asynchronous sequence of byte chunks, unlocking use-cases that require streaming the body instead of buffering it in memory. + +### Motivation + +OpenAPI describes two kinds of body payloads: _structured_ and _unstructured_. + +Structured payloads use JSON Schema for describing their structure, and some examples of structured content types are JSON, XML, URL encoded forms, multipart forms, and so on. As the name suggests, for structured payloads, the generator emits types that conform to `Codable`, providing the adopter with type-safe access to the underlying contents. Generally, in order to decode the bytes representing a structured payload into a `Decodable` type, all the bytes have to be buffered and provided to the decoder in one go. + +Unstructured payloads, on the other hand, are represented by content types such as `text/plain` (a string) and `application/octet-stream` (raw bytes). An example use-case for a string payload would be raw logs emitted by a web service, and for a raw byte payload a compressed archive of a directory. Or, a byte stream can represent a completely custom serialization scheme that the user interprets using a higher level library, for example Server-Sent Events using the `text/event-stream` content type. + +In contrast with structured payloads, unstructured payloads can generally be interpreted as a stream, without first buffering the full body into memory. This allows using unstructured content types to transfer large payloads, such as multi-GB files even through a process that only has a fraction of that memory available - by transferring the large payload in smaller chunks. + +Up to Swift OpenAPI Generator 0.2.x, all body payloads were treated as structured, meaning the generated code buffered both request and response bodies at the user/generated code boundary, and handed it over to the transport as `Foundation.Data`. This was a simple solution that optimized for the common JSON use-case, but as the project matures and is used in more areas where unstructued payloads are used, such as file uploads, buffering bodies has become a blocker. + +### Proposed solution + +Introduce a new type called `HTTPBody` in the runtime library, and use it both as the body type in the transport and middleware protocols, and also as the value nested in the respective generated `Input`/`Output` types for operations that use an unstructured payload. + +> Important: This proposal is pitched together with the proposal [SOAR-0005](https://github.com/czechboy0/swift-openapi-generator/blob/hd-soar-0005/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0005.md) that covers the updated transport and middleware protocols. The details of the changes to the transport and middleware protocols are thus deliberately vague in this proposal, so please read SOAR-0005 and provide feedback on the protocols in that thread. + +#### A unified streaming body type + +To understand why a single type is proposed, as opposed to one type for client and another for server, let's consider the entities that perform reading and writing. + +- Producers of bodies: + - client produces HTTP request bodies + - server produces HTTP response bodies +- Consumers of bodies: + - server consumes HTTP request bodies + - client consumes HTTP response bodies + +We can simplify the space by only talking about "body producers" and "body consumers", and they apply to both requests and responses, and clients and servers. + +Furthermore, instead of creating two types, one for producing a body and another for consuming a body, we also have to consider entities that do _both_ consuming and producing of bodies, such as middlewares. + +- Both consumers and producers of bodies: + - client middleware consumes and produces both HTTP request and response bodies + - server middleware consumes and produces both HTTP request and response bodies + +In the case of middlewares, sometimes a middleware might pass a body unmodified, and for example only add an extra header, but other times it might transform the body, such as by performing compression. + +The new `HTTPBody` type serves as the single unified type for producing and consuming bodies for clients, servers, and their respective middlewares. + +This is achieved by conforming the `HTTPBody` type to `AsyncSequence` where the element type is `ArraySlice`, and having initializers that can take another `AsyncSequence` of element type `ArraySlice`, in addition to many convenience initializers and `collect` methods. + +> Tip: The details of the `HTTPBody` type, including its complete public API, can be found in the "Detailed design" section later in this proposal. + +#### Streaming bodies in transport and middleware protocols + +Previously, the currency type for the underlying HTTP request and response bodies was `Foundation.Data`. + +We instead propose to replace it with `OpenAPIRuntime.HTTPBody`, both on the request and response side. + +In Swift-looking pseudo-code, the existing signature of a client transport currently looks something like this: + +```swift +protocol ClientTransport { + func send( + requestMetadata: HTTPRequestMetadata, + requestBody: Foundation.Data + ) async throws -> (HTTPResponseMetadata, Foundation.Data) +} +``` + +In goes the request metadata (the path, query, and header fields) together with a buffered request body, and out comes the response metadata (the status code and header fields) together with a buffered response body. + +Conceptually, we propose to change it to the following: + +```swift +protocol ClientTransport { + func send( + requestMetadata: HTTPRequestMetadata, + requestBody: OpenAPIRuntime.HTTPBody + ) async throws -> (HTTPResponseMetadata, OpenAPIRuntime.HTTPBody) +} +``` + +All that changed is the body type switched from `Foundation.Data` to `OpenAPIRuntime.HTTPBody`, which removes the forced buffering. + +> Note: This propagates the streaming nature of bodies through the generated code, between the user code and the transport, however it's still up to the concrete transport implementation whether and how it implements streaming over the network, whether it transforms the chunks further, and so on. Some transports might not support streaming at all, and might buffer the body before sending it over the network. If streaming is important to your use-case, consult the documentation of the concrete transport implementation you are using, or create a custom one. + +#### Streaming bodies in generated code + +The previous section discussed using the `HTTPBody` type in the transport and middleware protocols, as the currency type for HTTP bodies. The transport and middleware protocols are defined in the runtime library, and are _shared_ by all adopter of Swift OpenAPI Generator and all their OpenAPI documents. + +This section discusses how the concept of a streaming unstructured payload is surfaced in the generated code, which is _specific_ to each adopter's OpenAPI document. + +To better illustrate the change, let's consider an example service called "Stats service", which allows a client to get and post statistics in various serialization formats. + +> SeeAlso: The full OpenAPI document for the Stats service can be found in [Appendix 1](#appendix-1-stats-service-openapi-document) at the end of this proposal. + +The service has two operations, `getStats` and `postStats`. + +The first operation, `getStats`, is a `GET` call to the `/stats` path and returns the status code 200 on success, with one of the following three content types: + +```yaml +application/json: + schema: + $ref: '#/components/schemas/StatItems' +text/plain: {} +application/octet-stream: {} +``` + +The first option is JSON, a structured payload, with the structure described by the `StatItems` JSON schema value. For example: + +```json +[{"name":"CatCount","value":42},{"name":"DogCount","value":24}] +``` + +The second option is a text-based representation of the stats, that works similarly to CSV, but where all values are separated using an underscore: + +``` +CatCount_42_DogCount_24 +``` + +The third option is a tightly packed representation that uses individual bits to persist the data. The binary representation could look like the following, where two bits are used for signifying the name, and the next six hold the count. + +``` +0010101001011000 +``` + +Notice that while the JSON payload needs to be buffered fully before it can be parsed, the text and binary representations can be interpreted as the data comes in, whenever the next key-value pair arrives (in the text case, a key-value pair can be parsed once two underscores have been encountered, and in the binary case, every 8 bits represent one key-value pair). + +With Stats service in mind, let's compare the way code is generated today, and how it can be improved using a streaming `HTTPBody`. + +> Note: The generated code below has been stripped of details not relevant to the topic of this proposal. + +First, the whole Stats service API contract is represented by a generated protocol, used both by the client to make API calls, and by the server to implement the business logic. + +```swift +public protocol APIProtocol: Sendable { + func getStats(_ input: Operations.getStats.Input) async throws -> Operations.getStats.Output + func postStats(_ input: Operations.postStats.Input) async throws -> Operations.postStats.Output +} +``` + +And the generated types used by the `getStats` operation include the `Operations.getStats.Output.Ok.Body` enum, which represent the body of the 200 response: + +```swift +// Generated by Swift OpenAPI Generator 0.2.x. +public enum Body { + case json(Components.Schemas.StatItems) + case plainText(Swift.String) + case binary(Foundation.Data) +} +``` + +Notice that the plain text contents are generated as `Swift.String`, and the raw bytes contents as `Foundation.Data`. Both require buffering, which prevents continuously streaming the contents. + +```swift +// Proposed to be generated by Swift OpenAPI Generator 0.3.x. +public enum Body { + case json(Components.Schemas.StatItems) + case plainText(OpenAPIRuntime.HTTPBody) // <<< changed + case binary(OpenAPIRuntime.HTTPBody) // <<< changed +} +``` + +To address this shortcoming for unstructured payloads, we propose to use the `HTTPBody` type as the container for both the text and raw bytes contents. + +> Note: `HTTPBody` has APIs both for initialization from string, and for conversion to string once fully collected. The reason `HTTPBody` isn't generic over its chunk type, that could for example be `Swift.Substring`, is because it is not safe to split `Swift.String`-produced UTF-8 data at an arbitrary point, initialize strings from the chunks, and then concatenate them back - you will not receive the same string contents, especially when multi-scalar characters are used. For this reason, the only API to convert the contents of `HTTPBody` to string is `String.init(collecting:upTo:)`, which converts the fully buffered body. Converting any individual chunks of data to string should be done by the user only when they know it is safe, for example by accumulating a whole line of ASCII characters first, or similar. These rules will be specific to the serialization format used by each application. + +Note that `HTTPBody` contains convenience initializers and helper methods that make working with it easy for the simple cases, some examples follow: + +- Creating a body from string: + +```swift +let body = HTTPBody("Hello, world!") +``` + +- Consuming the full body and converting it to string: + +```swift +let string = try await String(collecting: body, upTo: 2 * 1024 * 1024) +``` + +- Creating a body from data: + +```swift +let data: Foundation.Data = ... +let body = HTTPBody(data) +``` + +- Consuming the full body and converting it to data: + +```swift +let data: Foundation.Data = try await Data(collecting: body, upTo: 2 * 1024 * 1024) +``` + +Note that the request body example in `postStats` with its equivalent generated `Body` enum works the same way as the `getStats` response body above, so it's not repeated here. + +### Detailed design + +What follows is the generated API interface of the `HTTPBody` type. + +A reminder that the exact spelling of integrating `HTTPBody` into the transport and middleware protocols is included in the [SOAR-0005](https://github.com/czechboy0/swift-openapi-generator/blob/hd-soar-0005/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0005.md) proposal instead, so it is omitted from here. + +```swift +/// A body of an HTTP request or HTTP response. +/// +/// Under the hood, it represents an async sequence of byte chunks. +/// +/// ## Creating a body from a buffer +/// There are convenience initializers to create a body from common types, such +/// as `Data`, `[UInt8]`, `ArraySlice`, and `String`. +/// +/// Create an empty body: +/// ```swift +/// let body = HTTPBody() +/// ``` +/// +/// Create a body from a byte chunk: +/// ```swift +/// let bytes: ArraySlice = ... +/// let body = HTTPBody(bytes) +/// ``` +/// +/// Create a body from `Foundation.Data`: +/// ```swift +/// let data: Foundation.Data = ... +/// let body = HTTPBody(data) +/// ``` +/// +/// Create a body from a string: +/// ```swift +/// let body = HTTPBody("Hello, world!") +/// ``` +/// +/// ## Creating a body from an async sequence +/// The body type also supports initialization from an async sequence. +/// +/// ```swift +/// let producingSequence = ... // an AsyncSequence +/// let length: HTTPBody.Length = .known(1024) // or .unknown +/// let body = HTTPBody( +/// producingSequence, +/// length: length, +/// iterationBehavior: .single // or .multiple +/// ) +/// ``` +/// +/// In addition to the async sequence, also provide the total body length, +/// if known (this can be sent in the `content-length` header), and whether +/// the sequence is safe to be iterated multiple times, or can only be iterated +/// once. +/// +/// Sequences that can be iterated multiple times work better when an HTTP +/// request needs to be retried, or if a redirect is encountered. +/// +/// In addition to providing the async sequence, you can also produce the body +/// using an `AsyncStream` or `AsyncThrowingStream`: +/// +/// ```swift +/// let body = HTTPBody( +/// AsyncStream(ArraySlice.self, { continuation in +/// continuation.yield([72, 69]) +/// continuation.yield([76, 76, 79]) +/// continuation.finish() +/// }), +/// length: .known(5) +/// ) +/// ``` +/// +/// ## Consuming a body as an async sequence +/// The `HTTPBody` type conforms to `AsyncSequence` and uses `ArraySlice` +/// as its element type, so it can be consumed in a streaming fashion, without +/// ever buffering the whole body in your process. +/// +/// For example, to get another sequence that contains only the size of each +/// chunk, and print each size, use: +/// +/// ```swift +/// let chunkSizes = body.map { chunk in chunk.count } +/// for try await chunkSize in chunkSizes { +/// print("Chunk size: \(chunkSize)") +/// } +/// ``` +/// +/// ## Consuming a body as a buffer +/// If you need to collect the whole body before processing it, use one of +/// the convenience initializers on the target types that take an `HTTPBody`. +/// +/// To get all the bytes, use the initializer on `ArraySlice` or `[UInt8]`: +/// +/// ```swift +/// let buffer = try await ArraySlice(collecting: body, upTo: 2 * 1024 * 1024) +/// ``` +/// +/// The body type provides more variants of the collecting initializer on commonly +/// used buffers, such as: +/// - `Foundation.Data` +/// - `Swift.String` +/// +/// > Important: You must provide the maximum number of bytes you can buffer in +/// memory, in the example above we provide 2 MB. If more bytes are available, +/// the method throws the `TooManyBytesError` to stop the process running out +/// of memory. While discouraged, you can provide `upTo: .max` to +/// read all the available bytes, without a limit. +public final class HTTPBody : @unchecked Sendable { + + /// The underlying byte chunk type. + public typealias ByteChunk = ArraySlice + + /// Describes how many times the provided sequence can be iterated. + public enum IterationBehavior : Sendable { + + /// The input sequence can only be iterated once. + /// + /// If a retry or a redirect is encountered, fail the call with + /// a descriptive error. + case single + + /// The input sequence can be iterated multiple times. + /// + /// Supports retries and redirects, as a new iterator is created each + /// time. + case multiple + } + + /// The body's iteration behavior, which controls how many times + /// the input sequence can be iterated. + public let iterationBehavior: IterationBehavior + + /// Describes the total length of the body, if known. + public enum Length : Sendable { + + /// Total length not known yet. + case unknown + + /// Total length is known. + case known(Int) + } + + /// The total length of the body, if known. + public let length: Length + + /// Creates a new body. + /// - Parameters: + /// - sequence: The input sequence providing the byte chunks. + /// - length: The total length of the body, in other words the accumulated + /// length of all the byte chunks. + /// - iterationBehavior: The sequence's iteration behavior, which + /// indicates whether the sequence can be iterated multiple times. + @usableFromInline + internal init(_ sequence: BodySequence, length: Length, iterationBehavior: IterationBehavior) + + /// Creates a new body with the provided sequence of byte chunks. + /// - Parameters: + /// - byteChunks: A sequence of byte chunks. + /// - length: The total length of the body. + /// - iterationBehavior: The iteration behavior of the sequence, which + /// indicates whether it can be iterated multiple times. + @usableFromInline + convenience internal init(_ byteChunks: some Sequence & Sendable, length: Length, iterationBehavior: IterationBehavior) +} + +extension HTTPBody : Equatable { + public static func == (lhs: HTTPBody, rhs: HTTPBody) -> Bool +} + +extension HTTPBody : Hashable { + public func hash(into hasher: inout Hasher) +} + +extension HTTPBody { + + /// Creates a new empty body. + @inlinable public convenience init() + + /// Creates a new body with the provided byte chunk. + /// - Parameters: + /// - bytes: A byte chunk. + /// - length: The total length of the body. + @inlinable public convenience init(_ bytes: ByteChunk, length: Length) + + /// Creates a new body with the provided byte chunk. + /// - Parameter bytes: A byte chunk. + @inlinable public convenience init(_ bytes: ByteChunk) + + /// Creates a new body with the provided byte sequence. + /// - Parameters: + /// - bytes: A byte chunk. + /// - length: The total length of the body. + /// - iterationBehavior: The iteration behavior of the sequence, which + /// indicates whether it can be iterated multiple times. + @inlinable public convenience init(_ bytes: some Sequence & Sendable, length: Length, iterationBehavior: IterationBehavior) + + /// Creates a new body with the provided byte collection. + /// - Parameters: + /// - bytes: A byte chunk. + /// - length: The total length of the body. + @inlinable public convenience init(_ bytes: some Collection & Sendable, length: Length) + + /// Creates a new body with the provided byte collection. + /// - Parameters: + /// - bytes: A byte chunk. + @inlinable public convenience init(_ bytes: some Collection & Sendable) + + /// Creates a new body with the provided async throwing stream. + /// - Parameters: + /// - stream: An async throwing stream that provides the byte chunks. + /// - length: The total length of the body. + @inlinable public convenience init(_ stream: AsyncThrowingStream, length: HTTPBody.Length) + + /// Creates a new body with the provided async stream. + /// - Parameters: + /// - stream: An async stream that provides the byte chunks. + /// - length: The total length of the body. + @inlinable public convenience init(_ stream: AsyncStream, length: HTTPBody.Length) + + /// Creates a new body with the provided async sequence. + /// - Parameters: + /// - sequence: An async sequence that provides the byte chunks. + /// - length: The total lenght of the body. + /// - iterationBehavior: The iteration behavior of the sequence, which + /// indicates whether it can be iterated multiple times. + @inlinable public convenience init(_ sequence: Bytes, length: HTTPBody.Length, iterationBehavior: IterationBehavior) where Bytes : Sendable, Bytes : AsyncSequence, Bytes.Element == ArraySlice + + /// Creates a new body with the provided async sequence of byte sequences. + /// - Parameters: + /// - sequence: An async sequence that provides the byte chunks. + /// - length: The total lenght of the body. + /// - iterationBehavior: The iteration behavior of the sequence, which + /// indicates whether it can be iterated multiple times. + @inlinable public convenience init(_ sequence: Bytes, length: HTTPBody.Length, iterationBehavior: IterationBehavior) where Bytes : Sendable, Bytes : AsyncSequence, Bytes.Element : Sequence, Bytes.Element.Element == UInt8 +} + +extension HTTPBody : AsyncSequence { + + /// The type of element produced by this asynchronous sequence. + public typealias Element = ByteChunk + + /// The type of asynchronous iterator that produces elements of this + /// asynchronous sequence. + public typealias AsyncIterator = Iterator + + /// Creates the asynchronous iterator that produces elements of this + /// asynchronous sequence. + /// + /// - Returns: An instance of the `AsyncIterator` type used to produce + /// elements of the asynchronous sequence. + public func makeAsyncIterator() -> AsyncIterator +} + + +extension HTTPBody.ByteChunk where Element == UInt8 { + + /// Creates a byte chunk by accumulating the full body in-memory into a single buffer + /// up to the provided maximum number of bytes and returning it. + /// - Parameters: + /// - body: The HTTP body to collect. + /// - maxBytes: The maximum number of bytes this method is allowed + /// to accumulate in memory before it throws an error. + /// - Throws: `TooManyBytesError` if the body contains more + /// than `maxBytes`. + public init(collecting body: HTTPBody, upTo maxBytes: Int) async throws +} + +extension Array where Element == UInt8 { + + /// Creates a byte array by accumulating the full body in-memory into a single buffer + /// up to the provided maximum number of bytes and returning it. + /// - Parameters: + /// - body: The HTTP body to collect. + /// - maxBytes: The maximum number of bytes this method is allowed + /// to accumulate in memory before it throws an error. + /// - Throws: `TooManyBytesError` if the body contains more + /// than `maxBytes`. + public init(collecting body: HTTPBody, upTo maxBytes: Int) async throws +} + +extension HTTPBody { + + /// Creates a new body with the provided string encoded as UTF-8 bytes. + /// - Parameters: + /// - string: A string to encode as bytes. + /// - length: The total length of the body. + @inlinable public convenience init(_ string: some StringProtocol & Sendable, length: Length) + + /// Creates a new body with the provided string encoded as UTF-8 bytes. + /// - Parameters: + /// - string: A string to encode as bytes. + @inlinable public convenience init(_ string: some StringProtocol & Sendable) + + /// Creates a new body with the provided async throwing stream of strings. + /// - Parameters: + /// - stream: An async throwing stream that provides the string chunks. + /// - length: The total length of the body. + @inlinable public convenience init(_ stream: AsyncThrowingStream, length: HTTPBody.Length) + + /// Creates a new body with the provided async stream of strings. + /// - Parameters: + /// - stream: An async stream that provides the string chunks. + /// - length: The total length of the body. + @inlinable public convenience init(_ stream: AsyncStream, length: HTTPBody.Length) + + /// Creates a new body with the provided async sequence of string chunks. + /// - Parameters: + /// - sequence: An async sequence that provides the string chunks. + /// - length: The total lenght of the body. + /// - iterationBehavior: The iteration behavior of the sequence, which + /// indicates whether it can be iterated multiple times. + @inlinable public convenience init(_ sequence: Strings, length: HTTPBody.Length, iterationBehavior: IterationBehavior) where Strings : Sendable, Strings : AsyncSequence, Strings.Element : Sendable, Strings.Element : StringProtocol +} + +extension HTTPBody.ByteChunk where Element == UInt8 { + + /// Creates a byte chunk compatible with the `HTTPBody` type from the provided string. + /// - Parameter string: The string to encode. + @inlinable internal init(_ string: some StringProtocol & Sendable) +} + +extension String { + + /// Creates a string by accumulating the full body in-memory into a single buffer up to + /// the provided maximum number of bytes, converting it to string using the provided encoding. + /// - Parameters: + /// - body: The HTTP body to collect. + /// - maxBytes: The maximum number of bytes this method is allowed + /// to accumulate in memory before it throws an error. + /// - Throws: `TooManyBytesError` if the body contains more + /// than `maxBytes`. + public init(collecting body: HTTPBody, upTo maxBytes: Int) async throws +} + +extension HTTPBody : ExpressibleByStringLiteral { + + /// Creates an instance initialized to the given string value. + /// + /// - Parameter value: The value of the new instance. + public convenience init(stringLiteral value: String) +} + +extension HTTPBody { + + /// Creates a new body from the provided array of bytes. + /// - Parameter bytes: An array of bytes. + @inlinable public convenience init(_ bytes: [UInt8]) +} + +extension HTTPBody : ExpressibleByArrayLiteral { + + /// The type of the elements of an array literal. + public typealias ArrayLiteralElement = UInt8 + + /// Creates an instance initialized with the given elements. + public convenience init(arrayLiteral elements: UInt8...) +} + +extension HTTPBody { + + /// Creates a new body from the provided data chunk. + /// - Parameter data: A single data chunk. + public convenience init(data: Data) +} + +extension Data { + + /// Creates a string by accumulating the full body in-memory into a single buffer up to + /// the provided maximum number of bytes and converting it to `Data`. + /// - Parameters: + /// - body: The HTTP body to collect. + /// - maxBytes: The maximum number of bytes this method is allowed + /// to accumulate in memory before it throws an error. + /// - Throws: `TooManyBytesError` if the body contains more + /// than `maxBytes`. + public init(collecting body: HTTPBody, upTo maxBytes: Int) async throws +} + +extension HTTPBody { + + /// An async iterator of both input async sequences and of the body itself. + public struct Iterator : AsyncIteratorProtocol { + + /// The element byte chunk type. + public typealias Element = HTTPBody.ByteChunk + + /// Creates a new type-erased iterator from the provided iterator. + /// - Parameter iterator: The iterator to type-erase. + @usableFromInline + internal init(_ iterator: Iterator) where Iterator : AsyncIteratorProtocol, Iterator.Element == ArraySlice + + /// Asynchronously advances to the next element and returns it, or ends the + /// sequence if there is no next element. + /// + /// - Returns: The next element, if it exists, or `nil` to signal the end of + /// the sequence. + public mutating func next() async throws -> Element? + } +} + +extension HTTPBody { + + /// A type-erased async sequence that wraps input sequences. + @usableFromInline + internal struct BodySequence : AsyncSequence, Sendable { + + /// The type of the type-erased iterator. + @usableFromInline + internal typealias AsyncIterator = HTTPBody.Iterator + + /// The byte chunk element type. + @usableFromInline + internal typealias Element = ByteChunk + + /// A closure that produces a new iterator. + @usableFromInline + internal let produceIterator: @Sendable () -> AsyncIterator + + /// Creates a new sequence. + /// - Parameter sequence: The input sequence to type-erase. + @inlinable internal init(_ sequence: Bytes) where Bytes : Sendable, Bytes : AsyncSequence, Bytes.Element == ArraySlice + + /// Creates the asynchronous iterator that produces elements of this + /// asynchronous sequence. + /// + /// - Returns: An instance of the `AsyncIterator` type used to produce + /// elements of the asynchronous sequence. + @usableFromInline + internal func makeAsyncIterator() -> AsyncIterator + } + + /// An async sequence wrapper for a sync sequence. + @usableFromInline + internal struct WrappedSyncSequence : AsyncSequence, Sendable where Bytes : Sendable, Bytes : Sequence, Bytes.Element == ArraySlice { + + /// The type of the iterator. + @usableFromInline + internal typealias AsyncIterator = Iterator + + /// The byte chunk element type. + @usableFromInline + internal typealias Element = ByteChunk + + /// An iterator type that wraps a sync sequence iterator. + @usableFromInline + internal struct Iterator : AsyncIteratorProtocol { + + /// The byte chunk element type. + @usableFromInline + internal typealias Element = ByteChunk + + /// The underlying sync sequence iterator. + internal var iterator: any IteratorProtocol + + /// Asynchronously advances to the next element and returns it, or ends the + /// sequence if there is no next element. + /// + /// - Returns: The next element, if it exists, or `nil` to signal the end of + /// the sequence. + @usableFromInline + internal mutating func next() async throws -> HTTPBody.ByteChunk? + } + + /// The underlying sync sequence. + @usableFromInline + internal let sequence: Bytes + + /// Creates a new async sequence with the provided sync sequence. + /// - Parameter sequence: The sync sequence to wrap. + @inlinable internal init(sequence: Bytes) + + /// Creates the asynchronous iterator that produces elements of this + /// asynchronous sequence. + /// + /// - Returns: An instance of the `AsyncIterator` type used to produce + /// elements of the asynchronous sequence. + @usableFromInline + internal func makeAsyncIterator() -> Iterator + } + + /// An empty async sequence. + @usableFromInline + internal struct EmptySequence : AsyncSequence, Sendable { + + /// The type of the empty iterator. + @usableFromInline + internal typealias AsyncIterator = EmptyIterator + + /// The byte chunk element type. + @usableFromInline + internal typealias Element = ByteChunk + + /// An async iterator of an empty sequence. + @usableFromInline + internal struct EmptyIterator : AsyncIteratorProtocol { + + /// The byte chunk element type. + @usableFromInline + internal typealias Element = ByteChunk + + /// Asynchronously advances to the next element and returns it, or ends the + /// sequence if there is no next element. + /// + /// - Returns: The next element, if it exists, or `nil` to signal the end of + /// the sequence. + @usableFromInline + internal mutating func next() async throws -> HTTPBody.ByteChunk? + } + + /// Creates a new empty async sequence. + @inlinable internal init() + + /// Creates the asynchronous iterator that produces elements of this + /// asynchronous sequence. + /// + /// - Returns: An instance of the `AsyncIterator` type used to produce + /// elements of the asynchronous sequence. + @usableFromInline + internal func makeAsyncIterator() -> EmptyIterator + } +} +``` + +### API stability + +This proposal, together with [SOAR-0005](https://github.com/czechboy0/swift-openapi-generator/blob/hd-soar-0005/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0005.md), proposes a holistic change to the transport and middleware protocols and currency types, including the bodies. The change is not backwards compatible and will require the authors of transports and middlewares to explicitly update their packages, once they upgrade to the latest version. + +In addition, users of the generated code that have any content types of unstructured payloads included in their OpenAPI document, such as `text/plain` and `application/octet-stream` will have to update their code to handle the switch from `Swift.String` and `Foundation.Data`, respectively, to `OpenAPIRuntime.HTTPBody`. Special care has been taken to provide convenience initializers and helper methods to convert to and from `Swift.String` and `Foundation.Data` for an easier migration and integration with the rest of the ecosystem. + +### Future directions + +#### Async writer in the API + +At the moment of writing this proposal, `AsyncSequence` seems like the most appropriate representation for HTTP bodies, which can be thought of as "byte chunks over time", both on the request and response side of both the client and the server. + +However, there are discussions in the Swift ecosystem about also providing a lower level abstraction using the "async writer" pattern, which might be even more appropriate especially for client request and server response body production. + +While the use of `AsyncSequence` is believed to be the most pragmatic solution at the time of writing, if the async writer pattern becomes part of the Swift standard library and embraced by HTTP clients and servers, we should consider also providing that lower level extension point at both the transport/middleware and the generated layer. + +This should be possible to do without requiring an API-breaking change, similar to how async middlewares were previously introduced to projects that supported synchronous and EventLoopFuture-based asynchronous middlewares. + +#### Native JSON Sequence support + +There might be room for introducing a concrete async sequence type with a concrete `Codable` element type, to allow representing a stream of structured payloads. + +One example of a content type describing such is `application/json-seq` from [RFC 7464](https://www.rfc-editor.org/rfc/rfc7464.html), which is being [considered](https://github.com/OAI/OpenAPI-Specification/issues/1576#issuecomment-1448735329) to be officially mentioned in the OpenAPI specification. + +Such a feature is out of scope of this proposal, and would likely be API breaking (unless introduced behind a feature flag or a configuration option), unless a newer version of OpenAPI specification does mention it, at which point we could only generate a more type-safe type for documents with the newer OpenAPI version. + +A straw man proposal follows: it could look something like `CodableStreamBody`, which would be an async sequence of `Cat` objects, where a Cat is a JSON Schema defined in the OpenAPI document and the content type `application/json-seq` is used. Behind the scenes, it would just be a wrapped `HTTPBody` with `Codable` encoding and decoding added on top. + +Today, any such work is left to the adopter to build on top of the proposed API. We verified with a prototype that it is possible to build a higher level pub/sub system with Codable "Event" types on top of the proposed API. + +### Alternatives considered + +#### Body generic over its chunk + +We originally started with the approach of `HTTPBody` being generic over its chunk type, mainly for more convenient support of both raw byte and string-based bodies. However, once we realized that strings bytes cannot be split at arbitrary locations, it became clear that only the user can safely split and concatenate encoded strings. So we only provide the lower level, the raw byte chunks, and the user can transform them into strings after taking care of doing so at the appropriate byte boundaries. + +### Acknowledgements + +Special thanks to David Nadoba and Franz Busch who contributed ideas and helped refine this proposal through thoughtful discussions. + +### Appendix 1: Stats service OpenAPI document + +```yaml +openapi: 3.0.3 +info: + title: Stats service + version: 1.0.0 +paths: + /stats: + get: + operationId: getStats + responses: + '200': + description: A successful response. + content: + application/json: + schema: + $ref: '#/components/schemas/StatItems' + text/plain: {} + application/octet-stream: {} + post: + operationId: postStats + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/StatItems' + text/plain: {} + application/octet-stream: {} + responses: + '202': + description: Successfully submitted. +components: + schemas: + StatItem: + type: object + properties: + name: + type: string + value: + type: integer + required: [name, value] + StatItems: + type: array + items: + $ref: '#/components/schemas/StatItem' +```