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 ) }