From 41a5717c4024918c7b65cefeaf38327170c7efbf Mon Sep 17 00:00:00 2001 From: Guoye Zhang Date: Fri, 12 Jun 2026 22:15:05 -0700 Subject: [PATCH 1/4] Adopt final element and request context from swift-http-api-proposal 0.1.0 --- Package.swift | 20 +- Sources/Example/Example.swift | 7 +- Sources/NIOHTTPServer/Disconnected.swift | 2 - .../NIOHTTPServer/HTTPKeepAliveHandler.swift | 1 - .../HTTPRequestConcludingAsyncReader.swift | 194 ------------------ .../HTTPResponseConcludingAsyncWriter.swift | 170 --------------- .../NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift | 29 ++- .../NIOHTTPServer+SecureUpgrade.swift | 57 +++-- Sources/NIOHTTPServer/NIOHTTPServer.swift | 64 +++--- .../NIOHTTPServer/NIOHTTPServerReader.swift | 123 +++++++++++ .../NIOHTTPServerResponseSender.swift | 117 +++++++++++ .../HTTPKeepAliveHandlerTests.swift | 54 ++--- ...TPResponseConcludingAsyncWriterTests.swift | 146 ------------- .../NIOHTTPServerTests/HTTPServerTests.swift | 35 ++-- .../NIOHTTPServer+ServiceLifecycleTests.swift | 99 ++++----- .../NIOHTTPServerEndToEndTests.swift | 20 +- ...s.swift => NIOHTTPServerReaderTests.swift} | 119 +++++------ ...=> NIOHTTPServerResponseSenderTests.swift} | 33 +-- .../NIOHTTPServerTests.swift | 143 +++++-------- .../NIOHTTPServerWriterTests.swift | 90 ++++++++ .../NIOHTTPServer+HTTP1.swift | 15 +- .../NIOHTTPServer+SecureUpgrade.swift | 15 +- .../TestingChannelServer+HTTP1.swift | 7 +- .../TestingChannelServer+SecureUpgrade.swift | 7 +- 24 files changed, 663 insertions(+), 904 deletions(-) delete mode 100644 Sources/NIOHTTPServer/HTTPRequestConcludingAsyncReader.swift delete mode 100644 Sources/NIOHTTPServer/HTTPResponseConcludingAsyncWriter.swift create mode 100644 Sources/NIOHTTPServer/NIOHTTPServerReader.swift create mode 100644 Sources/NIOHTTPServer/NIOHTTPServerResponseSender.swift delete mode 100644 Tests/NIOHTTPServerTests/HTTPResponseConcludingAsyncWriterTests.swift rename Tests/NIOHTTPServerTests/{HTTPRequestConcludingAsyncReaderTests.swift => NIOHTTPServerReaderTests.swift} (57%) rename Tests/NIOHTTPServerTests/{HTTPResponseSenderTests.swift => NIOHTTPServerResponseSenderTests.swift} (69%) create mode 100644 Tests/NIOHTTPServerTests/NIOHTTPServerWriterTests.swift diff --git a/Package.swift b/Package.swift index 1f27f53..a9857e3 100644 --- a/Package.swift +++ b/Package.swift @@ -49,23 +49,23 @@ let package = Package( dependencies: [ .package( url: "https://github.com/apple/swift-http-api-proposal.git", - revision: "d58fd6fa157e08bff44aa360ff83ebd424783392" + revision: "c12fdd4c48953a691b1ce52357101e844e5f0887" ), .package( url: "https://github.com/apple/swift-async-algorithms.git", - revision: "3bd2de010e30f8d41481e6c7a49a7e7222a878cf", + revision: "8ee3d2be1961950f94b6fa758477e3a0c5486aa9", traits: ["UnstableAsyncStreaming"] ), - .package(url: "https://github.com/apple/swift-http-types.git", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-distributed-tracing.git", from: "1.0.0"), - .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-http-types.git", from: "1.6.0"), + .package(url: "https://github.com/apple/swift-distributed-tracing.git", from: "1.4.1"), + .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.36.0"), - .package(url: "https://github.com/apple/swift-nio-extras.git", from: "1.30.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.0.0"), - .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.10.0"), + .package(url: "https://github.com/apple/swift-configuration.git", from: "1.2.0"), + .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.11.0"), ], targets: [ .executableTarget( diff --git a/Sources/Example/Example.swift b/Sources/Example/Example.swift index d7b3bc8..8d2d5a5 100644 --- a/Sources/Example/Example.swift +++ b/Sources/Example/Example.swift @@ -12,10 +12,9 @@ // //===----------------------------------------------------------------------===// +import BasicContainers import Crypto import Foundation -import HTTPAPIs -import HTTPTypes import Instrumentation import Logging import NIOHTTPServer @@ -64,8 +63,8 @@ struct Example { ) try await server.serve { request, requestContext, requestBodyAndTrailers, responseSender in - let writer = try await responseSender.send(HTTPResponse(status: .ok)) - try await writer.writeAndConclude("Well, hello!".utf8.span, finalElement: nil) + var body = UniqueArray.init(copying: "Hello World".utf8) + try await responseSender.sendAndFinish(HTTPResponse(status: .ok), buffer: &body) } } } diff --git a/Sources/NIOHTTPServer/Disconnected.swift b/Sources/NIOHTTPServer/Disconnected.swift index 31dea55..1d1e0a7 100644 --- a/Sources/NIOHTTPServer/Disconnected.swift +++ b/Sources/NIOHTTPServer/Disconnected.swift @@ -12,7 +12,6 @@ // //===----------------------------------------------------------------------===// -#if compiler(>=6.1) // This is a helper type to move a non-Sendable value across isolation regions. @usableFromInline struct Disconnected: ~Copyable, Sendable { @@ -38,4 +37,3 @@ struct Disconnected: ~Copyable, Sendable { return unsafe value } } -#endif diff --git a/Sources/NIOHTTPServer/HTTPKeepAliveHandler.swift b/Sources/NIOHTTPServer/HTTPKeepAliveHandler.swift index 4647f6b..26f7cb6 100644 --- a/Sources/NIOHTTPServer/HTTPKeepAliveHandler.swift +++ b/Sources/NIOHTTPServer/HTTPKeepAliveHandler.swift @@ -12,7 +12,6 @@ // //===----------------------------------------------------------------------===// -import HTTPTypes import NIOCore import NIOHTTPTypes diff --git a/Sources/NIOHTTPServer/HTTPRequestConcludingAsyncReader.swift b/Sources/NIOHTTPServer/HTTPRequestConcludingAsyncReader.swift deleted file mode 100644 index 40e4b3b..0000000 --- a/Sources/NIOHTTPServer/HTTPRequestConcludingAsyncReader.swift +++ /dev/null @@ -1,194 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift HTTP Server open source project -// -// Copyright (c) 2025 Apple Inc. and the Swift HTTP Server project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -public import AsyncStreaming -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. - /// Taken from `state` at construction; returned to `state` when this reader - /// observes request `.end` so the outer request loop can recover it for - /// HTTP/1.1 keep-alive. - private var iterator: NIOAsyncChannelInboundStream.AsyncIterator? - - /// A reusable buffer handed to the body closure on each call to ``read(body:)``. - /// Reusing it across calls preserves the allocation; the buffer is cleared - /// (while keeping its capacity) at the start of every read. - private var buffer: UniqueArray - - /// Initializes a new request body reader, taking the iterator from the - /// shared `ReaderState`. - fileprivate init(readerState: ReaderState) { - self.state = readerState - self.iterator = readerState.takeIterator() - self.buffer = UniqueArray() - } - - /// Reads a chunk of request body data. - public mutating func read( - body: nonisolated(nonsending) (inout Buffer) async throws(Failure) -> Return - ) async throws(EitherError) -> Return { - let requestPart: HTTPRequestPart? - do { - requestPart = try await self.iterator?.next(isolation: #isolation) - } catch { - throw .first(error) - } - - self.buffer.removeAll(keepingCapacity: true) - switch requestPart { - case .head: - fatalError() - case .body(let element): - self.buffer.reserveCapacity(element.readableBytes) - self.buffer.append(copying: element.readableBytesUInt8Span) - case .end(let trailers): - // Move the iterator back into ReaderState so the outer request - // loop can recover it for the next request on the same connection - // (HTTP/1.1 keep-alive). - nonisolated(unsafe) let iter = self.iterator.take() - self.state.wrapped.withLock { state in - state.trailers = trailers - state.finishedReading = true - _ = state.iterator.swap(newValue: iter) - } - case .none: - throw .first(RequestBodyReadError.streamEndedBeforeReceivingRequestEnd) - } - - do { - return try await body(&self.buffer) - } catch { - throw .second(error) - } - } - } - - final class ReaderState: Sendable { - struct Wrapped: ~Copyable { - var trailers: HTTPFields? = nil - var finishedReading: Bool = false - - /// The iterator. Initially populated from the channel; taken by the - /// body reader at construction time and returned by it once request - /// `.end` has been observed (for HTTP/1.1 keep-alive recovery). - var iterator: - Disconnected< - NIOAsyncChannelInboundStream.AsyncIterator? - > - } - - let wrapped: Mutex - - init(iterator: consuming sending NIOAsyncChannelInboundStream.AsyncIterator) { - self.wrapped = .init(.init(iterator: Disconnected(value: iterator))) - } - - /// Takes the iterator out of the state. Returns the iterator if present, - /// or `nil` if it's already been taken (e.g. by the body reader). - func takeIterator() -> sending NIOAsyncChannelInboundStream.AsyncIterator? { - self.wrapped.withLock { state in - state.iterator.swap(newValue: nil) - } - } - } - - /// 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 - - internal var state: ReaderState - - /// Initializes a new HTTP request body and trailers reader. - /// - /// - Parameter readerState: The shared reader state that holds the iterator and captures trailers. - init(readerState: ReaderState) { - 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?) { - let partsReader = RequestBodyAsyncReader(readerState: self.state) - let result = try await body(partsReader) - let trailers = self.state.wrapped.withLock { $0.trailers } - return (result, trailers) - } -} - -@available(*, unavailable) -extension HTTPRequestConcludingAsyncReader: Sendable {} - -@available(*, unavailable) -extension HTTPRequestConcludingAsyncReader.RequestBodyAsyncReader: Sendable {} diff --git a/Sources/NIOHTTPServer/HTTPResponseConcludingAsyncWriter.swift b/Sources/NIOHTTPServer/HTTPResponseConcludingAsyncWriter.swift deleted file mode 100644 index 97ddf01..0000000 --- a/Sources/NIOHTTPServer/HTTPResponseConcludingAsyncWriter.swift +++ /dev/null @@ -1,170 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift HTTP Server open source project -// -// Copyright (c) 2025 Apple Inc. and the Swift HTTP Server project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -public import AsyncStreaming -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, ~Copyable { - /// 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 - - /// A reusable buffer handed to the body closure on each call to ``write(_:)``. - /// Reusing it across calls preserves the allocation; the buffer is cleared - /// (while keeping its capacity) at the start of every write. - private var buffer: UniqueArray - - /// 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 - self.buffer = UniqueArray() - } - - /// Writes a chunk of response body data to the underlying writer. - public mutating func write( - _ body: nonisolated(nonsending) (inout Buffer) async throws(Failure) -> Return - ) async throws(EitherError) -> Return { - self.buffer.removeAll(keepingCapacity: true) - let result: Return - do { - result = try await body(&self.buffer) - } catch { - throw .second(error) - } - - if self.buffer.count == 0 { - return result - } - - var byteBuffer = ByteBuffer() - byteBuffer.reserveCapacity(self.buffer.count) - byteBuffer.writeBytes(self.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/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift index dc588a0..349116c 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift @@ -12,7 +12,6 @@ // //===----------------------------------------------------------------------===// -import HTTPAPIs import Logging import NIOCore import NIOExtras @@ -33,10 +32,18 @@ extension NIOHTTPServer { /// - handler: The request handler. /// /// - Throws: If an error occurs while iterating the incoming connection stream. - func serveInsecureHTTP1_1( + func serveInsecureHTTP1_1( serverChannel: NIOAsyncChannel, Never>, - handler: some HTTPServerRequestHandler - ) async throws { + handler: Handler + ) async throws + where + Handler.RequestContext: ~Copyable, + Handler.RequestContext == RequestContext, + Handler.Reader == Reader, + Handler.Reader: ~Copyable, + Handler.ResponseSender == ResponseSender, + Handler.ResponseSender: ~Copyable + { try await serverChannel.executeThenClose { inbound in // We don't use a `withThrowingDiscardingTaskGroup` here because an error thrown from the body or a child // task would immediately propagate upwards, cancelling all child tasks and bringing down the entire server. @@ -133,10 +140,18 @@ extension NIOHTTPServer { /// Handles an HTTP/1.1 connection channel, which may carry multiple serial requests on the /// same connection (keep-alive). - func handleHTTP1RequestChannel( + func handleHTTP1RequestChannel( channel: NIOAsyncChannel, - handler: some HTTPServerRequestHandler - ) async { + handler: Handler + ) async + where + Handler.RequestContext: ~Copyable, + Handler.RequestContext == RequestContext, + Handler.Reader == Reader, + Handler.Reader: ~Copyable, + Handler.ResponseSender == ResponseSender, + Handler.ResponseSender: ~Copyable + { do { try await channel.executeThenClose { inbound, outbound in var iterator = inbound.makeAsyncIterator() diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index bba1c16..406b334 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -12,7 +12,6 @@ // //===----------------------------------------------------------------------===// -import HTTPAPIs import Logging import NIOCertificateReloading import NIOCore @@ -46,10 +45,18 @@ extension NIOHTTPServer { /// - handler: The request handler. /// /// - Throws: If an error occurs while iterating the incoming connection stream. - func serveSecureUpgrade( + func serveSecureUpgrade( serverChannel: NIOAsyncChannel, Never>, - handler: some HTTPServerRequestHandler - ) async throws { + handler: Handler + ) async throws + where + Handler.RequestContext: ~Copyable, + Handler.RequestContext == RequestContext, + Handler.Reader == Reader, + Handler.Reader: ~Copyable, + Handler.ResponseSender == ResponseSender, + Handler.ResponseSender: ~Copyable + { try await serverChannel.executeThenClose { inbound in // We don't use a `withThrowingDiscardingTaskGroup` here because an error thrown from the body or a child // task would immediately propagate upwards, cancelling all child tasks and bringing down the entire server. @@ -103,10 +110,18 @@ extension NIOHTTPServer { /// - Parameters: /// - requestChannel: The HTTP/1.1 request channel. /// - handler: The request handler. - private func serveHTTP1Connection( + private func serveHTTP1Connection( requestChannel: NIOAsyncChannel, - handler: some HTTPServerRequestHandler - ) async { + handler: Handler + ) async + where + Handler.RequestContext: ~Copyable, + Handler.RequestContext == RequestContext, + Handler.Reader == Reader, + Handler.Reader: ~Copyable, + Handler.ResponseSender == ResponseSender, + Handler.ResponseSender: ~Copyable + { let chainFuture = requestChannel.channel.nioSSL_peerValidatedCertificateChain() await Self.$connectionContext.withValue(ConnectionContext(chainFuture)) { @@ -125,11 +140,19 @@ extension NIOHTTPServer { /// - connectionChannel: The underlying NIO channel for the HTTP/2 connection. /// - multiplexer: The HTTP/2 stream multiplexer. /// - handler: The request handler. - private func serveHTTP2Connection( + private func serveHTTP2Connection( connectionChannel: any Channel, multiplexer: NIOHTTP2Handler.AsyncStreamMultiplexer>, - handler: some HTTPServerRequestHandler - ) async { + handler: Handler + ) async + where + Handler.RequestContext: ~Copyable, + Handler.RequestContext == RequestContext, + Handler.Reader == Reader, + Handler.Reader: ~Copyable, + Handler.ResponseSender == ResponseSender, + Handler.ResponseSender: ~Copyable + { await withDiscardingTaskGroup { streamGroup in do { let chainFuture = connectionChannel.nioSSL_peerValidatedCertificateChain() @@ -303,10 +326,18 @@ extension NIOHTTPServer { } /// Handles an HTTP/2 stream channel, which carries exactly one request per stream. - func handleHTTP2StreamChannel( + func handleHTTP2StreamChannel( channel: NIOAsyncChannel, - handler: some HTTPServerRequestHandler - ) async { + handler: Handler + ) async + where + Handler.RequestContext: ~Copyable, + Handler.RequestContext == RequestContext, + Handler.Reader == Reader, + Handler.Reader: ~Copyable, + Handler.ResponseSender == ResponseSender, + Handler.ResponseSender: ~Copyable + { do { try await channel .executeThenClose { inbound, outbound in diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index 8210ca2..fb34273 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -12,8 +12,7 @@ // //===----------------------------------------------------------------------===// -public import HTTPAPIs -import HTTPTypes +@_exported public import HTTPAPIs public import Logging import NIOCertificateReloading import NIOConcurrencyHelpers @@ -82,8 +81,7 @@ import X509 /// ``` @available(anyAppleOS 26.0, *) public struct NIOHTTPServer: HTTPServer { - public typealias RequestConcludingReader = HTTPRequestConcludingAsyncReader - public typealias ResponseConcludingWriter = HTTPResponseConcludingAsyncWriter + public struct RequestContext: HTTPServerCapability.RequestContext, Sendable {} let logger: Logger let configuration: NIOHTTPServerConfiguration @@ -150,9 +148,15 @@ public struct NIOHTTPServer: HTTPServer { /// /// try await server.serve(handler: MyHandler()) /// ``` - public func serve( - handler: some HTTPServerRequestHandler - ) async throws { + public func serve(handler: Handler) async throws + where + Handler.RequestContext: ~Copyable, + Handler.RequestContext == RequestContext, + Handler.Reader == Reader, + Handler.Reader: ~Copyable, + Handler.ResponseSender == ResponseSender, + Handler.ResponseSender: ~Copyable + { // Ensure the listening address promise is always completed on the way out, regardless of whether // binding succeeded, the serve loop returned normally, or an error propagated. defer { self.finishListeningAddressPromise() } @@ -194,10 +198,18 @@ public struct NIOHTTPServer: HTTPServer { } } - private func _serve( + private func _serve( serverChannels: [ServerChannel], - handler: some HTTPServerRequestHandler - ) async throws { + handler: Handler + ) async throws + where + Handler.RequestContext: ~Copyable, + Handler.RequestContext == RequestContext, + Handler.Reader == Reader, + Handler.Reader: ~Copyable, + Handler.ResponseSender == ResponseSender, + Handler.ResponseSender: ~Copyable + { try await withThrowingTaskGroup(of: Void.self) { group in for serverChannel in serverChannels { group.addTask { @@ -247,31 +259,31 @@ public struct NIOHTTPServer: HTTPServer { /// Shared core: invokes the request handler with the appropriate reader/writer state. /// Returns the recovered iterator if the request was fully consumed (for HTTP/1.1 reuse), /// or `nil` if the request could not be fully consumed. - func invokeHandler( + func invokeHandler( request: HTTPRequest, iterator: consuming sending NIOAsyncChannelInboundStream.AsyncIterator, outbound: NIOAsyncChannelOutboundWriter, - handler: some HTTPServerRequestHandler - ) async throws -> NIOAsyncChannelInboundStream.AsyncIterator? { - let readerState = HTTPRequestConcludingAsyncReader.ReaderState(iterator: iterator) - let writerState = HTTPResponseConcludingAsyncWriter.WriterState() + handler: Handler + ) async throws -> NIOAsyncChannelInboundStream.AsyncIterator? + where + Handler.RequestContext: ~Copyable, + Handler.RequestContext == RequestContext, + Handler.Reader == Reader, + Handler.Reader: ~Copyable, + Handler.ResponseSender == ResponseSender, + Handler.ResponseSender: ~Copyable + { + let readerState = Reader.ReaderState(iterator: iterator) + let writerState = ResponseSender.WriterState() do { try await handler.handle( request: request, - requestContext: HTTPRequestContext(), - requestBodyAndTrailers: HTTPRequestConcludingAsyncReader( + requestContext: RequestContext(), + reader: Reader( readerState: readerState ), - responseSender: HTTPResponseSender { response in - try await outbound.write(.head(response)) - return HTTPResponseConcludingAsyncWriter( - writer: outbound, - writerState: writerState - ) - } sendInformational: { response in - try await outbound.write(.head(response)) - } + responseSender: ResponseSender(writer: outbound, writerState: writerState) ) } catch { logger.error("Error thrown while handling request: \(error)") diff --git a/Sources/NIOHTTPServer/NIOHTTPServerReader.swift b/Sources/NIOHTTPServer/NIOHTTPServerReader.swift new file mode 100644 index 0000000..4dee327 --- /dev/null +++ b/Sources/NIOHTTPServer/NIOHTTPServerReader.swift @@ -0,0 +1,123 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +public import BasicContainers +import NIOCore +import NIOHTTPTypes +import Synchronization + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServer { + public struct Reader: AsyncReader, ~Copyable { + final class ReaderState: Sendable { + struct Wrapped: ~Copyable { + var finishedReading: Bool = false + + /// The iterator. Initially populated from the channel; taken by the + /// body reader at construction time and returned by it once request + /// `.end` has been observed (for HTTP/1.1 keep-alive recovery). + var iterator: + Disconnected< + NIOAsyncChannelInboundStream.AsyncIterator? + > + } + + let wrapped: Mutex + + init(iterator: consuming sending NIOAsyncChannelInboundStream.AsyncIterator) { + self.wrapped = .init(.init(iterator: Disconnected(value: iterator))) + } + + /// Takes the iterator out of the state. Returns the iterator if present, + /// or `nil` if it's already been taken (e.g. by the body reader). + func takeIterator() -> sending NIOAsyncChannelInboundStream.AsyncIterator? { + self.wrapped.withLock { state in + state.iterator.swap(newValue: nil) + } + } + } + + public typealias ReadElement = UInt8 + + public typealias Buffer = UniqueArray + + public typealias FinalElement = HTTPFields? + + public typealias ReadFailure = any Error + + private var state: ReaderState + + /// The iterator that provides HTTP request parts from the underlying channel. + /// Taken from `state` at construction; returned to `state` when this reader + /// observes request `.end` so the outer request loop can recover it for + /// HTTP/1.1 keep-alive. + private var iterator: NIOAsyncChannelInboundStream.AsyncIterator? + + /// A reusable buffer handed to the body closure on each call to ``read(body:)``. + /// Reusing it across calls preserves the allocation; the buffer is cleared + /// (while keeping its capacity) at the start of every read. + private var buffer: UniqueArray + + /// Initializes a new request body reader, taking the iterator from the + /// shared `ReaderState`. + init(readerState: ReaderState) { + self.state = readerState + self.iterator = readerState.takeIterator() + self.buffer = UniqueArray() + } + + public mutating func read( + body: (inout Buffer, consuming HTTPFields??) async throws(Failure) -> Return + ) async throws(EitherError) -> Return { + let requestPart: HTTPRequestPart? + do { + requestPart = try await self.iterator?.next(isolation: #isolation) + } catch { + throw .first(error) + } + + let trailerFields: HTTPFields?? + self.buffer.removeAll(keepingCapacity: true) + switch requestPart { + case .head: + fatalError() + case .body(let element): + self.buffer.reserveCapacity(element.readableBytes) + self.buffer.append(copying: element.readableBytesUInt8Span) + trailerFields = nil + case .end(let trailer): + // Move the iterator back into ReaderState so the outer request + // loop can recover it for the next request on the same connection + // (HTTP/1.1 keep-alive). + nonisolated(unsafe) let iter = self.iterator.take() + self.state.wrapped.withLock { state in + state.finishedReading = true + _ = state.iterator.swap(newValue: iter) + } + trailerFields = trailer + case .none: + throw .first(RequestBodyReadError.streamEndedBeforeReceivingRequestEnd) + } + + do { + return try await body(&self.buffer, trailerFields) + } catch { + throw .second(error) + } + } + } +} + +@available(*, unavailable) +extension NIOHTTPServer.Reader: Sendable {} diff --git a/Sources/NIOHTTPServer/NIOHTTPServerResponseSender.swift b/Sources/NIOHTTPServer/NIOHTTPServerResponseSender.swift new file mode 100644 index 0000000..2e7d898 --- /dev/null +++ b/Sources/NIOHTTPServer/NIOHTTPServerResponseSender.swift @@ -0,0 +1,117 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore +import NIOHTTPTypes +import Synchronization + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServer { + public struct ResponseSender: HTTPResponseSender, ~Copyable { + let writer: NIOAsyncChannelOutboundWriter + let writerState: WriterState + + public mutating 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 -> Writer { + precondition(response.status.kind != .informational) + try await self.writer.write(.head(response)) + return Writer(writer: self.writer, writerState: self.writerState) + } + } +} + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServer.ResponseSender { + final class WriterState: Sendable { + struct Wrapped: ~Copyable { + var finishedWriting: Bool = false + } + + let wrapped: Mutex = .init(.init()) + } + + public struct Writer: CallerAsyncWriter, ~Copyable { + public typealias WriteElement = UInt8 + + public typealias WriteFailure = any Error + + public typealias FinalElement = HTTPFields? + + /// The underlying NIO writer for HTTP response parts. + let writer: NIOAsyncChannelOutboundWriter + + let writerState: WriterState + + public mutating func write( + buffer: inout some RangeReplaceableContainer & ~Copyable + ) async throws(WriteFailure) { + var byteBuffer = ByteBuffer() + byteBuffer.reserveCapacity(buffer.count) + + 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 { + byteBuffer.writeBytes(span.span.bytes) + } + } + + try await self.writer.write(.body(byteBuffer)) + } + + public consuming func finish( + buffer: inout some RangeReplaceableContainer & ~Copyable, + finalElement: consuming HTTPFields? + ) async throws(WriteFailure) { + if !buffer.isEmpty { + var byteBuffer = ByteBuffer() + byteBuffer.reserveCapacity(buffer.count) + + 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 { + byteBuffer.writeBytes(span.span.bytes) + } + } + + try await self.writer.write(.body(byteBuffer)) + } + try await self.writer.write(.end(finalElement)) + self.writerState.wrapped.withLock { $0.finishedWriting = true } + } + } +} + +@available(*, unavailable) +extension NIOHTTPServer.ResponseSender: Sendable {} + +@available(*, unavailable) +extension NIOHTTPServer.ResponseSender.Writer: Sendable {} diff --git a/Tests/NIOHTTPServerTests/HTTPKeepAliveHandlerTests.swift b/Tests/NIOHTTPServerTests/HTTPKeepAliveHandlerTests.swift index 8611778..077f0c6 100644 --- a/Tests/NIOHTTPServerTests/HTTPKeepAliveHandlerTests.swift +++ b/Tests/NIOHTTPServerTests/HTTPKeepAliveHandlerTests.swift @@ -12,8 +12,7 @@ // //===----------------------------------------------------------------------===// -import HTTPAPIs -import HTTPTypes +import BasicContainers import Logging import NIOCore import NIOHTTPTypes @@ -115,18 +114,15 @@ struct HTTPKeepAliveHandlerTests { try await NIOHTTPServerTests.withServer( server: server, serverHandler: HTTPServerClosureRequestHandler { _, _, reader, sender in + var reader = reader // Read just one byte of the body to confirm we got past the head, then // write a body-less response (head + end only). Because the head is // still buffered by the keep-alive handler when `.end` is written, the // handler amends the head with `Connection: close` before flushing. - let _ = try await reader.consumeAndConclude { partsReader in - var partsReader = partsReader - try await partsReader.read { _ in } - } - let writer = try await sender.send( + try await reader.read { _, _ in } + try await sender.sendAndFinish( .init(status: .ok, headerFields: [.contentLength: "0"]) ) - try await writer.writeAndConclude("".utf8.span, finalElement: nil) }, body: { serverAddress in let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) @@ -196,6 +192,7 @@ struct HTTPKeepAliveHandlerTests { try await NIOHTTPServerTests.withServer( server: server, serverHandler: HTTPServerClosureRequestHandler { request, _, reader, sender in + var sender = sender // Only the first request exercises informational semantics; the // pipelined second request (path "/second") just verifies keep-alive. if request.path == "/" { @@ -203,16 +200,14 @@ struct HTTPKeepAliveHandlerTests { } // Read the full request body (until .end). - let _ = try await reader.consumeAndConclude { partsReader in - var partsReader = partsReader - try await partsReader.collect(upTo: 1024) { _ in } - } + let _ = try await reader.collect(upTo: 1024) { _ in } // Write the final response. - let writer = try await sender.send( - .init(status: .ok, headerFields: [.contentLength: "5"]) + var buffer = UniqueArray(copying: "hello".utf8) + try await sender.sendAndFinish( + .init(status: .ok, headerFields: [.contentLength: "5"]), + buffer: &buffer ) - try await writer.writeAndConclude("hello".utf8.span, finalElement: nil) }, body: { serverAddress in let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) @@ -311,18 +306,8 @@ struct HTTPKeepAliveHandlerTests { serverHandler: HTTPServerClosureRequestHandler { _, _, reader, sender in // Echo request body parts back as response body parts, concurrently // with reading from the request body. - var maybeReader = Optional(reader) let writer = try await sender.send(.init(status: .ok)) - try await writer.produceAndConclude { responseBodyWriter in - var responseBodyWriter = responseBodyWriter - let reader = maybeReader.take()! - let _ = try await reader.consumeAndConclude { bodyReader in - try await bodyReader.forEachBuffer { buffer in - try await responseBodyWriter.write(buffer.span) - } - } - return nil - } + try await reader.pipe(into: writer) }, body: { serverAddress in let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) @@ -422,11 +407,9 @@ struct HTTPKeepAliveHandlerTests { _ = await canFinishIterator.next() // Drain the request body + end and then write the response body + end. - let _ = try await reader.consumeAndConclude { partsReader in - var partsReader = partsReader - try await partsReader.collect(upTo: 1024) { _ in } - } - try await writer.writeAndConclude("hello".utf8.span, finalElement: nil) + _ = try await reader.collect(upTo: 1024) { _ in } + var buffer = UniqueArray(copying: "hello".utf8) + try await writer.finish(buffer: &buffer) }, body: { serverAddress in let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) @@ -518,14 +501,11 @@ struct HTTPKeepAliveHandlerTests { try await NIOHTTPServerTests.withServer( server: server, serverHandler: HTTPServerClosureRequestHandler { _, _, reader, sender in - let _ = try await reader.consumeAndConclude { partsReader in - var partsReader = partsReader - try await partsReader.read { _ in } - } - let writer = try await sender.send( + var reader = reader + try await reader.read { _, _ in } + try await sender.sendAndFinish( .init(status: .ok, headerFields: [.contentLength: "0"]) ) - try await writer.writeAndConclude("".utf8.span, finalElement: nil) }, body: { serverAddress in let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) diff --git a/Tests/NIOHTTPServerTests/HTTPResponseConcludingAsyncWriterTests.swift b/Tests/NIOHTTPServerTests/HTTPResponseConcludingAsyncWriterTests.swift deleted file mode 100644 index c986b7f..0000000 --- a/Tests/NIOHTTPServerTests/HTTPResponseConcludingAsyncWriterTests.swift +++ /dev/null @@ -1,146 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift HTTP Server open source project -// -// Copyright (c) 2025 Apple Inc. and the Swift HTTP Server project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import HTTPTypes -import NIOCore -import NIOHTTPTypes -import Testing - -@testable import NIOHTTPServer - -@Suite -struct HTTPResponseConcludingAsyncWriterTests { - let bodySampleOne: UInt8 = 1 - let bodySampleTwo: UInt8 = 2 - - let trailerSampleOne: HTTPFields = [.serverTiming: "test"] - let trailerSampleTwo: HTTPFields = [.serverTiming: "test", .cookie: "cookie"] - - @Test("Write single body element") - @available(anyAppleOS 26.0, *) - func testSingleWriteAndConclude() async throws { - let (writer, sink) = NIOAsyncChannelOutboundWriter.makeTestingWriter() - let responseWriter = HTTPResponseConcludingAsyncWriter(writer: writer, writerState: .init()) - - try await responseWriter.writeAndConclude(self.bodySampleOne, finalElement: self.trailerSampleOne) - - // Now read the response - var responseIterator = sink.makeAsyncIterator() - - let element = try #require(await responseIterator.next()) - #expect(element == .body(.init(bytes: [self.bodySampleOne]))) - - let trailer = try #require(await responseIterator.next()) - #expect(trailer == .end(self.trailerSampleOne)) - } - - @Test("Write multiple body elements") - @available(anyAppleOS 26.0, *) - func testProduceMultipleElementsAndSingleTrailer() async throws { - let (writer, sink) = NIOAsyncChannelOutboundWriter.makeTestingWriter() - let responseWriter = HTTPResponseConcludingAsyncWriter(writer: writer, writerState: .init()) - - try await responseWriter.produceAndConclude { bodyWriter in - var bodyWriter = bodyWriter - - // Write multiple elements - try await bodyWriter.write(self.bodySampleOne) - try await bodyWriter.write(self.bodySampleTwo) - - return self.trailerSampleOne - } - - var responseIterator = sink.makeAsyncIterator() - - let firstElement = try #require(await responseIterator.next()) - let secondElement = try #require(await responseIterator.next()) - #expect(firstElement == .body(.init(bytes: [self.bodySampleOne]))) - #expect(secondElement == .body(.init(bytes: [self.bodySampleTwo]))) - - let trailer = try #require(await responseIterator.next()) - #expect(trailer == .end(self.trailerSampleOne)) - } - - @Test("Throw while writing response") - @available(anyAppleOS 26.0, *) - func testThrowWhileProducing() async throws { - let (writer, sink) = NIOAsyncChannelOutboundWriter.makeTestingWriter() - - // Check that the write error is propagated - try await #require(throws: TestError.errorWhileWriting) { - let responseWriter = HTTPResponseConcludingAsyncWriter(writer: writer, writerState: .init()) - try await responseWriter.produceAndConclude { bodyWriter in - var bodyWriter = bodyWriter - - // Write an element - try await bodyWriter.write(self.bodySampleOne) - // Then throw - throw TestError.errorWhileWriting - } - } - - var responseIterator = sink.makeAsyncIterator() - - let firstElement = try #require(await responseIterator.next()) - #expect(firstElement == .body(.init(bytes: [self.bodySampleOne]))) - } - - @Test("Write multiple elements and multiple trailers") - @available(anyAppleOS 26.0, *) - func testProduceMultipleElementsAndMultipleTrailers() async throws { - let (writer, sink) = NIOAsyncChannelOutboundWriter.makeTestingWriter() - let responseWriter = HTTPResponseConcludingAsyncWriter(writer: writer, writerState: .init()) - - try await responseWriter.produceAndConclude { bodyWriter in - var bodyWriter = bodyWriter - - // Write multiple elements - try await bodyWriter.write(self.bodySampleOne) - try await bodyWriter.write(self.bodySampleTwo) - - return self.trailerSampleTwo - } - - var responseIterator = sink.makeAsyncIterator() - - let firstElement = try #require(await responseIterator.next()) - let secondElement = try #require(await responseIterator.next()) - #expect(firstElement == .body(.init(bytes: [self.bodySampleOne]))) - #expect(secondElement == .body(.init(bytes: [self.bodySampleTwo]))) - - let trailer = try #require(await responseIterator.next()) - #expect(trailer == .end(self.trailerSampleTwo)) - } - - @Test("No body, just trailers") - @available(anyAppleOS 26.0, *) - func testNoBodyJustTrailers() async throws { - let (writer, sink) = NIOAsyncChannelOutboundWriter.makeTestingWriter() - let responseWriter = HTTPResponseConcludingAsyncWriter(writer: writer, writerState: .init()) - - try await responseWriter.produceAndConclude { bodyWriter in - self.trailerSampleTwo - } - - var responseIterator = sink.makeAsyncIterator() - let trailer = try #require(await responseIterator.next()) - #expect(trailer == .end(self.trailerSampleTwo)) - } -} - -extension HTTPField.Name { - static var serverTiming: Self { - Self("Server-Timing")! - } -} diff --git a/Tests/NIOHTTPServerTests/HTTPServerTests.swift b/Tests/NIOHTTPServerTests/HTTPServerTests.swift index 2c120bc..322af35 100644 --- a/Tests/NIOHTTPServerTests/HTTPServerTests.swift +++ b/Tests/NIOHTTPServerTests/HTTPServerTests.swift @@ -12,7 +12,7 @@ // //===----------------------------------------------------------------------===// -import HTTPTypes +import BasicContainers import Logging import NIOHTTPServer import Testing @@ -33,31 +33,22 @@ struct HTTPServerTests { try await withThrowingTaskGroup { group in group.addTask { - try await server.serve { request, context, requestBodyAndTrailers, responseSender in - _ = try await requestBodyAndTrailers.collect(upTo: 100) { _ in } - // Uncommenting this would cause a "requestBodyAndTrailers consumed more than once" error. - // _ = try await requestBodyAndTrailers.collect(upTo: 100) { _ in } + try await server.serve { request, context, reader, responseSender in + _ = try await reader.collect(upTo: 100) { _ in } + // Uncommenting this would cause a "reader consumed more than once" error. + // _ = try await reader.collect(upTo: 100) { _ in } - let responseConcludingWriter = try await responseSender.send(HTTPResponse(status: .ok)) + let responseWriter = try await responseSender.send(HTTPResponse(status: .ok)) // Uncommenting this would cause a "responseSender consumed more than once" error. - // let responseConcludingWriter2 = try await responseSender.send(HTTPResponse(status: .ok)) + // let responseWriter2 = try await responseSender.send(HTTPResponse(status: .ok)) - // Uncommenting this would cause a "requestBodyAndTrailers consumed more than once" error. - // _ = try await requestBodyAndTrailers.consumeAndConclude { reader in - // var reader = reader - // try await reader.read { elem in } - // } + var buffer = UniqueArray(copying: [1, 2]) + try await responseWriter.finish(buffer: &buffer) - try await responseConcludingWriter.produceAndConclude { writer in - var writer = writer - try await writer.write([1, 2].span) - return nil - } - - // Uncommenting this would cause a "responseConcludingWriter consumed more than once" error. - // try await responseConcludingWriter.writeAndConclude( - // element: [1, 2].span, - // finalElement: HTTPFields(dictionaryLiteral: (.acceptEncoding, "Encoding")) + // Uncommenting this would cause a "responseWriter consumed more than once" error. + // try await responseWriter.finish( + // buffer: &buffer, + // finalElement: [.acceptEncoding: "Encoding"] // ) } } diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift index 4145d02..24ac246 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift @@ -12,8 +12,7 @@ // //===----------------------------------------------------------------------===// -import AsyncStreaming -import HTTPTypes +import BasicContainers import Logging import NIOConcurrencyHelpers import NIOCore @@ -52,24 +51,18 @@ struct NIOHTTPServiceLifecycleTests { let serverService = ClosureService { try await server.serve { request, requestContext, requestReader, responseSender in - _ = try await requestReader.consumeAndConclude { bodyReader in - var bodyReader = bodyReader - try await bodyReader.read { _ in } + var requestReader = requestReader + try await requestReader.read { _, _ in } - firstChunkReadPromise.succeed() + firstChunkReadPromise.succeed() - var requestFinished = false - while !requestFinished { - try await bodyReader.read { if $0.isEmpty { requestFinished = true } } - } + var requestFinished = false + while !requestFinished { + try await requestReader.read { if $1 != nil { requestFinished = true } } } - let responseBodyWriter = try await responseSender.send(.init(status: .ok)) - try await responseBodyWriter.produceAndConclude { writer in - var writer = writer - try await writer.write([1, 2].span) - return .none - } + var buffer = UniqueArray(copying: [1, 2]) + try await responseSender.sendAndFinish(.init(status: .ok), buffer: &buffer) } } @@ -142,21 +135,19 @@ struct NIOHTTPServiceLifecycleTests { let serverService = ClosureService { await #expect(throws: CancellationError.self) { try await server.serve { request, requestContext, requestReader, responseSender in + var requestReader = requestReader // Read the first chunk, signal `firstChunkReadPromise`, then try to read the second chunk. - _ = try await requestReader.consumeAndConclude { bodyReader in - var bodyReader = bodyReader - let error = try await #require(throws: EitherError.self) { - try await bodyReader.read { _ in } + let error = try await #require(throws: EitherError.self) { + try await requestReader.read { _, _ in } - firstChunkReadPromise.succeed() + firstChunkReadPromise.succeed() - // The following call will block: the client will never send a request end part. This is - // intentional because we want to keep the connection alive. - try await bodyReader.read { _ in } - } - #expect(throws: CancellationError.self) { try error.unwrap() } + // The following call will block: the client will never send a request end part. This is + // intentional because we want to keep the connection alive. + try await requestReader.read { _, _ in } } + #expect(throws: CancellationError.self) { try error.unwrap() } } } } @@ -234,21 +225,19 @@ struct NIOHTTPServiceLifecycleTests { let serverService = ClosureService { try await server.serve { request, requestContext, requestReader, responseSender in + var requestReader = requestReader // Read the first chunk, signal `firstChunkReadPromise`, then try to read the second chunk. - _ = try await requestReader.consumeAndConclude { bodyReader in - var bodyReader = bodyReader - let error = try await #require(throws: EitherError.self) { - try await bodyReader.read { _ in } + let error = try await #require(throws: EitherError.self) { + try await requestReader.read { _, _ in } - firstChunkReadPromise.succeed() + firstChunkReadPromise.succeed() - // The following call will block: the client will never send a request end part. This is - // intentional because we want to keep the connection alive until the grace timer (500ms) fires. - try await bodyReader.read { _ in } - } - #expect(throws: RequestBodyReadError.streamEndedBeforeReceivingRequestEnd) { try error.unwrap() } + // The following call will block: the client will never send a request end part. This is + // intentional because we want to keep the connection alive until the grace timer (500ms) fires. + try await requestReader.read { _, _ in } } + #expect(throws: RequestBodyReadError.streamEndedBeforeReceivingRequestEnd) { try error.unwrap() } } } @@ -340,33 +329,27 @@ struct NIOHTTPServiceLifecycleTests { let serverService = ClosureService { try await server.serve { request, requestContext, requestReader, responseSender in - _ = try await requestReader.consumeAndConclude { bodyReader in - var bodyReader = bodyReader - try await bodyReader.read { _ in } - - let count = requestNumber.withLockedValue { n in - n += 1 - return n - } + var requestReader = requestReader + try await requestReader.read { _, _ in } - if count == 1 { - firstTargetRequestStartedPromise.succeed() - } else if count == 2 { - secondTargetRequestStartedPromise.succeed() - } + let count = requestNumber.withLockedValue { n in + n += 1 + return n + } - var requestFinished = false - while !requestFinished { - try await bodyReader.read { if $0.isEmpty { requestFinished = true } } - } + if count == 1 { + firstTargetRequestStartedPromise.succeed() + } else if count == 2 { + secondTargetRequestStartedPromise.succeed() } - let responseBodyWriter = try await responseSender.send(.init(status: .ok)) - try await responseBodyWriter.produceAndConclude { writer in - var writer = writer - try await writer.write([1, 2].span) - return .none + var requestFinished = false + while !requestFinished { + try await requestReader.read { if $1 != nil { requestFinished = true } } } + + var buffer = UniqueArray(copying: [1, 2]) + try await responseSender.sendAndFinish(.init(status: .ok), buffer: &buffer) } } diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift index f2fa748..18c439d 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift @@ -12,7 +12,7 @@ // //===----------------------------------------------------------------------===// -import HTTPAPIs +import BasicContainers import Logging import NIOSSL import Testing @@ -27,13 +27,8 @@ struct NIOHTTPServerEndToEndTests { try await TestingChannelHTTP1Server.serve( logger: Logger(label: "NIOHTTPServerEndToEndTests"), handler: HTTPServerClosureRequestHandler { request, reqContext, reqReader, resSender in - let sender = try await resSender.send(.init(status: .ok)) - - try await sender.produceAndConclude { writer in - var writer = writer - try await writer.write([1, 2].span) - return [.serverTiming: "test"] - } + var buffer = UniqueArray(copying: [1, 2]) + try await resSender.sendAndFinish(.init(status: .ok), buffer: &buffer, trailer: [.serverTiming: "test"]) } ) { server in try await server.withConnectedClient { connectionChannel in @@ -88,13 +83,8 @@ struct NIOHTTPServerEndToEndTests { ), supportedHTTPVersions: [.http1_1, .http2(config: .defaults)], handler: HTTPServerClosureRequestHandler { request, reqContext, reqReader, resSender in - let sender = try await resSender.send(.init(status: .ok)) - - try await sender.produceAndConclude { writer in - var writer = writer - try await writer.write([1, 2].span) - return [.serverTiming: "test"] - } + var buffer = UniqueArray(copying: [1, 2]) + try await resSender.sendAndFinish(.init(status: .ok), buffer: &buffer, trailer: [.serverTiming: "test"]) } ) { server in try await server.withConnectedClient(clientTLSConfig: clientTLSConfig) { negotiatedConnectionChannel in diff --git a/Tests/NIOHTTPServerTests/HTTPRequestConcludingAsyncReaderTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerReaderTests.swift similarity index 57% rename from Tests/NIOHTTPServerTests/HTTPRequestConcludingAsyncReaderTests.swift rename to Tests/NIOHTTPServerTests/NIOHTTPServerReaderTests.swift index ed60607..30a2b0f 100644 --- a/Tests/NIOHTTPServerTests/HTTPRequestConcludingAsyncReaderTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerReaderTests.swift @@ -12,9 +12,7 @@ // //===----------------------------------------------------------------------===// -import AsyncStreaming import BasicContainers -import HTTPTypes import NIOCore import NIOHTTP1 import NIOHTTPTypes @@ -24,7 +22,7 @@ import Testing @testable import NIOHTTPServer @Suite -struct HTTPRequestConcludingAsyncReaderTests { +struct NIOHTTPServerReaderTests { @Test("Head request not allowed") @available(anyAppleOS 26.0, *) func testWriteHeadRequestPartFatalError() async throws { @@ -36,14 +34,11 @@ struct HTTPRequestConcludingAsyncReaderTests { source.yield(.head(.init(method: .get, scheme: "http", authority: "", path: ""))) source.finish() - let requestReader = HTTPRequestConcludingAsyncReader( + var requestReader = NIOHTTPServer.Reader( readerState: .init(iterator: stream.makeAsyncIterator()) ) - _ = try await requestReader.consumeAndConclude { bodyReader in - var bodyReader = bodyReader - try await bodyReader.read { _ in } - } + try await requestReader.read { _, _ in } } } @@ -57,17 +52,13 @@ struct HTTPRequestConcludingAsyncReaderTests { source.yield(.body(.init())) source.finish() - let requestReader = HTTPRequestConcludingAsyncReader( + var requestReader = NIOHTTPServer.Reader( readerState: .init(iterator: stream.makeAsyncIterator()) ) - _ = try await requestReader.consumeAndConclude { bodyReader in - var bodyReader = bodyReader - - try await bodyReader.read { _ in } - // The stream has finished without an end part. Calling `read` now should result in a fatal error. - try await bodyReader.read { _ in } - } + try await requestReader.read { _, _ in } + // The stream has finished without an end part. Calling `read` now should result in a fatal error. + try await requestReader.read { _, _ in } } } @@ -83,29 +74,19 @@ struct HTTPRequestConcludingAsyncReaderTests { func testRequestWithConcludingElement(body: ByteBuffer, trailers: HTTPFields) async throws { let (stream, source) = NIOAsyncChannelInboundStream.makeTestingStream() - // First write the request source.yield(.body(body)) source.yield(.end(trailers)) source.finish() - // Then start reading the request - let requestReader = HTTPRequestConcludingAsyncReader(readerState: .init(iterator: stream.makeAsyncIterator())) - let (requestBody, finalElement) = try await requestReader.consumeAndConclude { bodyReader in - var bodyReader = bodyReader - - var requestBody = ByteBuffer() - // Read the body chunk - try await bodyReader.read { buffer in - _ = requestBody.writeBytes(buffer.span.bytes) - } + var requestReader = NIOHTTPServer.Reader(readerState: .init(iterator: stream.makeAsyncIterator())) + var requestBody = ByteBuffer() - // Now read the trailer. We should get back an empty element here, but the trailer should be available in - // the tuple returned by `consumeAndConclude` - try await bodyReader.read { element in - #expect(element.count == 0) - } + _ = try await requestReader.read { buffer, _ in + _ = requestBody.writeBytes(buffer.span.bytes) + } - return requestBody + let finalElement = try await requestReader.read { _, finalElement in + finalElement } #expect(requestBody == body) @@ -137,19 +118,19 @@ struct HTTPRequestConcludingAsyncReaderTests { } group.addTask { - let requestReader = HTTPRequestConcludingAsyncReader( + let requestReader = NIOHTTPServer.Reader( readerState: .init(iterator: stream.makeAsyncIterator()) ) - let (_, finalElement) = try await requestReader.consumeAndConclude { bodyReader in - // Read all body chunks - var chunksProcessed = 0 - try await bodyReader.forEachBuffer { buffer in - var chunk = ByteBuffer() - chunk.writeBytes(buffer.span.bytes) - #expect(bodyChunks[chunksProcessed] == chunk) - - chunksProcessed += 1 - } + // Read all body chunks + var chunksProcessed = 0 + let finalElement = try await requestReader.forEachBuffer { buffer in + if buffer.isEmpty { return } + + var chunk = ByteBuffer() + chunk.writeBytes(buffer.span.bytes) + #expect(bodyChunks[chunksProcessed] == chunk) + + chunksProcessed += 1 } #expect(finalElement == trailers) @@ -169,22 +150,18 @@ struct HTTPRequestConcludingAsyncReaderTests { source.yield(.end([.cookie: "test"])) source.finish() - let requestReader = HTTPRequestConcludingAsyncReader( + var requestReader = NIOHTTPServer.Reader( readerState: .init(iterator: stream.makeAsyncIterator()) ) - _ = await requestReader.consumeAndConclude { bodyReader in - var bodyReader = bodyReader - - // Check that the read error is propagated - await #expect(throws: TestError.errorWhileReading) { - do { - try await bodyReader.read { (element) throws(TestError) in - throw TestError.errorWhileReading - } - } catch let eitherError as EitherError { - try eitherError.unwrap() + // Check that the read error is propagated + await #expect(throws: TestError.errorWhileReading) { + do { + try await requestReader.read { _, _ throws(TestError) in + throw TestError.errorWhileReading } + } catch let eitherError as EitherError { + try eitherError.unwrap() } } } @@ -198,25 +175,21 @@ struct HTTPRequestConcludingAsyncReaderTests { source.yield(.body(.init(repeating: 5, count: 10))) source.finish() - let requestReader = HTTPRequestConcludingAsyncReader( - readerState: .init(iterator: stream.makeAsyncIterator()) - ) - - _ = await requestReader.consumeAndConclude { requestBodyReader in - var requestBodyReader = requestBodyReader + // There are more bytes available than our limit. + await #expect(throws: AsyncReaderLeftOverElementsError.self) { + let requestReader = NIOHTTPServer.Reader( + readerState: .init(iterator: stream.makeAsyncIterator()) + ) - // There are more bytes available than our limit. - await #expect(throws: AsyncReaderLeftOverElementsError.self) { + do { + _ = try await requestReader.collect(upTo: 9) { _ in } + } catch let eitherEitherError + as EitherError, Never> + { do { - try await requestBodyReader.collect(upTo: 9) { _ in } - } catch let eitherEitherError - as EitherError, Never> - { - do { - try eitherEitherError.unwrap() - } catch let eitherError as EitherError { - try eitherError.unwrap() - } + try eitherEitherError.unwrap() + } catch let eitherError as EitherError { + try eitherError.unwrap() } } } diff --git a/Tests/NIOHTTPServerTests/HTTPResponseSenderTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerResponseSenderTests.swift similarity index 69% rename from Tests/NIOHTTPServerTests/HTTPResponseSenderTests.swift rename to Tests/NIOHTTPServerTests/NIOHTTPServerResponseSenderTests.swift index 3a7d55f..f1c8736 100644 --- a/Tests/NIOHTTPServerTests/HTTPResponseSenderTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerResponseSenderTests.swift @@ -12,8 +12,7 @@ // //===----------------------------------------------------------------------===// -import HTTPAPIs -import HTTPTypes +import BasicContainers import NIOCore import NIOHTTPTypes import Testing @@ -21,22 +20,14 @@ import Testing @testable import NIOHTTPServer @Suite -struct HTTPResponseSenderTests { +struct NIOHTTPServerResponseSenderTests { @Test("Informational header without informational status code") @available(anyAppleOS 26.0, *) func testInformationalResponseStatusCodePrecondition() async throws { // Sending an informational header with a non-1xx status code shouldn't be allowed try await #require(processExitsWith: .failure) { let (outboundWriter, _) = NIOAsyncChannelOutboundWriter.makeTestingWriter() - let sender = HTTPResponseSender { response in - try await outboundWriter.write(.head(response)) - return HTTPResponseConcludingAsyncWriter( - writer: outboundWriter, - writerState: .init() - ) - } sendInformational: { response in - try await outboundWriter.write(.head(response)) - } + var sender = NIOHTTPServer.ResponseSender(writer: outboundWriter, writerState: .init()) try await sender.sendInformational(.init(status: .ok, headerFields: [.contentType: "application/json"])) } @@ -46,15 +37,7 @@ struct HTTPResponseSenderTests { @available(anyAppleOS 26.0, *) func testSendMultipleInformationalResponses() async throws { let (outboundWriter, sink) = NIOAsyncChannelOutboundWriter.makeTestingWriter() - let sender = HTTPResponseSender { response in - try await outboundWriter.write(.head(response)) - return HTTPResponseConcludingAsyncWriter( - writer: outboundWriter, - writerState: .init() - ) - } sendInformational: { response in - try await outboundWriter.write(.head(response)) - } + var sender = NIOHTTPServer.ResponseSender(writer: outboundWriter, writerState: .init()) // Send two informational responses let firstInfoHead = HTTPResponse(status: .continue, headerFields: [.contentType: "application/json"]) @@ -67,12 +50,8 @@ struct HTTPResponseSenderTests { let finalResponseBody = [UInt8]([1, 2]) let finalResponseTrailer: HTTPFields = [.cookie: "cookie"] - let responseWriter = try await sender.send(.init(status: .ok, headerFields: [:])) - try await responseWriter.produceAndConclude { bodyTrailerWriter in - var bodyTrailerWriter = bodyTrailerWriter - try await bodyTrailerWriter.write(finalResponseBody.span) - return finalResponseTrailer - } + var buffer = UniqueArray(copying: finalResponseBody) + try await sender.sendAndFinish(.init(status: .ok), buffer: &buffer, trailer: finalResponseTrailer) var responseIterator = sink.makeAsyncIterator() let firstHead = try #require(await responseIterator.next()) diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift index f06f044..2f01ff5 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift @@ -12,9 +12,7 @@ // //===----------------------------------------------------------------------===// -import AsyncStreaming -import HTTPAPIs -import HTTPTypes +import BasicContainers import Logging import NIOCore import NIOEmbedded @@ -80,22 +78,16 @@ struct NIOHTTPServerTests { serverHandler: HTTPServerClosureRequestHandler { request, requestContext, reader, responseWriter in #expect(request == Self.makeRequest(method: .post, scheme: "http", for: .http1_1)) - var buffer = ByteBuffer() - let (_, finalElement) = try await reader.consumeAndConclude { bodyReader in - var bodyReader = bodyReader - return try await bodyReader.collect(upTo: Self.bodyData.readableBytes + 1) { body in - buffer.writeBytes(body.span.bytes) - } + let (buffer, finalElement) = try await reader.collect(upTo: Self.bodyData.readableBytes + 1) { body in + var buffer = ByteBuffer() + buffer.writeBytes(body.span.bytes) + return buffer } #expect(buffer == Self.bodyData) #expect(finalElement == Self.trailer) - let responseBodySender = try await responseWriter.send(.init(status: .ok)) - try await responseBodySender.produceAndConclude { responseBodyWriter in - var responseBodyWriter = responseBodyWriter - try await responseBodyWriter.write(Self.bodyData.readableBytesUInt8Span) - return Self.trailer - } + var responseBody = UniqueArray(copying: Self.bodyData.readableBytesUInt8Span) + try await responseWriter.sendAndFinish(.init(status: .ok), buffer: &responseBody, trailer: Self.trailer) }, body: { serverAddress in let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) @@ -154,23 +146,21 @@ struct NIOHTTPServerTests { let peerChain = try #require(try await NIOHTTPServer.connectionContext.peerCertificateChain) #expect(Array(peerChain) == [clientChain.leaf]) - let (buffer, finalElement) = try await reader.consumeAndConclude { bodyReader in - var bodyReader = bodyReader + let (buffer, finalElement) = try await reader.collect(upTo: Self.bodyData.readableBytes + 1) { + body in var buffer = ByteBuffer() - _ = try await bodyReader.collect(upTo: Self.bodyData.readableBytes + 1) { body in - buffer.writeBytes(body.span.bytes) - } + buffer.writeBytes(body.span.bytes) return buffer } #expect(buffer == Self.bodyData) #expect(finalElement == Self.trailer) - let sender = try await responseWriter.send(.init(status: .ok)) - try await sender.produceAndConclude { bodyWriter in - var bodyWriter = bodyWriter - try await bodyWriter.write(Self.bodyData.readableBytesUInt8Span) - return Self.trailer - } + var responseBody = UniqueArray(copying: Self.bodyData.readableBytesUInt8Span) + try await responseWriter.sendAndFinish( + .init(status: .ok), + buffer: &responseBody, + trailer: Self.trailer + ) }, body: { serverAddress in let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) @@ -211,15 +201,11 @@ struct NIOHTTPServerTests { try await Self.withServer( server: server, serverHandler: HTTPServerClosureRequestHandler { request, requestContext, reader, responseSender in + var responseSender = responseSender try await responseSender.sendInformational(.init(status: .continue)) try await responseSender.sendInformational(.init(status: .earlyHints)) - let writer = try await responseSender.send(.init(status: .ok)) - - try await writer.produceAndConclude { bodyWriter in - var bodyWriter = bodyWriter - try await bodyWriter.write(Self.bodyData.readableBytesUInt8Span) - return Self.trailer - } + var buffer = UniqueArray(copying: Self.bodyData.readableBytesUInt8Span) + try await responseSender.sendAndFinish(.init(status: .ok), buffer: &buffer, trailer: Self.trailer) }, body: { serverAddress in let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) @@ -264,27 +250,24 @@ struct NIOHTTPServerTests { try await Self.withServer( server: server, serverHandler: HTTPServerClosureRequestHandler { request, requestContext, reader, responseSender in + var reader = reader #expect(request == Self.makeRequest(method: .post, for: httpVersion)) - _ = try await reader.consumeAndConclude { bodyReader in - var bodyReader = bodyReader - - // This should fail: the client has closed the stream without sending an end part. - let error = try await #require(throws: EitherError.self) { - try await bodyReader.read { _ in } - } + // This should fail: the client has closed the stream without sending an end part. + let error = try await #require(throws: EitherError.self) { + try await reader.read { _, _ in } + } - switch httpVersion { - case .http1_1: - #expect(throws: HTTPParserError.invalidEOFState) { try error.unwrap() } + switch httpVersion { + case .http1_1: + #expect(throws: HTTPParserError.invalidEOFState) { try error.unwrap() } - case .http2: - let h2Error = try #require(throws: NIOHTTP2Errors.StreamClosed.self) { try error.unwrap() } - #expect(h2Error.errorCode == .cancel) - } - - requestReadPromise.succeed() + case .http2: + let h2Error = try #require(throws: NIOHTTP2Errors.StreamClosed.self) { try error.unwrap() } + #expect(h2Error.errorCode == .cancel) } + + requestReadPromise.succeed() }, body: { serverAddress in let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) @@ -320,28 +303,22 @@ struct NIOHTTPServerTests { serverHandler: HTTPServerClosureRequestHandler { request, requestContext, requestReader, responseSender in #expect(request == Self.makeRequest(method: .post, for: httpVersion)) - var maybeReader = Optional(requestReader) - - try await responseSender.send(HTTPResponse(status: .ok)).produceAndConclude { responseBodyWriter in - var responseBodyWriter = responseBodyWriter + var responseBodyWriter = try await responseSender.send(HTTPResponse(status: .ok)) - let reader = maybeReader.take()! + var count = 1 + let finalElement = try await requestReader.forEachBuffer { buffer in + if buffer.isEmpty { return } - let (_, finalElement) = try await reader.consumeAndConclude { bodyAsyncReader in - var count = 1 - try await bodyAsyncReader.forEachBuffer { buffer in - var chunk = ByteBuffer() - chunk.writeBytes(buffer.span.bytes) - #expect(chunk == ByteBuffer(bytes: [UInt8(count)])) - count += 1 - - try await responseBodyWriter.write(buffer.span) - } - } - #expect(finalElement == Self.trailer) + var chunk = ByteBuffer() + chunk.writeBytes(buffer.span.bytes) + #expect(chunk == ByteBuffer(bytes: [UInt8(count)])) + count += 1 - return Self.trailer + try await responseBodyWriter.write(buffer: &buffer) } + #expect(finalElement == Self.trailer) + + try await responseBodyWriter.finish(trailer: Self.trailer) }, body: { serverAddress in let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) @@ -959,8 +936,9 @@ extension NIOHTTPServerTests { static func withServer( server: NIOHTTPServer, serverHandler: some HTTPServerRequestHandler< - NIOHTTPServer.RequestConcludingReader, - NIOHTTPServer.ResponseConcludingWriter + NIOHTTPServer.RequestContext, + NIOHTTPServer.Reader, + NIOHTTPServer.ResponseSender >, body: (NIOHTTPServer.SocketAddress) async throws -> Void ) async throws { @@ -977,8 +955,9 @@ extension NIOHTTPServerTests { static func withServer( server: NIOHTTPServer, serverHandler: some HTTPServerRequestHandler< - NIOHTTPServer.RequestConcludingReader, - NIOHTTPServer.ResponseConcludingWriter + NIOHTTPServer.RequestContext, + NIOHTTPServer.Reader, + NIOHTTPServer.ResponseSender >, body: ([NIOHTTPServer.SocketAddress]) async throws -> Void ) async throws { @@ -999,23 +978,13 @@ extension NIOHTTPServerTests { @available(anyAppleOS 26.0, *) static func echoResponse( readUpTo limit: Int, - reader: consuming HTTPRequestConcludingAsyncReader, - sender: consuming HTTPResponseSender + reader: consuming NIOHTTPServer.Reader, + sender: consuming NIOHTTPServer.ResponseSender ) async throws { - let (requestBody, trailers) = try await reader.consumeAndConclude { bodyReader in - var bodyReader = bodyReader - return try await bodyReader.collect(upTo: limit) { inputSpan in - var buffer = ByteBuffer() - buffer.writeBytes(inputSpan.span.bytes) - return buffer - } - } - - let writer = try await sender.send(.init(status: .ok)) - try await writer.produceAndConclude { bodyWriter in - var bodyWriter = bodyWriter - try await bodyWriter.write(requestBody.readableBytesUInt8Span) - return trailers + var buffer = UniqueArray() + let (_, trailer) = try await reader.collect(upTo: limit) { inputSpan in + buffer.append(copying: inputSpan) } + try await sender.sendAndFinish(.init(status: .ok), buffer: &buffer, trailer: trailer) } } diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerWriterTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerWriterTests.swift new file mode 100644 index 0000000..6ed44da --- /dev/null +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerWriterTests.swift @@ -0,0 +1,90 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import BasicContainers +import NIOCore +import NIOHTTPTypes +import Testing + +@testable import NIOHTTPServer + +@Suite +struct NIOHTTPServerWriterTests { + let bodySampleOne: UInt8 = 1 + let bodySampleTwo: UInt8 = 2 + + let trailerSampleOne: HTTPFields = [.serverTiming: "test"] + let trailerSampleTwo: HTTPFields = [.serverTiming: "test", .cookie: "cookie"] + + @Test("Write single body element") + @available(anyAppleOS 26.0, *) + func testSingleWriteAndConclude() async throws { + let (writer, sink) = NIOAsyncChannelOutboundWriter.makeTestingWriter() + let responseWriter = NIOHTTPServer.ResponseSender.Writer(writer: writer, writerState: .init()) + + var buffer = UniqueArray(copying: [self.bodySampleOne]) + try await responseWriter.finish(buffer: &buffer, finalElement: self.trailerSampleOne) + + // Now read the response + var responseIterator = sink.makeAsyncIterator() + + let element = try #require(await responseIterator.next()) + #expect(element == .body(.init(bytes: [self.bodySampleOne]))) + + let trailer = try #require(await responseIterator.next()) + #expect(trailer == .end(self.trailerSampleOne)) + } + + @Test("Write multiple body elements") + @available(anyAppleOS 26.0, *) + func testProduceMultipleElementsAndSingleTrailer() async throws { + let (writer, sink) = NIOAsyncChannelOutboundWriter.makeTestingWriter() + var responseWriter = NIOHTTPServer.ResponseSender.Writer(writer: writer, writerState: .init()) + + var buffer = UniqueArray(copying: [self.bodySampleOne]) + try await responseWriter.write(buffer: &buffer) + buffer = UniqueArray(copying: [self.bodySampleTwo]) + try await responseWriter.write(buffer: &buffer) + try await responseWriter.finish(trailer: self.trailerSampleOne) + + var responseIterator = sink.makeAsyncIterator() + + let firstElement = try #require(await responseIterator.next()) + let secondElement = try #require(await responseIterator.next()) + #expect(firstElement == .body(.init(bytes: [self.bodySampleOne]))) + #expect(secondElement == .body(.init(bytes: [self.bodySampleTwo]))) + + let trailer = try #require(await responseIterator.next()) + #expect(trailer == .end(self.trailerSampleOne)) + } + + @Test("No body, just trailers") + @available(anyAppleOS 26.0, *) + func testNoBodyJustTrailers() async throws { + let (writer, sink) = NIOAsyncChannelOutboundWriter.makeTestingWriter() + let responseWriter = NIOHTTPServer.ResponseSender.Writer(writer: writer, writerState: .init()) + + try await responseWriter.finish(trailer: self.trailerSampleTwo) + + var responseIterator = sink.makeAsyncIterator() + let trailer = try #require(await responseIterator.next()) + #expect(trailer == .end(self.trailerSampleTwo)) + } +} + +extension HTTPField.Name { + static var serverTiming: Self { + Self("Server-Timing")! + } +} diff --git a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+HTTP1.swift b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+HTTP1.swift index 44233ce..4621d12 100644 --- a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+HTTP1.swift +++ b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+HTTP1.swift @@ -16,17 +16,24 @@ import NIOCore import NIOEmbedded import NIOHTTPTypes -@testable import HTTPAPIs @testable import NIOHTTPServer @available(anyAppleOS 26.0, *) extension NIOHTTPServer { /// Starts serving plaintext HTTP/1.1 using the provided testing channel instead of using `ServerBootstrap` as /// `NIOHTTPServer` normally does. - func serveInsecureHTTP1_1WithTestChannel( + func serveInsecureHTTP1_1WithTestChannel( testChannel: NIOAsyncTestingChannel, - handler: some HTTPServerRequestHandler - ) async throws { + handler: Handler + ) async throws + where + Handler.RequestContext: ~Copyable, + Handler.RequestContext == RequestContext, + Handler.Reader == Reader, + Handler.Reader: ~Copyable, + Handler.ResponseSender == ResponseSender, + Handler.ResponseSender: ~Copyable + { // The server requires a NIOAsyncChannel, so we create one from the test channel let serverTestAsyncChannel = try await testChannel.eventLoop.submit { try NIOAsyncChannel, Never>( diff --git a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+SecureUpgrade.swift b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+SecureUpgrade.swift index 1d0a443..c51871d 100644 --- a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+SecureUpgrade.swift @@ -16,17 +16,24 @@ import NIOCore import NIOEmbedded import NIOHTTPTypes -@testable import HTTPAPIs @testable import NIOHTTPServer @available(anyAppleOS 26.0, *) extension NIOHTTPServer { /// Starts serving with the Secure Upgrade transport using the provided testing channel instead of using /// `ServerBootstrap` as `NIOHTTPServer` normally does. - func serveSecureUpgradeWithTestChannel( + func serveSecureUpgradeWithTestChannel( testChannel: NIOAsyncTestingChannel, - handler: some HTTPServerRequestHandler - ) async throws { + handler: Handler + ) async throws + where + Handler.RequestContext: ~Copyable, + Handler.RequestContext == RequestContext, + Handler.Reader == Reader, + Handler.Reader: ~Copyable, + Handler.ResponseSender == ResponseSender, + Handler.ResponseSender: ~Copyable + { // The server requires a NIOAsyncChannel, so we create one from the test channel let testAsyncChannel = try await testChannel.eventLoop.submit { try NIOAsyncChannel, Never>( diff --git a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+HTTP1.swift b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+HTTP1.swift index 2d3c5f6..12aadb5 100644 --- a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+HTTP1.swift +++ b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+HTTP1.swift @@ -12,7 +12,6 @@ // //===----------------------------------------------------------------------===// -import HTTPAPIs import Logging import NIOCore import NIOEmbedded @@ -41,7 +40,11 @@ struct TestingChannelHTTP1Server { /// provides `Self` to the `body` closure. static func serve( logger: Logger, - handler: some HTTPServerRequestHandler, + handler: some HTTPServerRequestHandler< + NIOHTTPServer.RequestContext, + NIOHTTPServer.Reader, + NIOHTTPServer.ResponseSender + >, body: (Self) async throws -> Void ) async throws { let server = NIOHTTPServer( diff --git a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+SecureUpgrade.swift b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+SecureUpgrade.swift index 2f185a2..19b1d33 100644 --- a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+SecureUpgrade.swift +++ b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+SecureUpgrade.swift @@ -12,7 +12,6 @@ // //===----------------------------------------------------------------------===// -import HTTPAPIs import Logging import NIOCore import NIOEmbedded @@ -36,7 +35,11 @@ struct TestingChannelSecureUpgradeServer { logger: Logger, transportSecurity: NIOHTTPServerConfiguration.TransportSecurity, supportedHTTPVersions: Set, - handler: some HTTPServerRequestHandler, + handler: some HTTPServerRequestHandler< + NIOHTTPServer.RequestContext, + NIOHTTPServer.Reader, + NIOHTTPServer.ResponseSender + >, body: (Self) async throws -> Void ) async throws { let server = NIOHTTPServer( From 16e2a5f3df43438aec9b3d250bcbc8b54a7a9f04 Mon Sep 17 00:00:00 2001 From: Guoye Zhang Date: Fri, 12 Jun 2026 22:46:07 -0700 Subject: [PATCH 2/4] Fix inconsistencies --- Package.swift | 2 - Sources/Example/Example.swift | 3 +- Sources/NIOHTTPServer/NIOHTTPServer.swift | 46 ++++++------------- .../NIOHTTPServerResponseSenderTests.swift | 4 +- 4 files changed, 17 insertions(+), 38 deletions(-) diff --git a/Package.swift b/Package.swift index a9857e3..a2f03b7 100644 --- a/Package.swift +++ b/Package.swift @@ -71,11 +71,9 @@ let package = Package( .executableTarget( name: "Example", dependencies: [ - .product(name: "HTTPTypes", package: "swift-http-types"), .product(name: "Tracing", package: "swift-distributed-tracing"), .product(name: "Instrumentation", package: "swift-distributed-tracing"), .product(name: "Logging", package: "swift-log"), - .product(name: "HTTPAPIs", package: "swift-http-api-proposal"), "NIOHTTPServer", ], swiftSettings: extraSettings diff --git a/Sources/Example/Example.swift b/Sources/Example/Example.swift index 8d2d5a5..d917a09 100644 --- a/Sources/Example/Example.swift +++ b/Sources/Example/Example.swift @@ -33,7 +33,6 @@ struct Example { var logger = Logger(label: "Logger") logger.logLevel = .trace - // Using the new extension method that doesn't require type hints let privateKey = P256.Signing.PrivateKey() let server = NIOHTTPServer( logger: logger, @@ -63,7 +62,7 @@ struct Example { ) try await server.serve { request, requestContext, requestBodyAndTrailers, responseSender in - var body = UniqueArray.init(copying: "Hello World".utf8) + var body = UniqueArray(copying: "Well, hello!".utf8) try await responseSender.sendAndFinish(HTTPResponse(status: .ok), buffer: &body) } } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index fb34273..3f72b69 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -33,7 +33,7 @@ import X509 /// A generic HTTP server that can handle incoming HTTP requests. /// -/// The `Server` class provides a high-level interface for creating HTTP servers with support for: +/// `NIOHTTPServer` provides a high-level interface for creating HTTP servers with support for: /// - TLS/SSL encryption /// - Custom request handlers /// - Configurable binding targets @@ -44,39 +44,21 @@ import X509 /// ## Usage /// /// ```swift -/// let configuration = NIOHTTPServerConfiguration( -/// bindTarget: .hostAndPort(host: "localhost", port: 8080), -/// tlsConfiguration: .insecure() -/// ) -/// -/// try await Server.serve( +/// let server = NIOHTTPServer( /// 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 -/// } +/// configuration: try .init( +/// bindTarget: .hostAndPort(host: "localhost", port: 8080), +/// supportedHTTPVersions: [.http1_1], +/// transportSecurity: .plaintext +/// ) +/// ) /// -/// // Create and send response -/// var response = HTTPResponse(status: .ok) -/// response.headerFields[.contentType] = "text/plain" -/// let responseWriter = try await sendResponse(response) -/// try await responseWriter.produceAndConclude { writer in -/// try await writer.write("Hello, World!".utf8CString.dropLast().span) -/// return ((), nil) -/// } +/// try await server.serve { request, requestContext, reader, responseSender in +/// var body = UniqueArray(copying: "Hello, World!".utf8) +/// try await responseSender.sendAndFinish( +/// HTTPResponse(status: .ok, headerFields: [.contentType: "text/plain"]), +/// buffer: &body +/// ) /// } /// ``` @available(anyAppleOS 26.0, *) diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerResponseSenderTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerResponseSenderTests.swift index f1c8736..e7403ed 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerResponseSenderTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerResponseSenderTests.swift @@ -46,12 +46,12 @@ struct NIOHTTPServerResponseSenderTests { try await sender.sendInformational(secondInfoHead) // Then send the final response - let finalResponseHead = HTTPResponse(status: .ok, headerFields: [:]) + let finalResponseHead = HTTPResponse(status: .ok) let finalResponseBody = [UInt8]([1, 2]) let finalResponseTrailer: HTTPFields = [.cookie: "cookie"] var buffer = UniqueArray(copying: finalResponseBody) - try await sender.sendAndFinish(.init(status: .ok), buffer: &buffer, trailer: finalResponseTrailer) + try await sender.sendAndFinish(finalResponseHead, buffer: &buffer, trailer: finalResponseTrailer) var responseIterator = sink.makeAsyncIterator() let firstHead = try #require(await responseIterator.next()) From e59e0b5b02b837fc269fed532b22f5912ad16bfb Mon Sep 17 00:00:00 2001 From: Guoye Zhang Date: Sat, 13 Jun 2026 00:37:42 -0700 Subject: [PATCH 3/4] Remove indirect packages --- Package.swift | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/Package.swift b/Package.swift index a2f03b7..91208fb 100644 --- a/Package.swift +++ b/Package.swift @@ -51,15 +51,9 @@ let package = Package( url: "https://github.com/apple/swift-http-api-proposal.git", revision: "c12fdd4c48953a691b1ce52357101e844e5f0887" ), - .package( - url: "https://github.com/apple/swift-async-algorithms.git", - revision: "8ee3d2be1961950f94b6fa758477e3a0c5486aa9", - traits: ["UnstableAsyncStreaming"] - ), - .package(url: "https://github.com/apple/swift-http-types.git", from: "1.6.0"), .package(url: "https://github.com/apple/swift-distributed-tracing.git", from: "1.4.1"), .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-log.git", from: "1.13.2"), .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"), @@ -81,9 +75,7 @@ let package = Package( .target( name: "NIOHTTPServer", dependencies: [ - .product(name: "AsyncStreaming", package: "swift-async-algorithms"), .product(name: "X509", package: "swift-certificates"), - .product(name: "HTTPTypes", package: "swift-http-types"), .product(name: "NIOCore", package: "swift-nio"), .product(name: "NIOPosix", package: "swift-nio"), .product(name: "NIOHTTP1", package: "swift-nio"), From 08543d5273607343a7a4a77ad2f70d0a279e132c Mon Sep 17 00:00:00 2001 From: Guoye Zhang Date: Mon, 15 Jun 2026 14:21:54 -0700 Subject: [PATCH 4/4] Enable strict memory safety --- Package.swift | 1 + Sources/NIOHTTPServer/NIOHTTPServerReader.swift | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 91208fb..dee08dc 100644 --- a/Package.swift +++ b/Package.swift @@ -16,6 +16,7 @@ import PackageDescription let extraSettings: [SwiftSetting] = [ + .strictMemorySafety(), .enableExperimentalFeature("SuppressedAssociatedTypesWithDefaults"), .enableExperimentalFeature("LifetimeDependence"), .enableExperimentalFeature("Lifetimes"), diff --git a/Sources/NIOHTTPServer/NIOHTTPServerReader.swift b/Sources/NIOHTTPServer/NIOHTTPServerReader.swift index 4dee327..2cfb2c9 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServerReader.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServerReader.swift @@ -103,7 +103,7 @@ extension NIOHTTPServer { nonisolated(unsafe) let iter = self.iterator.take() self.state.wrapped.withLock { state in state.finishedReading = true - _ = state.iterator.swap(newValue: iter) + _ = unsafe state.iterator.swap(newValue: iter) } trailerFields = trailer case .none: