From 62256c61e7700c07ec2623fd0d347bdf1f642ec6 Mon Sep 17 00:00:00 2001 From: Franz Busch Date: Fri, 15 May 2026 16:53:57 +0200 Subject: [PATCH] Change Concluding reader and writer to HTTP specific types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation We moved the reader and writer types over to `swift-async-algorithms` that left us with the concluding variants. Those types are really HTTP specific and there is no reason to make them general purpose. Furthermore, we have some more HTTP specific requirements that we wanted to fulfill around being able to one-shot respond to a request with a header, body and trailers. ## Modifications This PR introduces new receiver and sender types for the client and server aligned with existing `HTTPServerResponder` type. These new types are protocols instead of a concrete type which allows implementations to customize them. Furthermore, there are new one-shot APIs on the senders. ## Result No more concluding reader and writer APIs and instead only HTTP specific types. --- Examples/EchoServer/EchoServer.swift | 18 +- .../ForwardingMiddleware.swift | 2 +- .../HTTPClientMiddlewareInput.swift | 35 ++ ...ientRequestChecksumTrailerMiddleware.swift | 110 ++++++ .../HTTPServerLoggingMiddleware.swift | 261 +++++-------- .../HTTPServerMiddlewareInput.swift | 49 +-- .../HTTPServerRequestHandlerMiddleware.swift | 70 +--- ...verResponseChecksumTrailerMiddleware.swift | 145 +++++++ ...ServerResponsePrefixSuffixMiddleware.swift | 171 +++++++++ .../ExampleMiddlewareClient.swift | 45 ++- .../MiddlewareClient/MiddlewareClient.swift | 6 +- .../ExampleMiddlewareServer.swift | 26 +- .../MiddlewareServer/MiddlewareServer.swift | 10 +- Examples/ProxyServer/ProxyServer.swift | 44 +-- Examples/WASMClient/main.swift | 33 +- Sources/AHCHTTPClient/AHC+HTTPClient.swift | 127 +++--- Sources/FetchHTTPClient/FetchHTTPClient.swift | 77 ++-- .../HTTPAPIs/AsyncWriter+AsyncReader.swift | 29 ++ Sources/HTTPAPIs/AsyncWriter.swift | 24 -- .../Client/HTTPClient+Conveniences.swift | 166 +++----- Sources/HTTPAPIs/Client/HTTPClient.swift | 36 +- .../Client/HTTPClientRequestBody+Data.swift | 7 +- .../Client/HTTPClientRequestBody.swift | 91 +++-- .../ConcludingAsyncReader+collect.swift | 65 ---- Sources/HTTPAPIs/ConcludingAsyncReader.swift | 52 --- Sources/HTTPAPIs/ConcludingAsyncWriter.swift | 146 ------- Sources/HTTPAPIs/HTTPBodyReader.swift | 181 +++++++++ Sources/HTTPAPIs/HTTPBodyWriter.swift | 98 +++++ .../HTTPAPIs/Server/HTTPResponseSender.swift | 93 +++++ Sources/HTTPAPIs/Server/HTTPServer.swift | 52 ++- .../HTTPServerClosureRequestHandler.swift | 89 +---- .../Server/HTTPServerRequestHandler.swift | 75 ++-- .../Server/HTTPServerResponseSender.swift | 73 ---- Sources/HTTPClient/DefaultHTTPClient.swift | 74 +--- Sources/HTTPClient/HTTP+Conveniences.swift | 6 +- .../HTTPClientConformance.swift | 362 +++++++++--------- .../HTTPRequestConcludingAsyncReader.swift | 204 ---------- .../HTTPResponseConcludingAsyncWriter.swift | 162 -------- .../NIOHTTPRequestReceiver.swift | 122 ++++++ .../NIOHTTPResponseSender.swift | 151 ++++++++ .../NIOHTTPServer+HTTP1_1.swift | 4 +- .../NIOHTTPServer+SecureUpgrade.swift | 4 +- .../HTTPServerForTesting/NIOHTTPServer.swift | 73 ++-- .../RequestResponseMiddlewareBox.swift | 31 +- .../HTTPServerForTesting/TestHTTPServer.swift | 309 +++++++-------- Sources/Middleware/ChainedMiddleware.swift | 7 +- Sources/Middleware/MiddlewareBuilder.swift | 12 +- .../URLSessionHTTPClient.swift | 96 +++-- .../URLSessionTaskDelegateBridge.swift | 8 +- Tests/HTTPAPIsTests/EchoTests.swift | 37 +- .../Helpers/HTTPClientAndServerTests.swift | 227 +++++++---- 51 files changed, 2280 insertions(+), 2115 deletions(-) create mode 100644 Examples/ExampleMiddleware/HTTPClientMiddlewareInput.swift create mode 100644 Examples/ExampleMiddleware/HTTPClientRequestChecksumTrailerMiddleware.swift create mode 100644 Examples/ExampleMiddleware/HTTPServerResponseChecksumTrailerMiddleware.swift create mode 100644 Examples/ExampleMiddleware/HTTPServerResponsePrefixSuffixMiddleware.swift delete mode 100644 Sources/HTTPAPIs/ConcludingAsyncReader+collect.swift delete mode 100644 Sources/HTTPAPIs/ConcludingAsyncReader.swift delete mode 100644 Sources/HTTPAPIs/ConcludingAsyncWriter.swift create mode 100644 Sources/HTTPAPIs/HTTPBodyReader.swift create mode 100644 Sources/HTTPAPIs/HTTPBodyWriter.swift create mode 100644 Sources/HTTPAPIs/Server/HTTPResponseSender.swift delete mode 100644 Sources/HTTPAPIs/Server/HTTPServerResponseSender.swift delete mode 100644 Sources/HTTPClientConformance/HTTPServerForTesting/HTTPRequestConcludingAsyncReader.swift delete mode 100644 Sources/HTTPClientConformance/HTTPServerForTesting/HTTPResponseConcludingAsyncWriter.swift create mode 100644 Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPRequestReceiver.swift create mode 100644 Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPResponseSender.swift diff --git a/Examples/EchoServer/EchoServer.swift b/Examples/EchoServer/EchoServer.swift index 7cc29ef..839e934 100644 --- a/Examples/EchoServer/EchoServer.swift +++ b/Examples/EchoServer/EchoServer.swift @@ -22,19 +22,11 @@ struct EchoServer { fatalError("Waiting for a concrete HTTP server implementation") } - static func echo(server: Server) async throws { - try await server.serve { request, requestContext, requestBodyAndTrailers, responseSender in - // Needed since we are lacking call-once closures - var requestBodyAndTrailers = Optional(requestBodyAndTrailers) - let responseBodyAndTrailers = try await responseSender.send(.init(status: .ok)) - - try await responseBodyAndTrailers.produceAndConclude { responseBody in - // Needed since we are lacking call-once closures - var responseBody = responseBody - return try await requestBodyAndTrailers.take()!.consumeAndConclude { reader in - try await responseBody.write(reader) - } - } + static func echo(server: Server) async throws + where Server.Reader.Buffer == Server.ResponseSender.Writer.Buffer { + try await server.serve { request, requestContext, reader, responseSender in + let writer = try await responseSender.send(.init(status: .ok)) + try await reader.pipe(into: writer) } } } diff --git a/Examples/ExampleMiddleware/ForwardingMiddleware.swift b/Examples/ExampleMiddleware/ForwardingMiddleware.swift index d9b9ae3..32084be 100644 --- a/Examples/ExampleMiddleware/ForwardingMiddleware.swift +++ b/Examples/ExampleMiddleware/ForwardingMiddleware.swift @@ -26,7 +26,7 @@ public struct ForwardingMiddleware: Middleware { } @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) -extension Middleware { +extension Middleware where Input: ~Copyable & ~Escapable, NextInput: ~Copyable & ~Escapable { public func forwarding() -> ForwardingMiddleware { ForwardingMiddleware() } diff --git a/Examples/ExampleMiddleware/HTTPClientMiddlewareInput.swift b/Examples/ExampleMiddleware/HTTPClientMiddlewareInput.swift new file mode 100644 index 0000000..438fa29 --- /dev/null +++ b/Examples/ExampleMiddleware/HTTPClientMiddlewareInput.swift @@ -0,0 +1,35 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP API Proposal open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP API Proposal project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +public import HTTPAPIs + +/// The input passed through client-side middleware: the request head plus the +/// request body the user wants to send. +/// +/// Mirrors ``HTTPServerMiddlewareInput`` on the server side. Wrapping +/// middlewares can substitute a different `Writer` type for `NextInput` so +/// the inner stage sees a wrapped body that intercepts the bytes the user +/// wrote. +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +public struct HTTPClientMiddlewareInput: ~Copyable { + public var request: HTTPRequest + public var body: HTTPClientRequestBody? + + public init(request: HTTPRequest, body: consuming HTTPClientRequestBody?) { + self.request = request + self.body = body + } +} + +@available(*, unavailable) +extension HTTPClientMiddlewareInput: Sendable {} diff --git a/Examples/ExampleMiddleware/HTTPClientRequestChecksumTrailerMiddleware.swift b/Examples/ExampleMiddleware/HTTPClientRequestChecksumTrailerMiddleware.swift new file mode 100644 index 0000000..fe6550a --- /dev/null +++ b/Examples/ExampleMiddleware/HTTPClientRequestChecksumTrailerMiddleware.swift @@ -0,0 +1,110 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP API Proposal open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP API Proposal project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import BasicContainers +public import HTTPAPIs +public import Middleware + +/// A client-side middleware that observes all request body bytes and appends +/// a checksum (XOR of all bytes) as the `X-Body-Checksum` trailer. +/// +/// Client-side mirror of ``HTTPServerResponseChecksumTrailerMiddleware``. The +/// `body` field of the input is wrapped with a ``ChecksumRequestWriter`` so +/// the inner stage (eventually the underlying client) receives a body whose +/// writes are intercepted to update the checksum, and whose `finish` appends +/// the `X-Body-Checksum` trailer. +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +public struct HTTPClientRequestChecksumTrailerMiddleware< + Writer: HTTPBodyWriter & ~Copyable & SendableMetatype +>: Middleware, Sendable { + public typealias Input = HTTPClientMiddlewareInput> + public typealias NextInput = HTTPClientMiddlewareInput + + public init(writerType: Writer.Type = Writer.self) {} + + public func intercept( + input: consuming Input, + next: (consuming NextInput) async throws -> Return + ) async throws -> Return { + let translatedBody: HTTPClientRequestBody? = input.body.map { userBody in + HTTPClientRequestBody(other: userBody) { baseWriter in + ChecksumRequestWriter(wrapping: baseWriter) + } + } + return try await next( + HTTPClientMiddlewareInput(request: input.request, body: translatedBody) + ) + } +} + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension Middleware where Input: ~Copyable & ~Escapable, NextInput: ~Copyable & ~Escapable { + /// Adds a middleware that emits an `X-Body-Checksum` trailer covering the request body. + public func checksumTrailer() + -> HTTPClientRequestChecksumTrailerMiddleware + where + Input == HTTPClientMiddlewareInput, + Writer: HTTPBodyWriter & ~Copyable & SendableMetatype + { + HTTPClientRequestChecksumTrailerMiddleware() + } +} + +/// A wrapping ``HTTPBodyWriter`` that XORs every written byte into a running +/// checksum and emits an `X-Body-Checksum` trailer at conclude time. +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +public struct ChecksumRequestWriter: HTTPBodyWriter, ~Copyable, SendableMetatype +where Base: SendableMetatype { + public typealias WriteElement = UInt8 + public typealias WriteFailure = Base.WriteFailure + public typealias Buffer = Base.Buffer + + @usableFromInline + var underlying: Base + @usableFromInline + var checksum: UInt8 + + init(wrapping writer: consuming Base) { + self.underlying = writer + self.checksum = 0 + } + + public mutating func write( + _ body: (inout Buffer) async throws(Failure) -> Return + ) async throws(EitherError) -> Return { + try await self.underlying.write { buffer throws(Failure) in + let result = try await body(&buffer) + buffer._borrowingForEach { + self.checksum ^= $0 + } + return result + } + } + + public consuming func finish( + body: (inout Buffer) async throws(Failure) -> HTTPFields? + ) async throws(EitherError) { + // Move state out of self before the consuming call. Capturing + // `self.checksum` directly while consuming `self.underlying` triggers + // a use-after-consume on `self`. + var checksum = self.checksum + try await self.underlying.finish { buffer throws(Failure) in + var trailers = try await body(&buffer) ?? .init() + buffer._borrowingForEach { + checksum ^= $0 + } + trailers.append(.init(name: .init("X-Body-Checksum")!, value: String(checksum, radix: 16))) + return trailers + } + } +} diff --git a/Examples/ExampleMiddleware/HTTPServerLoggingMiddleware.swift b/Examples/ExampleMiddleware/HTTPServerLoggingMiddleware.swift index 5d1bbee..08d81fd 100644 --- a/Examples/ExampleMiddleware/HTTPServerLoggingMiddleware.swift +++ b/Examples/ExampleMiddleware/HTTPServerLoggingMiddleware.swift @@ -16,43 +16,27 @@ public import Logging public import Middleware /// A middleware that logs HTTP server requests and responses. -/// -/// ``HTTPServerLoggingMiddleware`` wraps the request reader and response writer with logging -/// decorators that output information about the HTTP request path, method, response status, -/// and the number of bytes read from the request body and written to the response body. -/// This middleware is useful for debugging and monitoring HTTP traffic. @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) public struct HTTPServerLoggingMiddleware< - RequestConcludingAsyncReader: ConcludingAsyncReader & ~Copyable, - ResponseConcludingAsyncWriter: ConcludingAsyncWriter & ~Copyable + Reader: HTTPBodyReader & ~Copyable, + ResponseSender: HTTPResponseSender & ~Copyable >: Middleware where - RequestConcludingAsyncReader: ~Copyable & Escapable, - RequestConcludingAsyncReader.Underlying: ~Copyable & Escapable, - RequestConcludingAsyncReader.Underlying.ReadElement == UInt8, - RequestConcludingAsyncReader.FinalElement == HTTPFields?, - ResponseConcludingAsyncWriter: ~Copyable & Escapable, - ResponseConcludingAsyncWriter.Underlying: ~Copyable & Escapable, - ResponseConcludingAsyncWriter.Underlying.WriteElement == UInt8, - ResponseConcludingAsyncWriter.FinalElement == HTTPFields? + Reader: ~Copyable & Escapable, + ResponseSender: ~Copyable & Escapable, + ResponseSender.Writer: ~Copyable & Escapable { - public typealias Input = HTTPServerMiddlewareInput + public typealias Input = HTTPServerMiddlewareInput public typealias NextInput = HTTPServerMiddlewareInput< - HTTPRequestLoggingConcludingAsyncReader, - HTTPResponseLoggingConcludingAsyncWriter + LoggingReader, + HTTPResponseLoggingSender > let logger: Logger - /// Creates a new logging middleware. - /// - /// - Parameters: - /// - requestConcludingAsyncReaderType: The type of the request reader. Defaults to the inferred type. - /// - responseConcludingAsyncWriterType: The type of the response writer. Defaults to the inferred type. - /// - logger: The logger instance to use for logging HTTP events. public init( - requestConcludingAsyncReaderType: RequestConcludingAsyncReader.Type = RequestConcludingAsyncReader.self, - responseConcludingAsyncWriterType: ResponseConcludingAsyncWriter.Type = ResponseConcludingAsyncWriter.self, + readerType: Reader.Type = Reader.self, + responseSenderType: ResponseSender.Type = ResponseSender.self, logger: Logger ) { self.logger = logger @@ -62,36 +46,21 @@ where input: consuming Input, next: (consuming NextInput) async throws -> Return ) async throws -> Return { - try await input.withContents { request, context, requestReader, responseSender in + try await input.withContents { request, context, reader, responseSender in self.logger.info("Received request \(request.path ?? "unknown" ) \(request.method.rawValue)") defer { self.logger.info("Finished request \(request.path ?? "unknown" ) \(request.method.rawValue)") } - let wrappedReader = HTTPRequestLoggingConcludingAsyncReader( - base: requestReader, + let wrappedReader = LoggingReader(wrapping: reader, logger: self.logger) + let wrappedSender = HTTPResponseLoggingSender( + base: responseSender, logger: self.logger ) - - var maybeSender = Optional(responseSender) let requestResponseBox = HTTPServerMiddlewareInput( request: request, requestContext: context, - requestReader: wrappedReader, - responseSender: HTTPResponseSender { [logger] response in - if let sender = maybeSender.take() { - logger.info("Sending response \(response)") - let writer = try await sender.send(response) - return HTTPResponseLoggingConcludingAsyncWriter( - base: writer, - logger: logger - ) - } else { - fatalError("Called closure more than once") - } - } sendInformational: { response in - self.logger.info("Sending informational response \(response)") - try await maybeSender?.sendInformational(response) - } + reader: wrappedReader, + responseSender: wrappedSender ) return try await next(requestResponseBox) } @@ -99,143 +68,109 @@ where } @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) -extension Middleware where Input: ~Copyable, NextInput: ~Copyable { +extension Middleware where Input: ~Copyable & ~Escapable, NextInput: ~Copyable & ~Escapable { /// Creates logging middleware for HTTP servers. - /// - /// This middleware logs all incoming requests and outgoing responses, including the request - /// path, method, response status, and the number of bytes read and written in the body. - /// - /// - Parameter logger: The logger to use for logging requests and responses. - /// - Returns: A middleware that logs HTTP request and response details. - /// - /// ## Example - /// - /// ```swift - /// @MiddlewareBuilder - /// func buildMiddleware() -> some Middleware<...> { - /// .logging(logger: Logger(label: "HTTPServer")) - /// .requestHandler() - /// } - /// ``` - public func logging( + public func logging( logger: Logger - ) -> HTTPServerLoggingMiddleware + ) -> HTTPServerLoggingMiddleware where - Input == HTTPServerMiddlewareInput, - RequestReader: ConcludingAsyncReader & ~Copyable & Escapable, - RequestReader.Underlying: ~Copyable & Escapable, - RequestReader.Underlying.ReadElement == UInt8, - RequestReader.FinalElement == HTTPFields?, - ResponseWriter: ConcludingAsyncWriter & ~Copyable & Escapable, - ResponseWriter.Underlying: ~Copyable & Escapable, - ResponseWriter.Underlying.WriteElement == UInt8, - ResponseWriter.FinalElement == HTTPFields? + Input == HTTPServerMiddlewareInput, + Reader: HTTPBodyReader & ~Copyable & Escapable, + ResponseSender: HTTPResponseSender & ~Copyable & Escapable, + ResponseSender.Writer: ~Copyable & Escapable { HTTPServerLoggingMiddleware(logger: logger) } } @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) -public struct HTTPRequestLoggingConcludingAsyncReader< - Base: ConcludingAsyncReader & ~Copyable ->: ConcludingAsyncReader, ~Copyable -where - Base.Underlying: ~Copyable, - Base.Underlying: Escapable, - Base.Underlying.ReadElement == UInt8, - Base.FinalElement == HTTPFields? -{ - public typealias Underlying = RequestBodyAsyncReader - public typealias FinalElement = HTTPFields? - - public struct RequestBodyAsyncReader: AsyncReader, ~Copyable { - public typealias ReadElement = UInt8 - public typealias ReadFailure = Base.Underlying.ReadFailure - public typealias Buffer = Base.Underlying.Buffer - - private var underlying: Base.Underlying - private let logger: Logger - - init(underlying: consuming Base.Underlying, logger: Logger) { - self.underlying = underlying - self.logger = logger - } - - public mutating func read( - body: (inout Buffer) async throws(Failure) -> Return - ) async throws(EitherError) -> Return { - let logger = self.logger - return try await self.underlying.read { (buffer: inout Buffer) async throws(Failure) -> Return in - logger.info("Received next chunk \(buffer.count)") - return try await body(&buffer) - } - } - } - - private var base: Base - private let logger: Logger +public struct LoggingReader: HTTPBodyReader, ~Copyable { + public typealias ReadElement = UInt8 + public typealias ReadFailure = Base.ReadFailure + public typealias Buffer = Base.Buffer + + @usableFromInline + var underlying: Base + @usableFromInline + let logger: Logger - init(base: consuming Base, logger: Logger) { - self.base = base + init(wrapping reader: consuming Base, logger: Logger) { + self.underlying = reader self.logger = logger } - public consuming func consumeAndConclude( - body: (consuming sending RequestBodyAsyncReader) async throws(Failure) -> Return - ) async throws(Failure) -> (Return, HTTPTypes.HTTPFields?) { - let (result, trailers) = try await self.base.consumeAndConclude { [logger] reader async throws(Failure) -> Return in - let wrappedReader = RequestBodyAsyncReader( - underlying: reader, - logger: logger - ) - return try await body(wrappedReader) - } - - if let trailers { - self.logger.info("Received request trailers \(trailers)") - } else { - self.logger.info("Received no request trailers") + public mutating func read( + body: (inout Buffer, HTTPFields?) async throws(Failure) -> Return + ) async throws(EitherError) -> Return { + let logger = self.logger + return try await self.underlying.read { (buffer: inout Buffer, trailers: HTTPFields?) async throws(Failure) -> Return in + logger.info("Received next chunk \(buffer.count)") + if let trailers { + logger.info("Received request trailers \(trailers)") + } + return try await body(&buffer, trailers) } - - return (result, trailers) } } @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) -public struct HTTPResponseLoggingConcludingAsyncWriter< - Base: ConcludingAsyncWriter & ~Copyable ->: ConcludingAsyncWriter, ~Copyable -where - Base.Underlying: ~Copyable, - Base.Underlying: Escapable, - Base.Underlying.WriteElement == UInt8, - Base.FinalElement == HTTPFields? -{ - public typealias Underlying = ResponseBodyAsyncWriter - public typealias FinalElement = HTTPFields? +public struct HTTPResponseLoggingSender< + Base: HTTPResponseSender & ~Copyable +>: HTTPResponseSender, ~Copyable +where Base.Writer: ~Copyable & Escapable { + public typealias Writer = LoggingWriter - public struct ResponseBodyAsyncWriter: AsyncWriter, ~Copyable { + public struct LoggingWriter: HTTPBodyWriter, ~Copyable { public typealias WriteElement = UInt8 - public typealias WriteFailure = Base.Underlying.WriteFailure - public typealias Buffer = Base.Underlying.Buffer + public typealias WriteFailure = Base.Writer.WriteFailure + public typealias Buffer = Base.Writer.Buffer - private var underlying: Base.Underlying - private let logger: Logger + @usableFromInline + var underlying: Base.Writer + @usableFromInline + let logger: Logger - init(underlying: consuming Base.Underlying, logger: Logger) { - self.underlying = underlying + init(wrapping writer: consuming Base.Writer, logger: Logger) { + self.underlying = writer self.logger = logger } public mutating func write( _ body: (inout Buffer) async throws(Failure) -> Result - ) async throws(EitherError) -> Result { + ) async throws(EitherError) -> Result { + let logger = self.logger return try await self.underlying.write { (buffer: inout Buffer) async throws(Failure) -> Result in let result = try await body(&buffer) - self.logger.info("Wrote response bytes \(buffer.count)") + logger.info("Wrote response bytes \(buffer.count)") return result } } + + public consuming func finish( + body: (inout Buffer) async throws(Failure) -> HTTPFields? + ) async throws(EitherError) { + // Copy out captures before the consuming call. Capturing `self.logger` + // directly while consuming `self.underlying` triggers a use-after- + // consume on `self`. + let logger = self.logger + try await self.underlying.finish { buffer throws(Failure) in + let trailers: HTTPFields? + do throws(Failure) { + trailers = try await body(&buffer) + } catch { + logger.info("Failed to write response bytes") + throw error + } + + if let trailers { + logger.info("Wrote response trailers \(trailers)") + } else { + logger.info("Wrote no response trailers") + } + + return trailers + } + } } private var base: Base @@ -246,20 +181,14 @@ where self.logger = logger } - public consuming func produceAndConclude( - body: (consuming sending ResponseBodyAsyncWriter) async throws -> (Return, HTTPFields?) - ) async throws -> Return { - let logger = self.logger - return try await self.base.produceAndConclude { writer in - let wrappedAsyncWriter = ResponseBodyAsyncWriter(underlying: writer, logger: logger) - let (result, trailers) = try await body(wrappedAsyncWriter) + public func sendInformational(_ response: HTTPResponse) async throws { + self.logger.info("Sending informational response \(response)") + try await self.base.sendInformational(response) + } - if let trailers { - logger.info("Wrote response trailers \(trailers)") - } else { - logger.info("Wrote no response trailers") - } - return (result, trailers) - } + public consuming func send(_ response: HTTPResponse) async throws -> LoggingWriter { + self.logger.info("Sending response \(response)") + let underlying = try await self.base.send(response) + return LoggingWriter(wrapping: underlying, logger: self.logger) } } diff --git a/Examples/ExampleMiddleware/HTTPServerMiddlewareInput.swift b/Examples/ExampleMiddleware/HTTPServerMiddlewareInput.swift index 5ecf49e..d759c9e 100644 --- a/Examples/ExampleMiddleware/HTTPServerMiddlewareInput.swift +++ b/Examples/ExampleMiddleware/HTTPServerMiddlewareInput.swift @@ -15,63 +15,46 @@ public import HTTPAPIs /// A struct that encapsulates all parameters passed to HTTP server request handlers. /// -/// ``HTTPServerMiddlewareInput`` serves as a container for the request, request context, -/// request body reader, and response sender. This boxing is necessary because some of these -/// parameters are `~Copyable` types that cannot be stored in tuples, and it provides a -/// convenient way to pass all request-handling components through the middleware chain. +/// ``HTTPServerMiddlewareInput`` serves as a container for the request, request +/// context, request body reader, and response sender. This boxing is necessary +/// because some of these parameters are `~Copyable` types that cannot be +/// stored in tuples. @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) public struct HTTPServerMiddlewareInput< - RequestReader: ConcludingAsyncReader & ~Copyable, - ResponseWriter: ConcludingAsyncWriter & ~Copyable ->: ~Copyable where RequestReader.Underlying: ~Copyable, ResponseWriter.Underlying: ~Copyable { + Reader: HTTPBodyReader & ~Copyable & ~Escapable, + ResponseSender: HTTPResponseSender & ~Copyable & ~Escapable +>: ~Copyable, ~Escapable where ResponseSender.Writer: ~Copyable & ~Escapable { private let request: HTTPRequest private let requestContext: HTTPRequestContext - private let requestReader: RequestReader - private let responseSender: HTTPResponseSender + private let reader: Reader + private let responseSender: ResponseSender - /// Creates a new HTTP server middleware input container. - /// - /// - Parameters: - /// - request: The HTTP request headers and metadata. - /// - requestContext: Additional context information for the request. - /// - requestReader: A reader for accessing the request body data and trailing headers. - /// - responseSender: A sender for transmitting the HTTP response and response body. + @_lifetime(copy reader, copy responseSender) public init( request: HTTPRequest, requestContext: HTTPRequestContext, - requestReader: consuming RequestReader, - responseSender: consuming HTTPResponseSender + reader: consuming Reader, + responseSender: consuming ResponseSender ) { self.request = request self.requestContext = requestContext - self.requestReader = requestReader + self.reader = reader self.responseSender = responseSender } - /// Provides scoped access to the contents of this input container. - /// - /// This method exposes all the encapsulated request components to a closure, allowing - /// middleware to access and process them. The closure receives the request, request context, - /// request reader, and response sender as separate parameters. - /// - /// - Parameter handler: A closure that processes the request components. - /// - /// - Returns: The value returned by the handler closure. - /// - /// - Throws: Any error thrown by the handler closure. public consuming func withContents( _ handler: ( HTTPRequest, HTTPRequestContext, - consuming RequestReader, - consuming HTTPResponseSender + consuming Reader, + consuming ResponseSender ) async throws -> Return ) async throws -> Return { try await handler( self.request, self.requestContext, - self.requestReader, + self.reader, self.responseSender ) } diff --git a/Examples/ExampleMiddleware/HTTPServerRequestHandlerMiddleware.swift b/Examples/ExampleMiddleware/HTTPServerRequestHandlerMiddleware.swift index b8f3ea3..8887a0a 100644 --- a/Examples/ExampleMiddleware/HTTPServerRequestHandlerMiddleware.swift +++ b/Examples/ExampleMiddleware/HTTPServerRequestHandlerMiddleware.swift @@ -15,24 +15,16 @@ public import HTTPAPIs public import Middleware /// A terminal middleware that echoes HTTP request bodies back as responses. -/// -/// ``HTTPServerRequestHandlerMiddleware`` serves as an example terminal middleware that reads -/// the entire request body and writes it back as the response body with a 200 OK status. -/// This middleware has `Never` as its `NextInput` type, indicating it's the end of the chain. @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) public struct HTTPServerRequestHandlerMiddleware< - RequestConcludingAsyncReader: ConcludingAsyncReader & ~Copyable, - ResponseConcludingAsyncWriter: ConcludingAsyncWriter & ~Copyable, + Reader: HTTPBodyReader & ~Copyable, + ResponseSender: HTTPResponseSender & ~Copyable, >: Middleware, Sendable where - RequestConcludingAsyncReader.Underlying: ~Copyable, - RequestConcludingAsyncReader.Underlying.ReadElement == UInt8, - RequestConcludingAsyncReader.FinalElement == HTTPFields?, - ResponseConcludingAsyncWriter.Underlying: ~Copyable, - ResponseConcludingAsyncWriter.Underlying.WriteElement == UInt8, - ResponseConcludingAsyncWriter.FinalElement == HTTPFields? + ResponseSender.Writer: ~Copyable, + Reader.Buffer == ResponseSender.Writer.Buffer { - public typealias Input = HTTPServerMiddlewareInput + public typealias Input = HTTPServerMiddlewareInput public typealias NextInput = Void /// Creates a new request handler middleware. @@ -42,21 +34,9 @@ where input: consuming Input, next: (consuming NextInput) async throws -> Return ) async throws -> Return { - try await input.withContents { request, _, requestBodyAndTrailers, responseSender in - // Needed since we are lacking call-once closures - var responseSender: HTTPResponseSender? = consume responseSender - - _ = try await requestBodyAndTrailers.consumeAndConclude { reader in - // Needed since we are lacking call-once closures - var reader: RequestConcludingAsyncReader.Underlying? = consume reader - - let responseBodyAndTrailers = try await responseSender.take()!.send(.init(status: .ok)) - try await responseBodyAndTrailers.produceAndConclude { responseBody in - var responseBody = responseBody - try await responseBody.write(reader.take()!) - return nil - } - } + try await input.withContents { request, _, reader, responseSender in + let writer = try await responseSender.send(.init(status: .ok)) + try await reader.pipe(into: writer) } return try await next(()) @@ -64,35 +44,15 @@ where } @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) -extension Middleware where Input: ~Copyable, NextInput: ~Copyable { +extension Middleware where Input: ~Copyable & ~Escapable, NextInput: ~Copyable & ~Escapable { /// Creates a request handler middleware that echoes the request body back as the response. - /// - /// This is a simple example middleware that reads the entire request body and writes it - /// back as the response with a 200 OK status. This middleware is the terminal middleware - /// in the chain and has `Never` as its `NextInput` type. - /// - /// - Returns: A middleware that handles HTTP requests by echoing the body. - /// - /// ## Example - /// - /// ```swift - /// @MiddlewareBuilder - /// func buildMiddleware() -> some Middleware<...> { - /// .logging(logger: Logger(label: "HTTPServer")) - /// .requestHandler() - /// } - /// ``` - public func requestHandler() -> HTTPServerRequestHandlerMiddleware + public func requestHandler() -> HTTPServerRequestHandlerMiddleware where - Input == HTTPServerMiddlewareInput, - RequestReader: ConcludingAsyncReader & ~Copyable, - RequestReader.Underlying: ~Copyable, - RequestReader.Underlying.ReadElement == UInt8, - RequestReader.FinalElement == HTTPFields?, - ResponseWriter: ConcludingAsyncWriter & ~Copyable, - ResponseWriter.Underlying: ~Copyable, - ResponseWriter.Underlying.WriteElement == UInt8, - ResponseWriter.FinalElement == HTTPFields? + Input == HTTPServerMiddlewareInput, + Reader: HTTPBodyReader & ~Copyable, + ResponseSender: HTTPResponseSender & ~Copyable, + ResponseSender.Writer: ~Copyable, + Reader.Buffer == ResponseSender.Writer.Buffer { HTTPServerRequestHandlerMiddleware() } diff --git a/Examples/ExampleMiddleware/HTTPServerResponseChecksumTrailerMiddleware.swift b/Examples/ExampleMiddleware/HTTPServerResponseChecksumTrailerMiddleware.swift new file mode 100644 index 0000000..847e66a --- /dev/null +++ b/Examples/ExampleMiddleware/HTTPServerResponseChecksumTrailerMiddleware.swift @@ -0,0 +1,145 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP API Proposal open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP API Proposal project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import BasicContainers +public import HTTPAPIs +public import Middleware + +/// A middleware that observes all response body bytes and appends a checksum +/// (XOR of all bytes) as the `X-Body-Checksum` trailer. +/// +/// This demonstrates a wrapping writer middleware that intercepts every write +/// to update internal state, then injects work into the body's `finish` step +/// so the trailer is fused with the final body chunk and the FIN signal. +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +public struct HTTPServerResponseChecksumTrailerMiddleware< + Reader: HTTPBodyReader & ~Copyable, + ResponseSender: HTTPResponseSender & ~Copyable +>: Middleware +where + Reader: ~Copyable & Escapable, + ResponseSender: ~Copyable & Escapable, + ResponseSender.Writer: ~Copyable & Escapable +{ + public typealias Input = HTTPServerMiddlewareInput + public typealias NextInput = HTTPServerMiddlewareInput< + Reader, + HTTPServerResponseChecksumTrailerSender + > + + public init( + readerType: Reader.Type = Reader.self, + responseSenderType: ResponseSender.Type = ResponseSender.self + ) {} + + public func intercept( + input: consuming Input, + next: (consuming NextInput) async throws -> Return + ) async throws -> Return { + try await input.withContents { request, context, reader, responseSender in + let wrappedSender = HTTPServerResponseChecksumTrailerSender(base: responseSender) + return try await next( + HTTPServerMiddlewareInput( + request: request, + requestContext: context, + reader: reader, + responseSender: wrappedSender + ) + ) + } + } +} + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension Middleware where Input: ~Copyable & ~Escapable, NextInput: ~Copyable & ~Escapable { + /// Adds a middleware that emits an `X-Body-Checksum` trailer covering the response body. + public func checksumTrailer() + -> HTTPServerResponseChecksumTrailerMiddleware + where + Input == HTTPServerMiddlewareInput, + Reader: HTTPBodyReader & ~Copyable & Escapable, + ResponseSender: HTTPResponseSender & ~Copyable & Escapable, + ResponseSender.Writer: ~Copyable & Escapable + { + HTTPServerResponseChecksumTrailerMiddleware() + } +} + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +public struct HTTPServerResponseChecksumTrailerSender< + Base: HTTPResponseSender & ~Copyable +>: HTTPResponseSender, ~Copyable +where Base.Writer: ~Copyable { + public typealias Writer = ChecksumWriter + + public struct ChecksumWriter: HTTPBodyWriter, ~Copyable { + public typealias WriteElement = UInt8 + public typealias WriteFailure = Base.Writer.WriteFailure + public typealias Buffer = Base.Writer.Buffer + + @usableFromInline + var underlying: Base.Writer + @usableFromInline + var checksum: UInt8 + + init(wrapping writer: consuming Base.Writer) { + self.underlying = writer + self.checksum = 0 + } + + public mutating func write( + _ body: (inout Buffer) async throws(Failure) -> Return + ) async throws(EitherError) -> Return { + try await self.underlying.write { buffer throws(Failure) in + let result = try await body(&buffer) + buffer._borrowingForEach { + self.checksum ^= $0 + } + return result + } + } + + public consuming func finish( + body: (inout Buffer) async throws(Failure) -> HTTPFields? + ) async throws(EitherError) { + // Move state out of self before the consuming call. Capturing + // `self.checksum` directly while consuming `self.underlying` + // triggers a use-after-consume on `self`. + var checksum = self.checksum + try await self.underlying.finish { buffer throws(Failure) in + var trailers = try await body(&buffer) ?? .init() + + buffer._borrowingForEach { + checksum ^= $0 + } + trailers.append(.init(name: .init("X-Body-Checksum")!, value: String(checksum, radix: 16))) + return trailers + } + } + } + + private var base: Base + + init(base: consuming Base) { + self.base = base + } + + public func sendInformational(_ response: HTTPResponse) async throws { + try await self.base.sendInformational(response) + } + + public consuming func send(_ response: HTTPResponse) async throws -> ChecksumWriter { + let underlying = try await self.base.send(response) + return ChecksumWriter(wrapping: underlying) + } +} diff --git a/Examples/ExampleMiddleware/HTTPServerResponsePrefixSuffixMiddleware.swift b/Examples/ExampleMiddleware/HTTPServerResponsePrefixSuffixMiddleware.swift new file mode 100644 index 0000000..d41f5f0 --- /dev/null +++ b/Examples/ExampleMiddleware/HTTPServerResponsePrefixSuffixMiddleware.swift @@ -0,0 +1,171 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP API Proposal open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP API Proposal project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import BasicContainers +public import HTTPAPIs +public import Middleware + +/// A middleware that frames the response body with a fixed prefix and suffix. +/// +/// The prefix is written before the user's handler runs, and the suffix is +/// fused with the user's last body chunk and the FIN signal via the wrapping +/// writer's `finish`. Useful as a minimal demonstration of a middleware that +/// needs work both *before* the user's handler writes anything and *after* it +/// declares the body finished. +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +public struct HTTPServerResponsePrefixSuffixMiddleware< + Reader: HTTPBodyReader & ~Copyable, + ResponseSender: HTTPResponseSender & ~Copyable +>: Middleware +where + Reader: ~Copyable & Escapable, + ResponseSender: ~Copyable & Escapable, + ResponseSender.Writer: ~Copyable & Escapable +{ + public typealias Input = HTTPServerMiddlewareInput + public typealias NextInput = HTTPServerMiddlewareInput< + Reader, + HTTPServerResponsePrefixSuffixSender + > + + let prefix: [UInt8] + let suffix: [UInt8] + + public init( + prefix: [UInt8], + suffix: [UInt8], + readerType: Reader.Type = Reader.self, + responseSenderType: ResponseSender.Type = ResponseSender.self + ) { + self.prefix = prefix + self.suffix = suffix + } + + public func intercept( + input: consuming Input, + next: (consuming NextInput) async throws -> Return + ) async throws -> Return { + try await input.withContents { request, context, reader, responseSender in + let wrappedSender = HTTPServerResponsePrefixSuffixSender( + base: responseSender, + prefix: self.prefix, + suffix: self.suffix + ) + return try await next( + HTTPServerMiddlewareInput( + request: request, + requestContext: context, + reader: reader, + responseSender: wrappedSender + ) + ) + } + } +} + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension Middleware where Input: ~Copyable & ~Escapable, NextInput: ~Copyable & ~Escapable { + /// Adds a middleware that frames the response body with a fixed prefix and suffix. + public func prefixSuffix( + prefix: [UInt8], + suffix: [UInt8] + ) -> HTTPServerResponsePrefixSuffixMiddleware + where + Input == HTTPServerMiddlewareInput, + Reader: HTTPBodyReader & ~Copyable & Escapable, + ResponseSender: HTTPResponseSender & ~Copyable & Escapable, + ResponseSender.Writer: ~Copyable & Escapable + { + HTTPServerResponsePrefixSuffixMiddleware(prefix: prefix, suffix: suffix) + } +} + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +public struct HTTPServerResponsePrefixSuffixSender< + Base: HTTPResponseSender & ~Copyable +>: HTTPResponseSender, ~Copyable +where Base.Writer: ~Copyable & Escapable { + public typealias Writer = PrefixSuffixWriter + + public struct PrefixSuffixWriter: HTTPBodyWriter, ~Copyable { + public typealias WriteElement = UInt8 + public typealias WriteFailure = Base.Writer.WriteFailure + public typealias Buffer = Base.Writer.Buffer + + @usableFromInline + var underlying: Base.Writer + @usableFromInline + let suffix: [UInt8] + + init(wrapping writer: consuming Base.Writer, suffix: [UInt8]) { + self.underlying = writer + self.suffix = suffix + } + + public mutating func write( + _ body: (inout Buffer) async throws(Failure) -> Return + ) async throws(EitherError) -> Return { + try await self.underlying.write(body) + } + + public consuming func finish( + body: (inout Buffer) async throws(Failure) -> HTTPFields? + ) async throws(EitherError) { + // Copy `suffix` out before the consuming call. Capturing `self.suffix` + // directly while consuming `self.underlying` triggers a use-after- + // consume on `self`. + let suffix = self.suffix + // Fuse user body + suffix + trailers into a single underlying + // `finish` call: the user's body writes its final bytes into the + // transport buffer, we append the suffix, and return the user's + // trailers — all in one transport frame. + // TODO: #11 — `buffer.append(b)` here assumes the underlying + // buffer grows on demand. If a future writer ships a fixed-capacity + // buffer, this silently truncates the suffix. Either guard with + // `freeCapacity` and split across multiple writes, or document the + // capacity contract on AsyncWriter so we can rely on it. + try await self.underlying.finish { buffer throws(Failure) -> HTTPFields? in + let trailers = try await body(&buffer) + for b in suffix { + buffer.append(b) + } + return trailers + } + } + } + + private var base: Base + private let prefix: [UInt8] + private let suffix: [UInt8] + + init(base: consuming Base, prefix: [UInt8], suffix: [UInt8]) { + self.base = base + self.prefix = prefix + self.suffix = suffix + } + + public func sendInformational(_ response: HTTPResponse) async throws { + try await self.base.sendInformational(response) + } + + public consuming func send(_ response: HTTPResponse) async throws -> PrefixSuffixWriter { + let prefix = self.prefix + let suffix = self.suffix + var writer = try await self.base.send(response) + // Write the prefix up front, before the user handler sees the writer. + try await writer.write { buffer in + buffer.append(copying: prefix) + } + return PrefixSuffixWriter(wrapping: writer, suffix: suffix) + } +} diff --git a/Examples/MiddlewareClient/ExampleMiddlewareClient.swift b/Examples/MiddlewareClient/ExampleMiddlewareClient.swift index e287ab6..7f07bd5 100644 --- a/Examples/MiddlewareClient/ExampleMiddlewareClient.swift +++ b/Examples/MiddlewareClient/ExampleMiddlewareClient.swift @@ -11,14 +11,26 @@ // //===----------------------------------------------------------------------===// +import ExampleMiddleware import HTTPAPIs import Middleware @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) -struct ExampleMiddlewareClient>: HTTPClient, ~Copyable { +struct ExampleMiddlewareClient< + Client: HTTPClient & ~Copyable, + OutWriter: HTTPBodyWriter & ~Copyable & SendableMetatype, + ClientMiddleware: Middleware & Sendable +>: HTTPClient, ~Copyable +where + Client.Writer: SendableMetatype, + ClientMiddleware.Input: ~Copyable, + ClientMiddleware.NextInput: ~Copyable, + ClientMiddleware.Input == HTTPClientMiddlewareInput, + ClientMiddleware.NextInput == HTTPClientMiddlewareInput +{ typealias RequestOptions = Client.RequestOptions - typealias RequestWriter = Client.RequestWriter - typealias ResponseConcludingReader = Client.ResponseConcludingReader + typealias Writer = OutWriter + typealias Reader = Client.Reader var defaultRequestOptions: Client.RequestOptions { self.client.defaultRequestOptions @@ -30,25 +42,24 @@ struct ExampleMiddlewareClient) -> ClientMiddleware + middlewareBuilder: (BaseRequestMiddleware) -> ClientMiddleware ) { self.client = client - self.middleware = middlewareBuilder(RequestMiddleware()) + self.middleware = middlewareBuilder(BaseRequestMiddleware()) } mutating func perform( request: HTTPRequest, - body: consuming HTTPClientRequestBody?, + body: consuming HTTPClientRequestBody?, options: RequestOptions, - responseHandler: (HTTPResponse, consuming ResponseConcludingReader) async throws -> Return + responseHandler: (HTTPResponse, consuming Reader) async throws -> Return ) async throws -> Return { - var body = Optional(body) return try await self.middleware.intercept( - input: request - ) { request in + input: HTTPClientMiddlewareInput(request: request, body: body) + ) { middlewareOutput in try await self.client.perform( - request: request, - body: body.take()!, + request: middlewareOutput.request, + body: middlewareOutput.body, options: options, responseHandler: responseHandler ) @@ -56,10 +67,14 @@ struct ExampleMiddlewareClient: Middleware { - typealias Input = HTTPRequest - typealias NextInput = Input +struct BaseRequestMiddleware: Middleware, Sendable +where Client.Writer: SendableMetatype { + typealias Input = HTTPClientMiddlewareInput + typealias NextInput = HTTPClientMiddlewareInput func intercept( input: consuming Input, diff --git a/Examples/MiddlewareClient/MiddlewareClient.swift b/Examples/MiddlewareClient/MiddlewareClient.swift index 97b15f5..571af0b 100644 --- a/Examples/MiddlewareClient/MiddlewareClient.swift +++ b/Examples/MiddlewareClient/MiddlewareClient.swift @@ -24,9 +24,9 @@ struct MiddlewareClient { static func main() async throws { var client = ExampleMiddlewareClient( client: DefaultHTTPClient.shared - ) { request in - request - .forwarding() + ) { base in + base.checksumTrailer() + base.forwarding() } let (_, responseBody) = try await client.get(url: URL(string: "https://httpbin.org/get")!, collectUpTo: 1024) print("Received \(String(data: responseBody, encoding: .utf8)!)") diff --git a/Examples/MiddlewareServer/ExampleMiddlewareServer.swift b/Examples/MiddlewareServer/ExampleMiddlewareServer.swift index c0ca7ce..4f8539d 100644 --- a/Examples/MiddlewareServer/ExampleMiddlewareServer.swift +++ b/Examples/MiddlewareServer/ExampleMiddlewareServer.swift @@ -23,16 +23,15 @@ struct ExampleMiddlewareServer< ServerMiddleware: Middleware & Sendable >: ~Copyable where - Server.RequestConcludingReader: ~Copyable, - Server.RequestConcludingReader.Underlying: ~Copyable, - Server.ResponseConcludingWriter: ~Copyable, - Server.ResponseConcludingWriter.Underlying: ~Copyable, + Server.Reader: ~Copyable, + Server.ResponseSender: ~Copyable, + Server.ResponseSender.Writer: ~Copyable, ServerMiddleware.Input: ~Copyable, ServerMiddleware.NextInput: ~Copyable, - ServerMiddleware.Input == HTTPServerMiddlewareInput + ServerMiddleware.Input == HTTPServerMiddlewareInput { - typealias RequestConcludingReader = Server.RequestConcludingReader - typealias ResponseConcludingWriter = Server.ResponseConcludingWriter + typealias Reader = Server.Reader + typealias ResponseSender = Server.ResponseSender private let server: Server private let middleware: ServerMiddleware @@ -48,11 +47,11 @@ where consuming func serve() async throws { let middleware = self.middleware - try await self.server.serve { request, requestContext, requestBodyAndTrailers, responseSender in + try await self.server.serve { request, requestContext, reader, responseSender in let input: ServerMiddleware.Input = ServerMiddleware.Input( request: request, requestContext: requestContext, - requestReader: requestBodyAndTrailers, + reader: reader, responseSender: responseSender ) return try await middleware.intercept( @@ -65,12 +64,11 @@ where @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) struct RequestMiddleware: Middleware where - Server.RequestConcludingReader: ~Copyable, - Server.RequestConcludingReader.Underlying: ~Copyable, - Server.ResponseConcludingWriter: ~Copyable, - Server.ResponseConcludingWriter.Underlying: ~Copyable + Server.Reader: ~Copyable, + Server.ResponseSender: ~Copyable, + Server.ResponseSender.Writer: ~Copyable { - typealias Input = HTTPServerMiddlewareInput + typealias Input = HTTPServerMiddlewareInput typealias NextInput = Input func intercept( diff --git a/Examples/MiddlewareServer/MiddlewareServer.swift b/Examples/MiddlewareServer/MiddlewareServer.swift index cd562ab..0b7a8a2 100644 --- a/Examples/MiddlewareServer/MiddlewareServer.swift +++ b/Examples/MiddlewareServer/MiddlewareServer.swift @@ -25,16 +25,18 @@ struct MiddlewareServer { static func serve(server: Server) async throws where - Server.RequestConcludingReader: ~Copyable, - Server.RequestConcludingReader.Underlying: ~Copyable & Escapable, - Server.ResponseConcludingWriter: ~Copyable, - Server.ResponseConcludingWriter.Underlying: ~Copyable & Escapable + Server.Reader: ~Copyable & Escapable, + Server.ResponseSender: ~Copyable, + Server.ResponseSender.Writer: ~Copyable & Escapable, + Server.Reader.Buffer == Server.ResponseSender.Writer.Buffer { try await ExampleMiddlewareServer( server: server ) { server in server .logging(logger: Logger(label: "Logger")) + .checksumTrailer() + .prefixSuffix(prefix: Array("<<".utf8), suffix: Array(">>".utf8)) .requestHandler() }.serve() } diff --git a/Examples/ProxyServer/ProxyServer.swift b/Examples/ProxyServer/ProxyServer.swift index c2d1570..9fdf522 100644 --- a/Examples/ProxyServer/ProxyServer.swift +++ b/Examples/ProxyServer/ProxyServer.swift @@ -26,44 +26,42 @@ struct ProxyServer { fatalError("Waiting for a concrete HTTP server implementation") } - static func proxy(server: some HTTPServer, client: some HTTPClient) async throws { + static func proxy( + server: Server, + client: Client + ) async throws + where + Server.Reader.Buffer == Client.Writer.Buffer, + Client.Reader.Buffer == Server.ResponseSender.Writer.Buffer + { try await server.serve { request, requestContext, - serverRequestBodyAndTrailers, + serverReader, responseSender in - // We need to use a mutex here to move the requestBodyAndTrailers into the + // We need to use a mutex here to move the reader into the // @Sendable restartable body - let serverRequestBodyAndTrailers = Mutex(Disconnected(value: Optional(serverRequestBodyAndTrailers))) + let serverReader = Mutex(Disconnected(value: Optional(serverReader))) // Needed since we are lacking call-once closures var responseSender = Optional(responseSender) var client = client try await client.perform( request: request, - body: .restartable { clientRequestBody in - var clientRequestBody = clientRequestBody - // This takes the request body out of the mutex. Any restarts would hit + body: .restartable { upstreamWriter in + // This takes the reader out of the mutex. Any restarts would hit // a force-unwrap. - let serverRequestBodyAndTrailers = serverRequestBodyAndTrailers.withLock { + let reader = serverReader.withLock { $0.swap(newValue: nil) }! - - return try await serverRequestBodyAndTrailers.consumeAndConclude { serverRequestBody in - try await clientRequestBody.write(serverRequestBody) - }.1 - } - ) { response, clientResponseBodyAndTrailers in - // Needed since we are lacking call-once closures - var clientResponseBodyAndTrailers = Optional(clientResponseBodyAndTrailers) - - let serverResponseBodyAndTrailers = try await responseSender.take()!.send(response) - try await serverResponseBodyAndTrailers.produceAndConclude { serverResponseBody in - var serverResponseBody = serverResponseBody - return try await clientResponseBodyAndTrailers.take()!.consumeAndConclude { clientResponseBody in - try await serverResponseBody.write(clientResponseBody) - } + // Pipe the server request body straight into the upstream writer. + try await reader.pipe(into: upstreamWriter) } + ) { response, upstreamReader in + // Pipe the upstream client response body straight into the + // downstream response sender. + let writer = try await responseSender.take()!.send(response) + try await upstreamReader.pipe(into: writer) } } } diff --git a/Examples/WASMClient/main.swift b/Examples/WASMClient/main.swift index 116f3e0..a756b84 100644 --- a/Examples/WASMClient/main.swift +++ b/Examples/WASMClient/main.swift @@ -42,15 +42,13 @@ guard let method = HTTPRequest.Method(methodString) else { } // Optionally accept a body -var body: HTTPClientRequestBody? = nil +var body: HTTPClientRequestBody? = nil if method == .post || method == .put { let bodyString = try prompt("Body:", "Hello World!") - body = .restartable { writer in - var writer = writer + body = .restartable { sender in let span = bodyString.utf8Span.span status.set("⏳ Writing \(span.count) bytes") - try await writer.write(span) - return nil + try await sender.send(body: span) } } @@ -84,23 +82,20 @@ do { status.set("⏳ Reading response body") // Read the body as it is streamed in - let (bytes, _) = try await reader.consumeAndConclude { reader in - var bytes = [UInt8]() + var reader = try await reader.receive() + var bytes = [UInt8]() - if let contentLength = contentLength { - bytes.reserveCapacity(contentLength) - } + if let contentLength = contentLength { + bytes.reserveCapacity(contentLength) + } - var reader = reader - status.set("⏳ Read \(bytes.count) bytes") - try await reader.forEachBuffer { buffer in - var consumer = buffer.consumeAll() - while let b = consumer.next() { - bytes.append(b) - } - status.set("⏳ Read \(bytes.count) bytes") + status.set("⏳ Read \(bytes.count) bytes") + try await reader.forEachBuffer { buffer in + var consumer = buffer.consumeAll() + while let b = consumer.next() { + bytes.append(b) } - return bytes + status.set("⏳ Read \(bytes.count) bytes") } status.set("✅ Read \(bytes.count) bytes") diff --git a/Sources/AHCHTTPClient/AHC+HTTPClient.swift b/Sources/AHCHTTPClient/AHC+HTTPClient.swift index 6952480..8158b72 100644 --- a/Sources/AHCHTTPClient/AHC+HTTPClient.swift +++ b/Sources/AHCHTTPClient/AHC+HTTPClient.swift @@ -22,16 +22,17 @@ import Synchronization @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, *) extension AsyncHTTPClient.HTTPClient: HTTPAPIs.HTTPClient { - public typealias RequestWriter = RequestBodyWriter - public typealias ResponseConcludingReader = ResponseReader + public typealias Writer = RequestBodyWriter + public typealias Reader = ResponseBodyReader public struct RequestOptions: HTTPClientCapability.RequestOptions { } - public struct RequestBodyWriter: AsyncWriter, ~Copyable { + public struct RequestBodyWriter: HTTPBodyWriter, ~Copyable { public typealias WriteElement = UInt8 public typealias WriteFailure = any Error + // TODO: This should become InputSpan most likely once spans conform to the container protocols public typealias Buffer = UniqueArray let requestWriter: HTTPClientRequest.Body.RequestWriter @@ -41,8 +42,8 @@ extension AsyncHTTPClient.HTTPClient: HTTPAPIs.HTTPClient { init(_ requestWriter: HTTPClientRequest.Body.RequestWriter) { self.requestWriter = requestWriter self.byteBuffer = ByteBuffer() - self.byteBuffer.reserveCapacity(2 ^ 16) - self.buffer = UniqueArray(minimumCapacity: 2 ^ 16) + self.byteBuffer.reserveCapacity(2 << 16) + self.buffer = UniqueArray(minimumCapacity: 2 << 16) } public mutating func write( @@ -65,7 +66,7 @@ extension AsyncHTTPClient.HTTPClient: HTTPAPIs.HTTPClient { do { self.byteBuffer.clear() - self.byteBuffer.writeBytes(buffer.span.bytes) + unsafe self.byteBuffer.writeBytes(buffer.span.bytes) buffer.removeAll() self.buffer = consume buffer try await self.requestWriter.writeRequestBodyPart(self.byteBuffer) @@ -75,65 +76,87 @@ extension AsyncHTTPClient.HTTPClient: HTTPAPIs.HTTPClient { return result } - } - - public struct ResponseReader: ConcludingAsyncReader { - public typealias Underlying = ResponseBodyReader - - let underlying: HTTPClientResponse.Body - - public typealias FinalElement = HTTPFields? - - init(underlying: HTTPClientResponse.Body) { - self.underlying = underlying - } - - public consuming func consumeAndConclude( - body: (consuming sending ResponseBodyReader) async throws(Failure) -> Return - ) async throws(Failure) -> (Return, HTTPFields?) where Failure: Error { - let iterator = self.underlying.makeAsyncIterator() - let reader = ResponseBodyReader(underlying: iterator) - let returnValue = try await body(reader) - let t = self.underlying.trailers?.compactMap { - if let name = HTTPField.Name($0.name) { - HTTPField(name: name, value: $0.value) + public consuming func finish( + body: (inout UniqueArray) async throws(Failure) -> HTTPFields? + ) async throws(AsyncStreaming.EitherError) { + var buffer = self.buffer.take()! + let trailers: HTTPFields? + do { + trailers = try await body(&buffer) + } catch { + throw .second(error) + } + if buffer.count > 0 { + do { + self.byteBuffer.clear() + unsafe self.byteBuffer.writeBytes(buffer.span.bytes) + try await self.requestWriter.writeRequestBodyPart(self.byteBuffer) + } catch { + throw .first(error) + } + } + let ahcTrailers: HTTPHeaders? = + if let trailers { + HTTPHeaders(.init(trailers.lazy.map({ ($0.name.rawName, $0.value) }))) } else { nil } - } - return (returnValue, t.flatMap({ HTTPFields($0) })) + self.requestWriter.requestBodyStreamFinished(trailers: ahcTrailers) } } - public struct ResponseBodyReader: AsyncReader, ~Copyable { + public struct ResponseBodyReader: HTTPBodyReader, ~Copyable { public typealias ReadElement = UInt8 public typealias ReadFailure = any Error public typealias Buffer = UniqueArray var underlying: HTTPClientResponse.Body.AsyncIterator + var body: HTTPClientResponse.Body var buffer = UniqueArray() + var trailersDelivered: Bool = false + + init(body: HTTPClientResponse.Body) { + self.body = body + self.underlying = body.makeAsyncIterator() + } public mutating func read( - body: (inout UniqueArray) async throws(Failure) -> Return + body: (inout UniqueArray, HTTPFields?) async throws(Failure) -> Return ) async throws(AsyncStreaming.EitherError) -> Return where Failure: Error { - let byteBuffer: ByteBuffer? - do { - byteBuffer = try await self.underlying.next(isolation: #isolation) - } catch { - throw .first(error) - } + var trailers: HTTPFields? = nil + + if !self.trailersDelivered { + let byteBuffer: ByteBuffer? + do { + byteBuffer = try await self.underlying.next(isolation: #isolation) + } catch { + throw .first(error) + } + + if let byteBuffer, byteBuffer.readableBytes > 0 { + self.buffer.reserveCapacity(byteBuffer.readableBytes) + unsafe byteBuffer.withUnsafeReadableBytes { rawBufferPtr in + let usbptr = unsafe rawBufferPtr.assumingMemoryBound(to: UInt8.self) + unsafe self.buffer.append(copying: usbptr) + } + } - if let byteBuffer, byteBuffer.readableBytes > 0 { - buffer.reserveCapacity(byteBuffer.readableBytes) - unsafe byteBuffer.withUnsafeReadableBytes { rawBufferPtr in - let usbptr = unsafe rawBufferPtr.assumingMemoryBound(to: UInt8.self) - unsafe self.buffer.append(copying: usbptr) + if byteBuffer == nil { + self.trailersDelivered = true + let collected = self.body.trailers?.compactMap { + if let name = HTTPField.Name($0.name) { + HTTPField(name: name, value: $0.value) + } else { + nil + } + } + trailers = collected.flatMap { HTTPFields($0) } ?? HTTPFields() } } do { - return try await body(&self.buffer) + return try await body(&self.buffer, trailers) } catch { throw .second(error) } @@ -148,7 +171,7 @@ extension AsyncHTTPClient.HTTPClient: HTTPAPIs.HTTPClient { request: HTTPRequest, body: consuming HTTPClientRequestBody?, options: RequestOptions, - responseHandler: (HTTPResponse, consuming ResponseReader) async throws -> Return + responseHandler: (HTTPResponse, consuming ResponseBodyReader) async throws -> Return ) async throws -> Return { guard let url = request.url else { fatalError() @@ -172,15 +195,9 @@ extension AsyncHTTPClient.HTTPClient: HTTPAPIs.HTTPClient { for await ahcWriter in asyncStream { do { - let writer = RequestWriter(ahcWriter) - let maybeTrailers = try await body.produce(into: writer) - let trailers: HTTPHeaders? = - if let trailers = maybeTrailers { - HTTPHeaders(.init(trailers.lazy.map({ ($0.name.rawName, $0.value) }))) - } else { - nil - } - ahcWriter.requestBodyStreamFinished(trailers: trailers) + let writer = RequestBodyWriter(ahcWriter) + try await body.produce(into: writer) + // writer.finish already calls requestBodyStreamFinished break // the loop } catch let error { // if we fail because the user throws in upload, we have to cancel the @@ -209,7 +226,7 @@ extension AsyncHTTPClient.HTTPClient: HTTPAPIs.HTTPClient { headerFields: responseFields ) - result = .success(try await responseHandler(response, .init(underlying: ahcResponse.body))) + result = .success(try await responseHandler(response, ResponseBodyReader(body: ahcResponse.body))) } catch { result = .failure(error) } diff --git a/Sources/FetchHTTPClient/FetchHTTPClient.swift b/Sources/FetchHTTPClient/FetchHTTPClient.swift index cb6825e..06f27c4 100644 --- a/Sources/FetchHTTPClient/FetchHTTPClient.swift +++ b/Sources/FetchHTTPClient/FetchHTTPClient.swift @@ -22,6 +22,7 @@ import JavaScriptKit // between FetchHTTPClient and RequestBodyWriter. class RequestBodyBuffer { var array = UniqueArray() + var trailers: HTTPFields? = nil } enum FetchError: Error { @@ -38,8 +39,8 @@ enum FetchError: Error { @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, *) public final class FetchHTTPClient: HTTPAPIs.HTTPClient { - public typealias RequestWriter = RequestBodyWriter - public typealias ResponseConcludingReader = ResponseReader + public typealias Writer = RequestBodyWriter + public typealias Reader = ResponseBodyReader public struct RequestOptions: HTTPClientCapability.RequestOptions, Sendable { public init() {} @@ -53,7 +54,7 @@ public final class FetchHTTPClient: HTTPAPIs.HTTPClient { request: HTTPTypes.HTTPRequest, body: consuming HTTPAPIs.HTTPClientRequestBody?, options: RequestOptions, - responseHandler: nonisolated(nonsending) (HTTPTypes.HTTPResponse, consuming ResponseReader) async throws -> Return + responseHandler: nonisolated(nonsending) (HTTPTypes.HTTPResponse, consuming ResponseBodyReader) async throws -> Return ) async throws -> Return where Return: ~Copyable { guard let url = request.url else { throw FetchError.BadURL @@ -63,14 +64,11 @@ public final class FetchHTTPClient: HTTPAPIs.HTTPClient { if let body = body { let buffer = RequestBodyBuffer() - let writer = RequestBodyWriter(buffer: buffer) - let trailers = try await body.produce(into: writer) - - if let trailers { + try await body.produce(into: writer) + if buffer.trailers != nil { throw FetchError.TrailersUnsupported } - jsBody = buffer.array.span.withUnsafeBufferPointer { bufferPtr in JSTypedArray(buffer: bufferPtr).jsObject } @@ -120,11 +118,11 @@ public final class FetchHTTPClient: HTTPAPIs.HTTPClient { return try await responseHandler( HTTPResponse(status: .init(code: responseStatus, reasonPhrase: responseStatusText), headerFields: responseHeaders), - ResponseReader(reader: reader) + ResponseBodyReader(reader: reader) ) } - public struct RequestBodyWriter: AsyncWriter, ~Copyable { + public struct RequestBodyWriter: HTTPBodyWriter, ~Copyable { public typealias WriteElement = UInt8 public typealias WriteFailure = any Error public typealias Buffer = UniqueArray @@ -142,48 +140,59 @@ public final class FetchHTTPClient: HTTPAPIs.HTTPClient { } return result } - } - public struct ResponseReader: ConcludingAsyncReader, ~Copyable { - let reader: ReadableStreamDefaultReader - - public consuming func consumeAndConclude( - body: nonisolated(nonsending) (consuming sending FetchHTTPClient.ResponseBodyReader) async throws(Failure) -> Return - ) async throws(Failure) -> (Return, HTTPTypes.HTTPFields?) where Failure: Error { - return (try await body(ResponseBodyReader(reader: reader)), nil) + public consuming func finish( + body: nonisolated(nonsending) (inout UniqueArray) async throws(Failure) -> HTTPFields? + ) async throws(AsyncStreaming.EitherError) { + let trailer: HTTPFields? + do { + trailers = try await body(&self.buffer.array) + } catch { + throw .second(error) + } + self.buffer.trailers = trailers } } - public struct ResponseBodyReader: AsyncReader, ~Copyable { + public struct ResponseBodyReader: HTTPBodyReader, ~Copyable { public typealias ReadElement = UInt8 public typealias ReadFailure = any Error public typealias Buffer = UniqueArray let reader: ReadableStreamDefaultReader var buffer = UniqueArray() + var trailersDelivered: Bool = false public mutating func read( - body: nonisolated(nonsending) (inout UniqueArray) async throws(Failure) -> Return + body: nonisolated(nonsending) (inout UniqueArray, HTTPFields?) async throws(Failure) -> Return ) async throws(AsyncStreaming.EitherError) -> Return where Failure: Error { - let chunk: Chunk - do { - chunk = try await self.reader.read() - } catch { - throw .first(error) - } - if !chunk.done { - guard let bytes = chunk.value, !bytes.isEmpty else { - // If not done, there must be bytes that can be read - throw .first(FetchError.BadAssumptionJS) + var trailers: HTTPFields? = nil + + if !self.trailersDelivered { + let chunk: Chunk + do { + chunk = try await self.reader.read() + } catch { + throw .first(error) } - buffer.reserveCapacity(bytes.count) - for b in bytes { - self.buffer.append(b) + if !chunk.done { + guard let bytes = chunk.value, !bytes.isEmpty else { + throw .first(FetchError.BadAssumptionJS) + } + self.buffer.reserveCapacity(bytes.count) + for b in bytes { + self.buffer.append(b) + } + } else { + // The fetch API does not surface trailers, so signal end of body + // with empty trailers. + self.trailersDelivered = true + trailers = HTTPFields() } } do { - return try await body(&self.buffer) + return try await body(&self.buffer, trailers) } catch { throw .second(error) } diff --git a/Sources/HTTPAPIs/AsyncWriter+AsyncReader.swift b/Sources/HTTPAPIs/AsyncWriter+AsyncReader.swift index 4c1d9b4..f2e81c9 100644 --- a/Sources/HTTPAPIs/AsyncWriter+AsyncReader.swift +++ b/Sources/HTTPAPIs/AsyncWriter+AsyncReader.swift @@ -55,3 +55,32 @@ extension AsyncWriter where Self: ~Copyable, Self: ~Escapable { } } } + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension AsyncReader where Self: ~Copyable, Self: ~Escapable { + /// A `mutating` (rather than `consuming`) variant of `forEachBuffer`. + /// + /// Iterates over all chunks from the reader without consuming it. Useful when + /// the reader is held as `inout` and ownership cannot be transferred — for + /// example, inside the body closure of an HTTP receiver's `receive` call, + /// where the reader is `inout sending`. + /// + /// - Parameter body: An asynchronous closure that processes each buffer of + /// elements read from the stream. + /// - Throws: An `EitherError` containing either a `ReadFailure` from the + /// read operation or a `Failure` from the body closure. + public mutating func forEachBufferMutating( + body: (inout Buffer) async throws(Failure) -> Void + ) async throws(EitherError) { + var shouldContinue = true + while shouldContinue { + try await self.read { (next) throws(Failure) -> Void in + guard next.count > 0 else { + shouldContinue = false + return + } + try await body(&next) + } + } + } +} diff --git a/Sources/HTTPAPIs/AsyncWriter.swift b/Sources/HTTPAPIs/AsyncWriter.swift index 6717599..ab0a533 100644 --- a/Sources/HTTPAPIs/AsyncWriter.swift +++ b/Sources/HTTPAPIs/AsyncWriter.swift @@ -55,28 +55,4 @@ extension AsyncWriter where Self: ~Copyable, Self: ~Escapable { } } } - - /// Writes the provided span of elements to the underlying destination. - /// - /// - Parameter span: The elements to write. - /// - /// - Throws: An error of type `WriteFailure` if the write operation cannot be completed successfully. - #if compiler(<6.3) - @_lifetime(self: copy self) - #endif - public mutating func write(_ span: Span) async throws(WriteFailure) - where WriteElement: Copyable { - do { - try await self.write { (buffer: inout Self.Buffer) in - buffer.append(copying: span) - } - } catch { - switch error { - case .first(let error): - throw error - case .second: - fatalError() - } - } - } } diff --git a/Sources/HTTPAPIs/Client/HTTPClient+Conveniences.swift b/Sources/HTTPAPIs/Client/HTTPClient+Conveniences.swift index 672a564..a0e247d 100644 --- a/Sources/HTTPAPIs/Client/HTTPClient+Conveniences.swift +++ b/Sources/HTTPAPIs/Client/HTTPClient+Conveniences.swift @@ -11,6 +11,8 @@ // //===----------------------------------------------------------------------===// +import BasicContainers + #if canImport(FoundationEssentials) public import struct FoundationEssentials.URL public import struct FoundationEssentials.Data @@ -23,55 +25,21 @@ public import struct Foundation.Data extension HTTPClient where Self: ~Copyable & ~Escapable, - ResponseConcludingReader: ~Copyable, - ResponseConcludingReader.Underlying: ~Copyable, - RequestWriter: ~Copyable + Reader: ~Copyable, + Writer: ~Copyable { /// Performs an HTTP request and processes the response. - /// - /// This convenience method provides default values for `body` and `options` arguments, - /// making it easier to execute HTTP requests without specifying optional parameters. - /// - /// - Parameters: - /// - request: The HTTP request header to send. - /// - body: The optional request body to send. Defaults to no body. - /// - options: The options for this request. Defaults to an empty initialized options. - /// - responseHandler: A closure that processes the response. The method invokes this - /// closure when it receives the response header, providing access to the response body. - /// - /// - Returns: The value returned by the response handler closure. - /// - /// - Throws: An error if the request fails or if the response handler throws. - #if compiler(<6.3) - @_lifetime(&self) - #endif public mutating func perform( request: HTTPRequest, - body: consuming HTTPClientRequestBody? = nil, + body: consuming HTTPClientRequestBody? = nil, options: RequestOptions? = nil, - responseHandler: (HTTPResponse, consuming ResponseConcludingReader) async throws -> Return, + responseHandler: (HTTPResponse, consuming Reader) async throws -> Return, ) async throws -> Return { let options = options ?? self.defaultRequestOptions return try await self.perform(request: request, body: body, options: options, responseHandler: responseHandler) } /// Performs an HTTP GET request and collects the response body. - /// - /// This convenience method executes a GET request to the specified URL and collects - /// the response body data up to the specified limit. - /// - /// - Parameters: - /// - url: The URL to send the GET request to. - /// - headerFields: The HTTP header fields to include in the request. Defaults to an empty collection. - /// - options: The options for this request. Defaults to an empty initialized options. - /// - limit: The maximum number of bytes to collect from the response body. - /// - /// - Returns: A tuple containing the HTTP response header and the collected response body data. - /// - /// - Throws: An error if the request fails, if the response body exceeds the limit, or if collection fails. - #if compiler(<6.3) - @_lifetime(&self) - #endif public mutating func get( url: URL, headerFields: HTTPFields = [:], @@ -80,32 +48,15 @@ where ) async throws -> (response: HTTPResponse, bodyData: Data) { let request = HTTPRequest(url: url, headerFields: headerFields) let options = options ?? self.defaultRequestOptions - return try await self.perform(request: request, body: nil, options: options) { response, body in + return try await self.perform(request: request, body: nil, options: options) { response, reader in ( response, - try await Self.collectBody(body, upTo: limit) + try await Self.collectBody(reader, upTo: limit) ) } } /// Performs an HTTP POST request with a body and collects the response body. - /// - /// This convenience method executes a POST request to the specified URL with the provided - /// request body data and collects the response body data up to the specified limit. - /// - /// - Parameters: - /// - url: The URL to send the POST request to. - /// - headerFields: The HTTP header fields to include in the request. Defaults to an empty collection. - /// - bodyData: The request body data to send. - /// - options: The options for this request. Defaults to an empty initialized options. - /// - limit: The maximum number of bytes to collect from the response body. - /// - /// - Returns: A tuple containing the HTTP response header and the collected response body data. - /// - /// - Throws: An error if the request fails, if the response body exceeds the limit, or if collection fails. - #if compiler(<6.3) - @_lifetime(&self) - #endif public mutating func post( url: URL, headerFields: HTTPFields = [:], @@ -115,32 +66,15 @@ where ) async throws -> (response: HTTPResponse, bodyData: Data) { let request = HTTPRequest(method: .post, url: url, headerFields: headerFields) let options = options ?? self.defaultRequestOptions - return try await self.perform(request: request, body: .data(bodyData), options: options) { response, body in + return try await self.perform(request: request, body: .data(bodyData), options: options) { response, reader in ( response, - try await Self.collectBody(body, upTo: limit) + try await Self.collectBody(reader, upTo: limit) ) } } /// Performs an HTTP PUT request with a body and collects the response body. - /// - /// This convenience method executes a PUT request to the specified URL with the provided - /// request body data and collects the response body data up to the specified limit. - /// - /// - Parameters: - /// - url: The URL to send the PUT request to. - /// - headerFields: The HTTP header fields to include in the request. Defaults to an empty collection. - /// - bodyData: The request body data to send. - /// - options: The options for this request. Defaults to an empty initialized options. - /// - limit: The maximum number of bytes to collect from the response body. - /// - /// - Returns: A tuple containing the HTTP response header and the collected response body data. - /// - /// - Throws: An error if the request fails, if the response body exceeds the limit, or if collection fails. - #if compiler(<6.3) - @_lifetime(&self) - #endif public mutating func put( url: URL, headerFields: HTTPFields = [:], @@ -150,32 +84,15 @@ where ) async throws -> (response: HTTPResponse, bodyData: Data) { let request = HTTPRequest(method: .put, url: url, headerFields: headerFields) let options = options ?? self.defaultRequestOptions - return try await self.perform(request: request, body: .data(bodyData), options: options) { response, body in + return try await self.perform(request: request, body: .data(bodyData), options: options) { response, reader in ( response, - try await Self.collectBody(body, upTo: limit) + try await Self.collectBody(reader, upTo: limit) ) } } /// Performs an HTTP DELETE request and collects the response body. - /// - /// This convenience method executes a DELETE request to the specified URL with an optional - /// request body and collects the response body data up to the specified limit. - /// - /// - Parameters: - /// - url: The URL to send the DELETE request to. - /// - headerFields: The HTTP header fields to include in the request. Defaults to an empty collection. - /// - bodyData: The optional request body data to send. Defaults to no body. - /// - options: The options for this request. Defaults to an empty initialized options. - /// - limit: The maximum number of bytes to collect from the response body. - /// - /// - Returns: A tuple containing the HTTP response header and the collected response body data. - /// - /// - Throws: An error if the request fails, if the response body exceeds the limit, or if collection fails. - #if compiler(<6.3) - @_lifetime(&self) - #endif public mutating func delete( url: URL, headerFields: HTTPFields = [:], @@ -185,32 +102,15 @@ where ) async throws -> (response: HTTPResponse, bodyData: Data) { let request = HTTPRequest(method: .delete, url: url, headerFields: headerFields) let options = options ?? self.defaultRequestOptions - return try await self.perform(request: request, body: bodyData.map { .data($0) }, options: options) { response, body in + return try await self.perform(request: request, body: bodyData.map { .data($0) }, options: options) { response, reader in ( response, - try await Self.collectBody(body, upTo: limit) + try await Self.collectBody(reader, upTo: limit) ) } } /// Performs an HTTP PATCH request with a body and collects the response body. - /// - /// This convenience method executes a PATCH request to the specified URL with the provided - /// request body data and collects the response body data up to the specified limit. - /// - /// - Parameters: - /// - url: The URL to send the PATCH request to. - /// - headerFields: The HTTP header fields to include in the request. Defaults to an empty collection. - /// - bodyData: The request body data to send. - /// - options: The options for this request. Defaults to an empty initialized options. - /// - limit: The maximum number of bytes to collect from the response body. - /// - /// - Returns: A tuple containing the HTTP response header and the collected response body data. - /// - /// - Throws: An error if the request fails, if the response body exceeds the limit, or if collection fails. - #if compiler(<6.3) - @_lifetime(&self) - #endif public mutating func patch( url: URL, headerFields: HTTPFields = [:], @@ -220,22 +120,44 @@ where ) async throws -> (response: HTTPResponse, bodyData: Data) { let request = HTTPRequest(method: .patch, url: url, headerFields: headerFields) let options = options ?? self.defaultRequestOptions - return try await self.perform(request: request, body: .data(bodyData), options: options) { response, body in + return try await self.perform(request: request, body: .data(bodyData), options: options) { response, reader in ( response, - try await Self.collectBody(body, upTo: limit) + try await Self.collectBody(reader, upTo: limit) ) } } - private static func collectBody(_ body: consuming Reader, upTo limit: Int) async throws -> Data - where Reader: ~Copyable, Reader.Underlying: ~Copyable, Reader.Underlying.ReadElement == UInt8 { - try await body.collect(upTo: limit == .max ? .max : limit + 1) { - if $0.count > limit { + private static func collectBody( + _ reader: consuming R, + upTo limit: Int + ) async throws -> Data { + // Read iteratively into a growable buffer rather than pre-allocating + // `limit` bytes (which can be Int.max). Check the cap after each chunk. + var buffer = UniqueArray() + var reader = reader + var done = false + while !done { + try await reader.read { (chunk: inout R.Buffer, trailers: HTTPFields?) in + if trailers != nil { + done = true + } + if chunk.count == 0 { + if trailers == nil { + done = true + } + return + } + buffer.append( + moving: chunk.startIndex.. limit { throw LengthLimitExceededError() } - return $0.span.withUnsafeBytes { unsafe Data($0) } - }.0 + } + return buffer.span.withUnsafeBytes { unsafe Data($0) } } } diff --git a/Sources/HTTPAPIs/Client/HTTPClient.swift b/Sources/HTTPAPIs/Client/HTTPClient.swift index 61d0196..495b17e 100644 --- a/Sources/HTTPAPIs/Client/HTTPClient.swift +++ b/Sources/HTTPAPIs/Client/HTTPClient.swift @@ -14,50 +14,40 @@ /// A protocol that defines the interface for an HTTP client. /// /// ``HTTPClient`` provides asynchronous request execution with streaming request -/// and response bodies. +/// and response bodies. Implementations expose the body reader and writer types +/// directly; there are no separate "receiver" or "request sender" wrapper types. @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) public protocol HTTPClient: Sendable, ~Copyable, ~Escapable { associatedtype RequestOptions: HTTPClientCapability.RequestOptions - /// The type used to write request body data and trailers. - // TODO: Check if we should allow ~Escapable readers https://github.com/apple/swift-http-api-proposal/issues/13 - associatedtype RequestWriter: AsyncWriter, ~Copyable, SendableMetatype - where RequestWriter.WriteElement == UInt8 + /// The body writer type used to stream request body bytes and signal end-of-body. + associatedtype Writer: HTTPBodyWriter, ~Copyable, SendableMetatype - /// The type used to read response body data and trailers. - // TODO: Check if we should allow ~Escapable writers https://github.com/apple/swift-http-api-proposal/issues/13 - associatedtype ResponseConcludingReader: ConcludingAsyncReader, ~Copyable, SendableMetatype - where - ResponseConcludingReader.Underlying: ~Copyable, - ResponseConcludingReader.Underlying.ReadElement == UInt8, - ResponseConcludingReader.FinalElement == HTTPFields? + /// The body reader type used to stream response body bytes and trailers. + associatedtype Reader: HTTPBodyReader, ~Copyable, SendableMetatype /// The default request options for `perform`. var defaultRequestOptions: RequestOptions { get } /// Performs an HTTP request and processes the response. /// - /// This method executes the HTTP request with the specified options, then invokes - /// the response handler when it receives the response header. The client streams - /// request and response bodies using its writer and reader types. - /// /// - Parameters: /// - request: The HTTP request header to send. /// - body: The optional request body to send. When `nil`, sends no body. /// - options: The options for this request. - /// - responseHandler: A closure that processes the response. The method invokes this - /// closure when it receives the response header, providing access to the response body. + /// - responseHandler: A closure that runs once the response head has + /// arrived. Receives the response head and a body reader. The reader + /// is owned by the closure and must be drained or its scope must end + /// before the closure returns; the surrounding `perform` performs + /// per-request cleanup based on the reader's terminal state. /// /// - Returns: The value returned by the response handler closure. /// /// - Throws: An error if the request fails or if the response handler throws. - #if compiler(<6.3) - @_lifetime(&self) - #endif mutating func perform( request: HTTPRequest, - body: consuming HTTPClientRequestBody?, + body: consuming HTTPClientRequestBody?, options: RequestOptions, - responseHandler: (HTTPResponse, consuming ResponseConcludingReader) async throws -> Return + responseHandler: (HTTPResponse, consuming Reader) async throws -> Return ) async throws -> Return } diff --git a/Sources/HTTPAPIs/Client/HTTPClientRequestBody+Data.swift b/Sources/HTTPAPIs/Client/HTTPClientRequestBody+Data.swift index 141727a..6e5121c 100644 --- a/Sources/HTTPAPIs/Client/HTTPClientRequestBody+Data.swift +++ b/Sources/HTTPAPIs/Client/HTTPClientRequestBody+Data.swift @@ -24,9 +24,10 @@ extension HTTPClientRequestBody where Writer: ~Copyable { /// - Parameter data: The data to send as the request body. public static func data(_ data: Data) -> Self { .seekable(knownLength: Int64(data.count)) { offset, writer in - var writer = writer - try await writer.write(data.span.extracting(droppingFirst: Int(offset))) - return nil + try await writer.finish { buffer in + buffer.append(copying: data.span.extracting(droppingFirst: Int(offset))) + return nil + } } } } diff --git a/Sources/HTTPAPIs/Client/HTTPClientRequestBody.swift b/Sources/HTTPAPIs/Client/HTTPClientRequestBody.swift index 962b0c5..2d17f4d 100644 --- a/Sources/HTTPAPIs/Client/HTTPClientRequestBody.swift +++ b/Sources/HTTPAPIs/Client/HTTPClientRequestBody.swift @@ -15,44 +15,46 @@ import AsyncStreaming /// A type that represents the body of an HTTP client request. /// -/// ``HTTPClientRequestBody`` wraps a closure that encapsulates the logic -/// to write a request body. It also contains extra hints and inputs to inform -/// the custom request body writing. +/// ``HTTPClientRequestBody`` wraps a closure that writes a request body using +/// an ``HTTPBodyWriter`` provided by the client. It also carries hints such +/// as the known body length so the client can set `Content-Length` correctly. /// /// ## Usage /// /// ### Seekable bodies /// -/// If the source of the request body bytes can be not only restarted from the beginning, -/// but even restarted from an arbitrary offset, prefer to create a seekable body. -/// -/// A seekable body allows the HTTP client to support resumable uploads. +/// If the source of the request body bytes can be restarted from an arbitrary +/// offset, prefer to create a seekable body. This allows the HTTP client to +/// support resumable uploads. /// /// ```swift -/// try await HTTP.perform(request: request, body: .seekable { byteOffset, writer in -/// // Inspect byteOffset and start writing contents into writer -/// }) { response, body in +/// try await client.perform(request: request, body: .seekable { offset, writer in +/// var writer = writer +/// // ... write from `offset` ... +/// try await writer.finish(trailers: nil) +/// }) { response, reader in /// // Handle the response /// } /// ``` /// /// ### Restartable bodies /// -/// If the source of the request body bytes cannot be restarted from an arbitrary offset, but -/// can be restarted from the beginning, use a restartable body. -/// -/// A restartable body allows the HTTP client to handle redirects and retries. +/// If the source of the request body bytes can only be restarted from the +/// beginning, use a restartable body. This allows the client to handle +/// redirects and retries. /// /// ```swift -/// try await HTTP.perform(request: request, body: .restartable { writer in -/// // Start writing contents into writer from the beginning -/// }) { response, body in +/// try await client.perform(request: request, body: .restartable { writer in +/// var writer = writer +/// // ... write the body ... +/// try await writer.finish(trailers: nil) +/// }) { response, reader in /// // Handle the response /// } /// ``` @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) -public struct HTTPClientRequestBody: Sendable -where Writer.WriteElement == UInt8, Writer: SendableMetatype { +public struct HTTPClientRequestBody: Sendable +where Writer: SendableMetatype { /// The body can be asked to restart writing from an arbitrary offset. public var isSeekable: Bool { switch self.writeBody { @@ -68,8 +70,8 @@ where Writer.WriteElement == UInt8, Writer: SendableMetatype { public let knownLength: Int64? private enum WriteBody { - case restartable(@Sendable (consuming Writer) async throws -> HTTPFields?) - case seekable(@Sendable (Int64, consuming Writer) async throws -> HTTPFields?) + case restartable(@Sendable (consuming sending Writer) async throws -> Void) + case seekable(@Sendable (Int64, consuming sending Writer) async throws -> Void) } private let writeBody: WriteBody @@ -77,7 +79,7 @@ where Writer.WriteElement == UInt8, Writer: SendableMetatype { /// - Parameters: /// - writer: The destination into which to write the body. /// - Throws: An error thrown from the body closure. - public func produce(into writer: consuming Writer) async throws -> HTTPFields? { + public func produce(into writer: consuming sending Writer) async throws { switch self.writeBody { case .restartable(let writeBody): try await writeBody(writer) @@ -92,7 +94,7 @@ where Writer.WriteElement == UInt8, Writer: SendableMetatype { /// - offset: The offset from which to start writing the body. /// - writer: The destination into which to write the body. /// - Throws: An error thrown from the body closure. - public func produce(offset: Int64, into writer: consuming Writer) async throws -> HTTPFields? { + public func produce(offset: Int64, into writer: consuming sending Writer) async throws { switch self.writeBody { case .restartable: fatalError("Request body is not seekable") @@ -103,20 +105,19 @@ where Writer.WriteElement == UInt8, Writer: SendableMetatype { /// A restartable request body that can be replayed from the beginning. /// - /// Use this case when the client may need to retry or follow redirects with - /// the same request body. The closure receives a writer and streams the entire - /// body content. The closure may be called multiple times if the request needs - /// to be retried. + /// The closure receives a body writer and streams the entire body. The + /// closure may be called multiple times if the request needs to be + /// retried. /// /// - Parameters: - /// - knownLength: The length of the body is known upfront and can be specified in - /// the `content-length` header field. - /// - body: The closure that writes the request body using the provided writer and - /// returns an optional trailer. - /// - writer: The writer that receives the request body bytes. + /// - knownLength: The length of the body is known upfront and can be + /// specified in the `content-length` header field. + /// - body: The closure that writes the request body using the provided + /// writer. The closure must call ``HTTPBodyWriter/finish(body:)`` + /// to terminate the body. public static func restartable( knownLength: Int64? = nil, - _ body: @escaping @Sendable (consuming Writer) async throws -> HTTPFields? + _ body: @escaping @Sendable (consuming sending Writer) async throws -> Void ) -> Self { Self.init( knownLength: knownLength, @@ -126,20 +127,15 @@ where Writer.WriteElement == UInt8, Writer: SendableMetatype { /// A seekable request body that supports resuming from a specific byte offset. /// - /// Use this case for resumable uploads where the client can start streaming - /// from a specific position in the body. The closure receives an offset indicating - /// where to begin writing and a writer for streaming the body content. - /// /// - Parameters: - /// - knownLength: The length of the body is known upfront and can be specified in - /// the `content-length` header field. - /// - body: The closure that writes the request body using the provided writer and - /// returns an optional trailer. - /// - offset: The byte offset from which to start writing the body. - /// - writer: The writer that receives the request body bytes. + /// - knownLength: The length of the body is known upfront and can be + /// specified in the `content-length` header field. + /// - body: The closure that writes the request body using the provided + /// writer. The closure must call ``HTTPBodyWriter/finish(body:)`` + /// to terminate the body. public static func seekable( knownLength: Int64? = nil, - _ body: @escaping @Sendable (Int64, consuming Writer) async throws -> HTTPFields? + _ body: @escaping @Sendable (Int64, consuming sending Writer) async throws -> Void ) -> Self { Self.init( knownLength: knownLength, @@ -152,10 +148,11 @@ where Writer.WriteElement == UInt8, Writer: SendableMetatype { self.writeBody = writeBody } - package init( + package init( other: HTTPClientRequestBody, - transform: @escaping @Sendable (consuming Writer) -> OtherWriter - ) { + transform: @escaping @Sendable (consuming sending Writer) -> sending OtherWriter + ) + where OtherWriter: SendableMetatype { self.knownLength = other.knownLength self.writeBody = switch other.writeBody { diff --git a/Sources/HTTPAPIs/ConcludingAsyncReader+collect.swift b/Sources/HTTPAPIs/ConcludingAsyncReader+collect.swift deleted file mode 100644 index e7efa26..0000000 --- a/Sources/HTTPAPIs/ConcludingAsyncReader+collect.swift +++ /dev/null @@ -1,65 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift HTTP API Proposal open source project -// -// Copyright (c) 2025 Apple Inc. and the Swift HTTP API Proposal project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -public import AsyncStreaming -import BasicContainers -public import ContainersPreview - -@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) -extension ConcludingAsyncReader where Self: ~Copyable, Underlying: ~Copyable { - /// Collects elements from the underlying async reader and returns both the processed result and final element. - /// - /// Reads elements from the underlying reader until either the accumulated count reaches `limit` - /// or the stream ends. Any elements the reader produces beyond `limit` are discarded. - /// - /// - Parameters: - /// - limit: The maximum number of elements to collect from the underlying reader. - /// - body: A closure that processes the collected elements as an `InputSpan` and returns a result. - /// - /// - Returns: A tuple containing the result from processing the collected elements and the final element. - /// - /// - Throws: Any error thrown by the underlying read operations or the body closure during - /// the collection and processing of elements. - public consuming func collect( - upTo limit: Int, - body: (consuming InputSpan) async throws -> Result - ) async throws -> (Result, FinalElement) { - try await self.consumeAndConclude { reader in - var reader = reader - var accumulated = UniqueArray() - var eof = false - while accumulated.count < limit && !eof { - try await reader.read { buffer in - if buffer.count == 0 { - eof = true - return - } - let remainingCapacity = limit - accumulated.count - if buffer.count <= remainingCapacity { - accumulated.append( - moving: buffer.startIndex..: ~Copyable, ~Escapable { - /// The underlying asynchronous reader type that produces elements. - associatedtype Underlying: AsyncReader, ~Copyable, ~Escapable - - /// The type of the final element produced after completing all reads. - associatedtype FinalElement - - /// Processes the underlying async reader until completion and returns both the result of processing - /// and a final element. - /// - /// - Parameter body: A closure that takes the underlying `AsyncReader` and returns a value. - /// - Returns: A tuple containing the value returned by the body closure and the final element. - /// - Throws: Any error thrown by the body closure or encountered while processing the reader. - /// - /// - Note: This method consumes the concluding async reader, meaning it can only be called once on a value type. - /// - /// ```swift - /// let responseReader: HTTPResponseReader = ... - /// - /// // Process the body while capturing the final response status - /// let (bodyData, statusCode) = try await responseReader.consumeAndConclude { reader in - /// var collectedData = Data() - /// while let chunk = try await reader.read(body: { $0 }) { - /// collectedData.append(chunk) - /// } - /// return collectedData - /// } - /// ``` - consuming func consumeAndConclude( - body: (consuming sending Underlying) async throws(Failure) -> Return - ) async throws(Failure) -> (Return, FinalElement) -} diff --git a/Sources/HTTPAPIs/ConcludingAsyncWriter.swift b/Sources/HTTPAPIs/ConcludingAsyncWriter.swift deleted file mode 100644 index c08d28f..0000000 --- a/Sources/HTTPAPIs/ConcludingAsyncWriter.swift +++ /dev/null @@ -1,146 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift HTTP API Proposal open source project -// -// Copyright (c) 2025 Apple Inc. and the Swift HTTP API Proposal project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -/// A protocol that represents an asynchronous writer that produces a final value upon completion. -/// -/// ``ConcludingAsyncWriter`` adds functionality to asynchronous writers that need to -/// provide a conclusive element after writing completes. This is particularly useful -/// for streams that have meaningful completion states, such as HTTP responses that need -/// to finalize with optional trailers. -@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) -public protocol ConcludingAsyncWriter: ~Copyable, ~Escapable { - /// The underlying asynchronous writer type. - associatedtype Underlying: AsyncWriter, ~Copyable, ~Escapable - - /// The type of the final element produced after writing completes. - associatedtype FinalElement - - /// Allows writing to the underlying async writer and produces a final element upon completion. - /// - /// - Parameter body: A closure that takes the underlying writer and returns both a value and a final element. - /// - Returns: The value returned by the body closure. - /// - Throws: Any error thrown by the body closure or encountered while writing. - /// - /// - Note: This method consumes the concluding async writer, meaning it can only be called once on a value type. - /// - /// ```swift - /// let responseWriter: HTTPResponseWriter = ... - /// - /// // Write the response body and produce a final status - /// let result = try await responseWriter.produceAndConclude { writer in - /// try await writer.write(data) - /// return (true, trailers) - /// } - /// ``` - consuming func produceAndConclude( - body: (consuming sending Underlying) async throws -> (Return, FinalElement) - ) async throws -> Return -} - -@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) -extension ConcludingAsyncWriter where Self: ~Copyable, Underlying: ~Copyable { - /// Produces a final element using the underlying async writer without returning a separate value. - /// - /// This is a convenience method for cases where you only need to produce a final element - /// and don't need to return any other value from the operation. It simplifies the interface - /// when the primary goal is to generate the concluding element. - /// - /// - Parameter body: A closure that takes the underlying writer and returns a final element. - /// - /// - Throws: Any error thrown by the body closure or encountered while writing. - /// - /// ```swift - /// let logWriter: LogConcludingWriter = ... - /// - /// // Write log entries and produce final statistics - /// try await logWriter.produceAndConclude { writer in - /// for entry in logEntries { - /// try await writer.write(entry) - /// } - /// return LogStatistics(entriesWritten: logEntries.count) - /// } - /// ``` - public consuming func produceAndConclude( - body: (consuming sending Underlying) async throws -> FinalElement - ) async throws { - try await self.produceAndConclude { writer in - ((), try await body(writer)) - } - } -} - -@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) -extension ConcludingAsyncWriter where Self: ~Copyable, Underlying: ~Copyable { - /// Writes a single element to the underlying writer and concludes with a final element. - /// - /// This is a convenience method for simple scenarios where you need to write exactly one - /// element and then conclude the writing operation with a final element. It provides a - /// streamlined interface for single-write operations. - /// - /// - Parameter element: The element to write to the underlying writer. - /// - Parameter finalElement: The final element to produce after writing is complete. - /// - /// - Throws: Any error encountered while writing the element or during the concluding operation. - /// - /// ```swift - /// let responseWriter: HTTPResponseWriter = ... - /// - /// // Write a single response chunk and conclude with headers - /// try await responseWriter.writeAndConclude( - /// element: responseData, - /// finalElement: responseHeaders - /// ) - /// ``` - public consuming func writeAndConclude( - _ element: consuming Underlying.WriteElement, - finalElement: FinalElement - ) async throws { - var element = Optional.some(element) - try await self.produceAndConclude { writer in - var writer = writer - try await writer.write(element.take()!) - return finalElement - } - } - - /// Writes a span of elements to the underlying writer and concludes with a final element. - /// - /// This is a convenience method for scenarios where you need to write multiple elements - /// from a span and then conclude the writing operation with a final element. It provides a - /// streamlined interface for batch write operations. - /// - /// - Parameter span: The span of elements to write to the underlying writer. - /// - Parameter finalElement: The final element to produce after writing is complete. - /// - /// - Throws: Any error encountered while writing the elements or during the concluding operation. - /// - /// ```swift - /// let responseWriter: HTTPResponseWriter = ... - /// - /// // Write multiple response chunks and conclude with headers - /// try await responseWriter.writeAndConclude( - /// dataSpan, - /// finalElement: responseHeaders - /// ) - /// ``` - public consuming func writeAndConclude( - _ span: consuming Span, - finalElement: FinalElement - ) async throws where Underlying.WriteElement: Copyable { - try await self.produceAndConclude { writer in - var writer = writer - try await writer.write(span) - return finalElement - } - } -} diff --git a/Sources/HTTPAPIs/HTTPBodyReader.swift b/Sources/HTTPAPIs/HTTPBodyReader.swift new file mode 100644 index 0000000..5480c23 --- /dev/null +++ b/Sources/HTTPAPIs/HTTPBodyReader.swift @@ -0,0 +1,181 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP API Proposal open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP API Proposal project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import BasicContainers + +/// A reader that streams HTTP body bytes and signals end-of-body by delivering +/// trailing fields together with the last body chunk. +/// +/// Refines ``AsyncReader`` for byte streams (``ReadElement`` is `UInt8`) by +/// adding a `read` overload whose closure receives an additional +/// ``HTTPFields`` argument. A non-`nil` value in that argument marks the +/// chunk as the last one and carries any trailing fields (which themselves +/// may be empty). Callers that don't care about trailers can use the +/// inherited ``AsyncReader/read(body:)`` overload, which silently drops the +/// trailers. +/// +/// Conformers must, after delivering a chunk with non-`nil` trailers, +/// continue to accept further `read(...)` calls and return an empty +/// buffer with `nil` trailers. This keeps callers that drive the reader via +/// the inherited ``AsyncReader/read(body:)`` overload (which loops until +/// they see an empty buffer) terminating correctly. +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +public protocol HTTPBodyReader: AsyncReader, ~Copyable, ~Escapable +where ReadElement == UInt8 { + /// Reads the next body chunk and signals end-of-body via `trailers`. + /// + /// - Parameter body: A closure that receives the body chunk (as an `inout` + /// buffer) together with the trailing fields, if any. A `nil` value for + /// trailers means more body bytes may follow. A non-`nil` value + /// (possibly empty) marks this chunk as the last one. + /// - Returns: The value the body closure returns. + /// - Throws: An ``EitherError`` carrying either the underlying read + /// failure or the failure thrown by `body`. + mutating func read( + body: (inout Buffer, HTTPFields?) async throws(Failure) -> Return + ) async throws(EitherError) -> Return +} + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension HTTPBodyReader where Self: ~Copyable { + /// Satisfies the ``AsyncReader`` requirement by forwarding to the + /// trailers-aware `read` and silently discarding any trailers. + public mutating func read( + body: (inout Buffer) async throws(Failure) -> Return + ) async throws(EitherError) -> Return { + try await self.read { (buf: inout Buffer, _: HTTPFields?) async throws(Failure) -> Return in + try await body(&buf) + } + } + + /// Streams every body chunk from this reader into `writer`, fusing the + /// last chunk with the trailers and FIN signal in a single + /// ``HTTPBodyWriter/finish(body:)`` call. No intermediate copy is made. + /// + /// Use this when forwarding a request body straight into a response + /// (echo, proxy) or any other reader-into-writer pipe where you want the + /// transport to see one fused write at the end of the body. + /// + /// - Parameter writer: The body writer to pipe into. Consumed. + // TODO: This moves a full reader chunk into the writer's buffer in a + // single `write` call. Today every conformer uses an unbounded + // `UniqueArray` so this works, but if a future writer ships a + // fixed-capacity buffer per `write` we'd silently truncate. Either + // document the capacity contract on `AsyncWriter` so we can rely on it, + // or loop here on `wbuf.freeCapacity` and split the source chunk across + // multiple writes. + public consuming func pipe( + into writer: consuming W + ) async throws where W.Buffer == Self.Buffer { + var reader = self + var writerOpt: W? = .some(writer) + var done = false + while !done { + try await reader.read { rbuf, trailers in + if let trailers { + let w = writerOpt.take()! + try await w.finish { wbuf in + wbuf.append( + moving: rbuf.startIndex..(minimumCapacity: limit)` (or + /// equivalent) to control how many bytes are kept; any bytes the reader + /// produces beyond what fits are read and discarded. + /// + /// > Important: A default-constructed `UniqueArray()` has free + /// > capacity zero, which causes this method to discard the entire body + /// > without storing anything. If you want to read the whole body, use + /// > ``collect(upTo:body:)`` (which collects up to a caller-supplied + /// > limit) or call ``read(body:)`` directly in a loop. + /// + /// - Parameter buffer: The destination container that receives the collected bytes. + /// - Returns: The HTTP trailing fields, if any were sent. + public consuming func collect & ~Copyable>( + into buffer: inout Buffer + ) async throws -> HTTPFields? { + var reader = self + var trailers: HTTPFields? = nil + var done = false + while !done { + try await reader.read { (readBuffer: inout Self.Buffer, t: HTTPFields?) in + if let t { + trailers = t.isEmpty ? nil : t + done = true + } + if readBuffer.count == 0 { + if t == nil { + done = true + } + return + } + let remaining = buffer.freeCapacity + if readBuffer.count <= remaining { + buffer.append( + moving: readBuffer.startIndex..( + upTo limit: Int, + body: (consuming InputSpan) async throws -> Result + ) async throws -> (Result, HTTPFields?) { + var accumulated = UniqueArray(minimumCapacity: limit) + let trailers = try await self.collect(into: &accumulated) + var consumer = accumulated.consumeAll() + let result = try await body(consumer.drainNext()) + return (result, trailers) + } +} diff --git a/Sources/HTTPAPIs/HTTPBodyWriter.swift b/Sources/HTTPAPIs/HTTPBodyWriter.swift new file mode 100644 index 0000000..f0f0b81 --- /dev/null +++ b/Sources/HTTPAPIs/HTTPBodyWriter.swift @@ -0,0 +1,98 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP API Proposal open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP API Proposal project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import BasicContainers + +/// A writer that streams HTTP body bytes and is terminated with a single +/// `finish` call carrying the optional last body chunk and trailing fields. +/// +/// Refines ``AsyncWriter`` for byte streams (``WriteElement`` is `UInt8`) by +/// adding a consuming `finish` that signals end-of-body. The `finish` call +/// communicates *both* the final body chunk (if any) and the trailing +/// ``HTTPFields`` (if any) in one operation, so implementations can fuse the +/// last DATA frame with the END_STREAM signal on transports that support it +/// (HTTP/2, HTTP/3, QUIC). +/// +/// Conformers must accept zero, one, or many `write(...)` calls followed by +/// exactly one `finish(...)` call. After `finish` returns, the writer is +/// consumed and no further calls are valid. +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +public protocol HTTPBodyWriter: AsyncWriter, ~Copyable, ~Escapable +where WriteElement == UInt8 { + /// Sends the final body chunk and trailing fields, and signals end-of-body + /// to the underlying transport. + /// + /// The `body` closure receives an `inout Buffer` to fill with the final + /// chunk's bytes, and returns the trailing ``HTTPFields`` to send after + /// it. Either may be empty: + /// + /// - Leave the buffer empty if there is no remaining body content to emit + /// alongside the terminator. + /// - Return `nil` from the closure to send no trailers; return a (possibly + /// empty) `HTTPFields` to send trailers. + /// + /// Returning trailers from the closure (rather than passing them as a + /// separate parameter) lets the closure compute trailers based on the + /// bytes it just wrote — for example a checksum trailer over the body + /// content — without needing a scratch buffer. + /// + /// - Parameter body: A closure that fills the buffer with the final body + /// bytes and returns the trailing fields, if any. + /// - Throws: An ``EitherError`` carrying either the underlying write + /// failure or the failure thrown by `body`. + consuming func finish( + body: (inout Buffer) async throws(Failure) -> HTTPFields? + ) async throws(EitherError) +} + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension HTTPBodyWriter where Self: ~Copyable, Self: ~Escapable { + /// Concludes the body with no final chunk and the given trailers (if any). + public consuming func finish(trailers: HTTPFields? = nil) async throws(WriteFailure) { + do { + try await self.finish { (_: inout Buffer) async throws(Never) -> HTTPFields? in + return trailers + } + } catch { + switch error { + case .first(let e): throw e + case .second: fatalError() + } + } + } + + /// Concludes the body by copying the contents of `buffer` into the final + /// chunk (fused with the terminator and any trailers). + /// + /// `buffer` is read but not drained; the caller retains its contents. + /// + /// - Parameters: + /// - buffer: The source container whose bytes form the final chunk. + /// - trailers: The trailing fields to send with the terminator, if any. + public consuming func finish & ~Copyable>( + copying buffer: inout B, + trailers: HTTPFields? = nil + ) async throws(WriteFailure) { + do { + try await self.finish { (writerBuffer: inout Buffer) async throws(Never) -> HTTPFields? in + writerBuffer.append(copying: buffer) + return trailers + } + } catch { + switch error { + case .first(let e): throw e + case .second: fatalError() + } + } + } +} diff --git a/Sources/HTTPAPIs/Server/HTTPResponseSender.swift b/Sources/HTTPAPIs/Server/HTTPResponseSender.swift new file mode 100644 index 0000000..0130df9 --- /dev/null +++ b/Sources/HTTPAPIs/Server/HTTPResponseSender.swift @@ -0,0 +1,93 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP API Proposal open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP API Proposal project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import BasicContainers + +/// A protocol for sending an HTTP response, including the head, body, and trailing fields. +/// +/// ``HTTPResponseSender`` is used on the server side to send exactly one +/// non-informational response per request. Conformers may also send any number +/// of informational (1xx) responses before the final response by calling +/// ``sendInformational(_:)``. +/// +/// ``send(_:)`` writes the response head and returns an ``HTTPBodyWriter`` for +/// streaming the body. The caller is responsible for terminating the body via +/// ``HTTPBodyWriter/finish(body:)``. +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +public protocol HTTPResponseSender: ~Copyable, ~Escapable { + /// The body writer type used to stream response body bytes and signal end-of-body. + associatedtype Writer: HTTPBodyWriter, ~Copyable, ~Escapable + + /// Sends an informational HTTP response. + /// + /// This method may be called any number of times before the final response is sent + /// with ``send(_:)``. Common informational responses include 100 Continue, + /// 102 Processing, and 103 Early Hints. + /// + /// - Parameter response: An informational HTTP response. Must have a 1xx status. + func sendInformational(_ response: HTTPResponse) async throws + + /// Sends the final HTTP response head and returns a body writer. + /// + /// The caller takes ownership of the writer and must terminate it by + /// calling ``HTTPBodyWriter/finish(body:)`` exactly once before + /// dropping it. The writer's lifetime is bounded by the enclosing server + /// request handler scope. Dropping the writer without calling `finish` + /// causes the response to be aborted when the handler scope exits. + /// + /// - Parameter response: The final HTTP response head. Must not be informational (1xx). + /// - Returns: A body writer for streaming the response body. + /// - Throws: Any error encountered while writing the response head. + /// + /// - Note: This method consumes the sender, ensuring exactly one final response is sent. + @_lifetime(copy self) + consuming func send(_ response: HTTPResponse) async throws -> Writer +} + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension HTTPResponseSender where Self: ~Copyable, Writer: ~Copyable { + /// Sends the response head, the contents of `body`, and optional trailing fields in one call. + public consuming func send( + _ response: HTTPResponse, + body: (inout Writer.Buffer) async throws -> HTTPFields? + ) async throws { + let writer = try await self.send(response) + try await writer.finish(body: body) + } + + /// Sends the response head, the contents of `body`, and optional trailing fields in one call. + public consuming func send & ~Copyable>( + _ response: HTTPResponse, + copying buffer: inout Buffer, + trailers: HTTPFields? = nil + ) async throws { + let writer = try await self.send(response) + + try await writer.finish { writerBuffer in + writerBuffer.append(copying: buffer) + return trailers + } + } + + /// Sends the response head and trailing fields with no body. + public consuming func send(_ response: HTTPResponse, trailers: HTTPFields?) async throws { + let writer = try await self.send(response) + try await writer.finish(trailers: trailers) + } + + /// Sends the response head with no body and no trailing fields. + public consuming func send(_ response: HTTPResponse) async throws { + let writer = try await self.send(response) + try await writer.finish(trailers: nil) + } +} diff --git a/Sources/HTTPAPIs/Server/HTTPServer.swift b/Sources/HTTPAPIs/Server/HTTPServer.swift index 0679a2a..9566431 100644 --- a/Sources/HTTPAPIs/Server/HTTPServer.swift +++ b/Sources/HTTPAPIs/Server/HTTPServer.swift @@ -11,49 +11,41 @@ // //===----------------------------------------------------------------------===// -@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) /// A protocol that defines the interface for an HTTP server. /// /// ``HTTPServer`` provides the contract for server implementations that accept -/// incoming HTTP connections and process requests using a ``HTTPServerRequestHandler``. -public protocol HTTPServer: Sendable, ~Copyable, ~Escapable { - /// The type used to read request body data and trailers. - // TODO: Check if we should allow ~Escapable readers https://github.com/apple/swift-http-api-proposal/issues/13 - associatedtype RequestConcludingReader: ConcludingAsyncReader, ~Copyable, SendableMetatype - where - RequestConcludingReader.Underlying: ~Copyable, - RequestConcludingReader.Underlying.ReadElement == UInt8, - RequestConcludingReader.FinalElement == HTTPFields? +/// incoming HTTP connections and process requests using a +/// ``HTTPServerRequestHandler``. The body reader and response sender types are +/// surfaced directly; there are no separate "request receiver" wrapper types. +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +public protocol HTTPServer: Sendable, ~Copyable, ~Escapable { + /// The body reader type used to stream request body bytes and trailers. + associatedtype Reader: HTTPBodyReader, ~Copyable, SendableMetatype - /// The type used to write response body data and trailers. - // TODO: Check if we should allow ~Escapable writers https://github.com/apple/swift-http-api-proposal/issues/13 - associatedtype ResponseConcludingWriter: ConcludingAsyncWriter, ~Copyable, SendableMetatype - where - ResponseConcludingWriter.Underlying: ~Copyable, - ResponseConcludingWriter.Underlying.WriteElement == UInt8, - ResponseConcludingWriter.FinalElement == HTTPFields? + /// The type used to write response head, body, and trailing fields. + associatedtype ResponseSender: HTTPResponseSender, ~Copyable, SendableMetatype + where ResponseSender.Writer: ~Copyable /// Starts an HTTP server with the specified request handler. /// - /// This method creates and runs an HTTP server that processes incoming requests using the provided - /// ``HTTPServerRequestHandler`` implementation. - /// - /// Implementations of this method should handle each connection concurrently using Swift's structured concurrency. - /// /// - Parameters: - /// - handler: A ``HTTPServerRequestHandler`` implementation that processes incoming HTTP requests. The handler - /// receives each request along with a body reader and ``HTTPResponseSender``. + /// - handler: A ``HTTPServerRequestHandler`` implementation that + /// processes incoming HTTP requests. The handler receives each + /// request along with a request body reader and an + /// ``HTTPResponseSender``. /// /// ## Example /// /// ```swift - /// let server = // create an instance of a type conforming to the `ServerProtocol` - /// try await server.serve(handler: YourRequestHandler()) + /// try await server.serve { request, _, reader, responseSender in + /// let writer = try await responseSender.send(.init(status: .ok)) + /// try await reader.pipe(to: writer) + /// } /// ``` func serve(handler: Handler) async throws where - Handler.RequestReader == RequestConcludingReader, - Handler.RequestReader: ~Copyable, - Handler.ResponseWriter == ResponseConcludingWriter, - Handler.ResponseWriter: ~Copyable + Handler.Reader == Reader, + Handler.Reader: ~Copyable, + Handler.ResponseSender == ResponseSender, + Handler.ResponseSender: ~Copyable } diff --git a/Sources/HTTPAPIs/Server/HTTPServerClosureRequestHandler.swift b/Sources/HTTPAPIs/Server/HTTPServerClosureRequestHandler.swift index dbe5e44..779a053 100644 --- a/Sources/HTTPAPIs/Server/HTTPServerClosureRequestHandler.swift +++ b/Sources/HTTPAPIs/Server/HTTPServerClosureRequestHandler.swift @@ -13,82 +13,49 @@ /// A closure-based implementation of ``HTTPServerRequestHandler``. /// -/// ``HTTPServerClosureRequestHandler`` provides a convenient way to create an HTTP request handler -/// using a closure instead of conforming a custom type to the ``HTTPServerRequestHandler`` protocol. -/// This is useful for simple handlers or when you need to create handlers dynamically. -/// /// - Example: /// ```swift -/// let echoHandler = HTTPServerClosureRequestHandler { request, context, bodyReader, responseSender in -/// // Read the entire request body -/// let (bodyData, _) = try await bodyReader.consumeAndConclude { reader in -/// // ... body reading code ... -/// } -/// -/// // Create and send response -/// var response = HTTPResponse(status: .ok) -/// let responseWriter = try await responseSender.send(response) -/// try await responseWriter.produceAndConclude { writer in -/// try await writer.write(bodyData.span) -/// return ((), nil) -/// } +/// let echoHandler = HTTPServerClosureRequestHandler { request, _, reader, responseSender in +/// let writer = try await responseSender.send(.init(status: .ok)) +/// try await reader.pipe(to: writer) /// } /// ``` @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) public struct HTTPServerClosureRequestHandler< - RequestReader: ConcludingAsyncReader & ~Copyable, - ResponseWriter: ConcludingAsyncWriter & ~Copyable, + Reader: HTTPBodyReader & ~Copyable, + ResponseSender: HTTPResponseSender & ~Copyable, >: HTTPServerRequestHandler -where - RequestReader.Underlying: ~Copyable, - ResponseWriter.Underlying: ~Copyable, - RequestReader.Underlying.ReadElement == UInt8, - ResponseWriter.Underlying.WriteElement == UInt8, - RequestReader.FinalElement == HTTPFields?, - ResponseWriter.FinalElement == HTTPFields? -{ +where ResponseSender.Writer: ~Copyable { /// The underlying closure that handles HTTP requests. private let _handler: @Sendable ( HTTPRequest, HTTPRequestContext, - consuming sending RequestReader, - consuming sending HTTPResponseSender + consuming sending Reader, + consuming sending ResponseSender ) async throws -> Void /// Creates a new closure-based HTTP request handler. - /// - /// - Parameter handler: A closure that will be called to handle each incoming HTTP request. - /// The closure takes the same parameters as the - /// ``HTTPServerRequestHandler/handle(request:requestContext:requestBodyAndTrailers:responseSender:)`` method. public init( handler: @Sendable @escaping ( HTTPRequest, HTTPRequestContext, - consuming sending RequestReader, - consuming sending HTTPResponseSender + consuming sending Reader, + consuming sending ResponseSender ) async throws -> Void ) { self._handler = handler } /// Handles an incoming HTTP request by delegating to the closure provided at initialization. - /// - /// This method simply forwards all parameters to the handler closure. - /// - /// - Parameters: - /// - request: The HTTP request headers and metadata. - /// - requestContext: A ``HTTPRequestContext``. - /// - requestBodyAndTrailers: A reader for accessing the request body data and trailing headers. - /// - responseSender: An ``HTTPResponseSender`` to send the HTTP response. public func handle( request: HTTPRequest, requestContext: HTTPRequestContext, - requestBodyAndTrailers: consuming sending RequestReader, - responseSender: consuming sending HTTPResponseSender + reader: consuming sending Reader, + responseSender: consuming sending ResponseSender ) async throws { - try await self._handler(request, requestContext, requestBodyAndTrailers, responseSender) + try await self._handler(request, requestContext, reader, responseSender) } } @@ -97,33 +64,17 @@ extension HTTPServer where Self: ~Copyable, Self: ~Escapable, - RequestConcludingReader: ~Copyable, - RequestConcludingReader.Underlying: ~Copyable, - ResponseConcludingWriter: ~Copyable, - ResponseConcludingWriter.Underlying: ~Copyable + Reader: ~Copyable, + ResponseSender: ~Copyable, + ResponseSender.Writer: ~Copyable { /// Starts an HTTP server with a closure-based request handler. /// - /// This method provides a convenient way to start an HTTP server using a closure to handle incoming requests. - /// - /// - Parameters: - /// - handler: An async closure that processes HTTP requests. The closure receives: - /// - `HTTPRequest`: The incoming HTTP request with headers and metadata. - /// - ``HTTPRequestContext``: The request's context. - /// - ``HTTPRequestConcludingAsyncReader``: An async reader for consuming the request body and trailers. - /// - ``HTTPResponseSender``: A non-copyable wrapper for a function that accepts an `HTTPResponse` and provides access to an ``HTTPResponseConcludingAsyncWriter``. - /// /// ## Example /// /// ```swift - /// try await server.serve { request, bodyReader, responseSender in - /// // Process the request - /// let response = HTTPResponse(status: .ok) - /// let writer = try await responseSender.send(response) - /// try await writer.produceAndConclude { writer in - /// try await writer.write("Hello, World!".utf8) - /// return ((), nil) - /// } + /// try await server.serve { request, _, reader, responseSender in + /// try await responseSender.send(.init(status: .ok), body: "Hello, World!".utf8.span) /// } /// ``` public func serve( @@ -131,8 +82,8 @@ where @Sendable @escaping ( _ request: HTTPRequest, _ requestContext: HTTPRequestContext, - _ requestBodyAndTrailers: consuming sending RequestConcludingReader, - _ responseSender: consuming sending HTTPResponseSender + _ reader: consuming sending Reader, + _ responseSender: consuming sending ResponseSender ) async throws -> Void ) async throws { try await self.serve( diff --git a/Sources/HTTPAPIs/Server/HTTPServerRequestHandler.swift b/Sources/HTTPAPIs/Server/HTTPServerRequestHandler.swift index 28e3c92..dae3e6b 100644 --- a/Sources/HTTPAPIs/Server/HTTPServerRequestHandler.swift +++ b/Sources/HTTPAPIs/Server/HTTPServerRequestHandler.swift @@ -13,79 +13,66 @@ /// A protocol that defines the contract for handling HTTP server requests. /// -/// ``HTTPServerRequestHandler`` provides a structured way to process incoming HTTP requests -/// and generate appropriate responses. Conforming types implement the -/// ``handle(request:requestContext:requestBodyAndTrailers:responseSender:)`` method, which is -/// called by the HTTP server for each incoming request. The handler is responsible for reading -/// the request body, processing the request, and sending a response. +/// ``HTTPServerRequestHandler`` provides a structured way to process incoming +/// HTTP requests and generate appropriate responses. Conforming types +/// implement ``handle(request:requestContext:reader:responseSender:)``, which +/// is called by the HTTP server for each incoming request. The handler is +/// responsible for reading the request body, processing the request, and +/// sending a response. /// -/// This protocol fully supports bidirectional streaming HTTP request handling, including -/// optional request and response trailers. +/// This protocol fully supports bidirectional streaming HTTP request +/// handling, including optional request and response trailers. /// /// # Example /// /// ```swift /// struct EchoHandler< -/// ConcludingRequestReader: ConcludingAsyncReader & ~Copyable, -/// RequestReader: AsyncReader & ~Copyable, -/// ConcludingResponseWriter: ConcludingAsyncWriter & ~Copyable, -/// ResponseWriter: AsyncWriter & ~Copyable -/// >: HTTPServerRequestHandler { +/// Reader: HTTPBodyReader & ~Copyable, +/// ResponseSender: HTTPResponseSender & ~Copyable +/// >: HTTPServerRequestHandler +/// where ResponseSender.Writer: ~Copyable, Reader.Buffer == ResponseSender.Writer.Buffer { /// func handle( /// request: HTTPRequest, /// requestContext: HTTPRequestContext, -/// requestBodyAndTrailers: consuming sending ConcludingRequestReader, -/// responseSender: consuming sending HTTPResponseSender +/// reader: consuming sending Reader, +/// responseSender: consuming sending ResponseSender /// ) async throws { -/// var responseSender: HTTPResponseSender? = responseSender -/// _ = try await requestBodyAndTrailers.consumeAndConclude { reader in -/// var reader: RequestReader? = reader -/// let responseBodyAndTrailers = try await responseSender.take()!.send( -/// .init(status: .ok) -/// ) -/// try await responseBodyAndTrailers.produceAndConclude { writer in -/// var writer = writer -/// try await reader.take()!.forEach { span in -/// try await writer.write(span) -/// } -/// return ((), nil) -/// } -/// } +/// let writer = try await responseSender.send(.init(status: .ok)) +/// try await reader.pipe(to: writer) /// } /// } /// ``` @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) -public protocol HTTPServerRequestHandler: Sendable { - /// The type used to read request body data and trailers. - associatedtype RequestReader: ConcludingAsyncReader, ~Copyable - where RequestReader.Underlying: ~Copyable, RequestReader.Underlying.ReadElement == UInt8, RequestReader.FinalElement == HTTPFields? +public protocol HTTPServerRequestHandler: Sendable { + /// The body reader type used to stream request body bytes and trailers. + associatedtype Reader: HTTPBodyReader, ~Copyable - /// The type used to write response body data and trailers. - associatedtype ResponseWriter: ConcludingAsyncWriter, ~Copyable - where ResponseWriter.Underlying: ~Copyable, ResponseWriter.Underlying.WriteElement == UInt8, ResponseWriter.FinalElement == HTTPFields? + /// The type used to write response head, body, and trailing fields. + associatedtype ResponseSender: HTTPResponseSender, ~Copyable + where ResponseSender.Writer: ~Copyable /// Handles an incoming HTTP request and generates a response. /// - /// The HTTP server calls this method for each incoming client request. Implementations should: + /// The HTTP server calls this method for each incoming client request. + /// Implementations should: /// 1. Examine the request headers in the `request` parameter. - /// 2. Read the request body data from the `requestBodyAndTrailers` reader as needed. + /// 2. Read the request body data from `reader` as needed. /// 3. Process the request and prepare a response. /// 4. Optionally call ``HTTPResponseSender/sendInformational(_:)`` for informational responses. - /// 5. Call ``HTTPResponseSender/send(_:)`` with the final HTTP response. - /// 6. Write the response body data to the returned writer. + /// 5. Call ``HTTPResponseSender/send(_:)`` (or one of its convenience overloads) to + /// send the response head, body, and trailing fields. /// /// - Parameters: /// - request: The HTTP request headers and metadata. /// - requestContext: A ``HTTPRequestContext`` carrying additional request information. - /// - requestBodyAndTrailers: A reader for accessing the request body data and trailing headers. - /// - responseSender: An ``HTTPResponseSender`` that accepts an HTTP response and returns a writer for the - /// response body. The returned writer allows for incremental writing of the response body and supports trailers. + /// - reader: A body reader for the request body and trailing fields. + /// - responseSender: An ``HTTPResponseSender`` for sending the response head, body, and trailing fields. /// /// - Throws: Any error encountered during request processing or response generation. func handle( request: HTTPRequest, requestContext: HTTPRequestContext, - requestBodyAndTrailers: consuming sending RequestReader, - responseSender: consuming sending HTTPResponseSender + reader: consuming sending Reader, + responseSender: consuming sending ResponseSender ) async throws } diff --git a/Sources/HTTPAPIs/Server/HTTPServerResponseSender.swift b/Sources/HTTPAPIs/Server/HTTPServerResponseSender.swift deleted file mode 100644 index 7c4289b..0000000 --- a/Sources/HTTPAPIs/Server/HTTPServerResponseSender.swift +++ /dev/null @@ -1,73 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift HTTP API Proposal open source project -// -// Copyright (c) 2025 Apple Inc. and the Swift HTTP API Proposal project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -/// A struct that sends exactly one non-informational HTTP response per request. -/// -/// ``HTTPResponseSender`` enforces structured response handling by allowing only one call to -/// ``send(_:)`` before consuming the sender. You can send informational responses zero or -/// more times using ``sendInformational(_:)`` before sending the final response. This design -/// enforces proper HTTP semantics: exactly one non-informational response, followed by -/// optional response body streaming and trailers. -@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) -public struct HTTPResponseSender: ~Copyable where ResponseWriter.Underlying: ~Copyable { - private let _sendInformational: (HTTPResponse) async throws -> Void - private let _send: (HTTPResponse) async throws -> ResponseWriter - - /// Creates a new HTTP response sender. - /// - /// - Parameters: - /// - send: A closure that sends the final HTTP response and returns a writer for the response body. - /// - sendInformational: A closure that sends informational (1xx) HTTP responses. - public init( - send: @escaping (HTTPResponse) async throws -> ResponseWriter, - sendInformational: @escaping (HTTPResponse) async throws -> Void - ) { - self._send = send - self._sendInformational = sendInformational - } - - /// Sends the final HTTP response and returns a writer for the response body. - /// - /// This method consumes the sender, ensuring only one non-informational response can be sent. - /// After calling this method, the sender cannot be used again. For informational (1xx) responses, - /// use ``sendInformational(_:)`` instead. - /// - /// - Parameter response: The final HTTP response to send to the client. Must not be an - /// informational (1xx) response. - /// - /// - Returns: A writer for streaming the response body data and optional trailers. - /// - /// - Throws: An error if sending the response fails. - consuming public func send(_ response: HTTPResponse) async throws -> ResponseWriter { - precondition(response.status.kind != .informational) - return try await self._send(response) - } - - /// Sends an informational HTTP response. - /// - /// This method can be called multiple times to send informational (1xx) responses before - /// sending the final response with ``send(_:)``. Common informational responses include - /// 100 Continue, 102 Processing, and 103 Early Hints. - /// - /// - Parameter response: An informational HTTP response to send to the client. Must be a - /// 1xx status response. - /// - /// - Throws: An error if sending the informational response fails. - public func sendInformational(_ response: HTTPResponse) async throws { - precondition(response.status.kind == .informational) - return try await _sendInformational(response) - } -} - -@available(*, unavailable) -extension HTTPResponseSender: Sendable {} diff --git a/Sources/HTTPClient/DefaultHTTPClient.swift b/Sources/HTTPClient/DefaultHTTPClient.swift index 1647631..48b9468 100644 --- a/Sources/HTTPClient/DefaultHTTPClient.swift +++ b/Sources/HTTPClient/DefaultHTTPClient.swift @@ -16,19 +16,19 @@ @_exported public import HTTPAPIs #if canImport(Darwin) || os(Linux) -public import BasicContainers +import BasicContainers #if canImport(Darwin) -import URLSessionHTTPClient +public import URLSessionHTTPClient @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) -typealias ActualHTTPClient = URLSessionHTTPClient +public typealias ActualHTTPClient = URLSessionHTTPClient #else -import AsyncHTTPClient -import AHCHTTPClient +public import AsyncHTTPClient +public import AHCHTTPClient @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) -typealias ActualHTTPClient = AsyncHTTPClient.HTTPClient +public typealias ActualHTTPClient = AsyncHTTPClient.HTTPClient #endif /// The default HTTP client that manages persistent connections to HTTP servers. @@ -38,45 +38,8 @@ typealias ActualHTTPClient = AsyncHTTPClient.HTTPClient /// automatically handling connection management, protocol negotiation, and resource cleanup. @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) public final class DefaultHTTPClient: HTTPAPIs.HTTPClient { - public struct RequestWriter: AsyncWriter, ~Copyable { - public typealias WriteElement = UInt8 - public typealias WriteFailure = any Error - public typealias Buffer = UniqueArray - - public mutating func write( - _ body: (inout UniqueArray) async throws(Failure) -> Return - ) async throws(AsyncStreaming.EitherError) -> Return where Failure: Error { - try await self.actual.write(body) - } - - var actual: ActualHTTPClient.RequestWriter - } - - public struct ResponseConcludingReader: ConcludingAsyncReader, ~Copyable { - public struct Underlying: AsyncReader, ~Copyable { - public typealias ReadElement = UInt8 - public typealias ReadFailure = any Error - public typealias Buffer = UniqueArray - - public mutating func read( - body: (inout UniqueArray) async throws(Failure) -> Return - ) async throws(AsyncStreaming.EitherError) -> Return where Failure: Error { - try await self.actual.read(body: body) - } - - var actual: ActualHTTPClient.ResponseConcludingReader.Underlying - } - - public func consumeAndConclude( - body: (consuming sending Underlying) async throws(Failure) -> Return - ) async throws(Failure) -> (Return, HTTPFields?) where Failure: Error { - try await self.actual.consumeAndConclude { actual throws(Failure) in - try await body(Underlying(actual: actual)) - } - } - - let actual: ActualHTTPClient.ResponseConcludingReader - } + public typealias Writer = ActualHTTPClient.Writer + public typealias Reader = ActualHTTPClient.Reader /// A shared connection pool instance with default configuration. public static var shared: DefaultHTTPClient { @@ -84,16 +47,6 @@ public final class DefaultHTTPClient: HTTPAPIs.HTTPClient { } /// Creates a client with custom pool configuration and executes a closure with it. - /// - /// This method provides a scoped way to use a custom-configured connection pool. - /// The pool is automatically cleaned up after the closure completes. - /// - /// - Parameters: - /// - poolConfiguration: The configuration to use for the connection pool. - /// - body: A closure that receives the configured connection pool and performs - /// HTTP operations with it. - /// - Returns: The value returned by the `body` closure. - /// - Throws: Any error thrown by the `body` closure. public static func withClient( poolConfiguration: HTTPConnectionPoolConfiguration, body: (borrowing DefaultHTTPClient) async throws(Failure) -> Return @@ -135,18 +88,13 @@ public final class DefaultHTTPClient: HTTPAPIs.HTTPClient { public func perform( request: HTTPRequest, - body: consuming HTTPClientRequestBody?, + body: consuming HTTPClientRequestBody?, options: HTTPRequestOptions, - responseHandler: (HTTPResponse, consuming ResponseConcludingReader) async throws -> Return + responseHandler: (HTTPResponse, consuming Reader) async throws -> Return ) async throws -> Return { // TODO: translate request options let options = self.client.defaultRequestOptions - let body = body.map { - HTTPClientRequestBody(other: $0) { RequestWriter(actual: $0) } - } - return try await self.client.perform(request: request, body: body, options: options) { response, body in - try await responseHandler(response, ResponseConcludingReader(actual: body)) - } + return try await self.client.perform(request: request, body: body, options: options, responseHandler: responseHandler) } } diff --git a/Sources/HTTPClient/HTTP+Conveniences.swift b/Sources/HTTPClient/HTTP+Conveniences.swift index e01a8c8..4464a8e 100644 --- a/Sources/HTTPClient/HTTP+Conveniences.swift +++ b/Sources/HTTPClient/HTTP+Conveniences.swift @@ -39,12 +39,12 @@ extension HTTP { @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) public static func perform( request: HTTPRequest, - body: consuming HTTPClientRequestBody? = nil, + body: consuming HTTPClientRequestBody? = nil, options: HTTPRequestOptions = .init(), on client: DefaultHTTPClient = .shared, - responseHandler: (HTTPResponse, consuming DefaultHTTPClient.ResponseConcludingReader) async throws -> Return, + responseHandler: (HTTPResponse, consuming DefaultHTTPClient.Reader) async throws -> Return, ) async throws -> Return { - try await client.perform(request: request, body: body, options: options, responseHandler: responseHandler) + return try await client.perform(request: request, body: body, options: options, responseHandler: responseHandler) } /// Performs an HTTP GET request and collects the response body. diff --git a/Sources/HTTPClientConformance/HTTPClientConformance.swift b/Sources/HTTPClientConformance/HTTPClientConformance.swift index 82249c4..15ae152 100644 --- a/Sources/HTTPClientConformance/HTTPClientConformance.swift +++ b/Sources/HTTPClientConformance/HTTPClientConformance.swift @@ -212,10 +212,10 @@ struct ConformanceTestSuite { request: request ) { response, responseBodyAndTrailers in #expect(response.status == .noContent) - let (_, _) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in - let isEmpty = span.isEmpty - #expect(isEmpty) - } + var array = UniqueArray(minimumCapacity: 1024) + _ = try await responseBodyAndTrailers.collect(into: &array) + let isEmpty = array.isEmpty + #expect(isEmpty) } } @@ -231,10 +231,10 @@ struct ConformanceTestSuite { request: request ) { response, responseBodyAndTrailers in #expect(response.status == .notModified) - let (_, _) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in - let isEmpty = span.isEmpty - #expect(isEmpty) - } + var array = UniqueArray(minimumCapacity: 1024) + _ = try await responseBodyAndTrailers.collect(into: &array) + let isEmpty = array.isEmpty + #expect(isEmpty) } } @@ -253,10 +253,10 @@ struct ConformanceTestSuite { request: request ) { response, responseBodyAndTrailers in #expect(response.status == .ok) - let (_, _) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in - let isEmpty = span.isEmpty - #expect(isEmpty) - } + var array = UniqueArray(minimumCapacity: 1024) + _ = try await responseBodyAndTrailers.collect(into: &array) + let isEmpty = array.isEmpty + #expect(isEmpty) } } } @@ -276,10 +276,10 @@ struct ConformanceTestSuite { request: request ) { response, responseBodyAndTrailers in #expect(response.status == .ok) - let (_, _) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in - let isEmpty = span.isEmpty - #expect(isEmpty) - } + var array = UniqueArray(minimumCapacity: 1024) + _ = try await responseBodyAndTrailers.collect(into: &array) + let isEmpty = array.isEmpty + #expect(isEmpty) } } } @@ -297,9 +297,9 @@ struct ConformanceTestSuite { request: request ) { response, responseBodyAndTrailers in #expect(response.status == .ok) - let (body, _) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in - return String(copying: try UTF8Span(validating: span.span)) - } + var array = UniqueArray(minimumCapacity: 1024) + _ = try await responseBodyAndTrailers.collect(into: &array) + let body = String(copying: try UTF8Span(validating: array.span)) #expect(body == "1234") } } @@ -318,9 +318,9 @@ struct ConformanceTestSuite { request: request, ) { response, responseBodyAndTrailers in #expect(response.status == .ok) - let (body, trailers) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in - return String(copying: try UTF8Span(validating: span.span)) - } + var array = UniqueArray(minimumCapacity: 1024) + let trailers = try await responseBodyAndTrailers.collect(into: &array) + let body = String(copying: try UTF8Span(validating: array.span)) #expect(body.isEmpty) #expect(trailers == nil) } @@ -337,18 +337,16 @@ struct ConformanceTestSuite { ) try await client.perform( request: request, - body: .restartable(knownLength: 0) { writer in - var writer = writer - try await writer.write(Span()) - return nil + body: .restartable(knownLength: 0) { sender in + try await sender.finish() } ) { response, responseBodyAndTrailers in #expect(response.status == .ok) - let (jsonRequest, _) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in - let body = String(copying: try UTF8Span(validating: span.span)) - let data = body.data(using: .utf8)! - return try JSONDecoder().decode(JSONHTTPRequest.self, from: data) - } + var array = UniqueArray(minimumCapacity: 1024) + _ = try await responseBodyAndTrailers.collect(into: &array) + let body = String(copying: try UTF8Span(validating: array.span)) + let data = body.data(using: .utf8)! + let jsonRequest = try JSONDecoder().decode(JSONHTTPRequest.self, from: data) #expect(jsonRequest.body.isEmpty) } } @@ -363,18 +361,15 @@ struct ConformanceTestSuite { ) try await client.perform( request: request, - body: .restartable { writer in - var writer = writer - let body = "Hello World" - try await writer.write(body.utf8Span.span) - return nil + body: .restartable { sender in + var body = UniqueArray.init(copying: "Hello World".utf8) + try await sender.finish(copying: &body) } ) { response, responseBodyAndTrailers in #expect(response.status == .ok) - let (body, _) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in - let body = String(copying: try UTF8Span(validating: span.span)) - return body - } + var array = UniqueArray(minimumCapacity: 1024) + _ = try await responseBodyAndTrailers.collect(into: &array) + let body = String(copying: try UTF8Span(validating: array.span)) // Check that the request body was in the response #expect(body == "Hello World") @@ -403,9 +398,9 @@ struct ConformanceTestSuite { contentEncoding == nil || contentEncoding == "identity" } - let (body, _) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in - return String(copying: try UTF8Span(validating: span.span)) - } + var array = UniqueArray(minimumCapacity: 1024) + _ = try await responseBodyAndTrailers.collect(into: &array) + let body = String(copying: try UTF8Span(validating: array.span)) #expect(body == "TEST\n") } } @@ -432,9 +427,9 @@ struct ConformanceTestSuite { contentEncoding == nil || contentEncoding == "identity" } - let (body, _) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in - return String(copying: try UTF8Span(validating: span.span)) - } + var array = UniqueArray(minimumCapacity: 1024) + _ = try await responseBodyAndTrailers.collect(into: &array) + let body = String(copying: try UTF8Span(validating: array.span)) #expect(body == "TEST\n") } } @@ -461,9 +456,9 @@ struct ConformanceTestSuite { contentEncoding == nil || contentEncoding == "identity" } - let (body, _) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in - return String(copying: try UTF8Span(validating: span.span)) - } + var array = UniqueArray(minimumCapacity: 1024) + _ = try await responseBodyAndTrailers.collect(into: &array) + let body = String(copying: try UTF8Span(validating: array.span)) #expect(body == "TEST\n") } } @@ -482,9 +477,9 @@ struct ConformanceTestSuite { #expect(response.status == .ok) let contentEncoding = response.headerFields[.contentEncoding] #expect(contentEncoding == nil || contentEncoding == "identity") - let (body, _) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in - return String(copying: try UTF8Span(validating: span.span)) - } + var array = UniqueArray(minimumCapacity: 1024) + _ = try await responseBodyAndTrailers.collect(into: &array) + let body = String(copying: try UTF8Span(validating: array.span)) #expect(body == "TEST\n") } } @@ -501,18 +496,17 @@ struct ConformanceTestSuite { try await client.perform( request: request, - body: .restartable { writer in - var writer = writer - try await writer.write("Hello World".utf8.span) - return nil + body: .restartable { sender in + var body = UniqueArray.init(copying: "Hello World".utf8) + try await sender.finish(copying: &body) } ) { response, responseBodyAndTrailers in #expect(response.status == .ok) - let (jsonRequest, _) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in - let body = String(copying: try UTF8Span(validating: span.span)) - let data = body.data(using: .utf8)! - return try JSONDecoder().decode(JSONHTTPRequest.self, from: data) - } + var array = UniqueArray(minimumCapacity: 1024) + _ = try await responseBodyAndTrailers.collect(into: &array) + let body = String(copying: try UTF8Span(validating: array.span)) + let data = body.data(using: .utf8)! + let jsonRequest = try JSONDecoder().decode(JSONHTTPRequest.self, from: data) #expect(jsonRequest.headers["X-Foo"] == ["BARbaz"]) } } @@ -533,11 +527,11 @@ struct ConformanceTestSuite { request: request, ) { response, responseBodyAndTrailers in #expect(response.status == .ok) - let (jsonRequest, _) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in - let body = String(copying: try UTF8Span(validating: span.span)) - let data = body.data(using: .utf8)! - return try JSONDecoder().decode(JSONHTTPRequest.self, from: data) - } + var array = UniqueArray(minimumCapacity: 1024) + _ = try await responseBodyAndTrailers.collect(into: &array) + let body = String(copying: try UTF8Span(validating: array.span)) + let data = body.data(using: .utf8)! + let jsonRequest = try JSONDecoder().decode(JSONHTTPRequest.self, from: data) #expect(jsonRequest.method == "GET") #expect(jsonRequest.body.isEmpty) } @@ -575,10 +569,10 @@ struct ConformanceTestSuite { request: request, ) { response, responseBodyAndTrailers in #expect(response.status == .notFound) - let (_, _) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in - let isEmpty = span.isEmpty - #expect(isEmpty) - } + var array = UniqueArray(minimumCapacity: 1024) + _ = try await responseBodyAndTrailers.collect(into: &array) + let isEmpty = array.isEmpty + #expect(isEmpty) } } @@ -595,10 +589,10 @@ struct ConformanceTestSuite { request: request, ) { response, responseBodyAndTrailers in #expect(response.status == 999) - let (_, _) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in - let isEmpty = span.isEmpty - #expect(isEmpty) - } + var array = UniqueArray(minimumCapacity: 1024) + _ = try await responseBodyAndTrailers.collect(into: &array) + let isEmpty = array.isEmpty + #expect(isEmpty) } } @@ -618,10 +612,10 @@ struct ConformanceTestSuite { request: request, ) { response, responseBodyAndTrailers in #expect(response.status == .ok) - let _ = try await responseBodyAndTrailers.collect(upTo: 1024) { span in - let isEmpty = span.isEmpty - #expect(!isEmpty) - } + var array = UniqueArray(minimumCapacity: 1024) + _ = try await responseBodyAndTrailers.collect(into: &array) + let isEmpty = array.isEmpty + #expect(!isEmpty) } } } @@ -649,34 +643,32 @@ struct ConformanceTestSuite { try await client.perform( request: request, - body: .restartable { writer in - var writer = writer - + body: .restartable { sender in + var writer = sender for _ in 0..<1000 { // Write a 1-byte chunk - try await writer.write("A".utf8.span) + try await writer.write(UInt8(ascii: "A")) // Only proceed once the client receives the echo. await writerWaiting.first(where: { true }) } - return nil + try await writer.finish(trailers: nil) } ) { response, responseBodyAndTrailers in #expect(response.status == .ok) - let _ = try await responseBodyAndTrailers.consumeAndConclude { reader in - var numberOfChunks = 0 - try await reader.forEachBuffer { buffer in - numberOfChunks += 1 - #expect(buffer.count == 1) - var consumer = buffer.consumeAll() - let first = consumer.next() - #expect(first == UInt8(ascii: "A")) - - // Unblock the writer - continuation.yield() - } - #expect(numberOfChunks == 1000) + var reader = responseBodyAndTrailers + var numberOfChunks = 0 + try await reader.forEachBufferMutating { buffer in + numberOfChunks += 1 + #expect(buffer.count == 1) + var consumer = buffer.consumeAll() + let first = consumer.next() + #expect(first == UInt8(ascii: "A")) + + // Unblock the writer + continuation.yield() } + #expect(numberOfChunks == 1000) } } @@ -696,11 +688,11 @@ struct ConformanceTestSuite { request: request, ) { response, responseBodyAndTrailers in #expect(response.status == .ok) - let (jsonRequest, _) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in - let body = String(copying: try UTF8Span(validating: span.span)) - let data = body.data(using: .utf8)! - return try JSONDecoder().decode(JSONHTTPRequest.self, from: data) - } + var array = UniqueArray(minimumCapacity: 1024) + _ = try await responseBodyAndTrailers.collect(into: &array) + let body = String(copying: try UTF8Span(validating: array.span)) + let data = body.data(using: .utf8)! + let jsonRequest = try JSONDecoder().decode(JSONHTTPRequest.self, from: data) #expect(jsonRequest.headers["X-Test"] == [""]) } @@ -720,35 +712,37 @@ struct ConformanceTestSuite { try await client.perform( request: request, - body: .restartable { writer in - var writer = writer + body: .restartable { sender in + var writer = sender var iterator = stream.makeAsyncIterator() // Wait for a chunk from the server while let chunk = await iterator.next() { // Write it back to the server - try await writer.write(chunk.utf8.span) + + try await writer.write { buffer in + buffer.append(copying: chunk.utf8Span.span) + } } - return nil + try await writer.finish(trailers: nil) } ) { response, responseBodyAndTrailers in #expect(response.status == .ok) - let _ = try await responseBodyAndTrailers.consumeAndConclude { reader in - // Read all chunks from server - try await reader.forEachBuffer { buffer in - var bytes = [UInt8]() - var consumer = buffer.consumeAll() - while let b = consumer.next() { bytes.append(b) } - let chunk = String(copying: try UTF8Span(validating: bytes.span)) - #expect(chunk == "A") - - // Give chunk to the writer to echo back - continuation.yield(chunk) - } - - // No more chunks from server. Stop writing as well. - continuation.finish() + var reader = responseBodyAndTrailers + // Read all chunks from server + try await reader.forEachBufferMutating { buffer in + var bytes = [UInt8]() + var consumer = buffer.consumeAll() + while let b = consumer.next() { bytes.append(b) } + let chunk = String(copying: try UTF8Span(validating: bytes.span)) + #expect(chunk == "A") + + // Give chunk to the writer to echo back + continuation.yield(chunk) } + + // No more chunks from server. Stop writing as well. + continuation.finish() } } @@ -810,20 +804,18 @@ struct ConformanceTestSuite { request: request, ) { response, responseBodyAndTrailers in #expect(response.status == .ok) - let _ = try await responseBodyAndTrailers.consumeAndConclude { reader in - var reader = reader - - // Now trigger the task group cancellation. - continuation.yield() - - // The client may choose to return however much of the body it already - // has downloaded, but eventually it must throw an exception because - // the response is incomplete and the task has been cancelled. - try await reader.forEachBuffer { buffer in - #expect(buffer.count > 0) - var consumer = buffer.consumeAll() - while consumer.next() != nil {} - } + var reader = responseBodyAndTrailers + + // Now trigger the task group cancellation. + continuation.yield() + + // The client may choose to return however much of the body it already + // has downloaded, but eventually it must throw an exception because + // the response is incomplete and the task has been cancelled. + try await reader.forEachBufferMutating { buffer in + #expect(buffer.count > 0) + var consumer = buffer.consumeAll() + while consumer.next() != nil {} } } } @@ -877,18 +869,16 @@ struct ConformanceTestSuite { try await client.perform( request: request, - body: .restartable(knownLength: 1_000_000) { writer in + body: .restartable(knownLength: 1_000_000) { sender in // Write out 1Mb of "A" - var writer = writer - let data = String(repeating: "A", count: 1_000_000).data(using: .ascii)! - try await writer.write(data.span) - return nil + var body = UniqueArray.init(copying: String(repeating: "A", count: 1_000_000).data(using: .ascii)!) + try await sender.finish(copying: &body) } ) { response, responseBodyAndTrailers in #expect(response.status == .ok) - let (echo, _) = try await responseBodyAndTrailers.collect(upTo: 2_000_000) { span in - return String(copying: try UTF8Span(validating: span.span)) - } + var array = UniqueArray(minimumCapacity: 2_000_000) + _ = try await responseBodyAndTrailers.collect(into: &array) + let echo = String(copying: try UTF8Span(validating: array.span)) #expect(echo == String(repeating: "A", count: 1_000_000)) } } @@ -929,14 +919,12 @@ struct ConformanceTestSuite { ) { response, responseBodyAndTrailers in #expect(response.status == .ok) - let (result, _) = try await responseBodyAndTrailers.consumeAndConclude { reader in - var result = [UInt8]() - var reader = reader - try await reader.forEachBuffer { buffer in - var consumer = buffer.consumeAll() - while let b = consumer.next() { result.append(b) } - } - return result + var reader = responseBodyAndTrailers + var result = [UInt8]() + + try await reader.forEachBufferMutating { buffer in + var consumer = buffer.consumeAll() + while let b = consumer.next() { result.append(b) } } #expect(result == [UInt8](repeating: UInt8(ascii: "A"), count: 1_000_000)) } @@ -954,9 +942,9 @@ struct ConformanceTestSuite { request: request, ) { response, responseBodyAndTrailers in #expect(response.status == .ok) - let (body, trailers) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in - return String(copying: try UTF8Span(validating: span.span)) - } + var array = UniqueArray(minimumCapacity: 1024) + let trailers = try await responseBodyAndTrailers.collect(into: &array) + let body = String(copying: try UTF8Span(validating: array.span)) #expect(body.isEmpty) #expect(trailers == nil) } @@ -1002,11 +990,11 @@ struct ConformanceTestSuite { try await client.perform( request: request, ) { response, responseBodyAndTrailers in - let (jsonRequest, _) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in - let body = String(copying: try UTF8Span(validating: span.span)) - let data = body.data(using: .utf8)! - return try JSONDecoder().decode(JSONHTTPRequest.self, from: data) - } + var array = UniqueArray(minimumCapacity: 1024) + _ = try await responseBodyAndTrailers.collect(into: &array) + let body = String(copying: try UTF8Span(validating: array.span)) + let data = body.data(using: .utf8)! + let jsonRequest = try JSONDecoder().decode(JSONHTTPRequest.self, from: data) let values = jsonRequest.headers["X-Test"]! @@ -1049,11 +1037,11 @@ struct ConformanceTestSuite { ) let clientCookie = try await client.perform(request: request2) { response, responseBodyAndTrailers in // The server gave us the request back. Check that the cookie was in the request. - let (jsonRequest, _) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in - let body = String(copying: try UTF8Span(validating: span.span)) - let data = body.data(using: .utf8)! - return try JSONDecoder().decode(JSONHTTPRequest.self, from: data) - } + var array = UniqueArray(minimumCapacity: 1024) + _ = try await responseBodyAndTrailers.collect(into: &array) + let body = String(copying: try UTF8Span(validating: array.span)) + let data = body.data(using: .utf8)! + let jsonRequest = try JSONDecoder().decode(JSONHTTPRequest.self, from: data) // Parse the cookie let values = jsonRequest.headers["Cookie"] ?? [] @@ -1115,9 +1103,9 @@ struct ConformanceTestSuite { // Second attempt, Cached == true #expect(response.headerFields[.cached] == "true") } - let (response, _) = try await responseBodyAndTrailers.collect(upTo: 5) { span in - return String(copying: try UTF8Span(validating: span.span)) - } + var array = UniqueArray(minimumCapacity: 1024) + _ = try await responseBodyAndTrailers.collect(into: &array) + let response = String(copying: try UTF8Span(validating: array.span)) #expect(response == expectedResponse) } } @@ -1138,11 +1126,11 @@ struct ConformanceTestSuite { try await client.perform( request: request, ) { response, responseBodyAndTrailers in - let (jsonRequest, _) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in - let body = String(copying: try UTF8Span(validating: span.span)) - let data = body.data(using: .utf8)! - return try JSONDecoder().decode(JSONHTTPRequest.self, from: data) - } + var array = UniqueArray(minimumCapacity: 1024) + _ = try await responseBodyAndTrailers.collect(into: &array) + let body = String(copying: try UTF8Span(validating: array.span)) + let data = body.data(using: .utf8)! + let jsonRequest = try JSONDecoder().decode(JSONHTTPRequest.self, from: data) #expect( jsonRequest.params == [ @@ -1167,9 +1155,9 @@ struct ConformanceTestSuite { request: request ) { response, responseBodyAndTrailers in #expect(response.status == .ok) - let (body, trailers) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in - return String(copying: try UTF8Span(validating: span.span)) - } + var array = UniqueArray(minimumCapacity: 1024) + let trailers = try await responseBodyAndTrailers.collect(into: &array) + let body = String(copying: try UTF8Span(validating: array.span)) // Verify the body #expect(body == "Response body") @@ -1194,21 +1182,23 @@ struct ConformanceTestSuite { ) try await client.perform( request: request, - body: .restartable { writer in - var writer = writer - try await writer.write("Hello World".utf8.span) - return [ - .init("X-Request-Trailer-One")!: "first-trailer-value", - .init("X-Request-Trailer-Two")!: "second-trailer-value", - ] + body: .restartable { sender in + var body = UniqueArray.init(copying: "Hello World".utf8) + try await sender.finish( + copying: &body, + trailers: [ + .init("X-Request-Trailer-One")!: "first-trailer-value", + .init("X-Request-Trailer-Two")!: "second-trailer-value", + ] + ) } ) { response, responseBodyAndTrailers in #expect(response.status == .ok) - let (jsonRequest, _) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in - let body = String(copying: try UTF8Span(validating: span.span)) - let data = body.data(using: .utf8)! - return try JSONDecoder().decode(JSONHTTPRequest.self, from: data) - } + var array = UniqueArray(minimumCapacity: 1024) + _ = try await responseBodyAndTrailers.collect(into: &array) + let body = String(copying: try UTF8Span(validating: array.span)) + let data = body.data(using: .utf8)! + let jsonRequest = try JSONDecoder().decode(JSONHTTPRequest.self, from: data) #expect(jsonRequest.body == "Hello World") #expect(jsonRequest.trailers["X-Request-Trailer-One"] == ["first-trailer-value"]) #expect(jsonRequest.trailers["X-Request-Trailer-Two"] == ["second-trailer-value"]) diff --git a/Sources/HTTPClientConformance/HTTPServerForTesting/HTTPRequestConcludingAsyncReader.swift b/Sources/HTTPClientConformance/HTTPServerForTesting/HTTPRequestConcludingAsyncReader.swift deleted file mode 100644 index 8d2c56d..0000000 --- a/Sources/HTTPClientConformance/HTTPServerForTesting/HTTPRequestConcludingAsyncReader.swift +++ /dev/null @@ -1,204 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift HTTP API Proposal open source project -// -// Copyright (c) 2025 Apple Inc. and the Swift HTTP API Proposal project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -public import BasicContainers -public import HTTPAPIs -public import HTTPTypes -import NIOCore -import NIOHTTPTypes -import Synchronization - -/// A specialized reader for HTTP request bodies and trailers that manages the reading process -/// and captures the final trailer fields. -/// -/// ``HTTPRequestConcludingAsyncReader`` enables reading request body chunks incrementally -/// and concluding with the HTTP trailer fields received at the end of the request. This type -/// follows the ``ConcludingAsyncReader`` pattern, which allows for asynchronous consumption of -/// a stream with a conclusive final element. -@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) -public struct HTTPRequestConcludingAsyncReader: ConcludingAsyncReader, ~Copyable { - /// A reader for HTTP request body chunks that implements the ``AsyncReader`` protocol. - /// - /// This reader processes the body parts of an HTTP request and provides them as spans of bytes, - /// while also capturing any trailer fields received at the end of the request. - public struct RequestBodyAsyncReader: AsyncReader, ~Copyable { - /// The type of elements this reader provides. - public typealias ReadElement = UInt8 - - /// The type of errors that can occur during reading operations. - public typealias ReadFailure = any Error - - /// The buffer type used to hand elements to the caller. - public typealias Buffer = UniqueArray - - /// The HTTP trailer fields captured at the end of the request. - fileprivate var state: ReaderState - - /// The iterator that provides HTTP request parts from the underlying channel. - private var iterator: NIOAsyncChannelInboundStream.AsyncIterator - - /// Initializes a new request body reader with the given NIO async channel iterator. - /// - /// - Parameter iterator: The NIO async channel inbound stream iterator to use for reading request parts. - fileprivate init( - iterator: consuming sending NIOAsyncChannelInboundStream.AsyncIterator, - readerState: ReaderState - ) { - self.iterator = iterator - self.state = readerState - } - - /// Reads a chunk of request body data. - public mutating func read( - body: nonisolated(nonsending) (inout UniqueArray) async throws(Failure) -> Return - ) async throws(EitherError) -> Return { - let requestPart: HTTPRequestPart? - do { - requestPart = try await self.iterator.next(isolation: #isolation) - } catch { - throw .first(error) - } - - var buffer = UniqueArray() - switch requestPart { - case .head: - fatalError() - case .body(let element): - buffer.reserveCapacity(element.readableBytes) - unsafe element.withUnsafeReadableBytes { rawBufferPtr in - let usbptr = unsafe rawBufferPtr.assumingMemoryBound(to: UInt8.self) - unsafe buffer.append(copying: usbptr) - } - case .end(let trailers): - self.state.wrapped.withLock { state in - state.trailers = trailers - state.finishedReading = true - } - case .none: - break - } - - do { - return try await body(&buffer) - } catch { - throw .second(error) - } - } - } - - final class ReaderState: Sendable { - struct Wrapped { - var trailers: HTTPFields? = nil - var finishedReading: Bool = false - } - - let wrapped: Mutex - - init() { - self.wrapped = .init(.init()) - } - } - - /// The underlying reader type for the HTTP request body. - public typealias Underlying = RequestBodyAsyncReader - - /// The type of the final element produced after all reads are completed (optional HTTP trailer fields). - public typealias FinalElement = HTTPFields? - - /// The type of errors that can occur during reading operations. - public typealias Failure = any Error - - private var iterator: Disconnected.AsyncIterator?> - - internal var state: ReaderState - - /// Initializes a new HTTP request body and trailers reader with the given NIO async channel iterator. - /// - /// - Parameter iterator: The NIO async channel inbound stream iterator to use for reading request parts. - init( - iterator: consuming sending NIOAsyncChannelInboundStream.AsyncIterator, - readerState: ReaderState - ) { - self.iterator = .init(value: iterator) - self.state = readerState - } - - /// Processes the request body reading operation and captures the final trailer fields. - /// - /// This method provides a request body reader to the given closure, allowing it to read - /// chunks of the request body incrementally. Once the closure completes, the method returns - /// both the result from the closure and any trailer fields that were received at the end - /// of the HTTP request. - /// - /// - Parameter body: A closure that takes a request body reader and returns a result value. - /// - Returns: A tuple containing the value returned by the body closure and the HTTP trailer fields (if any). - /// - Throws: Any error encountered during the reading process. - /// - /// - Example: - /// ```swift - /// let requestReader: HTTPRequestConcludingAsyncReader = ... - /// - /// let (bodyData, trailers) = try await requestReader.consumeAndConclude { reader in - /// var collectedData = [UInt8]() - /// - /// // Read chunks until end of stream - /// while let chunk = try await reader.read(body: { $0 }) { - /// collectedData.append(contentsOf: chunk) - /// } - /// return collectedData - /// } - /// ``` - public consuming func consumeAndConclude( - body: nonisolated(nonsending) (consuming sending RequestBodyAsyncReader) async throws(Failure) -> Return - ) async throws(Failure) -> (Return, HTTPFields?) { - if let iterator = self.iterator.take() { - let partsReader = RequestBodyAsyncReader(iterator: iterator, readerState: self.state) - let result = try await body(partsReader) - let trailers = self.state.wrapped.withLock { $0.trailers } - return (result, trailers) - } else { - fatalError("consumeAndConclude called more than once") - } - } -} - -@available(*, unavailable) -extension HTTPRequestConcludingAsyncReader: Sendable {} - -@available(*, unavailable) -extension HTTPRequestConcludingAsyncReader.RequestBodyAsyncReader: Sendable {} - -@usableFromInline -struct Disconnected: ~Copyable, Sendable { - // This is safe since we take the value as sending and take consumes it - // and returns it as sending. - private nonisolated(unsafe) var value: Value? - - @usableFromInline - init(value: consuming sending Value) { - unsafe self.value = .some(value) - } - - @usableFromInline - consuming func take() -> sending Value { - nonisolated(unsafe) let value = unsafe self.value.take()! - return unsafe value - } - - @usableFromInline - mutating func swap(newValue: consuming sending Value) -> sending Value { - nonisolated(unsafe) let value = unsafe self.value.take()! - unsafe self.value = consume newValue - return unsafe value - } -} diff --git a/Sources/HTTPClientConformance/HTTPServerForTesting/HTTPResponseConcludingAsyncWriter.swift b/Sources/HTTPClientConformance/HTTPServerForTesting/HTTPResponseConcludingAsyncWriter.swift deleted file mode 100644 index e34cc67..0000000 --- a/Sources/HTTPClientConformance/HTTPServerForTesting/HTTPResponseConcludingAsyncWriter.swift +++ /dev/null @@ -1,162 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift HTTP API Proposal open source project -// -// Copyright (c) 2025 Apple Inc. and the Swift HTTP API Proposal project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -public import BasicContainers -public import HTTPAPIs -public import HTTPTypes -import NIOCore -import NIOHTTPTypes -import Synchronization - -/// A specialized writer for HTTP response bodies and trailers that manages the writing process -/// and the final trailer fields. -/// -/// ``HTTPResponseConcludingAsyncWriter`` enables writing response body chunks incrementally -/// and concluding with optional HTTP trailer fields. This type follows the ``ConcludingAsyncWriter`` -/// pattern, which allows for asynchronous production of data with a conclusive final element. -/// -/// This writer is designed to work with HTTP responses where the body is streamed in chunks -/// and potentially followed by trailer fields. -@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) -public struct HTTPResponseConcludingAsyncWriter: ConcludingAsyncWriter, ~Copyable { - /// A writer for HTTP response body chunks that implements the ``AsyncWriter`` protocol. - /// - /// This writer handles the body parts of an HTTP response, allowing them to be written - /// incrementally as spans of bytes. - public struct ResponseBodyAsyncWriter: AsyncWriter { - /// The type of elements this writer accepts (byte arrays representing body chunks). - public typealias WriteElement = UInt8 - - /// The type of errors that can occur during writing operations. - public typealias WriteFailure = any Error - - /// The buffer type used to receive elements from the caller. - public typealias Buffer = UniqueArray - - /// The underlying NIO writer for HTTP response parts. - private var writer: NIOAsyncChannelOutboundWriter - - /// Initializes a new response body writer with the given NIO async channel writer. - /// - /// - Parameter writer: The NIO async channel outbound writer to use for writing response parts. - init(writer: NIOAsyncChannelOutboundWriter) { - self.writer = writer - } - - /// Writes a chunk of response body data to the underlying writer. - public mutating func write( - _ body: nonisolated(nonsending) (inout UniqueArray) async throws(Failure) -> Return - ) async throws(EitherError) -> Return { - var buffer = UniqueArray() - let result: Return - do { - result = try await body(&buffer) - } catch { - throw .second(error) - } - - if buffer.count == 0 { - return result - } - - var byteBuffer = ByteBuffer() - byteBuffer.reserveCapacity(buffer.count) - unsafe byteBuffer.writeBytes(buffer.span.bytes) - - do { - try await self.writer.write(.body(byteBuffer)) - } catch { - throw .first(error) - } - - return result - } - } - - final class WriterState: Sendable { - struct Wrapped { - var finishedWriting: Bool = false - } - - let wrapped: Mutex - - init() { - self.wrapped = .init(.init()) - } - } - - /// The underlying writer type for the HTTP response body. - public typealias Underlying = ResponseBodyAsyncWriter - - /// The type of the final element that concludes the response (optional HTTP trailer fields). - public typealias FinalElement = HTTPFields? - - /// The type of errors that can occur during writing operations. - public typealias Failure = any Error - - /// The underlying NIO writer for HTTP response parts. - private var writer: NIOAsyncChannelOutboundWriter - - private var writerState: WriterState - - /// Initializes a new HTTP response body and trailers writer with the given NIO async channel writer. - /// - /// - Parameter writer: The NIO async channel outbound writer to use for writing response parts. - init( - writer: NIOAsyncChannelOutboundWriter, - writerState: WriterState - ) { - self.writer = writer - self.writerState = writerState - } - - /// Processes the body writing operation and concludes with optional trailer fields. - /// - /// This method provides a response body writer to the given closure, allowing it to write - /// chunks of the response body incrementally. Once the closure completes, the resulting - /// final element (trailer fields) is used to conclude the HTTP response. - /// - /// - Parameter body: A closure that takes a response body writer and returns both a result value - /// and optional trailer fields to conclude the response. - /// - Returns: The value returned by the body closure. - /// - Throws: Any error encountered during the writing process. - /// - /// - Example: - /// ```swift - /// let responseWriter: HTTPResponseConcludingAsyncWriter = ... - /// - /// try await responseWriter.produceAndConclude { writer in - /// // Write response body chunks - /// try await writer.write([...]) - /// try await writer.write([...]) - /// - /// // Return a result and optional trailers - /// return (true, HTTPFields(trailerFields)) - /// } - /// ``` - public consuming func produceAndConclude( - body: (consuming sending ResponseBodyAsyncWriter) async throws -> (Return, FinalElement) - ) async throws -> Return { - let responseBodyAsyncWriter = ResponseBodyAsyncWriter(writer: self.writer) - let (result, finalElement) = try await body(responseBodyAsyncWriter) - try await self.writer.write(.end(finalElement)) - self.writerState.wrapped.withLock { $0.finishedWriting = true } - return result - } -} - -@available(*, unavailable) -extension HTTPResponseConcludingAsyncWriter: Sendable {} - -@available(*, unavailable) -extension HTTPResponseConcludingAsyncWriter.ResponseBodyAsyncWriter: Sendable {} diff --git a/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPRequestReceiver.swift b/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPRequestReceiver.swift new file mode 100644 index 0000000..f0eba55 --- /dev/null +++ b/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPRequestReceiver.swift @@ -0,0 +1,122 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP API Proposal open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift HTTP API Proposal project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +public import BasicContainers +public import HTTPAPIs +public import HTTPTypes +import NIOCore +import NIOHTTPTypes +import Synchronization + +/// A NIO-backed HTTP request body reader used by the test server. +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +public struct NIORequestBodyReader: HTTPBodyReader, ~Copyable { + public typealias ReadElement = UInt8 + public typealias ReadFailure = any Error + public typealias Buffer = UniqueArray + + public final class ReaderState: Sendable { + struct Wrapped { + var trailers: HTTPFields? = nil + var finishedReading: Bool = false + } + + let wrapped: Mutex + + public init() { + self.wrapped = .init(.init()) + } + } + + private var state: ReaderState + private var iterator: NIOAsyncChannelInboundStream.AsyncIterator + + init( + iterator: consuming sending NIOAsyncChannelInboundStream.AsyncIterator, + readerState: ReaderState + ) { + self.iterator = iterator + self.state = readerState + } + + public mutating func read( + body: nonisolated(nonsending) (inout UniqueArray, HTTPFields?) async throws(Failure) -> Return + ) async throws(EitherError) -> Return { + var buffer = UniqueArray() + var trailers: HTTPFields? = nil + + let alreadyFinished = self.state.wrapped.withLock { $0.finishedReading } + if !alreadyFinished { + let requestPart: HTTPRequestPart? + do { + requestPart = try await self.iterator.next(isolation: #isolation) + } catch { + throw .first(error) + } + + switch requestPart { + case .head: + fatalError() + case .body(let element): + buffer.reserveCapacity(element.readableBytes) + unsafe element.withUnsafeReadableBytes { rawBufferPtr in + let usbptr = unsafe rawBufferPtr.assumingMemoryBound(to: UInt8.self) + unsafe buffer.append(copying: usbptr) + } + case .end(let t): + self.state.wrapped.withLock { state in + state.trailers = t + state.finishedReading = true + } + trailers = t ?? HTTPFields() + case .none: + self.state.wrapped.withLock { $0.finishedReading = true } + trailers = HTTPFields() + } + } + + do { + return try await body(&buffer, trailers) + } catch { + throw .second(error) + } + } +} + +@available(*, unavailable) +extension NIORequestBodyReader: Sendable {} + +@usableFromInline +struct Disconnected: ~Copyable, Sendable { + // This is safe since we take the value as sending and take consumes it + // and returns it as sending. + private nonisolated(unsafe) var value: Value? + + @usableFromInline + init(value: consuming sending Value) { + unsafe self.value = .some(value) + } + + @usableFromInline + consuming func take() -> sending Value { + nonisolated(unsafe) let value = unsafe self.value.take()! + return unsafe value + } + + @usableFromInline + mutating func swap(newValue: consuming sending Value) -> sending Value { + nonisolated(unsafe) let value = unsafe self.value.take()! + unsafe self.value = consume newValue + return unsafe value + } +} diff --git a/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPResponseSender.swift b/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPResponseSender.swift new file mode 100644 index 0000000..49e4d6e --- /dev/null +++ b/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPResponseSender.swift @@ -0,0 +1,151 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP API Proposal open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift HTTP API Proposal project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +public import BasicContainers +public import HTTPAPIs +public import HTTPTypes +import NIOCore +import NIOHTTPTypes +import Synchronization + +/// A NIO-backed HTTP response sender used by the test server. +/// +/// ``NIOHTTPResponseSender`` writes the response head, streams the body, and concludes with +/// optional trailing fields, all to a NIO async channel outbound writer. It also supports +/// sending informational (1xx) responses before the final response. +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +public struct NIOHTTPResponseSender: HTTPResponseSender, ~Copyable { + /// A writer for HTTP response body chunks that implements the ``HTTPBodyWriter`` protocol. + public struct ResponseBodyAsyncWriter: HTTPBodyWriter { + public typealias WriteElement = UInt8 + public typealias WriteFailure = any Error + public typealias Buffer = UniqueArray + + private var writer: NIOAsyncChannelOutboundWriter + private var writerState: WriterState + + init( + writer: NIOAsyncChannelOutboundWriter, + writerState: WriterState + ) { + self.writer = writer + self.writerState = writerState + } + + public mutating func write( + _ body: nonisolated(nonsending) (inout UniqueArray) async throws(Failure) -> Return + ) async throws(EitherError) -> Return { + var buffer = UniqueArray() + let result: Return + do { + result = try await body(&buffer) + } catch { + throw .second(error) + } + + if buffer.count == 0 { + return result + } + + var byteBuffer = ByteBuffer() + byteBuffer.reserveCapacity(buffer.count) + unsafe byteBuffer.writeBytes(buffer.span.bytes) + + do { + try await self.writer.write(.body(byteBuffer)) + } catch { + throw .first(error) + } + + return result + } + + public consuming func finish( + body: nonisolated(nonsending) (inout UniqueArray) async throws(Failure) -> HTTPFields? + ) async throws(EitherError) { + var buffer = UniqueArray() + let trailers: HTTPFields? + do { + trailers = try await body(&buffer) + } catch { + throw .second(error) + } + + if buffer.count > 0 { + var byteBuffer = ByteBuffer() + byteBuffer.reserveCapacity(buffer.count) + unsafe byteBuffer.writeBytes(buffer.span.bytes) + + do { + try await self.writer.write(.body(byteBuffer)) + } catch { + throw .first(error) + } + } + + do { + try await self.writer.write(.end(trailers)) + } catch { + throw .first(error) + } + self.writerState.wrapped.withLock { $0.finishedWriting = true } + } + } + + public final class WriterState: Sendable { + struct Wrapped { + var finishedWriting: Bool = false + } + + let wrapped: Mutex + + public init() { + self.wrapped = .init(.init()) + } + } + + public typealias Writer = ResponseBodyAsyncWriter + + private var writer: NIOAsyncChannelOutboundWriter + private var writerState: WriterState + + init( + writer: NIOAsyncChannelOutboundWriter, + writerState: WriterState + ) { + self.writer = writer + self.writerState = writerState + } + + public func sendInformational(_ response: HTTPResponse) async throws { + precondition(response.status.kind == .informational) + try await self.writer.write(.head(response)) + } + + public consuming func send(_ response: HTTPResponse) async throws -> ResponseBodyAsyncWriter { + precondition(response.status.kind != .informational) + // TODO: This is a temporary fix that informs clients that this server does not support + // keep-alive. This server should be updated to eventually support keep-alive. + var response = response + response.headerFields[.connection] = "close" + try await self.writer.write(.head(response)) + + return ResponseBodyAsyncWriter(writer: self.writer, writerState: self.writerState) + } +} + +@available(*, unavailable) +extension NIOHTTPResponseSender: Sendable {} + +@available(*, unavailable) +extension NIOHTTPResponseSender.ResponseBodyAsyncWriter: Sendable {} diff --git a/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPServer+HTTP1_1.swift b/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPServer+HTTP1_1.swift index 8bffc4f..2cca9a9 100644 --- a/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPServer+HTTP1_1.swift +++ b/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPServer+HTTP1_1.swift @@ -23,7 +23,7 @@ import NIOPosix extension NIOHTTPServer { func serveInsecureHTTP1_1( bindTarget: NIOHTTPServerConfiguration.BindTarget, - handler: some HTTPServerRequestHandler, + handler: some HTTPServerRequestHandler, asyncChannelConfiguration: NIOAsyncChannel.Configuration ) async throws { let serverChannel = try await self.setupHTTP1_1ServerChannel( @@ -71,7 +71,7 @@ extension NIOHTTPServer { func _serveInsecureHTTP1_1( serverChannel: NIOAsyncChannel, Never>, - handler: some HTTPServerRequestHandler + handler: some HTTPServerRequestHandler ) async throws { try await withThrowingDiscardingTaskGroup { group in try await serverChannel.executeThenClose { inbound in diff --git a/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPServer+SecureUpgrade.swift b/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPServer+SecureUpgrade.swift index a46b3a5..cc25e90 100644 --- a/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPServer+SecureUpgrade.swift @@ -28,7 +28,7 @@ extension NIOHTTPServer { func serveSecureUpgrade( bindTarget: NIOHTTPServerConfiguration.BindTarget, tlsConfiguration: TLSConfiguration, - handler: some HTTPServerRequestHandler, + handler: some HTTPServerRequestHandler, asyncChannelConfiguration: NIOAsyncChannel.Configuration, http2Configuration: NIOHTTP2Handler.Configuration, verificationCallback: (@Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult)? = nil @@ -120,7 +120,7 @@ extension NIOHTTPServer { func _serveSecureUpgrade( serverChannel: NIOAsyncChannel, Never>, - handler: some HTTPServerRequestHandler + handler: some HTTPServerRequestHandler ) async throws { try await withThrowingDiscardingTaskGroup { group in try await serverChannel.executeThenClose { inbound in diff --git a/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPServer.swift b/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPServer.swift index 416728a..d29f5da 100644 --- a/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPServer.swift +++ b/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPServer.swift @@ -49,37 +49,21 @@ import X509 /// try await Server.serve( /// logger: logger, /// configuration: configuration -/// ) { request, bodyReader, sendResponse in +/// ) { request, _, requestReceiver, responseSender in /// // Read the entire request body -/// let (bodyData, trailers) = try await bodyReader.consumeAndConclude { reader in -/// var data = [UInt8]() -/// var shouldContinue = true -/// while shouldContinue { -/// try await reader.read { span in -/// guard let span else { -/// shouldContinue = false -/// return -/// } -/// data.append(contentsOf: span) -/// } -/// } -/// return data -/// } +/// var bodyBuffer = UniqueArray(minimumCapacity: 1024) +/// let trailers = try await requestReceiver.collect(into: &bodyBuffer) /// /// // Create and send response /// var response = HTTPResponse(status: .ok) /// response.headerFields[.contentType] = "text/plain" -/// let responseWriter = try await sendResponse(response) -/// try await responseWriter.produceAndConclude { writer in -/// try await writer.write("Hello, World!".utf8CString.dropLast().span) -/// return ((), nil) -/// } +/// try await responseSender.send(response, body: "Hello, World!".utf8.span) /// } /// ``` @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) public struct NIOHTTPServer: HTTPServer { - public typealias RequestConcludingReader = HTTPRequestConcludingAsyncReader - public typealias ResponseConcludingWriter = HTTPResponseConcludingAsyncWriter + public typealias Reader = NIORequestBodyReader + public typealias ResponseSender = NIOHTTPResponseSender let logger: Logger private let configuration: NIOHTTPServerConfiguration @@ -125,12 +109,18 @@ public struct NIOHTTPServer: HTTPServer { /// struct EchoHandler: HTTPServerRequestHandler { /// func handle( /// request: HTTPRequest, - /// requestBodyAndTrailers: HTTPRequestConcludingAsyncReader, - /// responseSender: @escaping (HTTPResponse) async throws -> HTTPResponseConcludingAsyncWriter + /// requestContext: HTTPRequestContext, + /// requestReceiver: consuming sending NIOHTTPRequestReceiver, + /// responseSender: consuming sending NIOHTTPResponseSender /// ) async throws { - /// let response = HTTPResponse(status: .ok) - /// let writer = try await sendResponse(response) - /// // Handle request and write response... + /// var requestReceiver = Optional(requestReceiver) + /// try await responseSender.send(.init(status: .ok)) { writer in + /// var writer = writer + /// let (_, trailers) = try await requestReceiver.take()!.receive { reader in + /// try await writer.write(reader) + /// } + /// return ((), trailers) + /// } /// } /// } /// @@ -145,7 +135,7 @@ public struct NIOHTTPServer: HTTPServer { /// handler: EchoHandler() /// ) /// ``` - public func serve(handler: some HTTPServerRequestHandler) async throws { + public func serve(handler: some HTTPServerRequestHandler) async throws { defer { switch self.listeningAddressState.withLockedValue({ $0.close() }) { case .failPromise(let promise, let error): @@ -265,7 +255,7 @@ public struct NIOHTTPServer: HTTPServer { func handleRequestChannel( channel: NIOAsyncChannel, - handler: some HTTPServerRequestHandler + handler: some HTTPServerRequestHandler ) async throws { do { try await channel @@ -289,32 +279,21 @@ public struct NIOHTTPServer: HTTPServer { return } - let readerState = HTTPRequestConcludingAsyncReader.ReaderState() - let writerState = HTTPResponseConcludingAsyncWriter.WriterState() + let readerState = NIORequestBodyReader.ReaderState() + let writerState = NIOHTTPResponseSender.WriterState() do { try await handler.handle( request: httpRequest, requestContext: HTTPRequestContext(), - requestBodyAndTrailers: HTTPRequestConcludingAsyncReader( + reader: NIORequestBodyReader( iterator: iterator, readerState: readerState ), - responseSender: HTTPResponseSender { response in - // TODO: This is a temporary fix that informs clients - // that this server does not support keep-alive. This - // server should be updated to eventually support - // keep-alive. - var response = response - response.headerFields[.connection] = "close" - try await outbound.write(.head(response)) - return HTTPResponseConcludingAsyncWriter( - writer: outbound, - writerState: writerState - ) - } sendInformational: { response in - try await outbound.write(.head(response)) - } + responseSender: NIOHTTPResponseSender( + writer: outbound, + writerState: writerState + ) ) } catch { logger.error("Error thrown while handling connection: \(error)") diff --git a/Sources/HTTPClientConformance/HTTPServerForTesting/RequestResponseMiddlewareBox.swift b/Sources/HTTPClientConformance/HTTPServerForTesting/RequestResponseMiddlewareBox.swift index 473f183..9437108 100644 --- a/Sources/HTTPClientConformance/HTTPServerForTesting/RequestResponseMiddlewareBox.swift +++ b/Sources/HTTPClientConformance/HTTPServerForTesting/RequestResponseMiddlewareBox.swift @@ -19,47 +19,42 @@ public import HTTPTypes /// It is necessary to box them together so that they can be used with `Middlewares`, as this will be the `Middleware.Input`. @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) public struct RequestResponseMiddlewareBox< - RequestReader: ConcludingAsyncReader & ~Copyable, - ResponseWriter: ConcludingAsyncWriter & ~Copyable ->: ~Copyable { + Reader: HTTPBodyReader & ~Copyable, + ResponseSender: HTTPResponseSender & ~Copyable +>: ~Copyable +where ResponseSender.Writer: ~Copyable { private let request: HTTPRequest private let requestContext: HTTPRequestContext - private let requestReader: RequestReader - private let responseSender: HTTPResponseSender + private let reader: Reader + private let responseSender: ResponseSender /// Create a new ``RequestResponseMiddlewareBox``. - /// - Parameters: - /// - request: The `HTTPRequest`. - /// - requestReader: The `RequestReader`. - /// - responseSender: The ``HTTPResponseSender``. public init( request: HTTPRequest, requestContext: HTTPRequestContext, - requestReader: consuming RequestReader, - responseSender: consuming HTTPResponseSender + reader: consuming Reader, + responseSender: consuming ResponseSender ) { self.request = request self.requestContext = requestContext - self.requestReader = requestReader + self.reader = reader self.responseSender = responseSender } - /// Provides a closure exposing the request, request reader and response sender contained in this box. - /// - Parameter handler: The handler for this box's contents. - /// - Returns: The value returned from `handler`. + /// Provides a closure exposing the request, reader, and response sender contained in this box. public consuming func withContents( _ handler: nonisolated(nonsending) ( HTTPRequest, HTTPRequestContext, - consuming RequestReader, - consuming HTTPResponseSender + consuming Reader, + consuming ResponseSender ) async throws -> T ) async throws -> T { try await handler( self.request, self.requestContext, - self.requestReader, + self.reader, self.responseSender ) } diff --git a/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift b/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift index 04feecd..254706b 100644 --- a/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift +++ b/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift @@ -87,18 +87,34 @@ struct ETag: Sendable & ~Copyable { @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) func serve(server: NIOHTTPServer) async throws { let eTag = ETag() - try await server.serve { request, requestContext, requestBodyAndTrailers, responseSender in + try await server.serve { + request, + requestContext, + requestReader, + responseSender in // This server expects a path guard let path = request.path else { - let writer = try await responseSender.send(HTTPResponse(status: .internalServerError)) - try await writer.writeAndConclude("No path specified".utf8.span, finalElement: nil) + var body = UniqueArray( + capacity: 17, + copying: "No path specified".utf8 + ) + try await responseSender.send( + HTTPResponse(status: .internalServerError), + copying: &body + ) return } // This server expects a valid path guard let components = URLComponents(string: path) else { - let writer = try await responseSender.send(HTTPResponse(status: .internalServerError)) - try await writer.writeAndConclude("Malformed path".utf8.span, finalElement: nil) + var body = UniqueArray( + capacity: 17, + copying: "Malformed path".utf8 + ) + try await responseSender.send( + HTTPResponse(status: .internalServerError), + copying: &body + ) return } @@ -121,9 +137,9 @@ func serve(server: NIOHTTPServer) async throws { } // Parse the body as a UTF8 string and capture trailers - let (body, requestTrailers) = try await requestBodyAndTrailers.collect(upTo: 1024) { span in - return String(copying: try UTF8Span(validating: span.span)) - } + var bodyBuffer = UniqueArray(minimumCapacity: 1024) + let requestTrailers = try await requestReader.collect(into: &bodyBuffer) + let body = String(copying: try UTF8Span(validating: bodyBuffer.span)) // Collect the trailers that were sent in with the request var trailers: [String: [String]] = [:] @@ -139,17 +155,16 @@ func serve(server: NIOHTTPServer) async throws { let response = JSONHTTPRequest(params: params, headers: headers, body: body, method: method, trailers: trailers) let responseData = try JSONEncoder().encode(response) - let responseSpan = responseData.span - let writer = try await responseSender.send(HTTPResponse(status: .ok)) - try await writer.writeAndConclude(responseSpan, finalElement: nil) + var arrayResponseData = UniqueArray(copying: responseData) + try await responseSender.send(HTTPResponse(status: .ok), copying: &arrayResponseData) case "/head_with_cl": if request.method != .head { - try await responseSender.send(HTTPResponse(status: .methodNotAllowed)) + _ = try await responseSender.send(HTTPResponse(status: .methodNotAllowed)) break } // OK with a theoretical 1000-byte body - try await responseSender.send( + _ = try await responseSender.send( HTTPResponse( status: .ok, headerFields: [ @@ -158,78 +173,102 @@ func serve(server: NIOHTTPServer) async throws { ) ) case "/200": - // OK - let writer = try await responseSender.send(HTTPResponse(status: .ok)) - // Do not write a response body for a HEAD request - if request.method == .head { break } - - try await writer.writeAndConclude("".utf8.span, finalElement: nil) + if request.method == .head { + _ = try await responseSender.send(HTTPResponse(status: .ok)) + } else { + var body = UniqueArray() + try await responseSender.send(HTTPResponse(status: .ok), copying: &body) + } case "/gzip": // If the client didn't say that they supported this encoding, // then fallback to no encoding. let acceptEncoding = request.headerFields[.acceptEncoding] - var bytes: [UInt8] + var bytes: UniqueArray var headers: HTTPFields if let acceptEncoding, acceptEncoding.contains("gzip") { // "TEST\n" as gzip - bytes = [ - 0x1f, 0x8b, 0x08, 0x00, 0xfd, 0xd6, 0x77, 0x69, 0x04, 0x03, 0x0b, 0x71, 0x0d, 0x0e, - 0xe1, 0x02, 0x00, 0xbe, 0xd7, 0x83, 0xf7, 0x05, 0x00, 0x00, 0x00, - ] + bytes = .init(copying: [ + 0x1f, + 0x8b, + 0x08, + 0x00, + 0xfd, + 0xd6, + 0x77, + 0x69, + 0x04, + 0x03, + 0x0b, + 0x71, + 0x0d, + 0x0e, + 0xe1, + 0x02, + 0x00, + 0xbe, + 0xd7, + 0x83, + 0xf7, + 0x05, + 0x00, + 0x00, + 0x00, + ]) headers = [.contentEncoding: "gzip"] } else { // "TEST\n" as raw ASCII - bytes = [84, 69, 83, 84, 10] + bytes = .init(copying: [84, 69, 83, 84, 10]) headers = [:] } - let writer = try await responseSender.send(HTTPResponse(status: .ok, headerFields: headers)) - try await writer.writeAndConclude(bytes.span, finalElement: nil) + try await responseSender.send(HTTPResponse(status: .ok, headerFields: headers), copying: &bytes) case "/deflate": // If the client didn't say that they supported this encoding, // then fallback to no encoding. let acceptEncoding = request.headerFields[.acceptEncoding] - var bytes: [UInt8] + var bytes: UniqueArray var headers: HTTPFields if let acceptEncoding, acceptEncoding.contains("deflate") { // "TEST\n" as deflate - bytes = [0x78, 0x9c, 0x0b, 0x71, 0x0d, 0x0e, 0xe1, 0x02, 0x00, 0x04, 0x68, 0x01, 0x4b] + bytes = .init(copying: [0x78, 0x9c, 0x0b, 0x71, 0x0d, 0x0e, 0xe1, 0x02, 0x00, 0x04, 0x68, 0x01, 0x4b]) headers = [.contentEncoding: "deflate"] } else { // "TEST\n" as raw ASCII - bytes = [84, 69, 83, 84, 10] + bytes = .init(copying: [84, 69, 83, 84, 10]) headers = [:] } - let writer = try await responseSender.send(HTTPResponse(status: .ok, headerFields: headers)) - try await writer.writeAndConclude(bytes.span, finalElement: nil) + try await responseSender + .send( + HTTPResponse(status: .ok, headerFields: headers), + copying: &bytes + ) case "/brotli": // If the client didn't say that they supported this encoding, // then fallback to no encoding. let acceptEncoding = request.headerFields[.acceptEncoding] - var bytes: [UInt8] + var bytes: UniqueArray var headers: HTTPFields if let acceptEncoding, acceptEncoding.contains("br") { // "TEST\n" as brotli - bytes = [0x0f, 0x02, 0x80, 0x54, 0x45, 0x53, 0x54, 0x0a, 0x03] + bytes = .init(copying: [0x0f, 0x02, 0x80, 0x54, 0x45, 0x53, 0x54, 0x0a, 0x03]) headers = [.contentEncoding: "br"] } else { // "TEST\n" as raw ASCII - bytes = [84, 69, 83, 84, 10] + bytes = .init(copying: [84, 69, 83, 84, 10]) headers = [:] } - let writer = try await responseSender.send(HTTPResponse(status: .ok, headerFields: headers)) - try await writer.writeAndConclude(bytes.span, finalElement: nil) + try await responseSender.send(HTTPResponse(status: .ok, headerFields: headers), copying: &bytes) case "/header_multivalue": - try await responseSender.send( + _ = try await responseSender.send( HTTPResponse( status: .ok, headerFields: [ @@ -241,112 +280,81 @@ func serve(server: NIOHTTPServer) async throws { case "/identity": // This will always write out the body with no encoding. // Used to check that a client can handle fallback to no encoding. - let writer = try await responseSender.send(HTTPResponse(status: .ok)) - try await writer.writeAndConclude("TEST\n".utf8.span, finalElement: nil) + var body = UniqueArray.init(copying: "TEST\n".utf8) + try await responseSender.send(HTTPResponse(status: .ok), copying: &body) case "/redirect_ping": // Infinite redirection as a result of arriving here - let writer = try await responseSender.send( - HTTPResponse(status: .movedPermanently, headerFields: HTTPFields([HTTPField(name: .location, value: "/redirect_pong")])) + var body = UniqueArray.init(copying: "".utf8) + try await responseSender.send( + HTTPResponse(status: .movedPermanently, headerFields: HTTPFields([HTTPField(name: .location, value: "/redirect_pong")])), + copying: &body ) - try await writer - .writeAndConclude("".utf8.span, finalElement: nil) case "/redirect_pong": // Infinite redirection as a result of arriving here - let writer = try await responseSender.send( - HTTPResponse(status: .movedPermanently, headerFields: HTTPFields([HTTPField(name: .location, value: "/redirect_ping")])) + var body = UniqueArray.init(copying: "".utf8) + try await responseSender.send( + HTTPResponse(status: .movedPermanently, headerFields: HTTPFields([HTTPField(name: .location, value: "/redirect_ping")])), + copying: &body ) - try await writer - .writeAndConclude("".utf8.span, finalElement: nil) case "/301": // Redirect to /request - let writer = try await responseSender.send( - HTTPResponse(status: .movedPermanently, headerFields: HTTPFields([HTTPField(name: .location, value: "/request")])) + var body = UniqueArray.init(copying: "".utf8) + try await responseSender.send( + HTTPResponse(status: .movedPermanently, headerFields: HTTPFields([HTTPField(name: .location, value: "/request")])), + copying: &body ) - try await writer - .writeAndConclude("".utf8.span, finalElement: nil) case "/308": // Redirect to /request - let writer = try await responseSender.send( + var body = UniqueArray.init(copying: "".utf8) + try await responseSender.send( HTTPResponse( status: .permanentRedirect, headerFields: HTTPFields( [HTTPField(name: .location, value: "/request")] ) - ) + ), + copying: &body ) - try await writer - .writeAndConclude("".utf8.span, finalElement: nil) case "/404": - let writer = try await responseSender.send( - HTTPResponse(status: .notFound) - ) - try await writer - .writeAndConclude("".utf8.span, finalElement: nil) + var body = UniqueArray.init(copying: "".utf8) + try await responseSender.send(HTTPResponse(status: .notFound), copying: &body) case "/999": - let writer = try await responseSender.send( - HTTPResponse(status: 999) - ) - try await writer - .writeAndConclude("".utf8.span, finalElement: nil) + var body = UniqueArray.init(copying: "".utf8) + try await responseSender.send(HTTPResponse(status: 999), copying: &body) case "/echo": // Bad method if request.method != .post { - let writer = try await responseSender.send( - HTTPResponse(status: .methodNotAllowed) + var body = UniqueArray.init(copying: "Incorrect method".utf8) + try await responseSender.send( + HTTPResponse(status: .methodNotAllowed), + copying: &body ) - try await writer - .writeAndConclude( - "Incorrect method".utf8.span, - finalElement: nil - ) return } - // Needed since we are lacking call-once closures - var responseSender = Optional(responseSender) - - _ = - try await requestBodyAndTrailers - .consumeAndConclude { reader in - // Needed since we are lacking call-once closures - var reader = Optional(reader) - let responseBodyAndTrailers = try await responseSender.take()!.send(.init(status: .ok)) - try await responseBodyAndTrailers.produceAndConclude { responseBody in - var responseBody = responseBody - try await responseBody.write(reader.take()!) - return nil - } - } + // Pipe the request body straight back into the response, + // fusing the last chunk + trailers + FIN into one writer.finish. + let writer = try await responseSender.send(.init(status: .ok)) + try await requestReader.pipe(into: writer) case "/speak": - // Send the headers for the response - let responseBodyAndTrailers = try await responseSender.send(.init(status: .ok)) - - // Needed since we are lacking call-once closures - var requestBodyAndTrailers = Optional(requestBodyAndTrailers) - - try await responseBodyAndTrailers.produceAndConclude { - var writer = $0 - let _ = try await requestBodyAndTrailers.take()!.consumeAndConclude { - var reader = $0 - - // Server writes 1000 1-byte chunks of "A" and expects each - // chunk to be written back by the client before proceeding - // with the next one. - for i in 0..<1000 { - // Write a single-byte chunk - try await writer.write("A".utf8.span) - - // Wait for the client to write the same chunk to the request body - try await reader.read { buffer in - if buffer.count != 1 || buffer[buffer.startIndex] != UInt8(ascii: "A") { - assertionFailure("Received unexpected span") - } - buffer.removeAll() - } + // Server writes 1000 1-byte chunks of "A" and expects each + // chunk to be written back by the client before proceeding + // with the next one. The interleaving is genuine: read and + // write are alternated within the same handler. + var requestReader = requestReader + var writer = try await responseSender.send(.init(status: .ok)) + for _ in 0..<1000 { + try await writer.write(UInt8(ascii: "A")) + // Read back the echo before sending the next chunk. + var got = 0 + while got == 0 { + try await requestReader.read { rbuf, _ in + var c = rbuf.consumeAll() + while c.next() != nil { got += 1 } } } - return nil } + try await writer.finish(trailers: nil) case "/stall": do { // Wait for an hour (effectively never giving an answer) @@ -356,30 +364,25 @@ func serve(server: NIOHTTPServer) async throws { // It is okay for the client to give up on the connection due to the stall. } case "/stall_body": - // Send headers and partial body - let responseBodyAndTrailers = try await responseSender.send(.init(status: .ok)) - do { - try await responseBodyAndTrailers.produceAndConclude { responseBody in - var responseBody = responseBody - try await responseBody.write([UInt8](repeating: UInt8(ascii: "A"), count: 1000).span) + var writer = try await responseSender.send(.init(status: .ok)) + try await writer.write { buffer in + buffer.append(copying: [UInt8](repeating: UInt8(ascii: "A"), count: 1000)) + } - // Wait for an hour (effectively never giving an answer) - try await Task.sleep(for: .seconds(60 * 60)) + // Wait for an hour (effectively never giving an answer) + try await Task.sleep(for: .seconds(60 * 60)) - assertionFailure("Not expected to complete hour-long wait") + assertionFailure("Not expected to complete hour-long wait") - return nil - } + try await writer.finish(trailers: nil) } catch { // It is okay for the client to give up on the connection due to the stall. } case "/1mb_body": - let responseBodyAndTrailers = try await responseSender.send(.init(status: .ok)) - let data = String(repeating: "A", count: 1_000_000).data(using: .ascii)! - + var body = UniqueArray.init(copying: String(repeating: "A", count: 1_000_000).data(using: .ascii)!) do { - try await responseBodyAndTrailers.writeAndConclude(data.span, finalElement: nil) + try await responseSender.send(.init(status: .ok), copying: &body) } catch { // It is okay for the client to give up while reading this response. // Example: a client may only want the first byte from this response. @@ -389,62 +392,68 @@ func serve(server: NIOHTTPServer) async throws { } case "/cookie": let cookie = UUID().uuidString - let responseBodyAndTrailers = try await responseSender.send( + var body = UniqueArray.init(copying: "".utf8) + try await responseSender.send( .init( status: .ok, headerFields: [ .setCookie: "foo=\(cookie)" ] - ) + ), + copying: &body ) - try await responseBodyAndTrailers.writeAndConclude(Span(), finalElement: nil) case "/etag": let clientETag = request.headerFields[.ifNoneMatch] let (serverETag, isNotModified) = eTag.next(clientETag: clientETag) if isNotModified { + var body = UniqueArray.init(copying: "".utf8) // Nothing has changed, so 304 Not Modified. - let responseBodyAndTrailers = try await responseSender.send( + try await responseSender.send( .init( status: .notModified, headerFields: [ .eTag: serverETag, .cached: "true", ] - ) + ), + copying: &body ) - try await responseBodyAndTrailers.writeAndConclude(Span(), finalElement: nil) } else { // The server wants to give a new ETag to the client - let responseBodyAndTrailers = try await responseSender.send( + // Give the etag itself as the new body + var body = UniqueArray.init(copying: serverETag.data(using: .ascii)!) + try await responseSender.send( .init( status: .ok, headerFields: [ .eTag: serverETag, .cached: "false", ] - ) + ), + copying: &body ) - // Give the etag itself as the new body - let data = serverETag.data(using: .ascii)! - try await responseBodyAndTrailers.writeAndConclude(data.span, finalElement: nil) } case "/trailers": // Send a response with custom trailers - let responseBodyAndTrailers = try await responseSender.send(.init(status: .ok)) - try await responseBodyAndTrailers.produceAndConclude { responseBody in - var responseBody = responseBody - // Write the body - try await responseBody.write("Response body".utf8.span) - // Return custom trailers - return [ + var writer = try await responseSender.send(.init(status: .ok)) + // Write the body + try await writer.write { buffer in + buffer.append(copying: "Response body".utf8) + } + // Send custom trailers + try await writer.finish( + trailers: [ .init("X-Trailer-One")!: "first-value", .init("X-Trailer-Two")!: "second-value", .init("X-Checksum")!: "abc123", ] - } + ) default: - let writer = try await responseSender.send(HTTPResponse(status: .internalServerError)) - try await writer.writeAndConclude("Unknown path".utf8.span, finalElement: nil) + var body = UniqueArray.init(copying: "Unknown path".utf8) + try await responseSender.send( + HTTPResponse(status: .internalServerError), + copying: &body + ) } } } diff --git a/Sources/Middleware/ChainedMiddleware.swift b/Sources/Middleware/ChainedMiddleware.swift index 594e71f..f4e3006 100644 --- a/Sources/Middleware/ChainedMiddleware.swift +++ b/Sources/Middleware/ChainedMiddleware.swift @@ -21,7 +21,12 @@ /// middleware components in a type-safe way. // TODO: Revisit if this type should be public public struct ChainedMiddleware: Middleware -where First.Input: ~Copyable, First.NextInput: ~Copyable, Second.NextInput: ~Copyable, First.NextInput == Second.Input { +where + First.Input: ~Copyable & ~Escapable, + First.NextInput: ~Copyable & ~Escapable, + Second.NextInput: ~Copyable & ~Escapable, + First.NextInput == Second.Input +{ /// The first middleware in the chain. private let first: First diff --git a/Sources/Middleware/MiddlewareBuilder.swift b/Sources/Middleware/MiddlewareBuilder.swift index cedd124..95be106 100644 --- a/Sources/Middleware/MiddlewareBuilder.swift +++ b/Sources/Middleware/MiddlewareBuilder.swift @@ -43,7 +43,7 @@ public struct MiddlewareBuilder { /// - Returns: A middleware chain containing the single component. public static func buildPartialBlock( first middleware: M - ) -> M where M.Input: ~Copyable, M.NextInput: ~Copyable { + ) -> M where M.Input: ~Copyable & ~Escapable, M.NextInput: ~Copyable & ~Escapable { middleware } @@ -63,7 +63,13 @@ public struct MiddlewareBuilder { accumulated: First, next: Second ) -> ChainedMiddleware - where First.Input: ~Copyable, First.NextInput: ~Copyable, Second.Input: ~Copyable, Second.NextInput: ~Copyable, First.NextInput == Second.Input { + where + First.Input: ~Copyable & ~Escapable, + First.NextInput: ~Copyable & ~Escapable, + Second.Input: ~Copyable & ~Escapable, + Second.NextInput: ~Copyable, + First.NextInput == Second.Input + { return ChainedMiddleware(first: accumulated, second: next) } @@ -75,7 +81,7 @@ public struct MiddlewareBuilder { /// - Returns: A middleware chain wrapping the input middleware. public static func buildExpression( _ middleware: M - ) -> M where M.Input: ~Copyable, M.NextInput: ~Copyable { + ) -> M where M.Input: ~Copyable & ~Escapable, M.NextInput: ~Copyable & ~Escapable { middleware } } diff --git a/Sources/URLSessionHTTPClient/URLSessionHTTPClient.swift b/Sources/URLSessionHTTPClient/URLSessionHTTPClient.swift index ca30f06..4359429 100644 --- a/Sources/URLSessionHTTPClient/URLSessionHTTPClient.swift +++ b/Sources/URLSessionHTTPClient/URLSessionHTTPClient.swift @@ -23,9 +23,13 @@ import Synchronization /// The HTTPClient implementation backed by URLSession. @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) public final class URLSessionHTTPClient: HTTPClient, IdleTimerEntryProvider { - public struct RequestWriter: AsyncWriter, ~Copyable { + public typealias Writer = RequestWriter + public typealias Reader = ResponseReader + + public struct RequestWriter: HTTPBodyWriter, ~Copyable { public typealias WriteElement = UInt8 public typealias WriteFailure = any Error + // TODO: This should become InputSpan or Data most likely once they conform to the container protocols public typealias Buffer = UniqueArray var actual: URLSessionRequestStreamBridge @@ -65,61 +69,81 @@ public final class URLSessionHTTPClient: HTTPClient, IdleTimerEntryProvider { } return result } + + public consuming func finish( + body: (inout UniqueArray) async throws(Failure) -> HTTPFields? + ) async throws(AsyncStreaming.EitherError) { + var buffer = self.buffer.take()! + let trailers: HTTPFields? + do { + trailers = try await body(&buffer) + } catch { + throw .second(error) + } + if buffer.count > 0 { + do { + try await self.actual.internalWrite(buffer.span) + } catch { + throw .first(error) + } + } + self.actual.close(trailerFields: trailers) + } } - public struct ResponseConcludingReader: ConcludingAsyncReader, ~Copyable { - public struct Underlying: AsyncReader, ~Copyable { - public typealias ReadElement = UInt8 - public typealias ReadFailure = any Error - public typealias Buffer = UniqueArray + public struct ResponseReader: HTTPBodyReader, ~Copyable { + public typealias ReadElement = UInt8 + public typealias ReadFailure = any Error + public typealias Buffer = UniqueArray - var actual: URLSessionTaskDelegateBridge - var buffer: UniqueArray? + var actual: URLSessionTaskDelegateBridge + var buffer: UniqueArray? + var trailersDelivered: Bool = false - init(actual: URLSessionTaskDelegateBridge) { - self.actual = actual - self.buffer = UniqueArray(minimumCapacity: 1024) - } + init(actual: URLSessionTaskDelegateBridge) { + self.actual = actual + self.buffer = UniqueArray(minimumCapacity: 1024) + } - public mutating func read( - body: (inout UniqueArray) async throws(Failure) -> Return - ) async throws(AsyncStreaming.EitherError) -> Return where Failure: Error { + public mutating func read( + body: (inout UniqueArray, HTTPFields?) async throws(Failure) -> Return + ) async throws(AsyncStreaming.EitherError) -> Return where Failure: Error { + // This force-unwrap is safe since there can only be one concurrent read. + var buffer = self.buffer.take()! + var trailers: HTTPFields? = nil + + if !self.trailersDelivered { let data: Data? do { data = try await self.actual.data(maximumCount: nil) } catch { + self.buffer = consume buffer throw .first(error) } - // This force-unwrap is safe since there can only be one concurrent read. - var buffer = self.buffer.take()! if let data, !data.isEmpty { buffer.reserveCapacity(data.count) buffer.append(copying: data.span) } - let result: Return - do { - result = try await body(&buffer) - } catch { - buffer.removeAll() - self.buffer = consume buffer - throw .second(error) + if data == nil { + self.trailersDelivered = true + trailers = self.actual.responseTrailerFields ?? HTTPFields() } + } + + let result: Return + do { + result = try await body(&buffer, trailers) + } catch { buffer.removeAll() self.buffer = consume buffer - return result + throw .second(error) } + buffer.removeAll() + self.buffer = consume buffer + return result } - - public func consumeAndConclude( - body: (consuming sending Underlying) async throws(Failure) -> Return - ) async throws(Failure) -> (Return, HTTPFields?) where Failure: Error { - let result = try await body(Underlying(actual: self.actual)) - return (result, self.actual.responseTrailerFields) - } - - let actual: URLSessionTaskDelegateBridge } public typealias RequestOptions = URLSessionRequestOptions @@ -361,7 +385,7 @@ public final class URLSessionHTTPClient: HTTPClient, IdleTimerEntryProvider { request: HTTPRequest, body: consuming HTTPClientRequestBody?, options: URLSessionRequestOptions, - responseHandler: (HTTPResponse, consuming ResponseConcludingReader) async throws -> Return + responseHandler: (HTTPResponse, consuming ResponseReader) async throws -> Return ) async throws -> Return { guard request.schemeSupported else { throw HTTPTypeConversionError.unsupportedScheme @@ -390,7 +414,7 @@ public final class URLSessionHTTPClient: HTTPClient, IdleTimerEntryProvider { guard let response = (response as? HTTPURLResponse)?.httpResponse else { throw HTTPTypeConversionError.failedToConvertURLTypeToHTTPTypes } - result = .success(try await responseHandler(response, .init(actual: delegateBridge))) + result = .success(try await responseHandler(response, ResponseReader(actual: delegateBridge))) } catch { result = .failure(error) } diff --git a/Sources/URLSessionHTTPClient/URLSessionTaskDelegateBridge.swift b/Sources/URLSessionHTTPClient/URLSessionTaskDelegateBridge.swift index 58295a2..ccbf799 100644 --- a/Sources/URLSessionHTTPClient/URLSessionTaskDelegateBridge.swift +++ b/Sources/URLSessionHTTPClient/URLSessionTaskDelegateBridge.swift @@ -260,8 +260,8 @@ final class URLSessionTaskDelegateBridge: NSObject, Sendable, URLSessionDataDele let bridge = URLSessionRequestStreamBridge(task: task) completionHandler(bridge.inputStream) do { - let trailerFields = try await requestBody.produce(into: URLSessionHTTPClient.RequestWriter(actual: bridge)) - bridge.close(trailerFields: trailerFields) + try await requestBody.produce(into: URLSessionHTTPClient.RequestWriter(actual: bridge)) + // bridge is closed by writer.finish } catch { if bridge.writeFailed { // Ignore error @@ -291,8 +291,8 @@ final class URLSessionTaskDelegateBridge: NSObject, Sendable, URLSessionDataDele let bridge = URLSessionRequestStreamBridge(task: task) completionHandler(bridge.inputStream) do { - let trailerFields = try await requestBody.produce(offset: offset, into: URLSessionHTTPClient.RequestWriter(actual: bridge)) - bridge.close(trailerFields: trailerFields) + try await requestBody.produce(offset: offset, into: URLSessionHTTPClient.RequestWriter(actual: bridge)) + // bridge is closed by writer.finish } catch { if bridge.writeFailed { // Ignore error diff --git a/Tests/HTTPAPIsTests/EchoTests.swift b/Tests/HTTPAPIsTests/EchoTests.swift index 0afbeb5..dfbe1e2 100644 --- a/Tests/HTTPAPIsTests/EchoTests.swift +++ b/Tests/HTTPAPIsTests/EchoTests.swift @@ -19,18 +19,9 @@ import Testing @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) extension TestClientAndServer { func echo() async throws { - try await self.serve { request, requestContext, requestBodyAndTrailers, responseSender in - // Needed since we are lacking call-once closures - var requestBodyAndTrailers = Optional(requestBodyAndTrailers) - let responseBodyAndTrailers = try await responseSender.send(.init(status: .ok)) - - try await responseBodyAndTrailers.produceAndConclude { responseBody in - // Needed since we are lacking call-once closures - var responseBody = responseBody - return try await requestBodyAndTrailers.take()!.consumeAndConclude { reader in - try await responseBody.write(reader) - } - } + try await self.serve { request, requestContext, reader, responseSender in + let writer = try await responseSender.send(.init(status: .ok)) + try await reader.pipe(into: writer) } } } @@ -55,19 +46,19 @@ struct HTTPClientAndServerTests { var client = clientAndServer try await client.perform( request: request, - body: .restartable { (requestBody: consuming TestClientAndServer.RequestWriter) async throws -> HTTPFields? in - try await requestBody.write("Hello".utf8.span) - return HTTPFields([.init(name: .date, value: "test")]) + body: .restartable { writer in + var body = UniqueArray.init(copying: "Hello".utf8) + try await writer.finish( + copying: &body, + trailers: HTTPFields([.init(name: .date, value: "test")]) + ) } - ) { response, responseBodyAndTrailers in + ) { (response: HTTPResponse, reader: consuming TestClientAndServer.AsyncChannelBodyReader) in #expect(response.status == .ok) - let (response, trailers) = try await responseBodyAndTrailers.consumeAndConclude { responseBody in - var responseBody = responseBody - return try await responseBody.collect(upTo: 100) { span in - String(copying: try UTF8Span(validating: span.span)) - } - } - #expect(response == "Hello") + var responseBody = UniqueArray(minimumCapacity: 100) + let trailers = try await reader.collect(into: &responseBody) + let isEqual = responseBody == UniqueArray(copying: "Hello".utf8) + #expect(isEqual) #expect(trailers == HTTPFields([.init(name: .date, value: "test")])) } diff --git a/Tests/HTTPAPIsTests/Helpers/HTTPClientAndServerTests.swift b/Tests/HTTPAPIsTests/Helpers/HTTPClientAndServerTests.swift index bd0e98b..8c2cab8 100644 --- a/Tests/HTTPAPIsTests/Helpers/HTTPClientAndServerTests.swift +++ b/Tests/HTTPAPIsTests/Helpers/HTTPClientAndServerTests.swift @@ -27,60 +27,131 @@ final class TestClientAndServer: HTTPClient, HTTPServer { struct RequestOptions: HTTPClientCapability.RequestOptions { init() {} } - /// A concluding async reader backed by an underlying MPSCAsyncChannel. - struct AsyncChannelConcludingAsyncReader: ConcludingAsyncReader, ~Copyable, SendableMetatype { - typealias Underlying = MultiProducerSingleConsumerAsyncChannel - typealias FinalElement = HTTPFields? - var channel: Disconnected?> + typealias UnderlyingChannel = MultiProducerSingleConsumerAsyncChannel + typealias UnderlyingSource = UnderlyingChannel.Source + + /// A body writer for the test client/server. Wraps an MPSC source and a + /// trailers side-channel so trailers and end-of-body flow together. + struct AsyncChannelBodyWriter: HTTPBodyWriter, ~Copyable, SendableMetatype { + typealias WriteElement = UInt8 + typealias WriteFailure = any Error + typealias Buffer = UniqueArray + + var source: UnderlyingSource var trailersChannel: AsyncChannel - init( - channel: consuming sending MultiProducerSingleConsumerAsyncChannel, - trailersChannel: AsyncChannel - ) { - self.channel = Disconnected(value: channel) + init(source: consuming UnderlyingSource, trailersChannel: AsyncChannel) { + self.source = source self.trailersChannel = trailersChannel } - consuming func consumeAndConclude( - body: (consuming sending MultiProducerSingleConsumerAsyncChannel) async throws(Failure) -> Return - ) async throws(Failure) -> (Return, HTTPFields?) { - let channel = self.channel.swap(newValue: nil)! - let result = try await body(channel) - let trailers = await self.trailersChannel.first { _ in true } ?? nil - return (result, trailers) + mutating func write( + _ body: (inout UniqueArray) async throws(F) -> Return + ) async throws(EitherError) -> Return { + try await self.source.write(body) + } + + consuming func finish( + body: (inout UniqueArray) async throws(Failure) -> HTTPFields? + ) async throws(EitherError) { + var buffer = UniqueArray() + let trailers: HTTPFields? + do { + trailers = try await body(&buffer) + } catch { + throw .second(error) + } + var consumer = buffer.consumeAll() + while let element = consumer.next() { + do { + try await self.source.send(element) + } catch { + throw .first(error) + } + } + self.source.finish() + await self.trailersChannel.send(trailers) } } - /// A concluding async writer backed by an underlying MPSCAsyncChannel.Source. - struct AsyncChannelConcludingAsyncWriter: ConcludingAsyncWriter, ~Copyable, SendableMetatype { - typealias Underlying = MultiProducerSingleConsumerAsyncChannel.Source - typealias FinalElement = HTTPFields? + /// A body reader for the test client/server. Wraps an MPSC channel and a + /// trailers side-channel so trailers ride on the read that emits the last + /// chunk. + struct AsyncChannelBodyReader: HTTPBodyReader, ~Copyable, SendableMetatype { + typealias ReadElement = UInt8 + typealias ReadFailure = any Error + typealias Buffer = UniqueArray - var source: Disconnected.Source?> + var channel: UnderlyingChannel + var trailersChannel: AsyncChannel + var trailersDelivered: Bool = false + + init(channel: consuming UnderlyingChannel, trailersChannel: AsyncChannel) { + self.channel = channel + self.trailersChannel = trailersChannel + } + + mutating func read( + body: (inout UniqueArray, HTTPFields?) async throws(Failure) -> Return + ) async throws(EitherError) -> Return { + var buffer = UniqueArray() + var trailers: HTTPFields? = nil + + if !self.trailersDelivered { + let element: UInt8? + do { + element = try await self.channel.next() + } catch { + throw .first(error) + } + + if let element { + buffer.append(element) + } else { + self.trailersDelivered = true + let received = await self.trailersChannel.first { _ in true } ?? nil + trailers = received ?? HTTPFields() + } + } + + do { + return try await body(&buffer, trailers) + } catch { + throw .second(error) + } + } + } + + /// A response sender backed by an MPSCAsyncChannel.Source. + struct AsyncChannelResponseSender: HTTPResponseSender, ~Copyable, SendableMetatype { + typealias Writer = AsyncChannelBodyWriter + + let resumeWith: @Sendable (HTTPResponse, consuming sending AsyncChannelBodyReader) -> Void + var source: Disconnected + let responseReader: Disconnected var trailersChannel: AsyncChannel init( - source: consuming sending MultiProducerSingleConsumerAsyncChannel.Source, + resumeWith: @escaping @Sendable (HTTPResponse, consuming sending AsyncChannelBodyReader) -> Void, + source: consuming sending UnderlyingSource, + responseReader: consuming sending AsyncChannelBodyReader, trailersChannel: AsyncChannel ) { + self.resumeWith = resumeWith self.source = Disconnected(value: consume source) + self.responseReader = Disconnected(value: consume responseReader) self.trailersChannel = trailersChannel } - consuming func produceAndConclude( - body: (consuming sending MultiProducerSingleConsumerAsyncChannel.Source) async throws -> (Return, HTTPFields?) - ) async throws -> Return { - do { - let source = self.source.swap(newValue: nil)! - let (result, trailers) = try await body(source) - await self.trailersChannel.send(trailers) - return result - } catch { - self.trailersChannel.finish() - throw error - } + func sendInformational(_ response: HTTPResponse) async throws { + // No-op + } + + consuming func send(_ response: HTTPResponse) async throws -> AsyncChannelBodyWriter { + self.resumeWith(response, self.responseReader.take()!) + let source = self.source.swap(newValue: nil)! + return AsyncChannelBodyWriter(source: source, trailersChannel: self.trailersChannel) } } @@ -88,24 +159,24 @@ final class TestClientAndServer: HTTPClient, HTTPServer { private struct BufferedRequest: ~Copyable { final class Response { var response: HTTPResponse - private var responseReader: AsyncChannelConcludingAsyncReader? + private var responseReader: AsyncChannelBodyReader? - init(response: HTTPResponse, responseReader: consuming AsyncChannelConcludingAsyncReader) { + init(response: HTTPResponse, responseReader: consuming AsyncChannelBodyReader) { self.response = response self.responseReader = consume responseReader } - func takeResponseReader() -> AsyncChannelConcludingAsyncReader { + func takeResponseReader() -> AsyncChannelBodyReader { self.responseReader.take()! } } var request: HTTPRequest - var body: Disconnected??> + var body: Disconnected??> var responseContinuation: CheckedContinuation init( request: HTTPRequest, - body: consuming sending HTTPClientRequestBody?, + body: consuming sending HTTPClientRequestBody?, responseContinuation: CheckedContinuation ) { self.request = request @@ -113,15 +184,14 @@ final class TestClientAndServer: HTTPClient, HTTPServer { self.responseContinuation = responseContinuation } - mutating func takeBody() -> sending HTTPClientRequestBody? { + mutating func takeBody() -> sending HTTPClientRequestBody? { self.body.swap(newValue: nil)! } } - typealias RequestWriter = AsyncChannelConcludingAsyncWriter.Underlying - typealias ResponseConcludingReader = AsyncChannelConcludingAsyncReader - typealias RequestConcludingReader = AsyncChannelConcludingAsyncReader - typealias ResponseConcludingWriter = AsyncChannelConcludingAsyncWriter + typealias Writer = AsyncChannelBodyWriter + typealias Reader = AsyncChannelBodyReader + typealias ResponseSender = AsyncChannelResponseSender private let requests = Mutex>(.init()) private let (stream, continuation): (AsyncStream, AsyncStream.Continuation) @@ -138,9 +208,9 @@ final class TestClientAndServer: HTTPClient, HTTPServer { func perform( request: HTTPRequest, - body: consuming HTTPClientRequestBody?, + body: consuming HTTPClientRequestBody?, options: RequestOptions, - responseHandler: (HTTPResponse, consuming AsyncChannelConcludingAsyncReader) async throws -> Return + responseHandler: (HTTPResponse, consuming AsyncChannelBodyReader) async throws -> Return ) async throws -> Return { let response = try await withCheckedThrowingContinuation { continuation in self.requests.withLock { requests in @@ -164,7 +234,7 @@ final class TestClientAndServer: HTTPClient, HTTPServer { } func serve( - handler: some HTTPServerRequestHandler + handler: some HTTPServerRequestHandler ) async throws { try await withThrowingDiscardingTaskGroup { group in for await _ in self.stream { @@ -184,38 +254,38 @@ final class TestClientAndServer: HTTPClient, HTTPServer { private static func handleRequest( request: consuming BufferedRequest, - handler: some HTTPServerRequestHandler + handler: some HTTPServerRequestHandler ) async throws { - try await withThrowingTaskGroup { group in + try await withThrowingTaskGroup(of: Void.self) { group in let trailersChannel = AsyncChannel() - var requestChannelAndSource = MultiProducerSingleConsumerAsyncChannel.makeChannel( + var requestChannelAndSource = UnderlyingChannel.makeChannel( throwing: (any Error).self, backpressureStrategy: .watermark(low: 10, high: 20) ) let requestChannel = requestChannelAndSource.takeChannel() let requestSource = requestChannelAndSource.source // Needed since we are lacking call-once closures - var requestWriter: AsyncChannelConcludingAsyncWriter? = AsyncChannelConcludingAsyncWriter( - source: requestSource, - trailersChannel: trailersChannel + let requestWriterSlot = Mutex( + Disconnected( + value: Optional( + AsyncChannelBodyWriter( + source: requestSource, + trailersChannel: trailersChannel + ) + ) + ) ) - let requestReader = AsyncChannelConcludingAsyncReader( + let requestReader = AsyncChannelBodyReader( channel: requestChannel, trailersChannel: trailersChannel ) - var responseChannelAndSource = MultiProducerSingleConsumerAsyncChannel.makeChannel( + var responseChannelAndSource = UnderlyingChannel.makeChannel( throwing: (any Error).self, backpressureStrategy: .watermark(low: 10, high: 20) ) let responseChannel = responseChannelAndSource.takeChannel() let responseSource = responseChannelAndSource.source - // Needed since we are lacking call-once closures - var responseWriter: AsyncChannelConcludingAsyncWriter? = AsyncChannelConcludingAsyncWriter( - source: responseSource, - trailersChannel: trailersChannel - ) - // Needed since we are lacking call-once closures - var responseReader: AsyncChannelConcludingAsyncReader? = AsyncChannelConcludingAsyncReader( + let responseReader = AsyncChannelBodyReader( channel: responseChannel, trailersChannel: trailersChannel ) @@ -223,31 +293,32 @@ final class TestClientAndServer: HTTPClient, HTTPServer { // Needed since we are lacking call-once closures let body = request.takeBody() group.addTask { - try await requestWriter.take()!.produceAndConclude { writer in - try await body?.produce(into: writer) + let writer = requestWriterSlot.withLock { $0.swap(newValue: nil) }! + if let body { + try await body.produce(into: writer) + } else { + // No body: just signal end-of-stream with no trailers. + try await writer.finish(trailers: nil) } } let responseContinuation = request.responseContinuation - let responseSender = HTTPResponseSender { response in - responseContinuation - .resume( - returning: .init( - response: response, - // Needed since we are lacking call-once closures - responseReader: responseReader.take()! - ) + let responseSender = AsyncChannelResponseSender( + resumeWith: { response, reader in + responseContinuation.resume( + returning: .init(response: response, responseReader: reader) ) - // Needed since we are lacking call-once closures - return responseWriter.take()! - } sendInformational: { _ in - } + }, + source: responseSource, + responseReader: responseReader, + trailersChannel: trailersChannel + ) try await handler .handle( request: request.request, requestContext: .init(), - requestBodyAndTrailers: requestReader, + reader: requestReader, responseSender: responseSender ) }