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 a92b237..aa87fb6 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 f94dd00..03c9670 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/apple/swift-async-algorithms.git", - from: "1.1.4", + 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/swift-server/async-http-client.git", revision: "393104434ea57710f2469036e816672fe15e8212"), + .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"]) diff --git a/Sources/AHCHTTPClient/AHC+HTTPClient.swift b/Sources/AHCHTTPClient/AHC+HTTPClient.swift index 5500e63..616b61c 100644 --- a/Sources/AHCHTTPClient/AHC+HTTPClient.swift +++ b/Sources/AHCHTTPClient/AHC+HTTPClient.swift @@ -22,118 +22,126 @@ import Synchronization @available(anyAppleOS 26.0, *) extension AsyncHTTPClient.HTTPClient: HTTPAPIs.HTTPClient { - public typealias RequestWriter = RequestBodyWriter - public typealias ResponseConcludingReader = ResponseReader - public struct RequestOptions: HTTPClientCapability.RequestOptions { } - public struct RequestBodyWriter: AsyncWriter, ~Copyable { + public struct Writer: 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 !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 { + done = true + } else { + 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() + // See note in `write(buffer:)`. + var done = false + while !done { + let span = consumer.drainNext() + if span.isEmpty { + done = true + } else { + 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) } } - public struct ResponseBodyReader: AsyncReader, ~Copyable { + public struct Reader: AsyncReader, ~Copyable { 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 { - 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 { + 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 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) } @@ -146,9 +154,9 @@ extension AsyncHTTPClient.HTTPClient: HTTPAPIs.HTTPClient { public func perform( request: HTTPRequest, - body: consuming HTTPClientRequestBody?, + body: consuming HTTPClientRequestBody?, options: RequestOptions, - responseHandler: (HTTPResponse, consuming ResponseReader) async throws -> Return + responseHandler: (HTTPResponse, consuming Reader) async throws -> Return ) async throws -> Return { guard let url = request.url else { fatalError() @@ -172,15 +180,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 = Writer(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 +211,7 @@ extension AsyncHTTPClient.HTTPClient: HTTPAPIs.HTTPClient { headerFields: responseFields ) - result = .success(try await responseHandler(response, .init(underlying: ahcResponse.body))) + result = .success(try await responseHandler(response, Reader(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..804b7ef --- /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(trailer: HTTPFields?) async throws(WriteFailure) { + var empty = UniqueArray() + try await self.finish(buffer: &empty, finalElement: trailer) + } +} diff --git a/Sources/HTTPAPIs/CallerAsyncWriter+Optional.swift b/Sources/HTTPAPIs/CallerAsyncWriter+Optional.swift new file mode 100644 index 0000000..de77841 --- /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..03f3db8 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,11 @@ 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 + 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..af2bbcc 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: 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 { @@ -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, @@ -152,10 +154,12 @@ where Writer.WriteElement == UInt8, Writer: SendableMetatype { self.writeBody = writeBody } - package init( + // 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 { 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..3eab67e --- /dev/null +++ b/Sources/HTTPAPIs/Server/HTTPResponseSender.swift @@ -0,0 +1,123 @@ +//===----------------------------------------------------------------------===// +// +// 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:)``. +/// +/// 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. + /// + /// 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 + + /// 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 an empty + /// `buffer` or use the ``sendAndFinish(_:)`` convenience; for responses + /// without a trailer, pass `nil` for `trailer`. + /// + /// - Parameters: + /// - response: The final HTTP response head. Must not be informational (1xx). + /// - 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, + trailer: HTTPFields? + ) async throws where Buffer.Element: ~Copyable +} + +@available(anyAppleOS 26.0, *) +extension HTTPResponseSender where Self: ~Copyable, Writer: ~Copyable { + public consuming func sendAndFinish & ~Copyable>( + _ response: HTTPResponse, + buffer: inout Buffer, + trailer: HTTPFields? = nil + ) async throws where Buffer.Element: ~Copyable { + let writer = try await self.send(response) + try await writer.finish(buffer: &buffer, finalElement: trailer) + } + + /// 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: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() + try await self.sendAndFinish(response, buffer: &noBody, trailer: 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..e890ff4 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 - 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) + init(actual: consuming ActualHTTPClient.Writer) { + self.actual = actual } - var actual: ActualHTTPClient.RequestWriter + public mutating func write & ~Copyable>( + buffer: inout Buffer + ) async throws(WriteFailure) where Buffer.Element: ~Copyable { + try await self.actual.write(buffer: &buffer) + } + + 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,17 @@ 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) } + HTTPClientRequestBody(other: $0) { Writer(actual: $0) } } - return try await self.client.perform(request: request, body: body, options: options) { response, body in - try await responseHandler(response, ResponseConcludingReader(actual: body)) + return try await self.client.perform(request: request, body: body, options: options) { 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..f074be1 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") } } @@ -496,23 +491,22 @@ 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( 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(trailer: 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(trailer: 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..2b36616 --- /dev/null +++ b/Sources/HTTPClientConformance/HTTPServerForTesting/NIOHTTPResponseSender.swift @@ -0,0 +1,141 @@ +//===----------------------------------------------------------------------===// +// +// 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 !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 { + done = true + } else { + 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() + // See note in `write(buffer:)`. + var done = false + while !done { + let span = consumer.drainNext() + if span.isEmpty { + done = true + } else { + 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/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/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..8870fb0 100644 --- a/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift +++ b/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift @@ -87,18 +87,28 @@ 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.sendAndFinish(HTTPResponse(status: .internalServerError), buffer: &body, trailer: nil) 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.sendAndFinish(HTTPResponse(status: .internalServerError), buffer: &body, trailer: nil) return } @@ -121,8 +131,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 +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) - 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.sendAndFinish(HTTPResponse(status: .ok), buffer: &arrayResponseData, trailer: 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: [ @@ -158,78 +167,93 @@ 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) + 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. 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.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: [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 + .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: [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.sendAndFinish(HTTPResponse(status: .ok, headerFields: headers), buffer: &bytes, trailer: nil) case "/header_multivalue": - try await responseSender.send( + try await responseSender.sendAndFinish( HTTPResponse( status: .ok, headerFields: [ @@ -241,112 +265,69 @@ 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(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 - let writer = try await responseSender.send( - HTTPResponse(status: .movedPermanently, headerFields: HTTPFields([HTTPField(name: .location, value: "/redirect_pong")])) + try await responseSender.sendAndFinish( + HTTPResponse(status: .movedPermanently, headerFields: [.location: "/redirect_pong"]) ) - 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")])) + try await responseSender.sendAndFinish( + HTTPResponse(status: .movedPermanently, headerFields: [.location: "/redirect_ping"]) ) - 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")])) + try await responseSender.sendAndFinish( + HTTPResponse(status: .movedPermanently, headerFields: [.location: "/request"]) ) - try await writer - .writeAndConclude("".utf8.span, finalElement: nil) case "/308": // Redirect to /request - let writer = try await responseSender.send( - HTTPResponse( - status: .permanentRedirect, - headerFields: HTTPFields( - [HTTPField(name: .location, value: "/request")] - ) - ) + try await responseSender.sendAndFinish( + HTTPResponse(status: .permanentRedirect, headerFields: [.location: "/request"]) ) - 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) + try await responseSender.sendAndFinish(HTTPResponse(status: .notFound)) case "/999": - let writer = try await responseSender.send( - HTTPResponse(status: 999) - ) - try await writer - .writeAndConclude("".utf8.span, finalElement: nil) + try await responseSender.sendAndFinish(HTTPResponse(status: 999)) case "/echo": // Bad method if request.method != .post { - let writer = try await responseSender.send( - HTTPResponse(status: .methodNotAllowed) + var body = UniqueArray(copying: "Incorrect method".utf8) + try await responseSender.sendAndFinish( + HTTPResponse(status: .methodNotAllowed), + buffer: &body, + trailer: nil ) - 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(trailer: nil) case "/stall": do { // Wait for an hour (effectively never giving an answer) @@ -356,30 +337,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(trailer: 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(copying: String(repeating: "A", count: 1_000_000).data(using: .ascii)!) do { - try await responseBodyAndTrailers.writeAndConclude(data.span, finalElement: 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. @@ -389,7 +364,7 @@ func serve(server: NIOHTTPServer) async throws { } case "/cookie": let cookie = UUID().uuidString - let responseBodyAndTrailers = try await responseSender.send( + try await responseSender.sendAndFinish( .init( status: .ok, headerFields: [ @@ -397,13 +372,12 @@ func serve(server: NIOHTTPServer) async throws { ] ) ) - try await responseBodyAndTrailers.writeAndConclude(Span(), finalElement: nil) case "/etag": let clientETag = request.headerFields[.ifNoneMatch] let (serverETag, isNotModified) = eTag.next(clientETag: clientETag) if isNotModified { // Nothing has changed, so 304 Not Modified. - let responseBodyAndTrailers = try await responseSender.send( + try await responseSender.sendAndFinish( .init( status: .notModified, headerFields: [ @@ -412,39 +386,37 @@ func serve(server: NIOHTTPServer) async throws { ] ) ) - 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(copying: serverETag.data(using: .ascii)!) + try await responseSender.sendAndFinish( .init( status: .ok, headerFields: [ .eTag: serverETag, .cached: "false", ] - ) + ), + buffer: &body, + trailer: nil ) - // 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(copying: "Unknown path".utf8) + try await responseSender.sendAndFinish(HTTPResponse(status: .internalServerError), buffer: &body, trailer: nil) } } } 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..56b66e7 100644 --- a/Sources/URLSessionHTTPClient/URLSessionHTTPClient.swift +++ b/Sources/URLSessionHTTPClient/URLSessionHTTPClient.swift @@ -23,76 +23,88 @@ import Synchronization /// The HTTPClient implementation backed by URLSession. @available(anyAppleOS 26.0, *) public final class URLSessionHTTPClient: HTTPClient, IdleTimerEntryProvider { - public struct RequestWriter: AsyncWriter, ~Copyable { + public struct Writer: 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 !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 { + done = true + } else { + 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() + // See note in `write(buffer:)`. + var done = false + while !done { + let span = consumer.drainNext() + if span.isEmpty { + done = true + } else { + 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 Reader: 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() + } - 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 +112,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 @@ -361,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 ResponseConcludingReader) async throws -> Return + responseHandler: (HTTPResponse, consuming Reader) async throws -> Return ) async throws -> Return { guard request.schemeSupported else { throw HTTPTypeConversionError.unsupportedScheme @@ -392,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, .init(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 2d120e8..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 { - let trailerFields = try await requestBody.produce(into: URLSessionHTTPClient.RequestWriter(actual: bridge)) - bridge.close(trailerFields: trailerFields) + 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 { - 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.Writer(actual: bridge)) } catch { if bridge.writeFailed { // Ignore error 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/EchoTests.swift b/Tests/HTTPAPIsTests/EchoTests.swift index 7c60575..a320051 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,20 +46,20 @@ 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: [.date: "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") - #expect(trailers == HTTPFields([.init(name: .date, value: "test")])) + var responseBody = UniqueArray(minimumCapacity: 100) + let trailer = try await reader.collect(into: &responseBody) + let isEqual = responseBody == UniqueArray(copying: "Hello".utf8) + #expect(isEqual) + #expect(trailer == [.date: "test"]) } group.cancelAll() diff --git a/Tests/HTTPAPIsTests/Helpers/HTTPClientAndServerTests.swift b/Tests/HTTPAPIsTests/Helpers/HTTPClientAndServerTests.swift index a9c8342..84c5198 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,136 @@ 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 +177,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 +202,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 +220,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 +237,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 +257,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 trailer. + try await writer.finish(trailer: 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..711e561 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,11 @@ 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) + try await responseSender.sendAndFinish(.init(status: .ok)) } } } @@ -60,12 +60,9 @@ 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 } - } + _ = try await reader.collect(upTo: 100) { _ in } } group.cancelAll()