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
Status: Implemented (0.3.0)
Implementation: apple/swift-openapi-generator#245, apple/swift-openapi-runtime#47, apple/swift-openapi-urlsession#15, swift-server/swift-openapi-async-http-client#16
Review: (review)
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.
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<UInt8>
, and having initializers that can take another AsyncSequence
of element type ArraySlice<UInt8>
, in addition to many convenience initializers and collect
methods.
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:
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:
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.
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.
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:
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:
[{"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
.
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.
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:
// 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.
// 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 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:
let body = HTTPBody("Hello, world!")
Consuming the full body and converting it to string:
let string = try await String(collecting: body, upTo: 2 * 1024 * 1024)
Creating a body from data:
let data: Foundation.Data = ...
let body = HTTPBody(data)
Consuming the full body and converting it to data:
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 proposal instead, so it is omitted from here.
/// 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<UInt8>`, and `String`.
///
/// Create an empty body:
/// ```swift
/// let body = HTTPBody()
/// ```
///
/// Create a body from a byte chunk:
/// ```swift
/// let bytes: ArraySlice<UInt8> = ...
/// 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<UInt8>.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<UInt8>`
/// 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<UInt8>` 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<UInt8>
/// 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<ByteChunk> & 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<UInt8> & 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<UInt8> & Sendable, length: Length)
/// Creates a new body with the provided byte collection.
/// - Parameters:
/// - bytes: A byte chunk.
@inlinable public convenience init(_ bytes: some Collection<UInt8> & 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<ByteChunk, any Error>, 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<ByteChunk>, 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<Bytes>(_ sequence: Bytes, length: HTTPBody.Length, iterationBehavior: IterationBehavior) where Bytes : Sendable, Bytes : AsyncSequence, Bytes.Element == ArraySlice<UInt8>
/// 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<Bytes>(_ 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<some StringProtocol & Sendable, any Error & Sendable>, 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<some StringProtocol & Sendable>, 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<Strings>(_ 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: Iterator) where Iterator : AsyncIteratorProtocol, Iterator.Element == ArraySlice<UInt8>
/// 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<Bytes>(_ sequence: Bytes) where Bytes : Sendable, Bytes : AsyncSequence, Bytes.Element == ArraySlice<UInt8>
/// 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<Bytes> : AsyncSequence, Sendable where Bytes : Sendable, Bytes : Sequence, Bytes.Element == ArraySlice<UInt8> {
/// 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<Element>
/// 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, 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, which is being considered 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<Cat>
, 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
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'