From 45aa7e1afa576c7a99bf346c5c65f2f2b710b431 Mon Sep 17 00:00:00 2001 From: Franz Busch Date: Fri, 15 May 2026 16:53:57 +0200 Subject: [PATCH 01/11] Use new final element support from `AsyncStreaming` ## Motivation `AsyncStreaming` added support for final elements, which lets us drop our concluding reader and writer protocols and use `AsyncReader` and `CallerAsyncWriter` directly with `FinalElement == HTTPFields?` to carry the HTTP trailers inline. ## Modifications This PR moves the HTTP body shape over to `AsyncReader` and `CallerAsyncWriter` from `AsyncStreaming` and introduces a new `HTTPResponseSender` protocol. With both together the code becomes significantly easier, we get to fuse the final buffer and the fin flag on the wire, and `HTTPResponseSender` makes it possible to flush head, body and trailers all in one go. All four client and server conformers (URLSession, AsyncHTTPClient, Fetch, NIO) and the example middlewares are updated to the new shape, and the in-process test harness uses upstream `DuplexAsyncChannel` for both directions. ## Result No more concluding reader and writer APIs and instead only async streaming and HTTP specific types. --- Examples/EchoServer/EchoServer.swift | 15 +- .../ForwardingMiddleware.swift | 2 +- .../HTTPClientMiddlewareInput.swift | 37 ++ .../HTTPServerLoggingMiddleware.swift | 260 +++++------- .../HTTPServerMiddlewareInput.swift | 54 +-- .../HTTPServerRequestHandlerMiddleware.swift | 75 +--- .../ExampleMiddlewareClient.swift | 46 ++- .../ExampleMiddlewareServer.swift | 29 +- .../MiddlewareServer/MiddlewareServer.swift | 7 +- Examples/ProxyServer/ProxyServer.swift | 40 +- Examples/WASMClient/main.swift | 33 +- Package.swift | 4 +- Sources/AHCHTTPClient/AHC+HTTPClient.swift | 155 ++++--- Sources/FetchHTTPClient/FetchHTTPClient.swift | 88 ++-- .../HTTPAPIs/AsyncReader+CollectInto.swift | 54 +++ .../HTTPAPIs/AsyncWriter+AsyncReader.swift | 54 --- Sources/HTTPAPIs/AsyncWriter.swift | 76 ---- Sources/HTTPAPIs/CallerAsyncWriter+HTTP.swift | 27 ++ .../HTTPAPIs/CallerAsyncWriter+Optional.swift | 39 ++ .../Client/HTTPClient+Conveniences.swift | 74 +++- Sources/HTTPAPIs/Client/HTTPClient.swift | 38 +- .../Client/HTTPClientRequestBody+Data.swift | 11 +- .../Client/HTTPClientRequestBody.swift | 102 ++--- .../ConcludingAsyncReader+collect.swift | 65 --- Sources/HTTPAPIs/ConcludingAsyncReader.swift | 52 --- Sources/HTTPAPIs/ConcludingAsyncWriter.swift | 146 ------- .../HTTPAPIs/Server/HTTPResponseSender.swift | 89 ++++ Sources/HTTPAPIs/Server/HTTPServer.swift | 49 +-- .../HTTPServerClosureRequestHandler.swift | 87 ++-- .../Server/HTTPServerRequestHandler.swift | 88 ++-- .../Server/HTTPServerResponseSender.swift | 73 ---- Sources/HTTPClient/DefaultHTTPClient.swift | 77 ++-- Sources/HTTPClient/HTTP+Conveniences.swift | 6 +- .../HTTPClientConformance.swift | 385 +++++++++--------- .../HTTPRequestConcludingAsyncReader.swift | 204 ---------- .../HTTPRequestContext.swift | 2 +- .../HTTPResponseConcludingAsyncWriter.swift | 162 -------- .../NIOHTTPResponseSender.swift | 129 ++++++ .../NIOHTTPServer+HTTP1_1.swift | 4 +- .../NIOHTTPServer+SecureUpgrade.swift | 4 +- .../HTTPServerForTesting/NIOHTTPServer.swift | 72 +--- .../NIORequestBodyReader.swift | 99 +++++ .../RequestResponseMiddlewareBox.swift | 37 +- .../HTTPServerForTesting/TestHTTPServer.swift | 307 +++++++------- Sources/Middleware/ChainedMiddleware.swift | 7 +- Sources/Middleware/MiddlewareBuilder.swift | 12 +- .../URLSessionHTTPClient.swift | 127 +++--- .../URLSessionTaskDelegateBridge.swift | 8 +- Tests/HTTPAPIsTests/EchoTests.swift | 37 +- .../Helpers/HTTPClientAndServerTests.swift | 298 +++++++++----- ...AsyncChannel+AsyncReader+AsyncWriter.swift | 75 ---- .../HTTPAPIsTests/ServerCapabilityTests.swift | 15 +- 52 files changed, 1765 insertions(+), 2271 deletions(-) create mode 100644 Examples/ExampleMiddleware/HTTPClientMiddlewareInput.swift create mode 100644 Sources/HTTPAPIs/AsyncReader+CollectInto.swift delete mode 100644 Sources/HTTPAPIs/AsyncWriter+AsyncReader.swift delete mode 100644 Sources/HTTPAPIs/AsyncWriter.swift create mode 100644 Sources/HTTPAPIs/CallerAsyncWriter+HTTP.swift create mode 100644 Sources/HTTPAPIs/CallerAsyncWriter+Optional.swift delete mode 100644 Sources/HTTPAPIs/ConcludingAsyncReader+collect.swift delete mode 100644 Sources/HTTPAPIs/ConcludingAsyncReader.swift delete mode 100644 Sources/HTTPAPIs/ConcludingAsyncWriter.swift create mode 100644 Sources/HTTPAPIs/Server/HTTPResponseSender.swift delete mode 100644 Sources/HTTPAPIs/Server/HTTPServerResponseSender.swift delete mode 100644 Sources/HTTPClientConformance/HTTPServerForTesting/HTTPRequestConcludingAsyncReader.swift delete mode 100644 Sources/HTTPClientConformance/HTTPServerForTesting/HTTPResponseConcludingAsyncWriter.swift create mode 100644 Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPResponseSender.swift create mode 100644 Sources/HTTPClientConformance/HTTPServerForTesting/NIORequestBodyReader.swift delete mode 100644 Tests/HTTPAPIsTests/Helpers/MultiProducerSingleConsumerAsyncChannel+AsyncReader+AsyncWriter.swift diff --git a/Examples/EchoServer/EchoServer.swift b/Examples/EchoServer/EchoServer.swift index c79d73f..78b02b8 100644 --- a/Examples/EchoServer/EchoServer.swift +++ b/Examples/EchoServer/EchoServer.swift @@ -23,18 +23,9 @@ struct EchoServer { } 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) - } - } + 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 4378238..147f00b 100644 --- a/Examples/ExampleMiddleware/ForwardingMiddleware.swift +++ b/Examples/ExampleMiddleware/ForwardingMiddleware.swift @@ -26,7 +26,7 @@ public struct ForwardingMiddleware: Middleware { } @available(anyAppleOS 26.0, *) -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..5f42b67 --- /dev/null +++ b/Examples/ExampleMiddleware/HTTPClientMiddlewareInput.swift @@ -0,0 +1,37 @@ +//===----------------------------------------------------------------------===// +// +// 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 AsyncStreaming +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(anyAppleOS 26.0, *) +public struct HTTPClientMiddlewareInput: ~Copyable +where Writer.WriteElement == UInt8, Writer.FinalElement == HTTPFields? { + 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/HTTPServerLoggingMiddleware.swift b/Examples/ExampleMiddleware/HTTPServerLoggingMiddleware.swift index a4e29c6..049d01e 100644 --- a/Examples/ExampleMiddleware/HTTPServerLoggingMiddleware.swift +++ b/Examples/ExampleMiddleware/HTTPServerLoggingMiddleware.swift @@ -11,50 +11,35 @@ // //===----------------------------------------------------------------------===// +public import AsyncStreaming public import HTTPAPIs 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(anyAppleOS 26.0, *) public struct HTTPServerLoggingMiddleware< RequestContext: HTTPServerCapability.RequestContext & ~Copyable, - RequestConcludingAsyncReader: ConcludingAsyncReader & ~Copyable, - ResponseConcludingAsyncWriter: ConcludingAsyncWriter & ~Copyable + Reader: AsyncReader & ~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.ReadElement == UInt8, + Reader.FinalElement == HTTPFields?, + ResponseSender.Writer: ~Copyable { - public typealias Input = HTTPServerMiddlewareInput + public typealias Input = HTTPServerMiddlewareInput public typealias NextInput = HTTPServerMiddlewareInput< RequestContext, - 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 @@ -64,36 +49,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) } @@ -101,146 +71,104 @@ where } @available(anyAppleOS 26.0, *) -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, + Input == HTTPServerMiddlewareInput, RequestContext: HTTPServerCapability.RequestContext & ~Copyable, - 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? + Reader: AsyncReader & ~Copyable, + Reader.ReadElement == UInt8, + Reader.FinalElement == HTTPFields?, + ResponseSender: HTTPResponseSender & ~Copyable, + ResponseSender.Writer: ~Copyable { HTTPServerLoggingMiddleware(logger: logger) } } @available(anyAppleOS 26.0, *) -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 struct LoggingReader: AsyncReader, ~Copyable +where Base.ReadElement == UInt8, Base.FinalElement == HTTPFields? { + public typealias ReadElement = UInt8 + public typealias ReadFailure = Base.ReadFailure + public typealias Buffer = Base.Buffer 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 + @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, consuming HTTPFields??) async throws(Failure) -> Return + ) async throws(EitherError) -> Return { + let logger = self.logger + return try await self.underlying.read { + (buffer: inout Buffer, finalElement: consuming HTTPFields??) async throws(Failure) -> Return in + logger.info("Received next chunk \(buffer.count)") + if let trailers = finalElement, let actual = trailers { + logger.info("Received request trailers \(actual)") + } + return try await body(&buffer, finalElement) } - - return (result, trailers) } } @available(anyAppleOS 26.0, *) -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 { - public struct ResponseBodyAsyncWriter: AsyncWriter, ~Copyable { + public struct LoggingWriter: CallerAsyncWriter, ~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 FinalElement = HTTPFields? - 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 { - 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)") - return result + public mutating func write & ~Copyable>( + buffer: inout Buffer + ) async throws(WriteFailure) where Buffer.Element: ~Copyable { + self.logger.info("Wrote response bytes \(buffer.count)") + try await self.underlying.write(buffer: &buffer) + } + + public consuming func finish & ~Copyable>( + buffer: inout Buffer, + finalElement: consuming HTTPFields? + ) async throws(WriteFailure) where Buffer.Element: ~Copyable { + // 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 + if let finalElement { + logger.info("Wrote response trailers \(finalElement)") + } else { + logger.info("Wrote no response trailers") } + try await self.underlying.finish(buffer: &buffer, finalElement: finalElement) } } + public typealias Writer = LoggingWriter + private var base: Base private let logger: Logger @@ -249,20 +177,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 mutating 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 ce069e4..3fcb1c8 100644 --- a/Examples/ExampleMiddleware/HTTPServerMiddlewareInput.swift +++ b/Examples/ExampleMiddleware/HTTPServerMiddlewareInput.swift @@ -11,68 +11,56 @@ // //===----------------------------------------------------------------------===// +public import AsyncStreaming 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(anyAppleOS 26.0, *) public struct HTTPServerMiddlewareInput< RequestContext: HTTPServerCapability.RequestContext & ~Copyable, - RequestReader: ConcludingAsyncReader & ~Copyable, - ResponseWriter: ConcludingAsyncWriter & ~Copyable ->: ~Copyable where RequestReader.Underlying: ~Copyable, ResponseWriter.Underlying: ~Copyable { + Reader: AsyncReader & ~Copyable, + ResponseSender: HTTPResponseSender & ~Copyable +>: ~Copyable +where + Reader.ReadElement == UInt8, + Reader.FinalElement == HTTPFields?, + ResponseSender.Writer: ~Copyable +{ private let request: HTTPRequest private let requestContext: RequestContext - 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. public init( request: HTTPRequest, requestContext: consuming RequestContext, - 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, consuming RequestContext, - 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 6e862ad..dafc86b 100644 --- a/Examples/ExampleMiddleware/HTTPServerRequestHandlerMiddleware.swift +++ b/Examples/ExampleMiddleware/HTTPServerRequestHandlerMiddleware.swift @@ -11,29 +11,23 @@ // //===----------------------------------------------------------------------===// +public import AsyncStreaming 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(anyAppleOS 26.0, *) public struct HTTPServerRequestHandlerMiddleware< RequestContext: HTTPServerCapability.RequestContext & ~Copyable, - RequestConcludingAsyncReader: ConcludingAsyncReader & ~Copyable, - ResponseConcludingAsyncWriter: ConcludingAsyncWriter & ~Copyable, + Reader: AsyncReader & ~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? + Reader.ReadElement == UInt8, + Reader.FinalElement == HTTPFields?, + ResponseSender.Writer: ~Copyable { - public typealias Input = HTTPServerMiddlewareInput + public typealias Input = HTTPServerMiddlewareInput public typealias NextInput = Void /// Creates a new request handler middleware. @@ -43,21 +37,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(()) @@ -65,38 +47,17 @@ where } @available(anyAppleOS 26.0, *) -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< - RequestContext, RequestReader, ResponseWriter - > + public func requestHandler() -> HTTPServerRequestHandlerMiddleware where - Input == HTTPServerMiddlewareInput, + Input == HTTPServerMiddlewareInput, RequestContext: HTTPServerCapability.RequestContext & ~Copyable, - 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? + Reader: AsyncReader & ~Copyable, + Reader.ReadElement == UInt8, + Reader.FinalElement == HTTPFields?, + ResponseSender: HTTPResponseSender & ~Copyable, + ResponseSender.Writer: ~Copyable { HTTPServerRequestHandlerMiddleware() } diff --git a/Examples/MiddlewareClient/ExampleMiddlewareClient.swift b/Examples/MiddlewareClient/ExampleMiddlewareClient.swift index d48d1bc..a86b8cb 100644 --- a/Examples/MiddlewareClient/ExampleMiddlewareClient.swift +++ b/Examples/MiddlewareClient/ExampleMiddlewareClient.swift @@ -11,14 +11,30 @@ // //===----------------------------------------------------------------------===// +import AsyncStreaming +import ExampleMiddleware import HTTPAPIs +import HTTPTypes import Middleware @available(anyAppleOS 26.0, *) -struct ExampleMiddlewareClient>: HTTPClient, ~Copyable { +struct ExampleMiddlewareClient< + Client: HTTPClient & ~Copyable, + OutWriter: CallerAsyncWriter & ~Copyable & SendableMetatype, + ClientMiddleware: Middleware & Sendable +>: HTTPClient, ~Copyable +where + OutWriter.WriteElement == UInt8, + OutWriter.FinalElement == HTTPFields?, + 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 +46,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 ) @@ -57,9 +72,10 @@ 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/MiddlewareServer/ExampleMiddlewareServer.swift b/Examples/MiddlewareServer/ExampleMiddlewareServer.swift index 848544f..068d98e 100644 --- a/Examples/MiddlewareServer/ExampleMiddlewareServer.swift +++ b/Examples/MiddlewareServer/ExampleMiddlewareServer.swift @@ -23,16 +23,17 @@ struct ExampleMiddlewareServer< ServerMiddleware: Middleware & Sendable >: ~Copyable where - Server.RequestConcludingReader: ~Copyable, - Server.RequestConcludingReader.Underlying: ~Copyable, - Server.ResponseConcludingWriter: ~Copyable, - Server.ResponseConcludingWriter.Underlying: ~Copyable, + Server.RequestContext: ~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 RequestContext = Server.RequestContext + typealias Reader = Server.Reader + typealias ResponseSender = Server.ResponseSender private let server: Server private let middleware: ServerMiddleware @@ -48,11 +49,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 +66,12 @@ where @available(anyAppleOS 26.0, *) struct RequestMiddleware: Middleware where - Server.RequestConcludingReader: ~Copyable, - Server.RequestConcludingReader.Underlying: ~Copyable, - Server.ResponseConcludingWriter: ~Copyable, - Server.ResponseConcludingWriter.Underlying: ~Copyable + Server.RequestContext: ~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 4c373b9..01261dd 100644 --- a/Examples/MiddlewareServer/MiddlewareServer.swift +++ b/Examples/MiddlewareServer/MiddlewareServer.swift @@ -25,10 +25,9 @@ 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, + Server.ResponseSender: ~Copyable, + Server.ResponseSender.Writer: ~Copyable { try await ExampleMiddlewareServer( server: server diff --git a/Examples/ProxyServer/ProxyServer.swift b/Examples/ProxyServer/ProxyServer.swift index 93bafbe..e0dce27 100644 --- a/Examples/ProxyServer/ProxyServer.swift +++ b/Examples/ProxyServer/ProxyServer.swift @@ -26,44 +26,38 @@ 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 { 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..de94a10 100644 --- a/Examples/WASMClient/main.swift +++ b/Examples/WASMClient/main.swift @@ -46,11 +46,10 @@ var body: HTTPClientRequestBody? = nil if method == .post || method == .put { let bodyString = try prompt("Body:", "Hello World!") body = .restartable { writer in - var writer = writer - let span = bodyString.utf8Span.span - status.set("⏳ Writing \(span.count) bytes") - try await writer.write(span) - return nil + let bytes = bodyString.utf8 + status.set("⏳ Writing \(bytes.count) bytes") + var buffer = UniqueArray(copying: bytes) + try await writer.finish(buffer: &buffer, finalElement: nil) } } @@ -83,24 +82,18 @@ do { h2("Body") 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 bytes = [UInt8]() + if let contentLength = contentLength { + bytes.reserveCapacity(contentLength) + } - if let contentLength = contentLength { - bytes.reserveCapacity(contentLength) + status.set("⏳ Read \(bytes.count) bytes") + _ = try await reader.forEachBuffer { buffer in + var consumer = buffer.consumeAll() + while let b = consumer.next() { + bytes.append(b) } - - 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") - } - return bytes } status.set("✅ Read \(bytes.count) bytes") diff --git a/Package.swift b/Package.swift index 96b5498..7ba41ca 100644 --- a/Package.swift +++ b/Package.swift @@ -41,8 +41,8 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-collections.git", from: "1.5.0"), .package( - url: "https://github.com/apple/swift-async-algorithms.git", - from: "1.1.4", + url: "https://github.com/FranzBusch/swift-async-algorithms.git", + revision: "e1a6dff375ca9fc079d02276f489663ad9204e01", traits: ["UnstableAsyncStreaming"] ), .package(url: "https://github.com/apple/swift-http-types.git", from: "1.5.1"), diff --git a/Sources/AHCHTTPClient/AHC+HTTPClient.swift b/Sources/AHCHTTPClient/AHC+HTTPClient.swift index 5500e63..9ab4bd8 100644 --- a/Sources/AHCHTTPClient/AHC+HTTPClient.swift +++ b/Sources/AHCHTTPClient/AHC+HTTPClient.swift @@ -22,87 +22,62 @@ import Synchronization @available(anyAppleOS 26.0, *) 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: CallerAsyncWriter, ~Copyable { public typealias WriteElement = UInt8 public typealias WriteFailure = any Error - public typealias Buffer = UniqueArray + public typealias FinalElement = HTTPFields? let requestWriter: HTTPClientRequest.Body.RequestWriter var byteBuffer: ByteBuffer - var buffer: UniqueArray? 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(1 << 16) } - public mutating func write( - _ body: (inout UniqueArray) async throws(Failure) -> Return - ) async throws(AsyncStreaming.EitherError) -> Return where Failure: Error { - let result: Return - // This force-unwrap is safe since there can only be one concurrent write - var buffer = self.buffer.take()! - do { - result = try await body(&buffer) - } catch { - buffer.removeAll() - self.buffer = consume buffer - throw .second(error) - } - if buffer.count == 0 { - self.buffer = consume buffer - return result + public mutating func write & ~Copyable>( + buffer: inout Buffer + ) async throws(WriteFailure) where Buffer.Element: ~Copyable { + guard buffer.count > 0 else { return } + self.byteBuffer.clear() + var consumer = buffer.consumeAll() + while true { + let span = consumer.drainNext() + if span.isEmpty { break } + unsafe self.byteBuffer.writeBytes(span.span.bytes) } + try await self.requestWriter.writeRequestBodyPart(self.byteBuffer) + } - do { + public consuming func finish & ~Copyable>( + buffer: inout Buffer, + finalElement: consuming HTTPFields? + ) async throws(WriteFailure) where Buffer.Element: ~Copyable { + if buffer.count > 0 { self.byteBuffer.clear() - self.byteBuffer.writeBytes(buffer.span.bytes) - buffer.removeAll() - self.buffer = consume buffer + var consumer = buffer.consumeAll() + while true { + let span = consumer.drainNext() + if span.isEmpty { break } + unsafe self.byteBuffer.writeBytes(span.span.bytes) + } try await self.requestWriter.writeRequestBodyPart(self.byteBuffer) - } catch { - throw .first(error) } - - 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) + let ahcTrailers: HTTPHeaders? = + if let finalElement { + HTTPHeaders(.init(finalElement.lazy.map({ ($0.name.rawName, $0.value) }))) } else { nil } - } - return (returnValue, t.flatMap({ HTTPFields($0) })) + self.requestWriter.requestBodyStreamFinished(trailers: ahcTrailers) } } @@ -110,30 +85,54 @@ extension AsyncHTTPClient.HTTPClient: HTTPAPIs.HTTPClient { public typealias ReadElement = UInt8 public typealias ReadFailure = any Error public typealias Buffer = UniqueArray + public typealias FinalElement = HTTPFields? 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, consuming 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 finalElement: 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 + } + } + finalElement = .some(collected.flatMap { HTTPFields($0) }) } } do { - return try await body(&self.buffer) + return try await body(&self.buffer, finalElement) } catch { throw .second(error) } @@ -148,7 +147,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 +171,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 +202,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 3d3ceb4..837cc9f 100644 --- a/Sources/FetchHTTPClient/FetchHTTPClient.swift +++ b/Sources/FetchHTTPClient/FetchHTTPClient.swift @@ -21,6 +21,7 @@ import JavaScriptKit // between FetchHTTPClient and RequestBodyWriter. class RequestBodyBuffer { var array = UniqueArray() + var trailers: HTTPFields? = nil } enum FetchError: Error { @@ -36,8 +37,8 @@ enum FetchError: Error { } 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() {} @@ -51,7 +52,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 @@ -61,14 +62,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 } @@ -118,37 +116,29 @@ 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: CallerAsyncWriter, ~Copyable { public typealias WriteElement = UInt8 public typealias WriteFailure = any Error - public typealias Buffer = UniqueArray + public typealias FinalElement = HTTPFields? let buffer: RequestBodyBuffer - public mutating func write( - _ body: nonisolated(nonsending) (inout UniqueArray) async throws(Failure) -> Return - ) async throws(AsyncStreaming.EitherError) -> Return where Failure: Error { - let result: Return - do { - result = try await body(&self.buffer.array) - } catch { - throw .second(error) - } - return result + public mutating func write & ~Copyable>( + buffer: inout Buffer + ) async throws(WriteFailure) where Buffer.Element: ~Copyable { + self.buffer.array.append(moving: buffer.startIndex..( - 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 & ~Copyable>( + buffer: inout Buffer, + finalElement: consuming HTTPFields? + ) async throws(WriteFailure) where Buffer.Element: ~Copyable { + self.buffer.array.append(moving: buffer.startIndex.. + public typealias FinalElement = HTTPFields? 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, consuming 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 finalElement: 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 no trailers. + self.trailersDelivered = true + finalElement = .some(nil) } } do { - return try await body(&self.buffer) + return try await body(&self.buffer, finalElement) } catch { throw .second(error) } diff --git a/Sources/HTTPAPIs/AsyncReader+CollectInto.swift b/Sources/HTTPAPIs/AsyncReader+CollectInto.swift new file mode 100644 index 0000000..178c24d --- /dev/null +++ b/Sources/HTTPAPIs/AsyncReader+CollectInto.swift @@ -0,0 +1,54 @@ +//===----------------------------------------------------------------------===// +// +// 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 AsyncStreaming +import BasicContainers + +@available(anyAppleOS 26.0, *) +extension AsyncReader where Self: ~Copyable, Self: ~Escapable { + /// Collects body bytes into the supplied buffer until end-of-stream, and + /// returns the unwrapped trailing fields. + /// + /// Convenience for HTTP body readers (`FinalElement == HTTPFields?`). + /// The buffer's initial free capacity acts as a hard cap; surplus bytes + /// are read and discarded. + // TODO: This should be moved to the AsyncStreaming module + public consuming func collect & ~Copyable>( + into buffer: inout Buffer + ) async throws -> HTTPFields? + where ReadElement == UInt8, FinalElement == HTTPFields? { + var reader = self + var trailers: HTTPFields? = nil + var done = false + while !done { + try await reader.read { (chunk: inout Self.Buffer, finalElement: consuming HTTPFields??) in + if let finalElement { + trailers = finalElement + done = true + } + if chunk.count == 0 { return } + let remaining = buffer.freeCapacity + if chunk.count <= remaining { + buffer.append(moving: chunk.startIndex..( - _ reader: consuming Reader - ) async throws - where - Reader: AsyncReader & ~Copyable & ~Escapable, - Reader.ReadElement == WriteElement - { - try await reader.forEachBuffer { (readBuffer: inout Reader.Buffer) in - try await self.write { (writeBuffer: inout Self.Buffer) in - writeBuffer.append( - moving: readBuffer.startIndex..) 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/CallerAsyncWriter+HTTP.swift b/Sources/HTTPAPIs/CallerAsyncWriter+HTTP.swift new file mode 100644 index 0000000..dfadaad --- /dev/null +++ b/Sources/HTTPAPIs/CallerAsyncWriter+HTTP.swift @@ -0,0 +1,27 @@ +//===----------------------------------------------------------------------===// +// +// 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 AsyncStreaming +import BasicContainers + +@available(anyAppleOS 26.0, *) +extension CallerAsyncWriter +where Self: ~Copyable, Self: ~Escapable, WriteElement == UInt8, FinalElement == HTTPFields? { + /// Concludes an HTTP body writer with no remaining buffer and the supplied + /// trailers. Sugar over ``finish(buffer:finalElement:)``. + // TODO: This should be moved to the AsyncStreaming module as a general purpose convenience + public consuming func finish(trailers: HTTPFields?) async throws(WriteFailure) { + var empty = UniqueArray() + try await self.finish(buffer: &empty, finalElement: trailers) + } +} diff --git a/Sources/HTTPAPIs/CallerAsyncWriter+Optional.swift b/Sources/HTTPAPIs/CallerAsyncWriter+Optional.swift new file mode 100644 index 0000000..2c82aae --- /dev/null +++ b/Sources/HTTPAPIs/CallerAsyncWriter+Optional.swift @@ -0,0 +1,39 @@ +//===----------------------------------------------------------------------===// +// +// 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 AsyncStreaming +import BasicContainers +public import ContainersPreview + +// TODO: This should be moved to the AsyncStreaming module +@available(anyAppleOS 26.0, *) +extension CallerAsyncWriter where Self: ~Copyable, Self: ~Escapable, WriteElement: ~Copyable { + /// Concludes the writer with no remaining buffer and no payload, when the + /// ``FinalElement`` is `Optional`. Equivalent to + /// ``finish(buffer:finalElement:)`` with an empty buffer and `nil`. + public consuming func finish() async throws(WriteFailure) + where FinalElement == Wrapped? { + var empty = UniqueArray() + try await self.finish(buffer: &empty, finalElement: nil) + } + + /// Concludes the writer with the supplied buffer and no payload, when the + /// ``FinalElement`` is `Optional`. Equivalent to + /// ``finish(buffer:finalElement:)`` with `finalElement: nil`. + public consuming func finish & ~Copyable>( + buffer: inout Buffer + ) async throws(WriteFailure) + where FinalElement == Wrapped?, Buffer.Element: ~Copyable { + try await self.finish(buffer: &buffer, finalElement: nil) + } +} diff --git a/Sources/HTTPAPIs/Client/HTTPClient+Conveniences.swift b/Sources/HTTPAPIs/Client/HTTPClient+Conveniences.swift index 9cb029e..165d1e8 100644 --- a/Sources/HTTPAPIs/Client/HTTPClient+Conveniences.swift +++ b/Sources/HTTPAPIs/Client/HTTPClient+Conveniences.swift @@ -11,6 +11,9 @@ // //===----------------------------------------------------------------------===// +import AsyncStreaming +import BasicContainers + #if canImport(FoundationEssentials) public import struct FoundationEssentials.URL public import struct FoundationEssentials.Data @@ -23,9 +26,8 @@ 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. /// @@ -44,9 +46,9 @@ where /// - Throws: An error if the request fails or if the response handler throws. 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) @@ -74,10 +76,10 @@ 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) ) } } @@ -106,10 +108,10 @@ 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) ) } } @@ -138,10 +140,10 @@ 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) ) } } @@ -170,10 +172,10 @@ 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) ) } } @@ -202,22 +204,52 @@ 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 + where R.ReadElement == UInt8, R.FinalElement == HTTPFields? { + // Read iteratively into a growable buffer rather than pre-allocating + // `limit` bytes (which can be Int.max). Check the cap after each chunk. + // TODO: This should be replaced once we have a collect(into:) in AsyncStreaming + var buffer = Data() + var reader = reader + var done = false + while !done { + try await reader.read { (chunk: inout R.Buffer, finalElement: HTTPFields??) in + if finalElement != nil { + done = true + } + if chunk.count == 0 { + if finalElement == nil { + done = true + } + return + } + var consumer = chunk.consumeAll() + while true { + var span = consumer.drainNext() + if span.isEmpty { + break + } + unsafe span.withUnsafeMutableBufferPointer { pointer, initializedCount in + unsafe buffer.append(contentsOf: pointer) + } + } + } + if buffer.count > limit { throw LengthLimitExceededError() } - return $0.span.withUnsafeBytes { unsafe Data($0) } - }.0 + } + return buffer } } diff --git a/Sources/HTTPAPIs/Client/HTTPClient.swift b/Sources/HTTPAPIs/Client/HTTPClient.swift index 2e0899f..776894a 100644 --- a/Sources/HTTPAPIs/Client/HTTPClient.swift +++ b/Sources/HTTPAPIs/Client/HTTPClient.swift @@ -11,26 +11,31 @@ // //===----------------------------------------------------------------------===// +public import AsyncStreaming + /// 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(anyAppleOS 26.0, *) public protocol HTTPClient: Sendable, ~Copyable, ~Escapable { associatedtype RequestOptions: HTTPClientCapability.RequestOptions - /// The type used to write request body data and trailers. + /// The body writer type used to stream request body bytes and signal end-of-body. + /// + /// Conforms to ``CallerAsyncWriter`` with ``HTTPFields`` as the optional + /// final element. // 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 + associatedtype Writer: CallerAsyncWriter, ~Copyable, SendableMetatype + where Writer.WriteElement == UInt8, Writer.FinalElement == HTTPFields? - /// 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. + /// + /// Conforms to ``AsyncReader`` with ``HTTPFields`` as the optional final element. + // TODO: Check if we should allow ~Escapable readers https://github.com/apple/swift-http-api-proposal/issues/13 + associatedtype Reader: AsyncReader, ~Copyable, SendableMetatype + where Reader.ReadElement == UInt8, Reader.FinalElement == HTTPFields? /// The default request options for `perform`. var defaultRequestOptions: RequestOptions { get } @@ -45,16 +50,19 @@ public protocol HTTPClient: Sendable, ~Copyable, ~Escapable { /// - 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. 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 fa91d8c..d646f4b 100644 --- a/Sources/HTTPAPIs/Client/HTTPClientRequestBody+Data.swift +++ b/Sources/HTTPAPIs/Client/HTTPClientRequestBody+Data.swift @@ -11,6 +11,8 @@ // //===----------------------------------------------------------------------===// +import BasicContainers + #if canImport(FoundationEssentials) public import struct FoundationEssentials.Data #else @@ -24,9 +26,12 @@ 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 + // TODO: Once data conforms to RangeReplaceableContainer we should remove this copy + let writer = writer + var buffer = UniqueArray( + copying: data.span.extracting(droppingFirst: Int(offset)) + ) + try await writer.finish(buffer: &buffer, finalElement: nil) } } } diff --git a/Sources/HTTPAPIs/Client/HTTPClientRequestBody.swift b/Sources/HTTPAPIs/Client/HTTPClientRequestBody.swift index 1bc4c20..c84b83e 100644 --- a/Sources/HTTPAPIs/Client/HTTPClientRequestBody.swift +++ b/Sources/HTTPAPIs/Client/HTTPClientRequestBody.swift @@ -11,48 +11,52 @@ // //===----------------------------------------------------------------------===// -import AsyncStreaming +public 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 +/// a ``CallerAsyncWriter`` 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 +/// var buffer = UniqueArray() +/// // ... fill buffer with bytes from `offset` ... +/// try await writer.finish(buffer: &buffer, finalElement: 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 +/// var buffer = UniqueArray() +/// // ... fill buffer with the body ... +/// try await writer.finish(buffer: &buffer, finalElement: nil) +/// }) { response, reader in /// // Handle the response /// } /// ``` @available(anyAppleOS 26.0, *) -public struct HTTPClientRequestBody: Sendable -where Writer.WriteElement == UInt8, Writer: SendableMetatype { +public struct HTTPClientRequestBody: Sendable +where Writer.WriteElement == UInt8, Writer.FinalElement == HTTPFields? { /// The body can be asked to restart writing from an arbitrary offset. public var isSeekable: Bool { switch self.writeBody { @@ -68,8 +72,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 Writer) async throws -> Void) + case seekable(@Sendable (Int64, consuming Writer) async throws -> Void) } private let writeBody: WriteBody @@ -77,7 +81,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 Writer) async throws { switch self.writeBody { case .restartable(let writeBody): try await writeBody(writer) @@ -92,7 +96,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 Writer) async throws { switch self.writeBody { case .restartable: fatalError("Request body is not seekable") @@ -103,20 +107,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 + /// ``CallerAsyncWriter/finish(buffer:finalElement:)`` to terminate the body. public static func restartable( knownLength: Int64? = nil, - _ body: @escaping @Sendable (consuming Writer) async throws -> HTTPFields? + _ body: @escaping @Sendable (consuming Writer) async throws -> Void ) -> Self { Self.init( knownLength: knownLength, @@ -131,15 +134,14 @@ where Writer.WriteElement == UInt8, Writer: SendableMetatype { /// 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 + /// ``CallerAsyncWriter/finish(buffer:finalElement:)`` 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 Writer) async throws -> Void ) -> Self { Self.init( knownLength: knownLength, @@ -151,22 +153,4 @@ where Writer.WriteElement == UInt8, Writer: SendableMetatype { self.knownLength = knownLength self.writeBody = writeBody } - - package init( - other: HTTPClientRequestBody, - transform: @escaping @Sendable (consuming Writer) -> OtherWriter - ) { - self.knownLength = other.knownLength - self.writeBody = - switch other.writeBody { - case .restartable(let writeBody): - .restartable { writer in - try await writeBody(transform(writer)) - } - case .seekable(let writeBody): - .seekable { offset, writer in - try await writeBody(offset, transform(writer)) - } - } - } } diff --git a/Sources/HTTPAPIs/ConcludingAsyncReader+collect.swift b/Sources/HTTPAPIs/ConcludingAsyncReader+collect.swift deleted file mode 100644 index 8b3acab..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(anyAppleOS 26.0, *) -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 a54b788..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(anyAppleOS 26.0, *) -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(anyAppleOS 26.0, *) -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(anyAppleOS 26.0, *) -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/Server/HTTPResponseSender.swift b/Sources/HTTPAPIs/Server/HTTPResponseSender.swift new file mode 100644 index 0000000..a29fce3 --- /dev/null +++ b/Sources/HTTPAPIs/Server/HTTPResponseSender.swift @@ -0,0 +1,89 @@ +//===----------------------------------------------------------------------===// +// +// 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 AsyncStreaming +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 a ``CallerAsyncWriter`` +/// for streaming the body. The caller is responsible for terminating the body +/// via ``CallerAsyncWriter/finish(buffer:finalElement:)``. +@available(anyAppleOS 26.0, *) +public protocol HTTPResponseSender: ~Copyable, ~Escapable { + /// The body writer type used to stream response body bytes and signal end-of-body. + /// + /// Conforms to ``CallerAsyncWriter`` with ``HTTPFields`` as the optional + /// trailing payload delivered alongside the FIN signal. + associatedtype Writer: CallerAsyncWriter, ~Copyable, ~Escapable + where Writer.WriteElement == UInt8, Writer.FinalElement == HTTPFields? + + /// 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. + mutating 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 ``CallerAsyncWriter/finish(buffer:finalElement:)`` 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 +} + +// TODO: Those methods should all become protocol requirements so server's can optimize them +@available(anyAppleOS 26.0, *) +extension HTTPResponseSender where Self: ~Copyable, Writer: ~Copyable { + /// Sends the response head, then the contents of `buffer` fused with + /// optional trailers in a single `finish` 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(buffer: &buffer, finalElement: 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) + var empty = UniqueArray() + try await writer.finish(buffer: &empty, finalElement: 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) + var empty = UniqueArray() + try await writer.finish(buffer: &empty, finalElement: nil) + } +} diff --git a/Sources/HTTPAPIs/Server/HTTPServer.swift b/Sources/HTTPAPIs/Server/HTTPServer.swift index a05045d..d3ea24a 100644 --- a/Sources/HTTPAPIs/Server/HTTPServer.swift +++ b/Sources/HTTPAPIs/Server/HTTPServer.swift @@ -11,33 +11,32 @@ // //===----------------------------------------------------------------------===// -@available(anyAppleOS 26.0, *) +public import AsyncStreaming + /// 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 { +/// 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. +// TODO: We should revisit if this should be Sendable +@available(anyAppleOS 26.0, *) +public protocol HTTPServer: Sendable, ~Copyable, ~Escapable { /// The type of context provided to request handlers for each incoming request. /// /// Server implementations define this type to carry per-request metadata that isn't part /// of the HTTP message itself, such as connection information or routing state. associatedtype RequestContext: HTTPServerCapability.RequestContext, ~Copyable - /// The type used to read request body data and trailers. + /// The body reader type used to stream request body bytes 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? + associatedtype Reader: AsyncReader, ~Copyable, SendableMetatype + where Reader.ReadElement == UInt8, Reader.FinalElement == HTTPFields? - /// 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. + // TODO: Check if we should allow ~Escapable readers https://github.com/apple/swift-http-api-proposal/issues/13 + associatedtype ResponseSender: HTTPResponseSender, ~Copyable, SendableMetatype + where ResponseSender.Writer: ~Copyable /// Starts an HTTP server with the specified request handler. /// @@ -46,22 +45,24 @@ public protocol HTTPServer(handler: Handler) async throws where Handler.RequestContext: ~Copyable, Handler.RequestContext == RequestContext, - 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 838fe54..c94f555 100644 --- a/Sources/HTTPAPIs/Server/HTTPServerClosureRequestHandler.swift +++ b/Sources/HTTPAPIs/Server/HTTPServerClosureRequestHandler.swift @@ -11,6 +11,8 @@ // //===----------------------------------------------------------------------===// +public import AsyncStreaming + /// A closure-based implementation of ``HTTPServerRequestHandler``. /// /// ``HTTPServerClosureRequestHandler`` provides a convenient way to create an HTTP request handler @@ -19,78 +21,52 @@ /// /// - 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, context, reader, responseSender in +/// let writer = try await responseSender.send(.init(status: .ok)) +/// try await reader.pipe(into: writer) /// } /// ``` @available(anyAppleOS 26.0, *) public struct HTTPServerClosureRequestHandler< RequestContext: HTTPServerCapability.RequestContext & ~Copyable, - RequestReader: ConcludingAsyncReader & ~Copyable, - ResponseWriter: ConcludingAsyncWriter & ~Copyable, + Reader: AsyncReader & ~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? + Reader.ReadElement == UInt8, + Reader.FinalElement == HTTPFields?, + ResponseSender.Writer: ~Copyable { - /// The underlying closure that handles HTTP requests. private let _handler: @Sendable ( HTTPRequest, consuming RequestContext, - 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, consuming RequestContext, - 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: The request context provided by the server. - /// - 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: consuming RequestContext, - 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) } } @@ -99,10 +75,10 @@ extension HTTPServer where Self: ~Copyable, Self: ~Escapable, - RequestConcludingReader: ~Copyable, - RequestConcludingReader.Underlying: ~Copyable, - ResponseConcludingWriter: ~Copyable, - ResponseConcludingWriter.Underlying: ~Copyable + RequestContext: ~Copyable, + Reader: ~Copyable, + ResponseSender: ~Copyable, + ResponseSender.Writer: ~Copyable { /// Starts an HTTP server with a closure-based request handler. /// @@ -112,20 +88,17 @@ where /// - handler: An async closure that processes HTTP requests. The closure receives: /// - `HTTPRequest`: The incoming HTTP request with headers and metadata. /// - `RequestContext`: The request context provided by the server. - /// - ``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``. + /// - ``AsyncStreaming/AsyncReader``: A reader for the request body and trailing fields. + /// - ``HTTPResponseSender``: A wrapper that accepts an `HTTPResponse` and returns a + /// ``AsyncStreaming/CallerAsyncWriter`` for streaming the response body and trailing fields. /// /// ## Example /// /// ```swift - /// try await server.serve { request, requestContext, 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, requestContext, reader, responseSender in + /// let writer = try await responseSender.send(.init(status: .ok)) + /// var buffer = UniqueArray(copying: "Hello, World!".utf8) + /// try await writer.finish(buffer: &buffer, finalElement: nil) /// } /// ``` public func serve( @@ -133,8 +106,8 @@ where @Sendable @escaping ( _ request: HTTPRequest, _ requestContext: consuming RequestContext, - _ 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 ae9b411..3afa656 100644 --- a/Sources/HTTPAPIs/Server/HTTPServerRequestHandler.swift +++ b/Sources/HTTPAPIs/Server/HTTPServerRequestHandler.swift @@ -11,85 +11,81 @@ // //===----------------------------------------------------------------------===// +public import AsyncStreaming + /// 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< -/// Context: HTTPServerCapability.RequestContext, -/// ConcludingRequestReader: ConcludingAsyncReader & ~Copyable, -/// RequestReader: AsyncReader & ~Copyable, -/// ConcludingResponseWriter: ConcludingAsyncWriter & ~Copyable, -/// ResponseWriter: AsyncWriter & ~Copyable -/// >: HTTPServerRequestHandler { +/// Context: HTTPServerCapability.RequestContext & ~Copyable, +/// Reader: AsyncReader & ~Copyable, +/// ResponseSender: HTTPResponseSender & ~Copyable +/// >: HTTPServerRequestHandler +/// where +/// Reader.ReadElement == UInt8, +/// Reader.FinalElement == HTTPFields?, +/// ResponseSender.Writer: ~Copyable +/// { /// func handle( /// request: HTTPRequest, -/// requestContext: Context, -/// requestBodyAndTrailers: consuming sending ConcludingRequestReader, -/// responseSender: consuming sending HTTPResponseSender +/// requestContext: consuming Context, +/// 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(into: writer) /// } /// } /// ``` @available(anyAppleOS 26.0, *) -public protocol HTTPServerRequestHandler: Sendable { +public protocol HTTPServerRequestHandler: Sendable { /// The type of the request context provided by the server. associatedtype RequestContext: HTTPServerCapability.RequestContext, ~Copyable - /// 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? + /// The body reader type used to stream request body bytes and trailers. + // TODO: Check if we should allow ~Escapable readers https://github.com/apple/swift-http-api-proposal/issues/13 + associatedtype Reader: AsyncReader, ~Copyable + where Reader.ReadElement == UInt8, Reader.FinalElement == HTTPFields? - /// 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. + // TODO: Check if we should allow ~Escapable readers https://github.com/apple/swift-http-api-proposal/issues/13 + 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 context carrying additional request information provided by the server. - /// - 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: consuming RequestContext, - 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 30b7dbb..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(anyAppleOS 26.0, *) -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 3d1d5d5..2673a0b 100644 --- a/Sources/HTTPClient/DefaultHTTPClient.swift +++ b/Sources/HTTPClient/DefaultHTTPClient.swift @@ -38,44 +38,50 @@ typealias ActualHTTPClient = AsyncHTTPClient.HTTPClient /// automatically handling connection management, protocol negotiation, and resource cleanup. @available(anyAppleOS 26.0, *) public final class DefaultHTTPClient: HTTPAPIs.HTTPClient { - public struct RequestWriter: AsyncWriter, ~Copyable { + /// The request body writer surfaced by ``DefaultHTTPClient``. + public struct Writer: CallerAsyncWriter, ~Copyable, SendableMetatype { public typealias WriteElement = UInt8 public typealias WriteFailure = any Error - public typealias Buffer = UniqueArray + public typealias FinalElement = HTTPFields? + + private var actual: ActualHTTPClient.Writer + + init(actual: consuming ActualHTTPClient.Writer) { + self.actual = actual + } - 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) + public mutating func write & ~Copyable>( + buffer: inout Buffer + ) async throws(WriteFailure) where Buffer.Element: ~Copyable { + try await self.actual.write(buffer: &buffer) } - var actual: ActualHTTPClient.RequestWriter + public consuming func finish & ~Copyable>( + buffer: inout Buffer, + finalElement: consuming HTTPFields? + ) async throws(WriteFailure) where Buffer.Element: ~Copyable { + try await self.actual.finish(buffer: &buffer, finalElement: finalElement) + } } - public struct ResponseConcludingReader: ConcludingAsyncReader, ~Copyable { - public struct Underlying: AsyncReader, ~Copyable { - public typealias ReadElement = UInt8 - public typealias ReadFailure = any Error - public typealias Buffer = UniqueArray + /// The response body reader surfaced by ``DefaultHTTPClient``. + public struct Reader: AsyncReader, ~Copyable, SendableMetatype { + public typealias ReadElement = UInt8 + public typealias ReadFailure = any Error + public typealias Buffer = UniqueArray + public typealias FinalElement = HTTPFields? - 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) - } + private var actual: ActualHTTPClient.Reader - var actual: ActualHTTPClient.ResponseConcludingReader.Underlying + init(actual: consuming ActualHTTPClient.Reader) { + self.actual = actual } - 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)) - } + public mutating func read( + body: (inout UniqueArray, consuming HTTPFields??) async throws(Failure) -> Return + ) async throws(EitherError) -> Return { + try await self.actual.read(body: body) } - - let actual: ActualHTTPClient.ResponseConcludingReader } /// A shared connection pool instance with default configuration. @@ -135,17 +141,24 @@ 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) } + let translatedBody: HTTPClientRequestBody? = body.map { userBody in + guard userBody.isSeekable else { + return HTTPClientRequestBody.restartable(knownLength: userBody.knownLength) { actualWriter in + try await userBody.produce(into: Writer(actual: actualWriter)) + } + } + return HTTPClientRequestBody.seekable(knownLength: userBody.knownLength) { offset, actualWriter in + try await userBody.produce(offset: offset, into: Writer(actual: actualWriter)) + } } - 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: translatedBody, options: options) { response, actualReader in + try await responseHandler(response, Reader(actual: actualReader)) } } } diff --git a/Sources/HTTPClient/HTTP+Conveniences.swift b/Sources/HTTPClient/HTTP+Conveniences.swift index 2ff2cd0..c2ae02f 100644 --- a/Sources/HTTPClient/HTTP+Conveniences.swift +++ b/Sources/HTTPClient/HTTP+Conveniences.swift @@ -39,12 +39,12 @@ extension HTTP { @available(anyAppleOS 26.0, *) 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 4c43cfb..47e0174 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(buffer: &body, finalElement: nil) } ) { 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(buffer: &body, finalElement: nil) } ) { 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,36 @@ 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 { + var buffer = UniqueArray.init(repeating: UInt8(ascii: "A"), count: 1) // Write a 1-byte chunk - try await writer.write("A".utf8.span) + try await writer.write(buffer: &buffer) // 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) + let reader = responseBodyAndTrailers + var numberOfChunks = 0 + _ = try await reader.forEachBuffer { buffer in + // The terminal read carries an empty buffer alongside the EOS marker; + // skip it so the per-chunk assertions only run for data chunks. + guard buffer.count > 0 else { return } + 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 +692,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 +716,38 @@ 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) + var buffer = UniqueArray(copying: chunk.utf8Span.span) + try await writer.write(buffer: &buffer) } - 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() + let reader = responseBodyAndTrailers + // Read all chunks from server + _ = try await reader.forEachBuffer { buffer in + // The terminal read carries an empty buffer alongside the EOS marker; + // skip it so the per-chunk assertion only runs for data chunks. + guard buffer.count > 0 else { return } + 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 +809,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 {} - } + let 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.forEachBuffer { buffer in + #expect(buffer.count > 0) + var consumer = buffer.consumeAll() + while consumer.next() != nil {} } } } @@ -877,18 +874,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(buffer: &body, finalElement: nil) } ) { 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)) } } @@ -903,14 +898,26 @@ struct ConformanceTestSuite { ) // Read only a single byte from the body. We do not care about the rest of the 1Mb. + // The upstream `collect(upTo:)` now throws `AsyncReaderLeftOverElementsError` + // when the reader produces more elements than the limit, so we tolerate it. try await client.perform( request: request, ) { response, responseBodyAndTrailers in #expect(response.status == .ok) - let (character, _) = try await responseBodyAndTrailers.collect(upTo: 1) { span in - return String(copying: try UTF8Span(validating: span.span)) + var reader = responseBodyAndTrailers + var firstByte: UInt8? = nil + do { + try await reader.read { (buffer: inout _, _) in + var consumer = buffer.consumeAll() + firstByte = consumer.next() + // Discard the rest of this chunk. + while consumer.next() != nil {} + } + } catch { + // It is acceptable for the read to fail (e.g., connection + // tear-down) once we stop draining the body. } - #expect(character == "A") + #expect(firstByte == UInt8(ascii: "A")) } } @@ -929,14 +936,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 + let reader = responseBodyAndTrailers + var result = [UInt8]() + + _ = try await reader.forEachBuffer { 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 +959,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 +1007,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 +1054,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 +1120,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 +1143,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 +1172,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 +1199,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( + buffer: &body, + finalElement: [ + .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 1fc6ef3..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(anyAppleOS 26.0, *) -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/HTTPRequestContext.swift b/Sources/HTTPClientConformance/HTTPServerForTesting/HTTPRequestContext.swift index 325d48f..9613ac8 100644 --- a/Sources/HTTPClientConformance/HTTPServerForTesting/HTTPRequestContext.swift +++ b/Sources/HTTPClientConformance/HTTPServerForTesting/HTTPRequestContext.swift @@ -13,7 +13,7 @@ public import HTTPAPIs -@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +@available(anyAppleOS 26.0, *) public struct HTTPRequestContext: HTTPServerCapability.RequestContext { public init() {} } diff --git a/Sources/HTTPClientConformance/HTTPServerForTesting/HTTPResponseConcludingAsyncWriter.swift b/Sources/HTTPClientConformance/HTTPServerForTesting/HTTPResponseConcludingAsyncWriter.swift deleted file mode 100644 index b75a936..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(anyAppleOS 26.0, *) -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/NIOHTTPResponseSender.swift b/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPResponseSender.swift new file mode 100644 index 0000000..fee7aca --- /dev/null +++ b/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPResponseSender.swift @@ -0,0 +1,129 @@ +//===----------------------------------------------------------------------===// +// +// 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 +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(anyAppleOS 26.0, *) +public struct NIOHTTPResponseSender: HTTPResponseSender, ~Copyable { + /// A writer for HTTP response body chunks that implements the ``CallerAsyncWriter`` protocol. + public struct ResponseBodyWriter: CallerAsyncWriter { + public typealias WriteElement = UInt8 + public typealias WriteFailure = any Error + public typealias FinalElement = HTTPFields? + + private var writer: NIOAsyncChannelOutboundWriter + private var writerState: WriterState + private var byteBuffer: ByteBuffer + + init( + writer: NIOAsyncChannelOutboundWriter, + writerState: WriterState + ) { + self.writer = writer + self.writerState = writerState + self.byteBuffer = ByteBuffer() + self.byteBuffer.reserveCapacity(1 << 14) + } + + public mutating func write & ~Copyable>( + buffer: inout Buffer + ) async throws(WriteFailure) where Buffer.Element: ~Copyable { + guard buffer.count > 0 else { return } + self.byteBuffer.clear() + var consumer = buffer.consumeAll() + while true { + let span = consumer.drainNext() + if span.isEmpty { break } + unsafe self.byteBuffer.writeBytes(span.span.bytes) + } + try await self.writer.write(.body(self.byteBuffer)) + } + + public consuming func finish & ~Copyable>( + buffer: inout Buffer, + finalElement: consuming HTTPFields? + ) async throws(WriteFailure) where Buffer.Element: ~Copyable { + if buffer.count > 0 { + self.byteBuffer.clear() + var consumer = buffer.consumeAll() + while true { + let span = consumer.drainNext() + if span.isEmpty { break } + unsafe self.byteBuffer.writeBytes(span.span.bytes) + } + try await self.writer.write(.body(self.byteBuffer)) + } + try await self.writer.write(.end(finalElement)) + 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 = ResponseBodyWriter + + 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 -> ResponseBodyWriter { + 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 ResponseBodyWriter(writer: self.writer, writerState: self.writerState) + } +} + +@available(*, unavailable) +extension NIOHTTPResponseSender: Sendable {} + +@available(*, unavailable) +extension NIOHTTPResponseSender.ResponseBodyWriter: Sendable {} diff --git a/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPServer+HTTP1_1.swift b/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPServer+HTTP1_1.swift index c80c263..f15f5d0 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 0588d12..61e867c 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 c40bd9a..e6cef90 100644 --- a/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPServer.swift +++ b/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPServer.swift @@ -49,38 +49,17 @@ import X509 /// try await Server.serve( /// logger: logger, /// configuration: configuration -/// ) { request, bodyReader, sendResponse 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 -/// } -/// -/// // 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) -/// } +/// ) { request, _, reader, responseSender in +/// // Echo the request body back as the response body +/// let writer = try await responseSender.send(.init(status: .ok)) +/// try await reader.pipe(into: writer) /// } /// ``` @available(anyAppleOS 26.0, *) public struct NIOHTTPServer: HTTPServer { public typealias RequestContext = HTTPRequestContext - public typealias RequestConcludingReader = HTTPRequestConcludingAsyncReader - public typealias ResponseConcludingWriter = HTTPResponseConcludingAsyncWriter + public typealias Reader = NIORequestBodyReader + public typealias ResponseSender = NIOHTTPResponseSender let logger: Logger private let configuration: NIOHTTPServerConfiguration @@ -126,12 +105,12 @@ public struct NIOHTTPServer: HTTPServer { /// struct EchoHandler: HTTPServerRequestHandler { /// func handle( /// request: HTTPRequest, - /// requestBodyAndTrailers: HTTPRequestConcludingAsyncReader, - /// responseSender: @escaping (HTTPResponse) async throws -> HTTPResponseConcludingAsyncWriter + /// requestContext: consuming HTTPRequestContext, + /// reader: consuming sending NIORequestBodyReader, + /// responseSender: consuming sending NIOHTTPResponseSender /// ) async throws { - /// let response = HTTPResponse(status: .ok) - /// let writer = try await sendResponse(response) - /// // Handle request and write response... + /// let writer = try await responseSender.send(.init(status: .ok)) + /// try await reader.pipe(into: writer) /// } /// } /// @@ -146,7 +125,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): @@ -266,7 +245,7 @@ public struct NIOHTTPServer: HTTPServer { func handleRequestChannel( channel: NIOAsyncChannel, - handler: some HTTPServerRequestHandler + handler: some HTTPServerRequestHandler ) async throws { do { try await channel @@ -290,32 +269,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/NIORequestBodyReader.swift b/Sources/HTTPClientConformance/HTTPServerForTesting/NIORequestBodyReader.swift new file mode 100644 index 0000000..ac231b5 --- /dev/null +++ b/Sources/HTTPClientConformance/HTTPServerForTesting/NIORequestBodyReader.swift @@ -0,0 +1,99 @@ +//===----------------------------------------------------------------------===// +// +// 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 +public import BasicContainers +import HTTPAPIs +public import HTTPTypes +import NIOCore +import NIOHTTPTypes +import Synchronization + +/// A NIO-backed HTTP request body reader used by the test server. +@available(anyAppleOS 26.0, *) +public struct NIORequestBodyReader: AsyncReader, ~Copyable { + public typealias ReadElement = UInt8 + public typealias ReadFailure = any Error + public typealias Buffer = UniqueArray + public typealias FinalElement = HTTPFields? + + 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, consuming HTTPFields??) async throws(Failure) -> Return + ) async throws(EitherError) -> Return { + var buffer = UniqueArray() + var finalElement: 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 + } + finalElement = .some(t) + case .none: + self.state.wrapped.withLock { $0.finishedReading = true } + finalElement = .some(nil) + } + } + + do { + return try await body(&buffer, finalElement) + } catch { + throw .second(error) + } + } +} + +@available(*, unavailable) +extension NIORequestBodyReader: Sendable {} diff --git a/Sources/HTTPClientConformance/HTTPServerForTesting/RequestResponseMiddlewareBox.swift b/Sources/HTTPClientConformance/HTTPServerForTesting/RequestResponseMiddlewareBox.swift index ce3419f..850dcfa 100644 --- a/Sources/HTTPClientConformance/HTTPServerForTesting/RequestResponseMiddlewareBox.swift +++ b/Sources/HTTPClientConformance/HTTPServerForTesting/RequestResponseMiddlewareBox.swift @@ -11,6 +11,7 @@ // //===----------------------------------------------------------------------===// +public import AsyncStreaming public import HTTPAPIs public import HTTPTypes @@ -20,48 +21,46 @@ public import HTTPTypes @available(anyAppleOS 26.0, *) public struct RequestResponseMiddlewareBox< RequestContext: HTTPServerCapability.RequestContext & ~Copyable, - RequestReader: ConcludingAsyncReader & ~Copyable, - ResponseWriter: ConcludingAsyncWriter & ~Copyable ->: ~Copyable { + Reader: AsyncReader & ~Copyable, + ResponseSender: HTTPResponseSender & ~Copyable +>: ~Copyable +where + Reader.ReadElement == UInt8, + Reader.FinalElement == HTTPFields?, + ResponseSender.Writer: ~Copyable +{ private let request: HTTPRequest private let requestContext: RequestContext - private let requestReader: RequestReader - private let responseSender: HTTPResponseSender + private let reader: Reader + private let responseSender: ResponseSender /// Create a new ``RequestResponseMiddlewareBox``. - /// - Parameters: - /// - request: The `HTTPRequest`. - /// - requestContext: The request context. - /// - requestReader: The `RequestReader`. - /// - responseSender: The ``HTTPResponseSender``. public init( request: HTTPRequest, requestContext: consuming RequestContext, - 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, consuming RequestContext, - 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 9397d8d..1245957 100644 --- a/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift +++ b/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift @@ -87,18 +87,34 @@ struct ETag: Sendable & ~Copyable { @available(anyAppleOS 26.0, *) 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,8 +137,8 @@ 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)) + let (body, requestTrailers) = try await requestReader.collect(upTo: 1_000_000) { span in + String(copying: try UTF8Span(validating: span.span)) } // Collect the trailers that were sent in with the request @@ -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,83 @@ 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 { + var buffer = UniqueArray.init(repeating: UInt8(ascii: "A"), count: 1) + try await writer + .write(buffer: &buffer) + // 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 +366,24 @@ 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)) + var buffer = UniqueArray(copying: [UInt8](repeating: UInt8(ascii: "A"), count: 1000)) + try await writer.write(buffer: &buffer) - // 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 +393,65 @@ 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 [ + // Send a response with custom trailers, fused with the body in a single finish call. + let writer = try await responseSender.send(.init(status: .ok)) + var buffer = UniqueArray(copying: "Response body".utf8) + try await writer.finish( + buffer: &buffer, + finalElement: [ .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 75216e9..4e2a949 100644 --- a/Sources/URLSessionHTTPClient/URLSessionHTTPClient.swift +++ b/Sources/URLSessionHTTPClient/URLSessionHTTPClient.swift @@ -23,76 +23,79 @@ import Synchronization /// The HTTPClient implementation backed by URLSession. @available(anyAppleOS 26.0, *) public final class URLSessionHTTPClient: HTTPClient, IdleTimerEntryProvider { - public struct RequestWriter: AsyncWriter, ~Copyable { + public typealias Writer = RequestWriter + public typealias Reader = ResponseReader + + public struct RequestWriter: CallerAsyncWriter, ~Copyable { public typealias WriteElement = UInt8 public typealias WriteFailure = any Error - public typealias Buffer = UniqueArray + public typealias FinalElement = HTTPFields? var actual: URLSessionRequestStreamBridge - var buffer: UniqueArray? init(actual: URLSessionRequestStreamBridge) { self.actual = actual - self.buffer = UniqueArray(minimumCapacity: 1024) } - public mutating func write( - _ body: (inout UniqueArray) async throws(Failure) -> Return - ) async throws(AsyncStreaming.EitherError) -> Return where Failure: Error { - let result: Return - // This force-unwrap is safe since there can only be one concurrent write. - var buffer = self.buffer.take()! - do { - result = try await body(&buffer) - } catch { - buffer.removeAll() - self.buffer = consume buffer - throw .second(error) - } - if buffer.count == 0 { - self.buffer = consume buffer - return result + public mutating func write & ~Copyable>( + buffer: inout Buffer + ) async throws(WriteFailure) where Buffer.Element: ~Copyable { + guard buffer.count > 0 else { return } + var consumer = buffer.consumeAll() + while true { + let span = consumer.drainNext() + if span.isEmpty { break } + try await self.actual.internalWrite(span.span) } + } - do { - try await self.actual.internalWrite(buffer.span) - buffer.removeAll() - self.buffer = consume buffer - } catch { - buffer.removeAll() - self.buffer = consume buffer - throw .first(error) + public consuming func finish & ~Copyable>( + buffer: inout Buffer, + finalElement: consuming HTTPFields? + ) async throws(WriteFailure) where Buffer.Element: ~Copyable { + if buffer.count > 0 { + var consumer = buffer.consumeAll() + while true { + let span = consumer.drainNext() + if span.isEmpty { break } + try await self.actual.internalWrite(span.span) + } } - return result + self.actual.close(trailerFields: finalElement) } } - 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: AsyncReader, ~Copyable { + public typealias ReadElement = UInt8 + public typealias ReadFailure = any Error + public typealias Buffer = UniqueArray + public typealias FinalElement = HTTPFields? - 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, consuming 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 finalElement: HTTPFields?? = nil + + if !self.trailersDelivered { let data: DispatchData? do { data = try await self.actual.data() } 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) for region in data.regions { @@ -100,28 +103,24 @@ public final class URLSessionHTTPClient: HTTPClient, IdleTimerEntryProvider { } } - 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 + finalElement = .some(self.actual.responseTrailerFields) } + } + + let result: Return + do { + result = try await body(&buffer, finalElement) + } 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 @@ -363,7 +362,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 @@ -392,7 +391,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 2d120e8..53fa0a9 100644 --- a/Sources/URLSessionHTTPClient/URLSessionTaskDelegateBridge.swift +++ b/Sources/URLSessionHTTPClient/URLSessionTaskDelegateBridge.swift @@ -257,8 +257,8 @@ final class URLSessionTaskDelegateBridge: NSObject, Sendable, URLSessionTaskDele 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 @@ -288,8 +288,8 @@ final class URLSessionTaskDelegateBridge: NSObject, Sendable, URLSessionTaskDele 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 7c60575..ab378f6 100644 --- a/Tests/HTTPAPIsTests/EchoTests.swift +++ b/Tests/HTTPAPIsTests/EchoTests.swift @@ -19,18 +19,9 @@ import Testing @available(anyAppleOS 26.0, *) 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( + buffer: &body, + finalElement: 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 a9c8342..dbefd72 100644 --- a/Tests/HTTPAPIsTests/Helpers/HTTPClientAndServerTests.swift +++ b/Tests/HTTPAPIsTests/Helpers/HTTPClientAndServerTests.swift @@ -11,9 +11,10 @@ // //===----------------------------------------------------------------------===// -public import AsyncAlgorithms // TODO: This public import is only needed to work around a compiler assertion which is fixed by https://github.com/swiftlang/swift/pull/88829 import AsyncStreaming import BasicContainers +import ContainersPreview +import DequeModule import HTTPAPIs import HTTPTypes import Synchronization @@ -21,7 +22,9 @@ import Testing /// A test client and server. /// -/// This type hooks up a client to a server in-process. +/// This type hooks up a client to a server in-process using +/// ``DuplexAsyncChannel`` for both directions: the client side writes the +/// request body and reads the response body; the server side mirrors that. @available(anyAppleOS 26.0, *) final class TestClientAndServer: HTTPClient, HTTPServer { struct HTTPRequestContext: HTTPServerCapability.RequestContext { @@ -37,85 +40,133 @@ 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 UnderlyingDuplex = DuplexAsyncChannel + + /// A body writer for the test client/server. Wraps one side of a + /// ``DuplexAsyncChannel`` and adapts its `EitherError` write/finish + /// failures to plain `any Error`. + struct AsyncChannelBodyWriter: CallerAsyncWriter, ~Copyable, SendableMetatype { + typealias WriteElement = UInt8 + typealias WriteFailure = any Error typealias FinalElement = HTTPFields? - var channel: Disconnected?> - var trailersChannel: AsyncChannel + var underlying: UnderlyingDuplex.Writer - init( - channel: consuming sending MultiProducerSingleConsumerAsyncChannel, - trailersChannel: AsyncChannel - ) { - self.channel = Disconnected(value: channel) - self.trailersChannel = trailersChannel + init(underlying: consuming sending UnderlyingDuplex.Writer) { + self.underlying = underlying + } + + mutating func write & ~Copyable>( + buffer: inout Buffer + ) async throws(WriteFailure) where Buffer.Element: ~Copyable { + try await self.underlying.write(buffer: &buffer) } - 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) + consuming func finish & ~Copyable>( + buffer: inout Buffer, + finalElement: consuming HTTPFields? + ) async throws(WriteFailure) where Buffer.Element: ~Copyable { + try await self.underlying.finish(buffer: &buffer, finalElement: finalElement) } } - /// A concluding async writer backed by an underlying MPSCAsyncChannel.Source. - struct AsyncChannelConcludingAsyncWriter: ConcludingAsyncWriter, ~Copyable, SendableMetatype { - typealias Underlying = MultiProducerSingleConsumerAsyncChannel.Source + /// A body reader for the test client/server. Wraps one side of a + /// ``DuplexAsyncChannel`` and flattens its nested `EitherError` read + /// failures into the shape this test type publishes. + struct AsyncChannelBodyReader: AsyncReader, ~Copyable, SendableMetatype { + typealias ReadElement = UInt8 + typealias ReadFailure = any Error + typealias Buffer = UniqueDeque typealias FinalElement = HTTPFields? - var source: Disconnected.Source?> - var trailersChannel: AsyncChannel + var underlying: UnderlyingDuplex.Reader - init( - source: consuming sending MultiProducerSingleConsumerAsyncChannel.Source, - trailersChannel: AsyncChannel - ) { - self.source = Disconnected(value: consume source) - self.trailersChannel = trailersChannel + init(underlying: consuming sending UnderlyingDuplex.Reader) { + self.underlying = underlying } - consuming func produceAndConclude( - body: (consuming sending MultiProducerSingleConsumerAsyncChannel.Source) async throws -> (Return, HTTPFields?) - ) async throws -> Return { + mutating func read( + body: (inout UniqueDeque, consuming HTTPFields??) async throws(Failure) -> Return + ) async throws(EitherError) -> Return { do { - let source = self.source.swap(newValue: nil)! - let (result, trailers) = try await body(source) - await self.trailersChannel.send(trailers) - return result + return try await self.underlying.read(body: body) } catch { - self.trailersChannel.finish() - throw error + // Flatten the underlying `EitherError, Failure>` + // into the `EitherError` shape consumers expect. + switch error { + case .first(let readSide): + switch readSide { + case .first(let inner): throw .first(inner) + case .second(let cancel): throw .first(cancel) + } + case .second(let bodyError): + throw .second(bodyError) + } } } } - // A helper struct to buffer everything belonging to the incoming request + /// A response sender backed by a ``DuplexAsyncChannel`` writer. + struct AsyncChannelResponseSender: HTTPResponseSender, ~Copyable, SendableMetatype { + typealias Writer = AsyncChannelBodyWriter + + let resumeWith: @Sendable (HTTPResponse, consuming sending AsyncChannelBodyReader) -> Void + var responseWriter: Disconnected + let responseReader: Disconnected + + init( + resumeWith: @escaping @Sendable (HTTPResponse, consuming sending AsyncChannelBodyReader) -> Void, + responseWriter: consuming sending UnderlyingDuplex.Writer, + responseReader: consuming sending AsyncChannelBodyReader + ) { + self.resumeWith = resumeWith + self.responseWriter = Disconnected(value: consume responseWriter) + self.responseReader = Disconnected(value: consume responseReader) + } + + func sendInformational(_ response: HTTPResponse) async throws { + // No-op + } + + consuming func send(_ response: HTTPResponse) async throws -> AsyncChannelBodyWriter { + self.resumeWith(response, self.responseReader.take()!) + let writer = self.responseWriter.swap(newValue: nil)! + return AsyncChannelBodyWriter(underlying: writer) + } + } + + // A helper struct to buffer everything belonging to the incoming request. private struct BufferedRequest: ~Copyable { final class Response { var response: HTTPResponse - private var responseReader: AsyncChannelConcludingAsyncReader? + private var responseReader: AsyncChannelBodyReader? + /// Signaled by the client when it's finished using the response + /// reader. The server awaits this before letting the + /// ``withDuplex`` scope tear the underlying channel down. + let clientDone: AsyncStream.Continuation - init(response: HTTPResponse, responseReader: consuming AsyncChannelConcludingAsyncReader) { + init( + response: HTTPResponse, + responseReader: consuming AsyncChannelBodyReader, + clientDone: AsyncStream.Continuation + ) { self.response = response self.responseReader = consume responseReader + self.clientDone = clientDone } - 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 @@ -123,16 +174,15 @@ 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 RequestContext = HTTPRequestContext - 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) @@ -149,9 +199,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 @@ -167,6 +217,15 @@ final class TestClientAndServer: HTTPClient, HTTPServer { self.continuation.yield() } + // Unconditionally signal that we're done with the reader so the + // server can exit its ``withDuplex`` scope, even if the response + // handler throws. + let clientDone = response.clientDone + defer { + clientDone.yield() + clientDone.finish() + } + return try await responseHandler( response.response, // Needed since we are lacking call-once closures @@ -175,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 { @@ -195,75 +254,92 @@ 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 - let trailersChannel = AsyncChannel() - var requestChannelAndSource = MultiProducerSingleConsumerAsyncChannel.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 + // Consume the request up front so the duplex body closure doesn't + // need to capture and re-consume it across scopes. + let httpRequest = request.request + let body = request.takeBody() + let responseContinuation = request.responseContinuation + let bodySlot = Mutex( + Disconnected??>(value: body) + ) + + // The signal the server awaits to know the client is finished with + // the response reader. Until then, the duplex scope must stay open + // so the reader's underlying storage is still valid. + let (clientDoneStream, clientDoneCont) = AsyncStream.makeStream() + + try await UnderlyingDuplex.withDuplex( + withFinalElement: HTTPFields?.self, + throwing: (any Error).self, + backpressureStrategy: .watermark(low: 10, high: 20) + ) { writerA, readerA, writerB, readerB in + // Side A is the client: writes the request body, reads the response. + // Side B is the server: reads the request body, writes the response. + // Consume each handle into a Sendable slot here so the inner + // task-group closure can take ownership across the boundary. + let requestWriterSlot = Mutex( + Disconnected( + value: Optional(AsyncChannelBodyWriter(underlying: writerA)) + ) ) - let requestReader = AsyncChannelConcludingAsyncReader( - channel: requestChannel, - trailersChannel: trailersChannel + let requestReaderSlot = Mutex( + Disconnected( + value: Optional(AsyncChannelBodyReader(underlying: readerB)) + ) ) - var responseChannelAndSource = MultiProducerSingleConsumerAsyncChannel.makeChannel( - throwing: (any Error).self, - backpressureStrategy: .watermark(low: 10, high: 20) + let responseWriterSlot = Mutex( + Disconnected(value: Optional(writerB)) ) - 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( - channel: responseChannel, - trailersChannel: trailersChannel + let responseReaderSlot = Mutex( + Disconnected( + value: Optional(AsyncChannelBodyReader(underlying: readerA)) + ) ) - // 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) + try await withThrowingTaskGroup(of: Void.self) { group in + let body = bodySlot.withLock { $0.swap(newValue: nil) }! + group.addTask { + 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, + clientDone: clientDoneCont + ) ) + }, + responseWriter: responseWriterSlot.withLock { $0.swap(newValue: nil) }!, + responseReader: responseReaderSlot.withLock { $0.swap(newValue: nil) }! + ) + + let requestReader = requestReaderSlot.withLock { $0.swap(newValue: nil) }! + try await handler + .handle( + request: httpRequest, + requestContext: HTTPRequestContext( + remoteAddress: "127.0.0.1:54321", + localAddress: "0.0.0.0:8080" + ), + reader: requestReader, + responseSender: responseSender ) - // Needed since we are lacking call-once closures - return responseWriter.take()! - } sendInformational: { _ in } - try await handler - .handle( - request: request.request, - requestContext: HTTPRequestContext( - remoteAddress: "127.0.0.1:54321", - localAddress: "0.0.0.0:8080" - ), - requestBodyAndTrailers: requestReader, - responseSender: responseSender - ) + // Wait for the client to release the response reader before + // letting ``withDuplex`` tear down the underlying storage. + for await _ in clientDoneStream { break } } } } diff --git a/Tests/HTTPAPIsTests/Helpers/MultiProducerSingleConsumerAsyncChannel+AsyncReader+AsyncWriter.swift b/Tests/HTTPAPIsTests/Helpers/MultiProducerSingleConsumerAsyncChannel+AsyncReader+AsyncWriter.swift deleted file mode 100644 index 1d91659..0000000 --- a/Tests/HTTPAPIsTests/Helpers/MultiProducerSingleConsumerAsyncChannel+AsyncReader+AsyncWriter.swift +++ /dev/null @@ -1,75 +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 AsyncAlgorithms -public import AsyncStreaming -public import BasicContainers -public import ContainersPreview - -@available(anyAppleOS 26.0, *) -extension MultiProducerSingleConsumerAsyncChannel: AsyncReader { - public typealias ReadElement = Element - public typealias ReadFailure = Failure - public typealias Buffer = UniqueArray - - public mutating func read( - body: nonisolated(nonsending) (inout UniqueArray) async throws(F) -> Return - ) async throws(EitherError) -> Return { - let element: Element? - do { - element = try await self.next() - } catch { - throw .first(error) - } - - var buffer = UniqueArray() - if let element { - buffer.append(element) - } - - do { - return try await body(&buffer) - } catch { - throw .second(error) - } - } -} - -@available(anyAppleOS 26.0, *) -extension MultiProducerSingleConsumerAsyncChannel.Source: AsyncWriter where Element == UInt8 { - public typealias WriteElement = Element - public typealias WriteFailure = any Error - public typealias Buffer = UniqueArray - - public mutating func write( - _ body: nonisolated(nonsending) (inout UniqueArray) async throws(F) -> Return - ) async throws(EitherError) -> Return { - var buffer = UniqueArray() - let result: Return - do { - result = try await body(&buffer) - } catch { - throw .second(error) - } - - var consumer = buffer.consumeAll() - while let element = consumer.next() { - do { - try await self.send(element) - } catch { - throw .first(error) - } - } - return result - } -} diff --git a/Tests/HTTPAPIsTests/ServerCapabilityTests.swift b/Tests/HTTPAPIsTests/ServerCapabilityTests.swift index ab736d2..a1a031d 100644 --- a/Tests/HTTPAPIsTests/ServerCapabilityTests.swift +++ b/Tests/HTTPAPIsTests/ServerCapabilityTests.swift @@ -11,6 +11,7 @@ // //===----------------------------------------------------------------------===// +import BasicContainers import Foundation import HTTPAPIs import Testing @@ -29,12 +30,12 @@ extension TestClientAndServer.HTTPRequestContext: HTTPServerCapability.Connectio @available(anyAppleOS 26.0, *) extension TestClientAndServer { func serveWithContextAssertions() async throws { - try await self.serve { request, requestContext, requestBodyAndTrailers, responseSender in + try await self.serve { request, requestContext, reader, responseSender in #expect(requestContext.remoteAddress == "127.0.0.1:54321") #expect(requestContext.localAddress == "0.0.0.0:8080") - let responseBodyAndTrailers = try await responseSender.send(.init(status: .ok)) - try await responseBodyAndTrailers.writeAndConclude("".utf8.span, finalElement: nil) + var emptyBody = UniqueArray() + try await responseSender.send(.init(status: .ok), copying: &emptyBody) } } } @@ -60,12 +61,10 @@ struct ServerCapabilityTests { try await client.perform( request: request, body: nil - ) { response, responseBodyAndTrailers in + ) { response, reader in #expect(response.status == .ok) - _ = try await responseBodyAndTrailers.consumeAndConclude { reader in - var reader = reader - try await reader.collect(upTo: 100) { _ in } - } + var reader = reader + _ = try await reader.collect(upTo: 100) { _ in } } group.cancelAll() From 2e77873b82bd052b4cda71e1f40579a331ed8400 Mon Sep 17 00:00:00 2001 From: Franz Busch Date: Tue, 2 Jun 2026 20:08:38 +0200 Subject: [PATCH 02/11] Workaround compiler crash --- Sources/AHCHTTPClient/AHC+HTTPClient.swift | 24 ++++++++++++++----- .../NIOHTTPResponseSender.swift | 24 ++++++++++++++----- .../URLSessionHTTPClient.swift | 24 ++++++++++++++----- 3 files changed, 54 insertions(+), 18 deletions(-) diff --git a/Sources/AHCHTTPClient/AHC+HTTPClient.swift b/Sources/AHCHTTPClient/AHC+HTTPClient.swift index 9ab4bd8..fb7c66e 100644 --- a/Sources/AHCHTTPClient/AHC+HTTPClient.swift +++ b/Sources/AHCHTTPClient/AHC+HTTPClient.swift @@ -49,10 +49,17 @@ extension AsyncHTTPClient.HTTPClient: HTTPAPIs.HTTPClient { guard buffer.count > 0 else { return } self.byteBuffer.clear() var consumer = buffer.consumeAll() - while true { + // `while !done { ... }` instead of `while true { ... break }` to + // dodge a SIL ownership-verifier crash on the nightly main + // toolchain (https://github.com/swiftlang/swift/issues/89639). + var done = false + while !done { let span = consumer.drainNext() - if span.isEmpty { break } - unsafe self.byteBuffer.writeBytes(span.span.bytes) + if span.isEmpty { + done = true + } else { + unsafe self.byteBuffer.writeBytes(span.span.bytes) + } } try await self.requestWriter.writeRequestBodyPart(self.byteBuffer) } @@ -64,10 +71,15 @@ extension AsyncHTTPClient.HTTPClient: HTTPAPIs.HTTPClient { if buffer.count > 0 { self.byteBuffer.clear() var consumer = buffer.consumeAll() - while true { + // See note in `write(buffer:)`. + var done = false + while !done { let span = consumer.drainNext() - if span.isEmpty { break } - unsafe self.byteBuffer.writeBytes(span.span.bytes) + if span.isEmpty { + done = true + } else { + unsafe self.byteBuffer.writeBytes(span.span.bytes) + } } try await self.requestWriter.writeRequestBodyPart(self.byteBuffer) } diff --git a/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPResponseSender.swift b/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPResponseSender.swift index fee7aca..42a7c5f 100644 --- a/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPResponseSender.swift +++ b/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPResponseSender.swift @@ -53,10 +53,17 @@ public struct NIOHTTPResponseSender: HTTPResponseSender, ~Copyable { guard buffer.count > 0 else { return } self.byteBuffer.clear() var consumer = buffer.consumeAll() - while true { + // `while !done { ... }` instead of `while true { ... break }` to + // dodge a SIL ownership-verifier crash on the nightly main + // toolchain (https://github.com/swiftlang/swift/issues/89639). + var done = false + while !done { let span = consumer.drainNext() - if span.isEmpty { break } - unsafe self.byteBuffer.writeBytes(span.span.bytes) + if span.isEmpty { + done = true + } else { + unsafe self.byteBuffer.writeBytes(span.span.bytes) + } } try await self.writer.write(.body(self.byteBuffer)) } @@ -68,10 +75,15 @@ public struct NIOHTTPResponseSender: HTTPResponseSender, ~Copyable { if buffer.count > 0 { self.byteBuffer.clear() var consumer = buffer.consumeAll() - while true { + // See note in `write(buffer:)`. + var done = false + while !done { let span = consumer.drainNext() - if span.isEmpty { break } - unsafe self.byteBuffer.writeBytes(span.span.bytes) + if span.isEmpty { + done = true + } else { + unsafe self.byteBuffer.writeBytes(span.span.bytes) + } } try await self.writer.write(.body(self.byteBuffer)) } diff --git a/Sources/URLSessionHTTPClient/URLSessionHTTPClient.swift b/Sources/URLSessionHTTPClient/URLSessionHTTPClient.swift index 4e2a949..d33a3e0 100644 --- a/Sources/URLSessionHTTPClient/URLSessionHTTPClient.swift +++ b/Sources/URLSessionHTTPClient/URLSessionHTTPClient.swift @@ -42,10 +42,17 @@ public final class URLSessionHTTPClient: HTTPClient, IdleTimerEntryProvider { ) async throws(WriteFailure) where Buffer.Element: ~Copyable { guard buffer.count > 0 else { return } var consumer = buffer.consumeAll() - while true { + // `while !done { ... }` instead of `while true { ... break }` to + // dodge a SIL ownership-verifier crash on the nightly main + // toolchain (https://github.com/swiftlang/swift/issues/89639). + var done = false + while !done { let span = consumer.drainNext() - if span.isEmpty { break } - try await self.actual.internalWrite(span.span) + if span.isEmpty { + done = true + } else { + try await self.actual.internalWrite(span.span) + } } } @@ -55,10 +62,15 @@ public final class URLSessionHTTPClient: HTTPClient, IdleTimerEntryProvider { ) async throws(WriteFailure) where Buffer.Element: ~Copyable { if buffer.count > 0 { var consumer = buffer.consumeAll() - while true { + // See note in `write(buffer:)`. + var done = false + while !done { let span = consumer.drainNext() - if span.isEmpty { break } - try await self.actual.internalWrite(span.span) + if span.isEmpty { + done = true + } else { + try await self.actual.internalWrite(span.span) + } } } self.actual.close(trailerFields: finalElement) From 55fa902ab165dd763e39ddf6230f8c198039b014 Mon Sep 17 00:00:00 2001 From: Franz Busch Date: Tue, 2 Jun 2026 20:37:58 +0200 Subject: [PATCH 03/11] formatter fixes --- .../HTTPAPIs/CallerAsyncWriter+Optional.swift | 12 +++++------ .../Helpers/HTTPClientAndServerTests.swift | 21 +++++++++++-------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/Sources/HTTPAPIs/CallerAsyncWriter+Optional.swift b/Sources/HTTPAPIs/CallerAsyncWriter+Optional.swift index 2c82aae..de77841 100644 --- a/Sources/HTTPAPIs/CallerAsyncWriter+Optional.swift +++ b/Sources/HTTPAPIs/CallerAsyncWriter+Optional.swift @@ -18,18 +18,18 @@ public import ContainersPreview // TODO: This should be moved to the AsyncStreaming module @available(anyAppleOS 26.0, *) extension CallerAsyncWriter where Self: ~Copyable, Self: ~Escapable, WriteElement: ~Copyable { - /// Concludes the writer with no remaining buffer and no payload, when the - /// ``FinalElement`` is `Optional`. Equivalent to - /// ``finish(buffer:finalElement:)`` with an empty buffer and `nil`. + /// Concludes the writer with no remaining buffer and no payload, when the ``FinalElement`` is `Optional`. + /// + /// Equivalent to ``finish(buffer:finalElement:)`` with an empty buffer and `nil`. public consuming func finish() async throws(WriteFailure) where FinalElement == Wrapped? { var empty = UniqueArray() try await self.finish(buffer: &empty, finalElement: nil) } - /// Concludes the writer with the supplied buffer and no payload, when the - /// ``FinalElement`` is `Optional`. Equivalent to - /// ``finish(buffer:finalElement:)`` with `finalElement: nil`. + /// Concludes the writer with the supplied buffer and no payload, when the ``FinalElement`` is `Optional`. + /// + /// Equivalent to ``finish(buffer:finalElement:)`` with `finalElement: nil`. public consuming func finish & ~Copyable>( buffer: inout Buffer ) async throws(WriteFailure) diff --git a/Tests/HTTPAPIsTests/Helpers/HTTPClientAndServerTests.swift b/Tests/HTTPAPIsTests/Helpers/HTTPClientAndServerTests.swift index dbefd72..30b31e8 100644 --- a/Tests/HTTPAPIsTests/Helpers/HTTPClientAndServerTests.swift +++ b/Tests/HTTPAPIsTests/Helpers/HTTPClientAndServerTests.swift @@ -43,9 +43,10 @@ final class TestClientAndServer: HTTPClient, HTTPServer { typealias UnderlyingDuplex = DuplexAsyncChannel - /// A body writer for the test client/server. Wraps one side of a - /// ``DuplexAsyncChannel`` and adapts its `EitherError` write/finish - /// failures to plain `any Error`. + /// A body writer for the test client/server. + /// + /// Wraps one side of a ``DuplexAsyncChannel`` and adapts its `EitherError` + /// write/finish failures to plain `any Error`. struct AsyncChannelBodyWriter: CallerAsyncWriter, ~Copyable, SendableMetatype { typealias WriteElement = UInt8 typealias WriteFailure = any Error @@ -71,9 +72,10 @@ final class TestClientAndServer: HTTPClient, HTTPServer { } } - /// A body reader for the test client/server. Wraps one side of a - /// ``DuplexAsyncChannel`` and flattens its nested `EitherError` read - /// failures into the shape this test type publishes. + /// A body reader for the test client/server. + /// + /// Wraps one side of a ``DuplexAsyncChannel`` and flattens its nested + /// `EitherError` read failures into the shape this test type publishes. struct AsyncChannelBodyReader: AsyncReader, ~Copyable, SendableMetatype { typealias ReadElement = UInt8 typealias ReadFailure = any Error @@ -141,9 +143,10 @@ final class TestClientAndServer: HTTPClient, HTTPServer { final class Response { var response: HTTPResponse private var responseReader: AsyncChannelBodyReader? - /// Signaled by the client when it's finished using the response - /// reader. The server awaits this before letting the - /// ``withDuplex`` scope tear the underlying channel down. + /// Signaled by the client when it's finished using the response reader. + /// + /// The server awaits this before letting the ``withDuplex`` scope + /// tear the underlying channel down. let clientDone: AsyncStream.Continuation init( From d79795f4ae6f38868e529483461514d834269bc2 Mon Sep 17 00:00:00 2001 From: Franz Busch Date: Tue, 2 Jun 2026 21:25:12 +0200 Subject: [PATCH 04/11] Code review --- .../Client/HTTPClientRequestBody.swift | 20 +++ .../HTTPAPIs/Server/HTTPResponseSender.swift | 80 ++++++++--- Sources/HTTPClient/DefaultHTTPClient.swift | 13 +- .../HTTPServerForTesting/TestHTTPServer.swift | 124 ++++++++---------- .../HTTPAPIsTests/ServerCapabilityTests.swift | 4 +- 5 files changed, 143 insertions(+), 98 deletions(-) diff --git a/Sources/HTTPAPIs/Client/HTTPClientRequestBody.swift b/Sources/HTTPAPIs/Client/HTTPClientRequestBody.swift index c84b83e..dbff258 100644 --- a/Sources/HTTPAPIs/Client/HTTPClientRequestBody.swift +++ b/Sources/HTTPAPIs/Client/HTTPClientRequestBody.swift @@ -153,4 +153,24 @@ where Writer.WriteElement == UInt8, Writer.FinalElement == HTTPFields? { self.knownLength = knownLength self.writeBody = writeBody } + + // TODO: We should revisit this method and decide how we want external + // modules to map bodies. + package init( + other: HTTPClientRequestBody, + transform: @escaping @Sendable (consuming Writer) -> OtherWriter + ) where Writer: SendableMetatype { + self.knownLength = other.knownLength + self.writeBody = + switch other.writeBody { + case .restartable(let writeBody): + .restartable { writer in + try await writeBody(transform(writer)) + } + case .seekable(let writeBody): + .seekable { offset, writer in + try await writeBody(offset, transform(writer)) + } + } + } } diff --git a/Sources/HTTPAPIs/Server/HTTPResponseSender.swift b/Sources/HTTPAPIs/Server/HTTPResponseSender.swift index a29fce3..7830b70 100644 --- a/Sources/HTTPAPIs/Server/HTTPResponseSender.swift +++ b/Sources/HTTPAPIs/Server/HTTPResponseSender.swift @@ -24,6 +24,13 @@ import BasicContainers /// ``send(_:)`` writes the response head and returns a ``CallerAsyncWriter`` /// for streaming the body. The caller is responsible for terminating the body /// via ``CallerAsyncWriter/finish(buffer:finalElement:)``. +/// +/// For the common case where the entire body and trailers are already in hand +/// before the response head is sent, ``sendAndFinish(_:copying:trailers:)`` +/// completes the entire response in a single call. It has a default +/// implementation on top of ``send(_:)``, but conformers are encouraged to +/// override it when the underlying transport can coalesce the head, body, and +/// trailing fields into a single frame. @available(anyAppleOS 26.0, *) public protocol HTTPResponseSender: ~Copyable, ~Escapable { /// The body writer type used to stream response body bytes and signal end-of-body. @@ -57,33 +64,66 @@ public protocol HTTPResponseSender: ~Copyable, ~Escapable { /// - Note: This method consumes the sender, ensuring exactly one final response is sent. @_lifetime(copy self) consuming func send(_ response: HTTPResponse) async throws -> Writer + + /// Sends the final HTTP response head, the contents of `buffer`, and the + /// optional trailing HTTP fields, completing the response in a single call. + /// + /// This is equivalent to calling ``send(_:)`` to obtain a writer and then + /// invoking ``CallerAsyncWriter/finish(buffer:finalElement:)`` on it, but + /// conformers may override this to optimize writing the head, body, and trailing + /// fields into a single write where the wire protocol allows. + /// The default implementation does the two-step expansion via ``send(_:)``. + /// + /// On return the response is fully sent and no further calls are possible + /// on the sender. For empty-body responses (such as `204 No Content`, + /// `304 Not Modified`, or error responses without a body), pass `nil` for + /// `buffer` or use the ``sendAndFinish(_:)`` convenience; for responses + /// without trailers, pass `nil` for `trailers`. + /// + /// - Parameters: + /// - response: The final HTTP response head. Must not be informational (1xx). + /// - buffer: The full response body, or `nil` for an empty body. When + /// non-`nil`, the buffer is drained as part of the call; on return + /// it is `nil`. + /// - trailers: The optional trailing HTTP fields, or `nil` to terminate + /// the body without trailers. + /// - Throws: Any error encountered while writing the head, body, or trailing fields. + consuming func sendAndFinish & ~Copyable>( + _ response: HTTPResponse, + buffer: inout Buffer?, + trailers: HTTPFields? + ) async throws where Buffer.Element: ~Copyable } -// TODO: Those methods should all become protocol requirements so server's can optimize them @available(anyAppleOS 26.0, *) extension HTTPResponseSender where Self: ~Copyable, Writer: ~Copyable { - /// Sends the response head, then the contents of `buffer` fused with - /// optional trailers in a single `finish` call. - public consuming func send & ~Copyable>( + public consuming func sendAndFinish & ~Copyable>( _ response: HTTPResponse, - copying buffer: inout Buffer, - trailers: HTTPFields? = nil - ) async throws { + buffer: inout Buffer?, + trailers: HTTPFields? + ) async throws where Buffer.Element: ~Copyable { let writer = try await self.send(response) - try await writer.finish(buffer: &buffer, finalElement: trailers) + if var unwrapped = buffer.take() { + try await writer.finish(buffer: &unwrapped, finalElement: trailers) + } else { + var empty = UniqueArray() + try await writer.finish(buffer: &empty, finalElement: 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) - var empty = UniqueArray() - try await writer.finish(buffer: &empty, finalElement: 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) - var empty = UniqueArray() - try await writer.finish(buffer: &empty, finalElement: nil) + /// Sends the final HTTP response head with no body and no trailing fields, + /// completing the response in a single call. + /// + /// Convenience for empty-body responses such as `204 No Content`, + /// `304 Not Modified`, or error responses without a body. Equivalent to + /// ``sendAndFinish(_:buffer:trailers:)`` with `nil` for both `buffer` and + /// `trailers`; conformers that override that requirement to fuse into a + /// single transport frame benefit here too. + /// + /// - Parameter response: The final HTTP response head. Must not be informational (1xx). + /// - Throws: Any error encountered while writing the response head or the FIN signal. + public consuming func sendAndFinish(_ response: HTTPResponse) async throws { + var noBody: UniqueArray? = nil + try await self.sendAndFinish(response, buffer: &noBody, trailers: nil) } } diff --git a/Sources/HTTPClient/DefaultHTTPClient.swift b/Sources/HTTPClient/DefaultHTTPClient.swift index 2673a0b..6e1cbc4 100644 --- a/Sources/HTTPClient/DefaultHTTPClient.swift +++ b/Sources/HTTPClient/DefaultHTTPClient.swift @@ -147,17 +147,10 @@ public final class DefaultHTTPClient: HTTPAPIs.HTTPClient { ) async throws -> Return { // TODO: translate request options let options = self.client.defaultRequestOptions - let translatedBody: HTTPClientRequestBody? = body.map { userBody in - guard userBody.isSeekable else { - return HTTPClientRequestBody.restartable(knownLength: userBody.knownLength) { actualWriter in - try await userBody.produce(into: Writer(actual: actualWriter)) - } - } - return HTTPClientRequestBody.seekable(knownLength: userBody.knownLength) { offset, actualWriter in - try await userBody.produce(offset: offset, into: Writer(actual: actualWriter)) - } + let body = body.map { + HTTPClientRequestBody(other: $0) { Writer(actual: $0) } } - return try await self.client.perform(request: request, body: translatedBody, options: options) { response, actualReader in + return try await self.client.perform(request: request, body: body, options: options) { response, actualReader in try await responseHandler(response, Reader(actual: actualReader)) } } diff --git a/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift b/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift index 1245957..0bd75cf 100644 --- a/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift +++ b/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift @@ -94,27 +94,21 @@ func serve(server: NIOHTTPServer) async throws { responseSender in // This server expects a path guard let path = request.path else { - var body = UniqueArray( + var body: UniqueArray? = UniqueArray( capacity: 17, copying: "No path specified".utf8 ) - try await responseSender.send( - HTTPResponse(status: .internalServerError), - copying: &body - ) + try await responseSender.sendAndFinish(HTTPResponse(status: .internalServerError), buffer: &body, trailers: nil) return } // This server expects a valid path guard let components = URLComponents(string: path) else { - var body = UniqueArray( + var body: UniqueArray? = UniqueArray( capacity: 17, copying: "Malformed path".utf8 ) - try await responseSender.send( - HTTPResponse(status: .internalServerError), - copying: &body - ) + try await responseSender.sendAndFinish(HTTPResponse(status: .internalServerError), buffer: &body, trailers: nil) return } @@ -155,16 +149,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) - var arrayResponseData = UniqueArray(copying: responseData) - try await responseSender.send(HTTPResponse(status: .ok), copying: &arrayResponseData) + var arrayResponseData: UniqueArray? = UniqueArray(copying: responseData) + try await responseSender.sendAndFinish(HTTPResponse(status: .ok), buffer: &arrayResponseData, trailers: nil) case "/head_with_cl": if request.method != .head { - _ = try await responseSender.send(HTTPResponse(status: .methodNotAllowed)) + try await responseSender.sendAndFinish(HTTPResponse(status: .methodNotAllowed)) break } // OK with a theoretical 1000-byte body - _ = try await responseSender.send( + try await responseSender.sendAndFinish( HTTPResponse( status: .ok, headerFields: [ @@ -175,16 +169,16 @@ func serve(server: NIOHTTPServer) async throws { case "/200": // Do not write a response body for a HEAD request if request.method == .head { - _ = try await responseSender.send(HTTPResponse(status: .ok)) + try await responseSender.sendAndFinish(HTTPResponse(status: .ok)) } else { - var body = UniqueArray() - try await responseSender.send(HTTPResponse(status: .ok), copying: &body) + var body: UniqueArray? = UniqueArray() + try await responseSender.sendAndFinish(HTTPResponse(status: .ok), buffer: &body, trailers: nil) } 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: UniqueArray + var bytes: UniqueArray? var headers: HTTPFields if let acceptEncoding, acceptEncoding.contains("gzip") @@ -224,12 +218,12 @@ func serve(server: NIOHTTPServer) async throws { headers = [:] } - try await responseSender.send(HTTPResponse(status: .ok, headerFields: headers), copying: &bytes) + try await responseSender.sendAndFinish(HTTPResponse(status: .ok, headerFields: headers), buffer: &bytes, trailers: nil) 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: UniqueArray + var bytes: UniqueArray? var headers: HTTPFields if let acceptEncoding, acceptEncoding.contains("deflate") @@ -244,15 +238,12 @@ func serve(server: NIOHTTPServer) async throws { } try await responseSender - .send( - HTTPResponse(status: .ok, headerFields: headers), - copying: &bytes - ) + .sendAndFinish(HTTPResponse(status: .ok, headerFields: headers), buffer: &bytes, trailers: nil) 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: UniqueArray + var bytes: UniqueArray? var headers: HTTPFields if let acceptEncoding, acceptEncoding.contains("br") @@ -266,9 +257,9 @@ func serve(server: NIOHTTPServer) async throws { headers = [:] } - try await responseSender.send(HTTPResponse(status: .ok, headerFields: headers), copying: &bytes) + try await responseSender.sendAndFinish(HTTPResponse(status: .ok, headerFields: headers), buffer: &bytes, trailers: nil) case "/header_multivalue": - _ = try await responseSender.send( + try await responseSender.sendAndFinish( HTTPResponse( status: .ok, headerFields: [ @@ -280,55 +271,56 @@ 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. - var body = UniqueArray.init(copying: "TEST\n".utf8) - try await responseSender.send(HTTPResponse(status: .ok), copying: &body) + var body: UniqueArray? = UniqueArray(copying: "TEST\n".utf8) + try await responseSender.sendAndFinish(HTTPResponse(status: .ok), buffer: &body, trailers: nil) case "/redirect_ping": // Infinite redirection as a result of arriving here - var body = UniqueArray.init(copying: "".utf8) - try await responseSender.send( + var body: UniqueArray? = UniqueArray(copying: "".utf8) + try await responseSender.sendAndFinish( HTTPResponse(status: .movedPermanently, headerFields: HTTPFields([HTTPField(name: .location, value: "/redirect_pong")])), - copying: &body + buffer: &body, + trailers: nil ) case "/redirect_pong": // Infinite redirection as a result of arriving here - var body = UniqueArray.init(copying: "".utf8) - try await responseSender.send( + var body: UniqueArray? = UniqueArray(copying: "".utf8) + try await responseSender.sendAndFinish( HTTPResponse(status: .movedPermanently, headerFields: HTTPFields([HTTPField(name: .location, value: "/redirect_ping")])), - copying: &body + buffer: &body, + trailers: nil ) case "/301": // Redirect to /request - var body = UniqueArray.init(copying: "".utf8) - try await responseSender.send( + var body: UniqueArray? = UniqueArray(copying: "".utf8) + try await responseSender.sendAndFinish( HTTPResponse(status: .movedPermanently, headerFields: HTTPFields([HTTPField(name: .location, value: "/request")])), - copying: &body + buffer: &body, + trailers: nil ) case "/308": // Redirect to /request - var body = UniqueArray.init(copying: "".utf8) - try await responseSender.send( + var body: UniqueArray? = UniqueArray(copying: "".utf8) + try await responseSender.sendAndFinish( HTTPResponse( status: .permanentRedirect, headerFields: HTTPFields( [HTTPField(name: .location, value: "/request")] ) ), - copying: &body + buffer: &body, + trailers: nil ) case "/404": - var body = UniqueArray.init(copying: "".utf8) - try await responseSender.send(HTTPResponse(status: .notFound), copying: &body) + var body: UniqueArray? = UniqueArray(copying: "".utf8) + try await responseSender.sendAndFinish(HTTPResponse(status: .notFound), buffer: &body, trailers: nil) case "/999": - var body = UniqueArray.init(copying: "".utf8) - try await responseSender.send(HTTPResponse(status: 999), copying: &body) + var body: UniqueArray? = UniqueArray(copying: "".utf8) + try await responseSender.sendAndFinish(HTTPResponse(status: 999), buffer: &body, trailers: nil) case "/echo": // Bad method if request.method != .post { - var body = UniqueArray.init(copying: "Incorrect method".utf8) - try await responseSender.send( - HTTPResponse(status: .methodNotAllowed), - copying: &body - ) + var body: UniqueArray? = UniqueArray(copying: "Incorrect method".utf8) + try await responseSender.sendAndFinish(HTTPResponse(status: .methodNotAllowed), buffer: &body, trailers: nil) return } @@ -381,9 +373,9 @@ func serve(server: NIOHTTPServer) async throws { // It is okay for the client to give up on the connection due to the stall. } case "/1mb_body": - var body = UniqueArray.init(copying: String(repeating: "A", count: 1_000_000).data(using: .ascii)!) + var body: UniqueArray? = UniqueArray(copying: String(repeating: "A", count: 1_000_000).data(using: .ascii)!) do { - try await responseSender.send(.init(status: .ok), copying: &body) + try await responseSender.sendAndFinish(.init(status: .ok), buffer: &body, trailers: nil) } 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. @@ -393,23 +385,24 @@ func serve(server: NIOHTTPServer) async throws { } case "/cookie": let cookie = UUID().uuidString - var body = UniqueArray.init(copying: "".utf8) - try await responseSender.send( + var body: UniqueArray? = UniqueArray(copying: "".utf8) + try await responseSender.sendAndFinish( .init( status: .ok, headerFields: [ .setCookie: "foo=\(cookie)" ] ), - copying: &body + buffer: &body, + trailers: nil ) case "/etag": let clientETag = request.headerFields[.ifNoneMatch] let (serverETag, isNotModified) = eTag.next(clientETag: clientETag) if isNotModified { - var body = UniqueArray.init(copying: "".utf8) + var body: UniqueArray? = UniqueArray(copying: "".utf8) // Nothing has changed, so 304 Not Modified. - try await responseSender.send( + try await responseSender.sendAndFinish( .init( status: .notModified, headerFields: [ @@ -417,13 +410,14 @@ func serve(server: NIOHTTPServer) async throws { .cached: "true", ] ), - copying: &body + buffer: &body, + trailers: nil ) } else { // The server wants to give a new ETag to the client // Give the etag itself as the new body - var body = UniqueArray.init(copying: serverETag.data(using: .ascii)!) - try await responseSender.send( + var body: UniqueArray? = UniqueArray(copying: serverETag.data(using: .ascii)!) + try await responseSender.sendAndFinish( .init( status: .ok, headerFields: [ @@ -431,7 +425,8 @@ func serve(server: NIOHTTPServer) async throws { .cached: "false", ] ), - copying: &body + buffer: &body, + trailers: nil ) } case "/trailers": @@ -447,11 +442,8 @@ func serve(server: NIOHTTPServer) async throws { ] ) default: - var body = UniqueArray.init(copying: "Unknown path".utf8) - try await responseSender.send( - HTTPResponse(status: .internalServerError), - copying: &body - ) + var body: UniqueArray? = UniqueArray(copying: "Unknown path".utf8) + try await responseSender.sendAndFinish(HTTPResponse(status: .internalServerError), buffer: &body, trailers: nil) } } } diff --git a/Tests/HTTPAPIsTests/ServerCapabilityTests.swift b/Tests/HTTPAPIsTests/ServerCapabilityTests.swift index a1a031d..0cdfcda 100644 --- a/Tests/HTTPAPIsTests/ServerCapabilityTests.swift +++ b/Tests/HTTPAPIsTests/ServerCapabilityTests.swift @@ -34,8 +34,8 @@ extension TestClientAndServer { #expect(requestContext.remoteAddress == "127.0.0.1:54321") #expect(requestContext.localAddress == "0.0.0.0:8080") - var emptyBody = UniqueArray() - try await responseSender.send(.init(status: .ok), copying: &emptyBody) + var emptyBody: UniqueArray? = UniqueArray() + try await responseSender.sendAndFinish(.init(status: .ok), buffer: &emptyBody, trailers: nil) } } } From 8b65462fcf1deee25fda2ecec20cef4d4f6aff76 Mon Sep 17 00:00:00 2001 From: Guoye Zhang Date: Thu, 4 Jun 2026 01:10:44 -0700 Subject: [PATCH 05/11] Fix build --- Sources/AHCHTTPClient/AHC+HTTPClient.swift | 19 ++++++++----------- .../Client/HTTPClientRequestBody+Data.swift | 1 - Sources/HTTPClient/DefaultHTTPClient.swift | 2 +- .../NIOHTTPResponseSender.swift | 4 ++-- .../URLSessionHTTPClient.swift | 15 ++++++--------- .../URLSessionTaskDelegateBridge.swift | 10 ++++------ 6 files changed, 21 insertions(+), 30 deletions(-) diff --git a/Sources/AHCHTTPClient/AHC+HTTPClient.swift b/Sources/AHCHTTPClient/AHC+HTTPClient.swift index fb7c66e..616b61c 100644 --- a/Sources/AHCHTTPClient/AHC+HTTPClient.swift +++ b/Sources/AHCHTTPClient/AHC+HTTPClient.swift @@ -22,14 +22,11 @@ import Synchronization @available(anyAppleOS 26.0, *) extension AsyncHTTPClient.HTTPClient: HTTPAPIs.HTTPClient { - public typealias Writer = RequestBodyWriter - public typealias Reader = ResponseBodyReader - public struct RequestOptions: HTTPClientCapability.RequestOptions { } - public struct RequestBodyWriter: CallerAsyncWriter, ~Copyable { + public struct Writer: CallerAsyncWriter, ~Copyable { public typealias WriteElement = UInt8 public typealias WriteFailure = any Error public typealias FinalElement = HTTPFields? @@ -58,7 +55,7 @@ extension AsyncHTTPClient.HTTPClient: HTTPAPIs.HTTPClient { if span.isEmpty { done = true } else { - unsafe self.byteBuffer.writeBytes(span.span.bytes) + self.byteBuffer.writeBytes(span.span.bytes) } } try await self.requestWriter.writeRequestBodyPart(self.byteBuffer) @@ -78,7 +75,7 @@ extension AsyncHTTPClient.HTTPClient: HTTPAPIs.HTTPClient { if span.isEmpty { done = true } else { - unsafe self.byteBuffer.writeBytes(span.span.bytes) + self.byteBuffer.writeBytes(span.span.bytes) } } try await self.requestWriter.writeRequestBodyPart(self.byteBuffer) @@ -93,7 +90,7 @@ extension AsyncHTTPClient.HTTPClient: HTTPAPIs.HTTPClient { } } - public struct ResponseBodyReader: AsyncReader, ~Copyable { + public struct Reader: AsyncReader, ~Copyable { public typealias ReadElement = UInt8 public typealias ReadFailure = any Error public typealias Buffer = UniqueArray @@ -157,9 +154,9 @@ extension AsyncHTTPClient.HTTPClient: HTTPAPIs.HTTPClient { public func perform( request: HTTPRequest, - body: consuming HTTPClientRequestBody?, + body: consuming HTTPClientRequestBody?, options: RequestOptions, - responseHandler: (HTTPResponse, consuming ResponseBodyReader) async throws -> Return + responseHandler: (HTTPResponse, consuming Reader) async throws -> Return ) async throws -> Return { guard let url = request.url else { fatalError() @@ -183,7 +180,7 @@ extension AsyncHTTPClient.HTTPClient: HTTPAPIs.HTTPClient { for await ahcWriter in asyncStream { do { - let writer = RequestBodyWriter(ahcWriter) + let writer = Writer(ahcWriter) try await body.produce(into: writer) // writer.finish already calls requestBodyStreamFinished break // the loop @@ -214,7 +211,7 @@ extension AsyncHTTPClient.HTTPClient: HTTPAPIs.HTTPClient { headerFields: responseFields ) - result = .success(try await responseHandler(response, ResponseBodyReader(body: ahcResponse.body))) + result = .success(try await responseHandler(response, Reader(body: ahcResponse.body))) } catch { result = .failure(error) } diff --git a/Sources/HTTPAPIs/Client/HTTPClientRequestBody+Data.swift b/Sources/HTTPAPIs/Client/HTTPClientRequestBody+Data.swift index d646f4b..03f3db8 100644 --- a/Sources/HTTPAPIs/Client/HTTPClientRequestBody+Data.swift +++ b/Sources/HTTPAPIs/Client/HTTPClientRequestBody+Data.swift @@ -27,7 +27,6 @@ extension HTTPClientRequestBody where Writer: ~Copyable { public static func data(_ data: Data) -> Self { .seekable(knownLength: Int64(data.count)) { offset, writer in // TODO: Once data conforms to RangeReplaceableContainer we should remove this copy - let writer = writer var buffer = UniqueArray( copying: data.span.extracting(droppingFirst: Int(offset)) ) diff --git a/Sources/HTTPClient/DefaultHTTPClient.swift b/Sources/HTTPClient/DefaultHTTPClient.swift index 6e1cbc4..e890ff4 100644 --- a/Sources/HTTPClient/DefaultHTTPClient.swift +++ b/Sources/HTTPClient/DefaultHTTPClient.swift @@ -148,7 +148,7 @@ public final class DefaultHTTPClient: HTTPAPIs.HTTPClient { // TODO: translate request options let options = self.client.defaultRequestOptions let body = body.map { - HTTPClientRequestBody(other: $0) { Writer(actual: $0) } + HTTPClientRequestBody(other: $0) { Writer(actual: $0) } } return try await self.client.perform(request: request, body: body, options: options) { response, actualReader in try await responseHandler(response, Reader(actual: actualReader)) diff --git a/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPResponseSender.swift b/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPResponseSender.swift index 42a7c5f..2b36616 100644 --- a/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPResponseSender.swift +++ b/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPResponseSender.swift @@ -62,7 +62,7 @@ public struct NIOHTTPResponseSender: HTTPResponseSender, ~Copyable { if span.isEmpty { done = true } else { - unsafe self.byteBuffer.writeBytes(span.span.bytes) + self.byteBuffer.writeBytes(span.span.bytes) } } try await self.writer.write(.body(self.byteBuffer)) @@ -82,7 +82,7 @@ public struct NIOHTTPResponseSender: HTTPResponseSender, ~Copyable { if span.isEmpty { done = true } else { - unsafe self.byteBuffer.writeBytes(span.span.bytes) + self.byteBuffer.writeBytes(span.span.bytes) } } try await self.writer.write(.body(self.byteBuffer)) diff --git a/Sources/URLSessionHTTPClient/URLSessionHTTPClient.swift b/Sources/URLSessionHTTPClient/URLSessionHTTPClient.swift index d33a3e0..56b66e7 100644 --- a/Sources/URLSessionHTTPClient/URLSessionHTTPClient.swift +++ b/Sources/URLSessionHTTPClient/URLSessionHTTPClient.swift @@ -23,10 +23,7 @@ import Synchronization /// The HTTPClient implementation backed by URLSession. @available(anyAppleOS 26.0, *) public final class URLSessionHTTPClient: HTTPClient, IdleTimerEntryProvider { - public typealias Writer = RequestWriter - public typealias Reader = ResponseReader - - public struct RequestWriter: CallerAsyncWriter, ~Copyable { + public struct Writer: CallerAsyncWriter, ~Copyable { public typealias WriteElement = UInt8 public typealias WriteFailure = any Error public typealias FinalElement = HTTPFields? @@ -77,7 +74,7 @@ public final class URLSessionHTTPClient: HTTPClient, IdleTimerEntryProvider { } } - public struct ResponseReader: AsyncReader, ~Copyable { + public struct Reader: AsyncReader, ~Copyable { public typealias ReadElement = UInt8 public typealias ReadFailure = any Error public typealias Buffer = UniqueArray @@ -89,7 +86,7 @@ public final class URLSessionHTTPClient: HTTPClient, IdleTimerEntryProvider { init(actual: URLSessionTaskDelegateBridge) { self.actual = actual - self.buffer = UniqueArray(minimumCapacity: 1024) + self.buffer = UniqueArray() } public mutating func read( @@ -372,9 +369,9 @@ public final class URLSessionHTTPClient: HTTPClient, IdleTimerEntryProvider { public func perform( request: HTTPRequest, - body: consuming HTTPClientRequestBody?, + body: consuming HTTPClientRequestBody?, options: URLSessionRequestOptions, - responseHandler: (HTTPResponse, consuming ResponseReader) async throws -> Return + responseHandler: (HTTPResponse, consuming Reader) async throws -> Return ) async throws -> Return { guard request.schemeSupported else { throw HTTPTypeConversionError.unsupportedScheme @@ -403,7 +400,7 @@ public final class URLSessionHTTPClient: HTTPClient, IdleTimerEntryProvider { guard let response = (response as? HTTPURLResponse)?.httpResponse else { throw HTTPTypeConversionError.failedToConvertURLTypeToHTTPTypes } - result = .success(try await responseHandler(response, ResponseReader(actual: delegateBridge))) + result = .success(try await responseHandler(response, Reader(actual: delegateBridge))) } catch { result = .failure(error) } diff --git a/Sources/URLSessionHTTPClient/URLSessionTaskDelegateBridge.swift b/Sources/URLSessionHTTPClient/URLSessionTaskDelegateBridge.swift index 53fa0a9..3c9152d 100644 --- a/Sources/URLSessionHTTPClient/URLSessionTaskDelegateBridge.swift +++ b/Sources/URLSessionHTTPClient/URLSessionTaskDelegateBridge.swift @@ -39,11 +39,11 @@ final class URLSessionTaskDelegateBridge: NSObject, Sendable, URLSessionTaskDele // limits. private let stream: AsyncStream private let continuation: AsyncStream.Continuation - private let requestBody: HTTPClientRequestBody? + private let requestBody: HTTPClientRequestBody? // TODO: Can we get rid of this task and instead use on task group per client? private let requestBodyTask: Mutex?> = .init(nil) - init(task: URLSessionTask, body: consuming HTTPClientRequestBody?) { + init(task: URLSessionTask, body: consuming HTTPClientRequestBody?) { self.task = task var continuation: AsyncStream.Continuation? self.stream = AsyncStream { continuation = $0 } @@ -257,8 +257,7 @@ final class URLSessionTaskDelegateBridge: NSObject, Sendable, URLSessionTaskDele let bridge = URLSessionRequestStreamBridge(task: task) completionHandler(bridge.inputStream) do { - try await requestBody.produce(into: URLSessionHTTPClient.RequestWriter(actual: bridge)) - // bridge is closed by writer.finish + try await requestBody.produce(into: URLSessionHTTPClient.Writer(actual: bridge)) } catch { if bridge.writeFailed { // Ignore error @@ -288,8 +287,7 @@ final class URLSessionTaskDelegateBridge: NSObject, Sendable, URLSessionTaskDele let bridge = URLSessionRequestStreamBridge(task: task) completionHandler(bridge.inputStream) do { - try await requestBody.produce(offset: offset, into: URLSessionHTTPClient.RequestWriter(actual: bridge)) - // bridge is closed by writer.finish + try await requestBody.produce(offset: offset, into: URLSessionHTTPClient.Writer(actual: bridge)) } catch { if bridge.writeFailed { // Ignore error From c1acb6837ed0aa0f96e519acca3ef86f58194705 Mon Sep 17 00:00:00 2001 From: Guoye Zhang Date: Thu, 4 Jun 2026 09:18:22 -0700 Subject: [PATCH 06/11] Update AHC --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 7ba41ca..daddd26 100644 --- a/Package.swift +++ b/Package.swift @@ -53,7 +53,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-nio-extras.git", from: "1.30.0"), .package(url: "https://github.com/apple/swift-nio-http2.git", from: "1.0.0"), .package(url: "https://github.com/apple/swift-configuration", from: "1.0.0"), - .package(url: "https://github.com/swift-server/async-http-client.git", revision: "393104434ea57710f2469036e816672fe15e8212"), + .package(url: "https://github.com/guoye-zhang/async-http-client.git", revision: "ce50e695f16c10a97bec2731c652eaedcf823fcf"), ], targets: [ // MARK: Libraries From 0f5a5f3dc8436a5c41fd90c310fb18fbfb706195 Mon Sep 17 00:00:00 2001 From: Guoye Zhang Date: Thu, 4 Jun 2026 10:54:39 -0700 Subject: [PATCH 07/11] Use convenience --- .../HTTPServerForTesting/TestHTTPServer.swift | 53 +++++-------------- .../HTTPAPIsTests/ServerCapabilityTests.swift | 3 +- 2 files changed, 13 insertions(+), 43 deletions(-) diff --git a/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift b/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift index 0bd75cf..72cab72 100644 --- a/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift +++ b/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift @@ -167,13 +167,7 @@ func serve(server: NIOHTTPServer) async throws { ) ) case "/200": - // Do not write a response body for a HEAD request - if request.method == .head { - try await responseSender.sendAndFinish(HTTPResponse(status: .ok)) - } else { - var body: UniqueArray? = UniqueArray() - try await responseSender.sendAndFinish(HTTPResponse(status: .ok), buffer: &body, trailers: nil) - } + try await responseSender.sendAndFinish(HTTPResponse(status: .ok)) case "/gzip": // If the client didn't say that they supported this encoding, // then fallback to no encoding. @@ -275,52 +269,35 @@ func serve(server: NIOHTTPServer) async throws { try await responseSender.sendAndFinish(HTTPResponse(status: .ok), buffer: &body, trailers: nil) case "/redirect_ping": // Infinite redirection as a result of arriving here - var body: UniqueArray? = UniqueArray(copying: "".utf8) try await responseSender.sendAndFinish( - HTTPResponse(status: .movedPermanently, headerFields: HTTPFields([HTTPField(name: .location, value: "/redirect_pong")])), - buffer: &body, - trailers: nil + HTTPResponse(status: .movedPermanently, headerFields: [.location: "/redirect_pong"]) ) case "/redirect_pong": // Infinite redirection as a result of arriving here - var body: UniqueArray? = UniqueArray(copying: "".utf8) try await responseSender.sendAndFinish( - HTTPResponse(status: .movedPermanently, headerFields: HTTPFields([HTTPField(name: .location, value: "/redirect_ping")])), - buffer: &body, - trailers: nil + HTTPResponse(status: .movedPermanently, headerFields: [.location: "/redirect_ping"]) ) case "/301": // Redirect to /request - var body: UniqueArray? = UniqueArray(copying: "".utf8) try await responseSender.sendAndFinish( - HTTPResponse(status: .movedPermanently, headerFields: HTTPFields([HTTPField(name: .location, value: "/request")])), - buffer: &body, - trailers: nil + HTTPResponse(status: .movedPermanently, headerFields: [.location: "/request"]) ) case "/308": // Redirect to /request - var body: UniqueArray? = UniqueArray(copying: "".utf8) try await responseSender.sendAndFinish( - HTTPResponse( - status: .permanentRedirect, - headerFields: HTTPFields( - [HTTPField(name: .location, value: "/request")] - ) - ), - buffer: &body, - trailers: nil + HTTPResponse(status: .permanentRedirect, headerFields: [.location: "/request"]) ) case "/404": - var body: UniqueArray? = UniqueArray(copying: "".utf8) - try await responseSender.sendAndFinish(HTTPResponse(status: .notFound), buffer: &body, trailers: nil) + try await responseSender.sendAndFinish(HTTPResponse(status: .notFound)) case "/999": - var body: UniqueArray? = UniqueArray(copying: "".utf8) - try await responseSender.sendAndFinish(HTTPResponse(status: 999), buffer: &body, trailers: nil) + try await responseSender.sendAndFinish(HTTPResponse(status: 999)) case "/echo": // Bad method if request.method != .post { var body: UniqueArray? = UniqueArray(copying: "Incorrect method".utf8) - try await responseSender.sendAndFinish(HTTPResponse(status: .methodNotAllowed), buffer: &body, trailers: nil) + try await responseSender.sendAndFinish( + HTTPResponse(status: .methodNotAllowed), buffer: &body, trailers: nil + ) return } @@ -385,22 +362,18 @@ func serve(server: NIOHTTPServer) async throws { } case "/cookie": let cookie = UUID().uuidString - var body: UniqueArray? = UniqueArray(copying: "".utf8) try await responseSender.sendAndFinish( .init( status: .ok, headerFields: [ .setCookie: "foo=\(cookie)" ] - ), - buffer: &body, - trailers: nil + ) ) case "/etag": let clientETag = request.headerFields[.ifNoneMatch] let (serverETag, isNotModified) = eTag.next(clientETag: clientETag) if isNotModified { - var body: UniqueArray? = UniqueArray(copying: "".utf8) // Nothing has changed, so 304 Not Modified. try await responseSender.sendAndFinish( .init( @@ -409,9 +382,7 @@ func serve(server: NIOHTTPServer) async throws { .eTag: serverETag, .cached: "true", ] - ), - buffer: &body, - trailers: nil + ) ) } else { // The server wants to give a new ETag to the client diff --git a/Tests/HTTPAPIsTests/ServerCapabilityTests.swift b/Tests/HTTPAPIsTests/ServerCapabilityTests.swift index 0cdfcda..e698c50 100644 --- a/Tests/HTTPAPIsTests/ServerCapabilityTests.swift +++ b/Tests/HTTPAPIsTests/ServerCapabilityTests.swift @@ -34,8 +34,7 @@ extension TestClientAndServer { #expect(requestContext.remoteAddress == "127.0.0.1:54321") #expect(requestContext.localAddress == "0.0.0.0:8080") - var emptyBody: UniqueArray? = UniqueArray() - try await responseSender.sendAndFinish(.init(status: .ok), buffer: &emptyBody, trailers: nil) + try await responseSender.sendAndFinish(.init(status: .ok)) } } } From afaa09cb9413597d9baf448fd38b16e8aa9d124b Mon Sep 17 00:00:00 2001 From: Guoye Zhang Date: Thu, 4 Jun 2026 11:24:03 -0700 Subject: [PATCH 08/11] Trailers -> trailer and make the buffer non-Optional --- Sources/HTTPAPIs/CallerAsyncWriter+HTTP.swift | 4 +- .../HTTPAPIs/Server/HTTPResponseSender.swift | 48 ++++++++----------- .../HTTPClientConformance.swift | 6 +-- .../HTTPServerForTesting/TestHTTPServer.swift | 48 +++++++++---------- Tests/HTTPAPIsTests/EchoTests.swift | 6 +-- .../Helpers/HTTPClientAndServerTests.swift | 4 +- 6 files changed, 55 insertions(+), 61 deletions(-) diff --git a/Sources/HTTPAPIs/CallerAsyncWriter+HTTP.swift b/Sources/HTTPAPIs/CallerAsyncWriter+HTTP.swift index dfadaad..804b7ef 100644 --- a/Sources/HTTPAPIs/CallerAsyncWriter+HTTP.swift +++ b/Sources/HTTPAPIs/CallerAsyncWriter+HTTP.swift @@ -20,8 +20,8 @@ where Self: ~Copyable, Self: ~Escapable, WriteElement == UInt8, FinalElement == /// Concludes an HTTP body writer with no remaining buffer and the supplied /// trailers. Sugar over ``finish(buffer:finalElement:)``. // TODO: This should be moved to the AsyncStreaming module as a general purpose convenience - public consuming func finish(trailers: HTTPFields?) async throws(WriteFailure) { + public consuming func finish(trailer: HTTPFields?) async throws(WriteFailure) { var empty = UniqueArray() - try await self.finish(buffer: &empty, finalElement: trailers) + try await self.finish(buffer: &empty, finalElement: trailer) } } diff --git a/Sources/HTTPAPIs/Server/HTTPResponseSender.swift b/Sources/HTTPAPIs/Server/HTTPResponseSender.swift index 7830b70..3eab67e 100644 --- a/Sources/HTTPAPIs/Server/HTTPResponseSender.swift +++ b/Sources/HTTPAPIs/Server/HTTPResponseSender.swift @@ -25,12 +25,12 @@ import BasicContainers /// for streaming the body. The caller is responsible for terminating the body /// via ``CallerAsyncWriter/finish(buffer:finalElement:)``. /// -/// For the common case where the entire body and trailers are already in hand -/// before the response head is sent, ``sendAndFinish(_:copying:trailers:)`` -/// completes the entire response in a single call. It has a default -/// implementation on top of ``send(_:)``, but conformers are encouraged to -/// override it when the underlying transport can coalesce the head, body, and -/// trailing fields into a single frame. +/// For the common case where the entire body is already in hand before the +/// response head is sent, ``sendAndFinish(_:copying:trailer:)`` completes the +/// entire response in a single call. It has a default implementation on top of +/// ``send(_:)``, but conformers are encouraged to override it when the +/// underlying transport can coalesce the head, body, and trailing fields into +/// a single frame. @available(anyAppleOS 26.0, *) public protocol HTTPResponseSender: ~Copyable, ~Escapable { /// The body writer type used to stream response body bytes and signal end-of-body. @@ -76,22 +76,21 @@ public protocol HTTPResponseSender: ~Copyable, ~Escapable { /// /// On return the response is fully sent and no further calls are possible /// on the sender. For empty-body responses (such as `204 No Content`, - /// `304 Not Modified`, or error responses without a body), pass `nil` for + /// `304 Not Modified`, or error responses without a body), pass an empty /// `buffer` or use the ``sendAndFinish(_:)`` convenience; for responses - /// without trailers, pass `nil` for `trailers`. + /// without a trailer, pass `nil` for `trailer`. /// /// - Parameters: /// - response: The final HTTP response head. Must not be informational (1xx). - /// - buffer: The full response body, or `nil` for an empty body. When - /// non-`nil`, the buffer is drained as part of the call; on return - /// it is `nil`. - /// - trailers: The optional trailing HTTP fields, or `nil` to terminate - /// the body without trailers. + /// - buffer: The full response body. The buffer is drained as part of + /// the call; on return it is `nil`. + /// - trailer: The optional trailing HTTP fields, or `nil` to terminate + /// the body without a trailer. /// - Throws: Any error encountered while writing the head, body, or trailing fields. consuming func sendAndFinish & ~Copyable>( _ response: HTTPResponse, - buffer: inout Buffer?, - trailers: HTTPFields? + buffer: inout Buffer, + trailer: HTTPFields? ) async throws where Buffer.Element: ~Copyable } @@ -99,16 +98,11 @@ public protocol HTTPResponseSender: ~Copyable, ~Escapable { extension HTTPResponseSender where Self: ~Copyable, Writer: ~Copyable { public consuming func sendAndFinish & ~Copyable>( _ response: HTTPResponse, - buffer: inout Buffer?, - trailers: HTTPFields? + buffer: inout Buffer, + trailer: HTTPFields? = nil ) async throws where Buffer.Element: ~Copyable { let writer = try await self.send(response) - if var unwrapped = buffer.take() { - try await writer.finish(buffer: &unwrapped, finalElement: trailers) - } else { - var empty = UniqueArray() - try await writer.finish(buffer: &empty, finalElement: trailers) - } + try await writer.finish(buffer: &buffer, finalElement: trailer) } /// Sends the final HTTP response head with no body and no trailing fields, @@ -116,14 +110,14 @@ extension HTTPResponseSender where Self: ~Copyable, Writer: ~Copyable { /// /// Convenience for empty-body responses such as `204 No Content`, /// `304 Not Modified`, or error responses without a body. Equivalent to - /// ``sendAndFinish(_:buffer:trailers:)`` with `nil` for both `buffer` and - /// `trailers`; conformers that override that requirement to fuse into a + /// ``sendAndFinish(_:buffer:trailer:)`` with an empty buffer and `nil` for + /// `trailer`; conformers that override that requirement to fuse into a /// single transport frame benefit here too. /// /// - Parameter response: The final HTTP response head. Must not be informational (1xx). /// - Throws: Any error encountered while writing the response head or the FIN signal. public consuming func sendAndFinish(_ response: HTTPResponse) async throws { - var noBody: UniqueArray? = nil - try await self.sendAndFinish(response, buffer: &noBody, trailers: nil) + var noBody = UniqueArray() + try await self.sendAndFinish(response, buffer: &noBody, trailer: nil) } } diff --git a/Sources/HTTPClientConformance/HTTPClientConformance.swift b/Sources/HTTPClientConformance/HTTPClientConformance.swift index 47e0174..f074be1 100644 --- a/Sources/HTTPClientConformance/HTTPClientConformance.swift +++ b/Sources/HTTPClientConformance/HTTPClientConformance.swift @@ -491,7 +491,7 @@ struct ConformanceTestSuite { scheme: "http", authority: "127.0.0.1:\(testServerPort)", path: "/request", - headerFields: HTTPFields([HTTPField(name: .init("X-Foo")!, value: "BARbaz")]) + headerFields: [.init("X-Foo")!: "BARbaz"] ) try await client.perform( @@ -653,7 +653,7 @@ struct ConformanceTestSuite { // Only proceed once the client receives the echo. await writerWaiting.first(where: { true }) } - try await writer.finish(trailers: nil) + try await writer.finish(trailer: nil) } ) { response, responseBodyAndTrailers in #expect(response.status == .ok) @@ -726,7 +726,7 @@ struct ConformanceTestSuite { var buffer = UniqueArray(copying: chunk.utf8Span.span) try await writer.write(buffer: &buffer) } - try await writer.finish(trailers: nil) + try await writer.finish(trailer: nil) } ) { response, responseBodyAndTrailers in #expect(response.status == .ok) diff --git a/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift b/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift index 72cab72..c37c607 100644 --- a/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift +++ b/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift @@ -94,21 +94,21 @@ func serve(server: NIOHTTPServer) async throws { responseSender in // This server expects a path guard let path = request.path else { - var body: UniqueArray? = UniqueArray( + var body = UniqueArray( capacity: 17, copying: "No path specified".utf8 ) - try await responseSender.sendAndFinish(HTTPResponse(status: .internalServerError), buffer: &body, trailers: nil) + try await responseSender.sendAndFinish(HTTPResponse(status: .internalServerError), buffer: &body, trailer: nil) return } // This server expects a valid path guard let components = URLComponents(string: path) else { - var body: UniqueArray? = UniqueArray( + var body = UniqueArray( capacity: 17, copying: "Malformed path".utf8 ) - try await responseSender.sendAndFinish(HTTPResponse(status: .internalServerError), buffer: &body, trailers: nil) + try await responseSender.sendAndFinish(HTTPResponse(status: .internalServerError), buffer: &body, trailer: nil) return } @@ -149,8 +149,8 @@ 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) - var arrayResponseData: UniqueArray? = UniqueArray(copying: responseData) - try await responseSender.sendAndFinish(HTTPResponse(status: .ok), buffer: &arrayResponseData, trailers: nil) + var arrayResponseData = UniqueArray(copying: responseData) + try await responseSender.sendAndFinish(HTTPResponse(status: .ok), buffer: &arrayResponseData, trailer: nil) case "/head_with_cl": if request.method != .head { try await responseSender.sendAndFinish(HTTPResponse(status: .methodNotAllowed)) @@ -172,7 +172,7 @@ func serve(server: NIOHTTPServer) async throws { // If the client didn't say that they supported this encoding, // then fallback to no encoding. let acceptEncoding = request.headerFields[.acceptEncoding] - var bytes: UniqueArray? + var bytes: UniqueArray var headers: HTTPFields if let acceptEncoding, acceptEncoding.contains("gzip") @@ -212,12 +212,12 @@ func serve(server: NIOHTTPServer) async throws { headers = [:] } - try await responseSender.sendAndFinish(HTTPResponse(status: .ok, headerFields: headers), buffer: &bytes, trailers: nil) + try await responseSender.sendAndFinish(HTTPResponse(status: .ok, headerFields: headers), buffer: &bytes, trailer: nil) 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: UniqueArray? + var bytes: UniqueArray var headers: HTTPFields if let acceptEncoding, acceptEncoding.contains("deflate") @@ -232,12 +232,12 @@ func serve(server: NIOHTTPServer) async throws { } try await responseSender - .sendAndFinish(HTTPResponse(status: .ok, headerFields: headers), buffer: &bytes, trailers: nil) + .sendAndFinish(HTTPResponse(status: .ok, headerFields: headers), buffer: &bytes, trailer: nil) 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: UniqueArray? + var bytes: UniqueArray var headers: HTTPFields if let acceptEncoding, acceptEncoding.contains("br") @@ -251,7 +251,7 @@ func serve(server: NIOHTTPServer) async throws { headers = [:] } - try await responseSender.sendAndFinish(HTTPResponse(status: .ok, headerFields: headers), buffer: &bytes, trailers: nil) + try await responseSender.sendAndFinish(HTTPResponse(status: .ok, headerFields: headers), buffer: &bytes, trailer: nil) case "/header_multivalue": try await responseSender.sendAndFinish( HTTPResponse( @@ -265,8 +265,8 @@ 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. - var body: UniqueArray? = UniqueArray(copying: "TEST\n".utf8) - try await responseSender.sendAndFinish(HTTPResponse(status: .ok), buffer: &body, trailers: nil) + var body = UniqueArray(copying: "TEST\n".utf8) + try await responseSender.sendAndFinish(HTTPResponse(status: .ok), buffer: &body, trailer: nil) case "/redirect_ping": // Infinite redirection as a result of arriving here try await responseSender.sendAndFinish( @@ -294,9 +294,9 @@ func serve(server: NIOHTTPServer) async throws { case "/echo": // Bad method if request.method != .post { - var body: UniqueArray? = UniqueArray(copying: "Incorrect method".utf8) + var body = UniqueArray(copying: "Incorrect method".utf8) try await responseSender.sendAndFinish( - HTTPResponse(status: .methodNotAllowed), buffer: &body, trailers: nil + HTTPResponse(status: .methodNotAllowed), buffer: &body, trailer: nil ) return } @@ -325,7 +325,7 @@ func serve(server: NIOHTTPServer) async throws { } } } - try await writer.finish(trailers: nil) + try await writer.finish(trailer: nil) case "/stall": do { // Wait for an hour (effectively never giving an answer) @@ -345,14 +345,14 @@ func serve(server: NIOHTTPServer) async throws { assertionFailure("Not expected to complete hour-long wait") - try await writer.finish(trailers: nil) + try await writer.finish(trailer: nil) } catch { // It is okay for the client to give up on the connection due to the stall. } case "/1mb_body": - var body: UniqueArray? = UniqueArray(copying: String(repeating: "A", count: 1_000_000).data(using: .ascii)!) + var body = UniqueArray(copying: String(repeating: "A", count: 1_000_000).data(using: .ascii)!) do { - try await responseSender.sendAndFinish(.init(status: .ok), buffer: &body, trailers: nil) + try await responseSender.sendAndFinish(.init(status: .ok), buffer: &body, trailer: nil) } 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. @@ -387,7 +387,7 @@ func serve(server: NIOHTTPServer) async throws { } else { // The server wants to give a new ETag to the client // Give the etag itself as the new body - var body: UniqueArray? = UniqueArray(copying: serverETag.data(using: .ascii)!) + var body = UniqueArray(copying: serverETag.data(using: .ascii)!) try await responseSender.sendAndFinish( .init( status: .ok, @@ -397,7 +397,7 @@ func serve(server: NIOHTTPServer) async throws { ] ), buffer: &body, - trailers: nil + trailer: nil ) } case "/trailers": @@ -413,8 +413,8 @@ func serve(server: NIOHTTPServer) async throws { ] ) default: - var body: UniqueArray? = UniqueArray(copying: "Unknown path".utf8) - try await responseSender.sendAndFinish(HTTPResponse(status: .internalServerError), buffer: &body, trailers: nil) + var body = UniqueArray(copying: "Unknown path".utf8) + try await responseSender.sendAndFinish(HTTPResponse(status: .internalServerError), buffer: &body, trailer: nil) } } } diff --git a/Tests/HTTPAPIsTests/EchoTests.swift b/Tests/HTTPAPIsTests/EchoTests.swift index ab378f6..a320051 100644 --- a/Tests/HTTPAPIsTests/EchoTests.swift +++ b/Tests/HTTPAPIsTests/EchoTests.swift @@ -50,16 +50,16 @@ struct HTTPClientAndServerTests { var body = UniqueArray.init(copying: "Hello".utf8) try await writer.finish( buffer: &body, - finalElement: HTTPFields([.init(name: .date, value: "test")]) + finalElement: [.date: "test"] ) } ) { (response: HTTPResponse, reader: consuming TestClientAndServer.AsyncChannelBodyReader) in #expect(response.status == .ok) var responseBody = UniqueArray(minimumCapacity: 100) - let trailers = try await reader.collect(into: &responseBody) + let trailer = try await reader.collect(into: &responseBody) let isEqual = responseBody == UniqueArray(copying: "Hello".utf8) #expect(isEqual) - #expect(trailers == HTTPFields([.init(name: .date, value: "test")])) + #expect(trailer == [.date: "test"]) } group.cancelAll() diff --git a/Tests/HTTPAPIsTests/Helpers/HTTPClientAndServerTests.swift b/Tests/HTTPAPIsTests/Helpers/HTTPClientAndServerTests.swift index 30b31e8..84c5198 100644 --- a/Tests/HTTPAPIsTests/Helpers/HTTPClientAndServerTests.swift +++ b/Tests/HTTPAPIsTests/Helpers/HTTPClientAndServerTests.swift @@ -308,8 +308,8 @@ final class TestClientAndServer: HTTPClient, HTTPServer { 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) + // No body: just signal end-of-stream with no trailer. + try await writer.finish(trailer: nil) } } From 076772ff1afa79486e5d7e06e5e30624a6a2ba6c Mon Sep 17 00:00:00 2001 From: Guoye Zhang Date: Thu, 4 Jun 2026 11:25:22 -0700 Subject: [PATCH 09/11] Format --- .../HTTPServerForTesting/TestHTTPServer.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift b/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift index c37c607..8870fb0 100644 --- a/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift +++ b/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift @@ -296,7 +296,9 @@ func serve(server: NIOHTTPServer) async throws { if request.method != .post { var body = UniqueArray(copying: "Incorrect method".utf8) try await responseSender.sendAndFinish( - HTTPResponse(status: .methodNotAllowed), buffer: &body, trailer: nil + HTTPResponse(status: .methodNotAllowed), + buffer: &body, + trailer: nil ) return } From 0645e6794e75e5fc92cdd393435dde7c236f0774 Mon Sep 17 00:00:00 2001 From: Guoye Zhang Date: Sat, 6 Jun 2026 01:15:07 -0700 Subject: [PATCH 10/11] Update packages --- Package.swift | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/Package.swift b/Package.swift index daddd26..883860b 100644 --- a/Package.swift +++ b/Package.swift @@ -39,21 +39,24 @@ let package = Package( .default(enabledTraits: ["Configuration"]), ], dependencies: [ - .package(url: "https://github.com/apple/swift-collections.git", from: "1.5.0"), + .package(url: "https://github.com/apple/swift-collections.git", from: "1.5.1"), .package( - url: "https://github.com/FranzBusch/swift-async-algorithms.git", - revision: "e1a6dff375ca9fc079d02276f489663ad9204e01", + url: "https://github.com/apple/swift-async-algorithms.git", + revision: "8ee3d2be1961950f94b6fa758477e3a0c5486aa9", traits: ["UnstableAsyncStreaming"] ), - .package(url: "https://github.com/apple/swift-http-types.git", from: "1.5.1"), - .package(url: "https://github.com/apple/swift-certificates.git", from: "1.16.0"), - .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-nio.git", from: "2.92.2"), - .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.36.0"), - .package(url: "https://github.com/apple/swift-nio-extras.git", from: "1.30.0"), - .package(url: "https://github.com/apple/swift-nio-http2.git", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-configuration", from: "1.0.0"), - .package(url: "https://github.com/guoye-zhang/async-http-client.git", revision: "ce50e695f16c10a97bec2731c652eaedcf823fcf"), + .package(url: "https://github.com/apple/swift-http-types.git", from: "1.6.0"), + .package(url: "https://github.com/apple/swift-certificates.git", from: "1.19.1"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.13.1"), + .package(url: "https://github.com/apple/swift-nio.git", from: "2.100.0"), + .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.37.0"), + .package(url: "https://github.com/apple/swift-nio-extras.git", from: "1.34.1"), + .package(url: "https://github.com/apple/swift-nio-http2.git", from: "1.44.0"), + .package(url: "https://github.com/apple/swift-configuration.git", from: "1.2.0"), + .package( + url: "https://github.com/swift-server/async-http-client.git", + revision: "a0ab90739bc856e7a097da8a4e71794aaaec651f" + ), ], targets: [ // MARK: Libraries @@ -256,7 +259,7 @@ if enableWASM { ] package.dependencies.append( - .package(url: "https://github.com/swiftwasm/JavaScriptKit", from: "0.53.0") + .package(url: "https://github.com/swiftwasm/JavaScriptKit.git", from: "0.53.0") ) package.products.append( .library(name: "FetchHTTPClient", targets: ["FetchHTTPClient"]) From 3e5cdfe544279a58b769d4164d9ff7eaea6a95b4 Mon Sep 17 00:00:00 2001 From: Guoye Zhang Date: Sat, 6 Jun 2026 16:26:24 -0700 Subject: [PATCH 11/11] Fix all warnings --- Sources/HTTPAPIs/Client/HTTPClientRequestBody.swift | 2 +- .../HTTPServerForTesting/RawHTTPServer.swift | 4 ++-- Tests/AsyncHTTPClientConformanceTests/Suite.swift | 2 +- Tests/HTTPAPIsTests/ServerCapabilityTests.swift | 1 - 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Sources/HTTPAPIs/Client/HTTPClientRequestBody.swift b/Sources/HTTPAPIs/Client/HTTPClientRequestBody.swift index dbff258..af2bbcc 100644 --- a/Sources/HTTPAPIs/Client/HTTPClientRequestBody.swift +++ b/Sources/HTTPAPIs/Client/HTTPClientRequestBody.swift @@ -56,7 +56,7 @@ public import AsyncStreaming /// ``` @available(anyAppleOS 26.0, *) public struct HTTPClientRequestBody: Sendable -where Writer.WriteElement == UInt8, Writer.FinalElement == HTTPFields? { +where Writer: SendableMetatype, Writer.WriteElement == UInt8, Writer.FinalElement == HTTPFields? { /// The body can be asked to restart writing from an arbitrary offset. public var isSeekable: Bool { switch self.writeBody { diff --git a/Sources/HTTPClientConformance/HTTPServerForTesting/RawHTTPServer.swift b/Sources/HTTPClientConformance/HTTPServerForTesting/RawHTTPServer.swift index 195cf40..50e0f11 100644 --- a/Sources/HTTPClientConformance/HTTPServerForTesting/RawHTTPServer.swift +++ b/Sources/HTTPClientConformance/HTTPServerForTesting/RawHTTPServer.swift @@ -126,7 +126,7 @@ actor RawHTTPServer { ) { channel in channel.eventLoop.makeCompletedFuture { let requestDecoder = ByteToMessageHandler(HTTPRequestDecoder(leftOverBytesStrategy: .forwardBytes)) - channel.pipeline.addHandler(requestDecoder) + try channel.pipeline.syncOperations.addHandler(requestDecoder) return try NIOAsyncChannel< HTTPServerRequestPart, IOData @@ -146,7 +146,7 @@ actor RawHTTPServer { // It must be the header. We don't care about the rest. guard case .head(let head) = requestPart else { - print("Server unexpectedly received non-header as first part of request: \(requestPart)") + print("Server unexpectedly received non-header as first part of request: \(String(describing: requestPart))") return } diff --git a/Tests/AsyncHTTPClientConformanceTests/Suite.swift b/Tests/AsyncHTTPClientConformanceTests/Suite.swift index 45d83e9..8bdea5c 100644 --- a/Tests/AsyncHTTPClientConformanceTests/Suite.swift +++ b/Tests/AsyncHTTPClientConformanceTests/Suite.swift @@ -24,7 +24,7 @@ import Testing config.httpVersion = .automatic config.decompression = .enabled(limit: .none) let httpClient = HTTPClient(eventLoopGroup: .singletonMultiThreadedEventLoopGroup, configuration: config) - defer { Task { try await httpClient.shutdown() } } + defer { try! await httpClient.shutdown() } try await runConformanceTests(excluding: [ // TODO: AHC does not support cookies diff --git a/Tests/HTTPAPIsTests/ServerCapabilityTests.swift b/Tests/HTTPAPIsTests/ServerCapabilityTests.swift index e698c50..711e561 100644 --- a/Tests/HTTPAPIsTests/ServerCapabilityTests.swift +++ b/Tests/HTTPAPIsTests/ServerCapabilityTests.swift @@ -62,7 +62,6 @@ struct ServerCapabilityTests { body: nil ) { response, reader in #expect(response.status == .ok) - var reader = reader _ = try await reader.collect(upTo: 100) { _ in } }