diff --git a/Package.swift b/Package.swift index dee08dc..ac45091 100644 --- a/Package.swift +++ b/Package.swift @@ -54,7 +54,7 @@ let package = Package( ), .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.2"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.14.0"), .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"), @@ -63,10 +63,27 @@ let package = Package( .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.11.0"), ], targets: [ + .target( + name: "ExampleSupport", + dependencies: [ + .product(name: "Tracing", package: "swift-distributed-tracing") + ], + swiftSettings: extraSettings + ), + .executableTarget( + name: "RequestHandlerExample", + dependencies: [ + "ExampleSupport", + .product(name: "Instrumentation", package: "swift-distributed-tracing"), + .product(name: "Logging", package: "swift-log"), + "NIOHTTPServer", + ], + swiftSettings: extraSettings + ), .executableTarget( - name: "Example", + name: "ConnectionHandlerExample", dependencies: [ - .product(name: "Tracing", package: "swift-distributed-tracing"), + "ExampleSupport", .product(name: "Instrumentation", package: "swift-distributed-tracing"), .product(name: "Logging", package: "swift-log"), "NIOHTTPServer", diff --git a/Sources/ConnectionHandlerExample/ConnectionHandlerExample.swift b/Sources/ConnectionHandlerExample/ConnectionHandlerExample.swift new file mode 100644 index 0000000..f1b45a0 --- /dev/null +++ b/Sources/ConnectionHandlerExample/ConnectionHandlerExample.swift @@ -0,0 +1,97 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 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 Crypto +import ExampleSupport +import Foundation +import Instrumentation +import Logging +import NIOHTTPServer +import X509 + +@main +@available(anyAppleOS 26.0, *) +struct ConnectionHandlerExample { + static func main() async throws { + try await serve() + } + + @concurrent + static func serve() async throws { + InstrumentationSystem.bootstrap(LogTracer()) + + var rootLogger = Logger(label: "ConnectionHandlerExample") + rootLogger.logLevel = .trace + try await withLogger(rootLogger) { rootLogger in + let privateKey = P256.Signing.PrivateKey() + let server = NIOHTTPServer( + logger: rootLogger, + configuration: try .init( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 12346), + supportedHTTPVersions: [.http1_1, .http2(config: .init())], + transportSecurity: .tls( + credentials: .inMemory( + certificateChain: [ + try Certificate( + version: .v3, + serialNumber: .init(bytes: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), + publicKey: .init(privateKey.publicKey), + notValidBefore: Date.now.addingTimeInterval(-60), + notValidAfter: Date.now.addingTimeInterval(60 * 60), + issuer: DistinguishedName(), + subject: DistinguishedName(), + signatureAlgorithm: .ecdsaWithSHA256, + extensions: .init(), + issuerPrivateKey: Certificate.PrivateKey(privateKey) + ) + ], + privateKey: Certificate.PrivateKey(privateKey) + ) + ) + ) + ) + + // A connection handler runs once per accepted connection and drives the + // request loop via `connection.handleRequests`. Both the connection + // handler and the request handler are inline closures. + try await server.serve { connection, context in + var connection = Optional(connection) + try await withLogger(mergingMetadata: [ + "peer": .string(context.remoteAddress.map { "\($0)" } ?? "unknown"), + "http": .string(context.httpVersion.rawValue), + ]) { connectionLogger in + connectionLogger.info("connection accepted") + defer { connectionLogger.info("connection closed") } + + await connection.take()!.handleRequests { request, _, _, responseSender in + var responseSender = Optional(responseSender) + try await withLogger(mergingMetadata: [ + "path": .string(request.path ?? "") + ]) { requestLogger in + requestLogger.info("request received") + defer { requestLogger.info("request completed") } + + var body = UniqueArray(copying: "Well, hello!".utf8) + try await responseSender.take()!.sendAndFinish( + HTTPResponse(status: .ok), + buffer: &body + ) + } + } + } + } + } + } +} diff --git a/Sources/Example/Example.swift b/Sources/Example/Example.swift deleted file mode 100644 index d917a09..0000000 --- a/Sources/Example/Example.swift +++ /dev/null @@ -1,69 +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 BasicContainers -import Crypto -import Foundation -import Instrumentation -import Logging -import NIOHTTPServer -import X509 - -@main -@available(anyAppleOS 26.0, *) -struct Example { - static func main() async throws { - try await serve() - } - - @concurrent - static func serve() async throws { - InstrumentationSystem.bootstrap(LogTracer()) - var logger = Logger(label: "Logger") - logger.logLevel = .trace - - let privateKey = P256.Signing.PrivateKey() - let server = NIOHTTPServer( - logger: logger, - configuration: try .init( - bindTarget: .hostAndPort(host: "127.0.0.1", port: 12345), - supportedHTTPVersions: [.http1_1, .http2(config: .init())], - transportSecurity: .tls( - credentials: .inMemory( - certificateChain: [ - try Certificate( - version: .v3, - serialNumber: .init(bytes: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), - publicKey: .init(privateKey.publicKey), - notValidBefore: Date.now.addingTimeInterval(-60), - notValidAfter: Date.now.addingTimeInterval(60 * 60), - issuer: DistinguishedName(), - subject: DistinguishedName(), - signatureAlgorithm: .ecdsaWithSHA256, - extensions: .init(), - issuerPrivateKey: Certificate.PrivateKey(privateKey) - ) - ], - privateKey: Certificate.PrivateKey(privateKey) - ) - ) - ) - ) - - try await server.serve { request, requestContext, requestBodyAndTrailers, responseSender in - var body = UniqueArray(copying: "Well, hello!".utf8) - try await responseSender.sendAndFinish(HTTPResponse(status: .ok), buffer: &body) - } - } -} diff --git a/Sources/Example/LogTracer.swift b/Sources/ExampleSupport/LogTracer.swift similarity index 60% rename from Sources/Example/LogTracer.swift rename to Sources/ExampleSupport/LogTracer.swift index 5c1bf8d..9b04d61 100644 --- a/Sources/Example/LogTracer.swift +++ b/Sources/ExampleSupport/LogTracer.swift @@ -12,14 +12,14 @@ // //===----------------------------------------------------------------------===// -import Tracing +package import Tracing -struct LogTracer: Tracer { - typealias Span = NoOpSpan +package struct LogTracer: Tracer { + package typealias Span = NoOpSpan - init() {} + package init() {} - func startAnySpan( + package func startAnySpan( _ operationName: String, context: @autoclosure () -> ServiceContext, ofKind kind: SpanKind, @@ -32,16 +32,20 @@ struct LogTracer: Tracer { return NoOpSpan(context: context()) } - func forceFlush() { + package func forceFlush() { print("Flushing") } - func inject(_ context: ServiceContext, into carrier: inout Carrier, using injector: Inject) + package func inject( + _ context: ServiceContext, + into carrier: inout Carrier, + using injector: Inject + ) where Inject: Injector, Carrier == Inject.Carrier { // no-op } - func extract( + package func extract( _ carrier: Carrier, into context: inout ServiceContext, using extractor: Extract @@ -50,47 +54,43 @@ struct LogTracer: Tracer { // no-op } - struct NoOpSpan: Tracing.Span { - let context: ServiceContext - var isRecording: Bool { + package struct NoOpSpan: Tracing.Span { + package let context: ServiceContext + package var isRecording: Bool { false } - var operationName: String { - get { - "noop" - } + package var operationName: String { + get { "noop" } nonmutating set { // ignore } } - init(context: ServiceContext) { + package init(context: ServiceContext) { self.context = context } - func setStatus(_ status: SpanStatus) {} + package func setStatus(_ status: SpanStatus) {} - func addLink(_ link: SpanLink) {} + package func addLink(_ link: SpanLink) {} - func addEvent(_ event: SpanEvent) {} + package func addEvent(_ event: SpanEvent) {} - func recordError( + package func recordError( _ error: any Error, attributes: SpanAttributes, at instant: @autoclosure () -> Instant ) {} - var attributes: SpanAttributes { - get { - [:] - } + package var attributes: SpanAttributes { + get { [:] } nonmutating set { // ignore } } - func end(at instant: @autoclosure () -> Instant) { + package func end(at instant: @autoclosure () -> Instant) { print("Ending span") } } diff --git a/Sources/NIOHTTPServer/Configuration/TransportSecurity+MTLSTrustConfiguration.swift b/Sources/NIOHTTPServer/Configuration/TransportSecurity+MTLSTrustConfiguration.swift index 4e5fec7..764a390 100644 --- a/Sources/NIOHTTPServer/Configuration/TransportSecurity+MTLSTrustConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/TransportSecurity+MTLSTrustConfiguration.swift @@ -84,8 +84,7 @@ extension NIOHTTPServerConfiguration.TransportSecurity { /// ``CertificateVerificationResult/certificateVerified(_:)`` from the callback if verification succeeds, /// optionally including the validated certificate chain you derived. Returning the validated certificate /// chain allows ``NIOHTTPServer`` to provide access to it in the request handler through - /// ``NIOHTTPServer/ConnectionContext/peerCertificateChain``, accessed via the task-local - /// ``NIOHTTPServer/connectionContext`` property. Otherwise, return + /// ``NIOHTTPServer/RequestContext/peerCertificateChain``. Otherwise, return /// ``CertificateVerificationResult/failed(_:)`` if verification fails. /// - certificateVerification: The client certificate verification behavior. Defaults to /// ``CertificateVerificationMode/noHostnameVerification``. diff --git a/Sources/NIOHTTPServer/HTTPServerCapability+ConnectionCapabilities.swift b/Sources/NIOHTTPServer/HTTPServerCapability+ConnectionCapabilities.swift new file mode 100644 index 0000000..ca07244 --- /dev/null +++ b/Sources/NIOHTTPServer/HTTPServerCapability+ConnectionCapabilities.swift @@ -0,0 +1,44 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 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 HTTPAPIs +public import X509 + +@available(anyAppleOS 26.0, *) +extension HTTPServerCapability { + /// A request-context capability exposing connection-scoped peer and local addresses. + /// + /// Servers whose request context conforms to this capability surface the + /// peer's address and the local address the connection is bound to. Both + /// are reported best-effort: implementations may return `nil` when the + /// underlying transport cannot report an address. + public protocol ConnectionInfo: RequestContext { + /// The peer's address, when known. + var remoteAddress: NIOHTTPServer.SocketAddress? { get } + + /// The local address the connection is bound to, when known. + var localAddress: NIOHTTPServer.SocketAddress? { get } + } + + /// A request-context capability exposing the validated peer certificate chain. + /// + /// Servers whose request context conforms to this capability surface the + /// peer's mTLS-validated certificate chain (when applicable). Implementations + /// return `nil` if mTLS isn't configured, or if no validated chain was + /// derived. + public protocol PeerCertificate: RequestContext { + /// The peer's validated certificate chain, when available. + var peerCertificateChain: X509.ValidatedCertificateChain? { get async throws } + } +} diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift b/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift new file mode 100644 index 0000000..09964da --- /dev/null +++ b/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift @@ -0,0 +1,124 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 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 NIOHTTP2 +import NIOHTTPTypes + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServer { + /// An active HTTP server connection. + /// + /// A `Connection` is owned by exactly one ``NIOHTTPServerConnectionHandler/handleConnection(connection:context:)`` + /// invocation. The handler typically passes it to ``handleRequests(handler:)``, + /// which runs the request loop until the peer closes the connection, the + /// server shuts down, or an error occurs. + /// + /// A handler that decides to terminate before any request can simply + /// return without calling ``handleRequests(handler:)``: the `Connection` + /// is dropped on scope exit and the underlying channel is torn down + /// cleanly by the server. + /// + /// Connection-scoped data is exposed on the accompanying + /// ``ConnectionContext`` passed alongside the connection to the handler. + public struct Connection: ~Copyable, Sendable { + /// Per-protocol state. HTTP/1.1 carries the request-channel's already-running + /// inbound stream and outbound writer (the dispatcher owns the channel and + /// drives `executeThenClose`, so the writer is finished cleanly even if the + /// connection handler returns without calling ``handleRequests(handler:)``). + /// HTTP/2 carries the connection channel and stream multiplexer. + enum HTTPProtocol: Sendable { + case http1_1( + inbound: NIOAsyncChannelInboundStream, + outbound: NIOAsyncChannelOutboundWriter + ) + case http2( + connectionChannel: any Channel, + multiplexer: NIOHTTP2Handler.AsyncStreamMultiplexer> + ) + } + + let server: NIOHTTPServer + let context: ConnectionContext + let httpProtocol: HTTPProtocol + + init(server: NIOHTTPServer, context: ConnectionContext, httpProtocol: HTTPProtocol) { + self.server = server + self.context = context + self.httpProtocol = httpProtocol + } + + /// Run the request loop on this connection until it terminates. + /// + /// Each request received on this connection is dispatched to `handler`. The + /// loop returns when the peer closes the connection, the server shuts down, + /// or an error occurs. + public consuming func handleRequests( + handler: Handler + ) async + where + Handler.RequestContext == NIOHTTPServer.RequestContext, + Handler.Reader == NIOHTTPServer.Reader, + Handler.ResponseSender == NIOHTTPServer.ResponseSender + { + let server = self.server + let context = self.context + switch self.httpProtocol { + case .http1_1(let inbound, let outbound): + await server.handleHTTP1RequestLoop( + inbound: inbound, + outbound: outbound, + handler: handler, + context: context + ) + case .http2(let connectionChannel, let multiplexer): + await server.handleHTTP2Connection( + connectionChannel: connectionChannel, + multiplexer: multiplexer, + handler: handler, + context: context + ) + } + } + + /// Convenience overload accepting a closure instead of a + /// ``HTTPServerRequestHandler`` conformance. + /// + /// ```swift + /// try await server.serve { connection, context in + /// try await connection.handleRequests { request, requestContext, reader, responseSender in + /// var body = UniqueArray(copying: "Hello, World!".utf8) + /// try await responseSender.sendAndFinish(.init(status: .ok), buffer: &body) + /// } + /// } + /// ``` + public consuming func handleRequests( + handler: + @Sendable @escaping ( + _ request: HTTPRequest, + _ requestContext: consuming NIOHTTPServer.RequestContext, + _ reader: consuming sending NIOHTTPServer.Reader, + _ responseSender: consuming sending NIOHTTPServer.ResponseSender + ) async throws -> Void + ) async { + await self.handleRequests( + handler: HTTPServerClosureRequestHandler< + NIOHTTPServer.RequestContext, + NIOHTTPServer.Reader, + NIOHTTPServer.ResponseSender + >(handler: handler) + ) + } + } +} diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift b/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift index 38fcfe2..d3be12a 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift @@ -18,19 +18,54 @@ public import X509 @available(anyAppleOS 26.0, *) extension NIOHTTPServer { - /// Connection-specific information available during request handling. + /// The application-level HTTP version negotiated for a connection. + public enum HTTPVersion: String, Sendable, Hashable { + case http1_1 = "http/1.1" + case http2 = "http/2" + } +} + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServer { + /// Connection-scoped state. /// - /// Provides access to data such as the peer's validated certificate chain. + /// Carries connection-scoped data such as the negotiated HTTP version, the + /// peer / local addresses, and the peer's validated certificate chain (when + /// applicable). + /// + /// User code accesses this state via the corresponding ``RequestContext`` + /// capabilities (``HTTPServerCapability/ConnectionInfo``, + /// ``HTTPServerCapability/PeerCertificate``) when handling individual + /// requests, and directly when implementing an + /// ``NIOHTTPServerConnectionHandler``. public struct ConnectionContext: Sendable { + /// The application-level HTTP version negotiated for this connection. + public let httpVersion: HTTPVersion + + /// The peer's address, when known. + public let remoteAddress: NIOHTTPServer.SocketAddress? + + /// The local address the connection is bound to, when known. + public let localAddress: NIOHTTPServer.SocketAddress? + var peerCertificateChainFuture: EventLoopFuture? - init(_ peerCertificateChainFuture: EventLoopFuture? = nil) { + init( + httpVersion: HTTPVersion, + remoteAddress: NIOHTTPServer.SocketAddress? = nil, + localAddress: NIOHTTPServer.SocketAddress? = nil, + peerCertificateChainFuture: EventLoopFuture? = nil + ) { + self.httpVersion = httpVersion + self.remoteAddress = remoteAddress + self.localAddress = localAddress self.peerCertificateChainFuture = peerCertificateChainFuture } - /// The peer's validated certificate chain. This returns `nil` if a custom verification callback was not set - /// when configuring mTLS in the server configuration, or if the custom verification callback did not return the - /// derived validated chain. + /// The peer's validated certificate chain. Returns `nil` if a custom + /// verification callback was not set when configuring mTLS in the + /// server configuration, or if the custom verification callback did not + /// return the derived validated chain. public var peerCertificateChain: X509.ValidatedCertificateChain? { get async throws { if let certs = try await self.peerCertificateChainFuture?.get() { diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift index 3a419bf..494f140 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift @@ -19,6 +19,7 @@ import NIOHTTP1 import NIOHTTPTypes import NIOHTTPTypesHTTP1 import NIOPosix +import NIOSSL @available(anyAppleOS 26.0, *) extension NIOHTTPServer { @@ -29,21 +30,13 @@ extension NIOHTTPServer { /// /// - Parameters: /// - serverChannel: The async channel that produces incoming HTTP/1.1 connections. - /// - handler: The request handler. + /// - connectionHandler: The connection handler invoked for each accepted connection. /// /// - Throws: If an error occurs while iterating the incoming connection stream. - func serveInsecureHTTP1_1( + func serveInsecureHTTP1_1( serverChannel: NIOAsyncChannel, Never>, - handler: Handler - ) async throws - where - Handler.RequestContext: ~Copyable, - Handler.RequestContext == RequestContext, - Handler.Reader == Reader, - Handler.Reader: ~Copyable, - Handler.ResponseSender == ResponseSender, - Handler.ResponseSender: ~Copyable - { + connectionHandler: Handler + ) async throws { 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. @@ -51,9 +44,12 @@ extension NIOHTTPServer { // `inbound`) must be caught and handled directly. let inboundConnectionIterationError = await withDiscardingTaskGroup { group -> (any Error)? in do { - for try await http1Channel in inbound { + for try await requestChannel in inbound { group.addTask { - await self.handleHTTP1RequestChannel(channel: http1Channel, handler: handler) + await self.dispatchPlaintextHTTP1_1Connection( + requestChannel: requestChannel, + connectionHandler: connectionHandler + ) } } @@ -70,6 +66,45 @@ extension NIOHTTPServer { } } + /// Builds the per-connection ``Connection`` and ``ConnectionContext`` for a + /// plaintext HTTP/1.1 child channel and dispatches to the connection + /// handler. Errors from the connection handler are logged. + /// + /// The dispatcher owns the channel's `executeThenClose` so the + /// `NIOAsyncWriter` is finished cleanly whether or not the connection + /// handler called ``Connection/handleRequests(handler:)``. + private func dispatchPlaintextHTTP1_1Connection( + requestChannel: sending NIOAsyncChannel, + connectionHandler: Handler + ) async { + do { + try await requestChannel.executeThenClose { inbound, outbound in + let context = NIOHTTPServer.makeHTTP1ConnectionContext( + requestChannel: requestChannel, + peerCertificateChainFuture: nil + ) + let connection = Connection( + server: self, + context: context, + httpProtocol: .http1_1(inbound: inbound, outbound: outbound) + ) + do { + try await connectionHandler.handleConnection(connection: connection, context: context) + } catch { + self.logger.debug( + "Error thrown by connection handler", + metadata: ["error": "\(error)"] + ) + } + } + } catch { + self.logger.debug( + "Error tearing down HTTP/1.1 channel", + metadata: ["error": "\(error)"] + ) + } + } + func setupHTTP1_1ServerChannels( bindTargets: [NIOHTTPServerConfiguration.BindTarget] ) async throws -> [( @@ -128,6 +163,7 @@ extension NIOHTTPServer { return serverChannels } + /// Configures the HTTP/1.1 server pipeline and the keep-alive handler. func setupHTTP1_1Connection( channel: any Channel, asyncChannelConfiguration: NIOAsyncChannel.Configuration, @@ -136,7 +172,6 @@ extension NIOHTTPServer { channel.pipeline.configureHTTPServerPipeline().flatMapThrowing { try channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPServerCodec(secure: isSecure)) try channel.pipeline.syncOperations.addHandler(HTTPKeepAliveHandler()) - try channel .pipeline .syncOperations @@ -149,48 +184,66 @@ extension NIOHTTPServer { } } - /// Handles an HTTP/1.1 connection channel, which may carry multiple serial requests on the - /// same connection (keep-alive). - func handleHTTP1RequestChannel( - channel: NIOAsyncChannel, - handler: Handler + /// Builds a ``ConnectionContext`` for an HTTP/1.1 request channel. + static func makeHTTP1ConnectionContext( + requestChannel: NIOAsyncChannel, + peerCertificateChainFuture: EventLoopFuture? + ) -> ConnectionContext { + ConnectionContext( + httpVersion: .http1_1, + remoteAddress: try? NIOHTTPServer.SocketAddress(requestChannel.channel.remoteAddress), + localAddress: try? NIOHTTPServer.SocketAddress(requestChannel.channel.localAddress), + peerCertificateChainFuture: peerCertificateChainFuture + ) + } + + /// Drives the request loop on an HTTP/1.1 connection that may carry + /// multiple serial requests (keep-alive). Invoked from + /// ``NIOHTTPServer/Connection/handleRequests(handler:)`` for the + /// HTTP/1.1 case. + /// + /// The caller (the dispatcher) owns the channel's `executeThenClose`, + /// so this method only iterates inbound requests and writes responses; + /// it never closes the channel itself. The loop terminates when the + /// peer closes the connection, the task is cancelled, or an error + /// occurs. + func handleHTTP1RequestLoop( + inbound: NIOAsyncChannelInboundStream, + outbound: NIOAsyncChannelOutboundWriter, + handler: Handler, + context: ConnectionContext ) async where - Handler.RequestContext: ~Copyable, Handler.RequestContext == RequestContext, Handler.Reader == Reader, - Handler.Reader: ~Copyable, - Handler.ResponseSender == ResponseSender, - Handler.ResponseSender: ~Copyable + Handler.ResponseSender == ResponseSender { do { - try await channel.executeThenClose { inbound, outbound in - var iterator = inbound.makeAsyncIterator() + var iterator = inbound.makeAsyncIterator() - requestLoop: while !Task.isCancelled { - guard let httpRequest = try await self.nextRequestHead(from: &iterator) else { - break requestLoop - } - - guard - let recoveredIterator = try await self.invokeHandler( - request: httpRequest, - iterator: iterator, - outbound: outbound, - handler: handler - ) - else { - // Handler did not fully consume the request; cannot continue on this - // connection. - break requestLoop - } + requestLoop: while !Task.isCancelled { + guard let httpRequest = try await self.nextRequestHead(from: &iterator) else { + break requestLoop + } - iterator = recoveredIterator + guard + let recoveredIterator = try await self.invokeHandler( + request: httpRequest, + iterator: iterator, + outbound: outbound, + handler: handler, + context: context + ) + else { + // Handler did not fully consume the request; cannot continue on this + // connection. + break requestLoop } + + iterator = recoveredIterator } } catch { self.logger.debug("Error thrown while handling HTTP/1.1 connection", metadata: ["error": "\(error)"]) - try? await channel.channel.close() } } } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift b/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift index f3462c9..b96de84 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift @@ -133,7 +133,7 @@ extension NIOHTTPServer { @available(anyAppleOS 26.0, *) extension NIOHTTPServer.SocketAddress { - fileprivate init(_ address: NIOCore.SocketAddress?) throws(ListeningAddressError) { + init(_ address: NIOCore.SocketAddress?) throws(ListeningAddressError) { guard let address, let port = address.port else { throw ListeningAddressError.addressOrPortNotAvailable } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+RequestContext.swift b/Sources/NIOHTTPServer/NIOHTTPServer+RequestContext.swift new file mode 100644 index 0000000..bb8eb70 --- /dev/null +++ b/Sources/NIOHTTPServer/NIOHTTPServer+RequestContext.swift @@ -0,0 +1,67 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 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 HTTPAPIs +public import X509 + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServer { + /// The request context provided to handlers by ``NIOHTTPServer``. + /// + /// Conforms to: + /// - ``HTTPServerCapability/ConnectionInfo`` — peer / local addresses. + /// - ``HTTPServerCapability/PeerCertificate`` — mTLS-validated peer chain. + /// + /// Generic library code can constrain on these capabilities to access + /// per-request data without depending on ``NIOHTTPServer`` directly. + public struct RequestContext: HTTPServerCapability.RequestContext, Sendable { + let connectionContext: ConnectionContext + + init(connectionContext: ConnectionContext) { + self.connectionContext = connectionContext + } + } +} + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServer.RequestContext: HTTPServerCapability.ConnectionInfo { + /// The peer's address, when known. + /// + /// Returns `nil` if the underlying transport could not report an address + /// or if the address is of an unsupported kind. + public var remoteAddress: NIOHTTPServer.SocketAddress? { + self.connectionContext.remoteAddress + } + + /// The local address the connection is bound to, when known. + /// + /// Returns `nil` under the same conditions as ``remoteAddress``. + public var localAddress: NIOHTTPServer.SocketAddress? { + self.connectionContext.localAddress + } +} + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServer.RequestContext: HTTPServerCapability.PeerCertificate { + /// The peer's mTLS-validated certificate chain, when available. + /// + /// Returns `nil` when mTLS is not configured, or when the configured + /// custom verification callback did not return the derived validated + /// chain. May throw if the chain cannot be retrieved. + public var peerCertificateChain: X509.ValidatedCertificateChain? { + get async throws { + try await self.connectionContext.peerCertificateChain + } + } +} diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index 6583fa7..f5cf9e5 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -42,21 +42,13 @@ extension NIOHTTPServer { /// /// - Parameters: /// - serverChannel: The async channel that produces incoming connections. - /// - handler: The request handler. + /// - connectionHandler: The connection handler invoked for each accepted connection. /// /// - Throws: If an error occurs while iterating the incoming connection stream. - func serveSecureUpgrade( + func serveSecureUpgrade( serverChannel: NIOAsyncChannel, Never>, - handler: Handler - ) async throws - where - Handler.RequestContext: ~Copyable, - Handler.RequestContext == RequestContext, - Handler.Reader == Reader, - Handler.Reader: ~Copyable, - Handler.ResponseSender == ResponseSender, - Handler.ResponseSender: ~Copyable - { + connectionHandler: Handler + ) async throws { 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. @@ -66,29 +58,10 @@ extension NIOHTTPServer { do { for try await upgradeResult in inbound { connectionGroup.addTask { - let negotiatedChannel: NegotiatedChannel - - do { - negotiatedChannel = try await upgradeResult.get() - } catch { - self.logger.debug("Negotiating ALPN failed", metadata: ["error": "\(error)"]) - return - } - - switch negotiatedChannel { - case .http1_1(let requestChannel): - await self.serveHTTP1Connection( - requestChannel: requestChannel, - handler: handler - ) - - case .http2((let connectionChannel, let multiplexer)): - await self.serveHTTP2Connection( - connectionChannel: connectionChannel, - multiplexer: multiplexer, - handler: handler - ) - } + await self.dispatchSecureConnection( + upgradeResult: upgradeResult, + connectionHandler: connectionHandler + ) } } @@ -105,66 +78,116 @@ extension NIOHTTPServer { } } - /// Serves a HTTP/1.1 connection. - /// - /// - Parameters: - /// - requestChannel: The HTTP/1.1 request channel. - /// - handler: The request handler. - private func serveHTTP1Connection( - requestChannel: NIOAsyncChannel, - 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() + private func dispatchSecureConnection( + upgradeResult: EventLoopFuture, + connectionHandler: Handler + ) async { + let negotiatedChannel: NegotiatedChannel + do { + negotiatedChannel = try await upgradeResult.get() + } catch { + self.logger.debug("Negotiating ALPN failed", metadata: ["error": "\(error)"]) + return + } + + switch negotiatedChannel { + case .http1_1(let requestChannel): + // The dispatcher owns the channel's `executeThenClose` so the + // `NIOAsyncWriter` is finished cleanly whether or not the + // connection handler called `handleRequests`. + do { + try await requestChannel.executeThenClose { inbound, outbound in + let chainFuture = requestChannel.channel.nioSSL_peerValidatedCertificateChain() + let context = NIOHTTPServer.makeHTTP1ConnectionContext( + requestChannel: requestChannel, + peerCertificateChainFuture: chainFuture + ) + let connection = Connection( + server: self, + context: context, + httpProtocol: .http1_1(inbound: inbound, outbound: outbound) + ) + do { + try await connectionHandler.handleConnection(connection: connection, context: context) + } catch { + self.logger.debug( + "Error thrown by connection handler", + metadata: ["error": "\(error)"] + ) + } + } + } catch { + self.logger.debug( + "Error handling HTTP/1.1 connection", + metadata: ["error": "\(error)"] + ) + } - await Self.$connectionContext.withValue(ConnectionContext(chainFuture)) { - await self.handleHTTP1RequestChannel( - channel: requestChannel, - handler: handler + case .http2((let connectionChannel, let multiplexer)): + let chainFuture = connectionChannel.nioSSL_peerValidatedCertificateChain() + let context = NIOHTTPServer.makeHTTP2ConnectionContext( + connectionChannel: connectionChannel, + peerCertificateChainFuture: chainFuture + ) + let connection = Connection( + server: self, + context: context, + httpProtocol: .http2(connectionChannel: connectionChannel, multiplexer: multiplexer) ) + + defer { try? await connectionChannel.close() } + do { + try await connectionHandler.handleConnection(connection: connection, context: context) + } catch { + self.logger.debug( + "Error thrown by connection handler", + metadata: ["error": "\(error)"] + ) + } } } - /// Serves a HTTP/2 connection by iterating the stream channels and handling each stream concurrently. + /// Builds a ``ConnectionContext`` for an HTTP/2 connection channel. + static func makeHTTP2ConnectionContext( + connectionChannel: any Channel, + peerCertificateChainFuture: EventLoopFuture? + ) -> ConnectionContext { + ConnectionContext( + httpVersion: .http2, + remoteAddress: try? NIOHTTPServer.SocketAddress(connectionChannel.remoteAddress), + localAddress: try? NIOHTTPServer.SocketAddress(connectionChannel.localAddress), + peerCertificateChainFuture: peerCertificateChainFuture + ) + } + + /// Drives the request loop on a HTTP/2 connection by iterating the stream + /// channels and handling each stream concurrently. /// - /// - Note: Stream iteration errors are logged but do not propagate to the caller. + /// This is the per-connection loop body invoked from + /// ``NIOHTTPServer/Connection/handleRequests(handler:)`` for the HTTP/2 + /// case. After iteration ends, this method closes the connection channel. /// - /// - Parameters: - /// - connectionChannel: The underlying NIO channel for the HTTP/2 connection. - /// - multiplexer: The HTTP/2 stream multiplexer. - /// - handler: The request handler. - private func serveHTTP2Connection( + /// - Note: Stream iteration errors are logged but do not propagate to the caller. + func handleHTTP2Connection( connectionChannel: any Channel, multiplexer: NIOHTTP2Handler.AsyncStreamMultiplexer>, - handler: Handler + handler: Handler, + context: ConnectionContext ) async where - Handler.RequestContext: ~Copyable, Handler.RequestContext == RequestContext, Handler.Reader == Reader, - Handler.Reader: ~Copyable, - Handler.ResponseSender == ResponseSender, - Handler.ResponseSender: ~Copyable + Handler.ResponseSender == ResponseSender { await withDiscardingTaskGroup { streamGroup in do { - let chainFuture = connectionChannel.nioSSL_peerValidatedCertificateChain() - - try await Self.$connectionContext.withValue(ConnectionContext(chainFuture)) { - for try await streamChannel in multiplexer.inbound { - streamGroup.addTask { - await self.handleHTTP2StreamChannel( - channel: streamChannel, - handler: handler - ) - } + for try await streamChannel in multiplexer.inbound { + streamGroup.addTask { + await self.handleHTTP2StreamChannel( + channel: streamChannel, + handler: handler, + context: context + ) } } } catch { @@ -174,13 +197,14 @@ extension NIOHTTPServer { ) } - // The `multiplexer.inbound` iteration exits when our task is cancelled, or when the HTTP/2 stream - // multiplexer finishes or throws. In any case, we are done with this connection here, so tear it down. + // Close the connection channel before the task group joins + // in-flight stream tasks. This drives NIO HTTP/2's + // `propagateChannelInactive`, which closes each stream channel so + // its `handleHTTP2StreamChannel` task can complete cleanly. do { try await connectionChannel.close() } catch ChannelError.alreadyClosed { - // We swallow the error here because the connection channel may already have closed at this point, e.g. - // if the client sent a TCP FIN or a TLS CLOSE_NOTIFY that the event loop processed before we got here. + () } catch { self.logger.error( "Error thrown while closing the HTTP/2 connection channel", @@ -354,15 +378,13 @@ extension NIOHTTPServer { /// Handles an HTTP/2 stream channel, which carries exactly one request per stream. func handleHTTP2StreamChannel( channel: NIOAsyncChannel, - handler: Handler + handler: Handler, + context: ConnectionContext ) async where - Handler.RequestContext: ~Copyable, Handler.RequestContext == RequestContext, Handler.Reader == Reader, - Handler.Reader: ~Copyable, - Handler.ResponseSender == ResponseSender, - Handler.ResponseSender: ~Copyable + Handler.ResponseSender == ResponseSender { do { try await channel @@ -378,7 +400,8 @@ extension NIOHTTPServer { request: httpRequest, iterator: iterator, outbound: outbound, - handler: handler + handler: handler, + context: context ) // TODO: handle other state scenarios. diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index 890c6d9..5de87a3 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -63,8 +63,6 @@ import X509 /// ``` @available(anyAppleOS 26.0, *) public struct NIOHTTPServer: HTTPServer { - public struct RequestContext: HTTPServerCapability.RequestContext, Sendable {} - let logger: Logger let configuration: NIOHTTPServerConfiguration @@ -77,11 +75,6 @@ public struct NIOHTTPServer: HTTPServer { var listeningAddressState: NIOLockedValueBox - /// Task-local storage for connection-specific information accessible from request handlers. - /// - /// - SeeAlso: ``ConnectionContext``. - @TaskLocal public static var connectionContext = ConnectionContext() - /// Create a new ``HTTPServer`` implemented over `SwiftNIO`. /// - Parameters: /// - logger: A logger instance for recording server events and debugging information. @@ -132,13 +125,40 @@ public struct NIOHTTPServer: HTTPServer { /// ``` 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 + Handler.ResponseSender == ResponseSender { + try await self.serve( + connectionHandler: NIOHTTPServerDefaultConnectionHandler(handler: handler) + ) + } + + /// Starts an HTTP server with the specified connection handler. + /// + /// This method is the connection-aware counterpart to ``serve(handler:)``. For + /// every accepted TCP/TLS connection (after ALPN negotiation on the secure + /// path), the server materialises a ``Connection`` and a ``ConnectionContext`` + /// and invokes ``NIOHTTPServerConnectionHandler/handleConnection(connection:context:)``. + /// + /// User code that only needs request-level processing should prefer + /// ``serve(handler:)``. Use this entry point when the user code needs to run + /// connection-scoped setup (per-connection logger, metric dimensions, + /// counters), share state between requests on the same connection, or + /// observe state after the connection's request loop returns. + /// + /// ## All-or-nothing listening + /// + /// The server treats its set of listening addresses as a single unit. If an + /// unrecoverable error occurs on any of the listening channels, the server + /// stops listening on **all** remaining addresses and this method returns. + /// + /// - Parameter connectionHandler: An ``NIOHTTPServerConnectionHandler`` + /// implementation that drives the request loop on each accepted + /// connection. + public func serve( + connectionHandler: Handler + ) async throws { // 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() } @@ -147,7 +167,7 @@ public struct NIOHTTPServer: HTTPServer { return try await withTaskCancellationHandler { try await withGracefulShutdownHandler { - try await self._serve(serverChannels: serverChannels, handler: handler) + try await self._serve(serverChannels: serverChannels, connectionHandler: connectionHandler) } onGracefulShutdown: { self.beginGracefulShutdown(serverChannels: serverChannels) } @@ -157,6 +177,32 @@ public struct NIOHTTPServer: HTTPServer { } } + /// Convenience overload accepting a closure instead of an + /// ``NIOHTTPServerConnectionHandler`` conformance. + /// + /// ```swift + /// try await server.serve { connection, context in + /// var connectionLogger = rootLogger + /// connectionLogger[metadataKey: "peer"] = + /// .string(context.remoteAddress?.host ?? "unknown") + /// connectionLogger.info("connection accepted") + /// defer { connectionLogger.info("connection closed") } + /// + /// try await connection.handleRequests(handler: MyHandler(logger: connectionLogger)) + /// } + /// ``` + public func serve( + connectionHandler: + @Sendable @escaping ( + _ connection: consuming sending Connection, + _ context: ConnectionContext + ) async throws -> Void + ) async throws { + try await self.serve( + connectionHandler: NIOHTTPServerClosureConnectionHandler(body: connectionHandler) + ) + } + /// Creates and returns server channels based on the configured transport security. private func makeServerChannels() async throws -> [ServerChannel] { switch self.configuration.transportSecurity.backing { @@ -180,27 +226,25 @@ public struct NIOHTTPServer: HTTPServer { } } - private func _serve( + private func _serve( serverChannels: [ServerChannel], - handler: Handler - ) async throws - where - Handler.RequestContext: ~Copyable, - Handler.RequestContext == RequestContext, - Handler.Reader == Reader, - Handler.Reader: ~Copyable, - Handler.ResponseSender == ResponseSender, - Handler.ResponseSender: ~Copyable - { + connectionHandler: Handler + ) async throws { try await withThrowingTaskGroup(of: Void.self) { group in for serverChannel in serverChannels { group.addTask { switch serverChannel { case .plaintextHTTP1_1(let http1Channel, _): - try await self.serveInsecureHTTP1_1(serverChannel: http1Channel, handler: handler) + try await self.serveInsecureHTTP1_1( + serverChannel: http1Channel, + connectionHandler: connectionHandler + ) case .secureUpgrade(let secureUpgradeChannel, _): - try await self.serveSecureUpgrade(serverChannel: secureUpgradeChannel, handler: handler) + try await self.serveSecureUpgrade( + serverChannel: secureUpgradeChannel, + connectionHandler: connectionHandler + ) } } } @@ -245,15 +289,13 @@ public struct NIOHTTPServer: HTTPServer { request: HTTPRequest, iterator: consuming sending NIOAsyncChannelInboundStream.AsyncIterator, outbound: NIOAsyncChannelOutboundWriter, - handler: Handler + handler: Handler, + context: ConnectionContext ) async throws -> NIOAsyncChannelInboundStream.AsyncIterator? where - Handler.RequestContext: ~Copyable, Handler.RequestContext == RequestContext, Handler.Reader == Reader, - Handler.Reader: ~Copyable, - Handler.ResponseSender == ResponseSender, - Handler.ResponseSender: ~Copyable + Handler.ResponseSender == ResponseSender { let readerState = Reader.ReaderState(iterator: iterator) let writerState = ResponseSender.WriterState() @@ -261,7 +303,7 @@ public struct NIOHTTPServer: HTTPServer { do { try await handler.handle( request: request, - requestContext: RequestContext(), + requestContext: RequestContext(connectionContext: context), reader: Reader( readerState: readerState ), diff --git a/Sources/NIOHTTPServer/NIOHTTPServerClosureConnectionHandler.swift b/Sources/NIOHTTPServer/NIOHTTPServerClosureConnectionHandler.swift new file mode 100644 index 0000000..a252cbb --- /dev/null +++ b/Sources/NIOHTTPServer/NIOHTTPServerClosureConnectionHandler.swift @@ -0,0 +1,33 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 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 +// +//===----------------------------------------------------------------------===// + +/// A closure-based ``NIOHTTPServerConnectionHandler``. +/// +/// Used internally by ``NIOHTTPServer/NIOHTTPServer/serve(connectionHandler:)-((NIOHTTPServer.Connection,NIOHTTPServer.ConnectionContext)->Void)`` +/// to bridge a closure to the protocol-based connection-handler API. +@available(anyAppleOS 26.0, *) +struct NIOHTTPServerClosureConnectionHandler: NIOHTTPServerConnectionHandler { + let body: + @Sendable ( + consuming sending NIOHTTPServer.Connection, + NIOHTTPServer.ConnectionContext + ) async throws -> Void + + func handleConnection( + connection: consuming sending NIOHTTPServer.Connection, + context: NIOHTTPServer.ConnectionContext + ) async throws { + try await self.body(connection, context) + } +} diff --git a/Sources/NIOHTTPServer/NIOHTTPServerConnectionHandler.swift b/Sources/NIOHTTPServer/NIOHTTPServerConnectionHandler.swift new file mode 100644 index 0000000..4b3b935 --- /dev/null +++ b/Sources/NIOHTTPServer/NIOHTTPServerConnectionHandler.swift @@ -0,0 +1,45 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 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 +// +//===----------------------------------------------------------------------===// + +/// A protocol for handling the lifecycle of a single ``NIOHTTPServer`` connection. +/// +/// Conforming types receive each new connection as it arrives, run any +/// connection-scoped setup (loggers, metric dimensions, atomic counters), and +/// drive the request loop via +/// ``NIOHTTPServer/NIOHTTPServer/Connection/handleRequests(handler:)``. State that must +/// outlive ``handleConnection(connection:context:)`` should live in reference +/// types (classes, actors, atomics) the conformer captures or closes over — +/// the `connection` argument is consumed. +/// +/// User code typically uses ``NIOHTTPServer/NIOHTTPServer/serve(connectionHandler:)-(Handler)`` +/// (the protocol-based form) or ``NIOHTTPServer/NIOHTTPServer/serve(connectionHandler:)-((NIOHTTPServer.Connection,NIOHTTPServer.ConnectionContext)->Void)`` +/// (the closure-based form). If only request-level work is needed, prefer +/// ``NIOHTTPServer/NIOHTTPServer/serve(handler:)``, which uses a built-in default connection handler. +@available(anyAppleOS 26.0, *) +public protocol NIOHTTPServerConnectionHandler: Sendable { + /// Handle a single connection. + /// + /// - Parameters: + /// - connection: The active connection. The handler is expected to drive + /// the request loop by calling ``NIOHTTPServer/NIOHTTPServer/Connection/handleRequests(handler:)`` + /// on it. If `handleConnection` returns without calling + /// `handleRequests`, the connection is dropped on scope exit, which + /// closes the underlying channel. + /// - context: Connection-scoped data. Borrowed for the duration of the + /// call. + func handleConnection( + connection: consuming sending NIOHTTPServer.Connection, + context: NIOHTTPServer.ConnectionContext + ) async throws +} diff --git a/Sources/NIOHTTPServer/NIOHTTPServerDefaultConnectionHandler.swift b/Sources/NIOHTTPServer/NIOHTTPServerDefaultConnectionHandler.swift new file mode 100644 index 0000000..c452ab6 --- /dev/null +++ b/Sources/NIOHTTPServer/NIOHTTPServerDefaultConnectionHandler.swift @@ -0,0 +1,46 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 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 HTTPAPIs + +/// A connection handler that forwards every request on the connection to a single +/// ``HTTPServerRequestHandler`` and performs no connection-scoped work. +/// +/// This is the default connection handler used by ``NIOHTTPServer/serve(handler:)``. +/// User code may also instantiate it directly when it wants to pass a request +/// handler through a connection-handler API without doing per-connection work. +@available(anyAppleOS 26.0, *) +public struct NIOHTTPServerDefaultConnectionHandler: + NIOHTTPServerConnectionHandler +where + Handler.RequestContext == NIOHTTPServer.RequestContext, + Handler.Reader == NIOHTTPServer.Reader, + Handler.ResponseSender == NIOHTTPServer.ResponseSender +{ + /// The request handler invoked for every request on the connection. + public let handler: Handler + + /// Create a default connection handler that forwards every request on the + /// connection to `handler`. + public init(handler: Handler) { + self.handler = handler + } + + public func handleConnection( + connection: consuming sending NIOHTTPServer.Connection, + context: NIOHTTPServer.ConnectionContext + ) async throws { + await connection.handleRequests(handler: self.handler) + } +} diff --git a/Sources/NIOHTTPServer/NIOHTTPServerResponseSender.swift b/Sources/NIOHTTPServer/NIOHTTPServerResponseSender.swift index 2e7d898..a767fcf 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServerResponseSender.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServerResponseSender.swift @@ -73,7 +73,7 @@ extension NIOHTTPServer.ResponseSender { if span.isEmpty { done = true } else { - byteBuffer.writeBytes(span.span.bytes) + unsafe byteBuffer.writeBytes(span.span.bytes) } } @@ -98,7 +98,7 @@ extension NIOHTTPServer.ResponseSender { if span.isEmpty { done = true } else { - byteBuffer.writeBytes(span.span.bytes) + unsafe byteBuffer.writeBytes(span.span.bytes) } } diff --git a/Sources/RequestHandlerExample/RequestHandlerExample.swift b/Sources/RequestHandlerExample/RequestHandlerExample.swift new file mode 100644 index 0000000..9da11d0 --- /dev/null +++ b/Sources/RequestHandlerExample/RequestHandlerExample.swift @@ -0,0 +1,85 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 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 Crypto +import ExampleSupport +import Foundation +import Instrumentation +import Logging +import NIOHTTPServer +import X509 + +@main +@available(anyAppleOS 26.0, *) +struct RequestHandlerExample { + static func main() async throws { + try await serve() + } + + @concurrent + static func serve() async throws { + InstrumentationSystem.bootstrap(LogTracer()) + var rootLogger = Logger(label: "RequestHandlerExample") + rootLogger.logLevel = .trace + try await withLogger(rootLogger) { rootLogger in + let privateKey = P256.Signing.PrivateKey() + let server = NIOHTTPServer( + logger: rootLogger, + configuration: try .init( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 12345), + supportedHTTPVersions: [.http1_1, .http2(config: .init())], + transportSecurity: .tls( + credentials: .inMemory( + certificateChain: [ + try Certificate( + version: .v3, + serialNumber: .init(bytes: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), + publicKey: .init(privateKey.publicKey), + notValidBefore: Date.now.addingTimeInterval(-60), + notValidAfter: Date.now.addingTimeInterval(60 * 60), + issuer: DistinguishedName(), + subject: DistinguishedName(), + signatureAlgorithm: .ecdsaWithSHA256, + extensions: .init(), + issuerPrivateKey: Certificate.PrivateKey(privateKey) + ) + ], + privateKey: Certificate.PrivateKey(privateKey) + ) + ) + ) + ) + + // A single request handler is invoked for every request, regardless of + // which connection it arrived on. Per-request connection-scoped data + // (peer address, mTLS chain) is accessed via the request context's + // capability conformances. + try await server.serve { request, requestContext, requestBodyAndTrailers, responseSender in + var responseSender = Optional(responseSender) + let peer = requestContext.remoteAddress.map { "\($0)" } ?? "unknown" + try await withLogger(mergingMetadata: ["peer": .string(peer)]) { requestLogger in + requestLogger.info("request started") + defer { requestLogger.info("request completed") } + + var body = UniqueArray(copying: "Well, hello!".utf8) + try await responseSender.take()!.sendAndFinish( + HTTPResponse(status: .ok), + buffer: &body + ) + } + } + } + } +} diff --git a/Tests/NIOHTTPServerTests/ConnectionLifecycleTests.swift b/Tests/NIOHTTPServerTests/ConnectionLifecycleTests.swift new file mode 100644 index 0000000..777207a --- /dev/null +++ b/Tests/NIOHTTPServerTests/ConnectionLifecycleTests.swift @@ -0,0 +1,725 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 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 Logging +import NIOConcurrencyHelpers +import NIOCore +import NIOPosix +import Testing + +@testable import NIOHTTPServer + +/// State observed by the connection handlers used in this test suite. The state lives +/// in a class so it survives `consuming` calls to `handleConnection` and can be +/// inspected from the test body. +@available(anyAppleOS 26.0, *) +final class ConnectionLifecycleTestState: Sendable { + let connectionInvocations = NIOLockedValueBox(0) + let requestInvocations = NIOLockedValueBox(0) + let observedRemoteAddresses = NIOLockedValueBox<[NIOHTTPServer.SocketAddress?]>([]) + let observedLocalAddresses = NIOLockedValueBox<[NIOHTTPServer.SocketAddress?]>([]) + let observedFinalCounter = NIOLockedValueBox(nil) +} + +/// A connection handler that counts invocations and runs a single request handler +/// for every request on the connection. +@available(anyAppleOS 26.0, *) +struct CountingConnectionHandler: NIOHTTPServerConnectionHandler +where + Handler.RequestContext == NIOHTTPServer.RequestContext, + Handler.Reader == NIOHTTPServer.Reader, + Handler.ResponseSender == NIOHTTPServer.ResponseSender +{ + let state: ConnectionLifecycleTestState + let requestHandler: Handler + + func handleConnection( + connection: consuming sending NIOHTTPServer.Connection, + context: NIOHTTPServer.ConnectionContext + ) async throws { + self.state.connectionInvocations.withLockedValue { $0 += 1 } + self.state.observedRemoteAddresses.withLockedValue { $0.append(context.remoteAddress) } + self.state.observedLocalAddresses.withLockedValue { $0.append(context.localAddress) } + + let connectionLocalCounter = NIOLockedValueBox(0) + let countingHandler = ConnectionScopedRequestHandler( + wrappedHandler: self.requestHandler, + globalCounter: self.state.requestInvocations, + localCounter: connectionLocalCounter + ) + await connection.handleRequests(handler: countingHandler) + + self.state.observedFinalCounter.withLockedValue { $0 = connectionLocalCounter.withLockedValue { $0 } } + } +} + +/// A request handler that increments two counters before delegating to the wrapped handler. +@available(anyAppleOS 26.0, *) +struct ConnectionScopedRequestHandler: HTTPServerRequestHandler +where + Wrapped.RequestContext == NIOHTTPServer.RequestContext, + Wrapped.Reader == NIOHTTPServer.Reader, + Wrapped.ResponseSender == NIOHTTPServer.ResponseSender +{ + typealias RequestContext = NIOHTTPServer.RequestContext + typealias Reader = NIOHTTPServer.Reader + typealias ResponseSender = NIOHTTPServer.ResponseSender + + let wrappedHandler: Wrapped + let globalCounter: NIOLockedValueBox + let localCounter: NIOLockedValueBox + + func handle( + request: HTTPRequest, + requestContext: consuming NIOHTTPServer.RequestContext, + reader: consuming sending NIOHTTPServer.Reader, + responseSender: consuming sending NIOHTTPServer.ResponseSender + ) async throws { + self.globalCounter.withLockedValue { $0 += 1 } + self.localCounter.withLockedValue { $0 += 1 } + try await self.wrappedHandler.handle( + request: request, + requestContext: requestContext, + reader: reader, + responseSender: responseSender + ) + } +} + +@Suite +struct ConnectionLifecycleTests { + @available(anyAppleOS 26.0, *) + static var serverLogger: Logger { + var logger = Logger(label: "ConnectionLifecycleTests") + logger.logLevel = .info + return logger + } + + /// Starts `server` with `connectionHandler`, waits for it to begin listening, runs `body` + /// with the listening address, then cancels the server task. + @available(anyAppleOS 26.0, *) + static func withServer( + server: NIOHTTPServer, + connectionHandler: Handler, + body: (NIOHTTPServer.SocketAddress) async throws -> Void + ) async throws { + try await withThrowingTaskGroup { group in + group.addTask { + try await server.serve(connectionHandler: connectionHandler) + } + + let listeningAddresses = try await server.listeningAddresses + let address = try #require(listeningAddresses.first) + + try await body(address) + + group.cancelAll() + } + } + + /// Helper that echoes the request body back as a 200 OK response. Fully + /// drains the request body, which is required for the per-channel loop + /// to recover the iterator and keep the HTTP/1.1 connection alive across + /// requests. + @available(anyAppleOS 26.0, *) + static func echoHandler() -> HTTPServerClosureRequestHandler< + NIOHTTPServer.RequestContext, + NIOHTTPServer.Reader, + NIOHTTPServer.ResponseSender + > { + HTTPServerClosureRequestHandler { request, requestContext, reader, responseSender in + try await NIOHTTPServerTests.echoResponse(readUpTo: 1024, reader: reader, sender: responseSender) + } + } + + /// HTTP/1.1: connecting twice results in two `handleConnection` invocations, + /// each with non-nil `remoteAddress` and `localAddress`. + @available(anyAppleOS 26.0, *) + @Test("serve(connectionHandler:) — per-connection invocation count (HTTP/1.1)", .timeLimit(.minutes(1))) + func testPerConnectionInvocationHTTP1_1() async throws { + let server = try NIOHTTPServerTests.makePlaintextHTTP1Server(logger: Self.serverLogger) + let state = ConnectionLifecycleTestState() + let connectionHandler = CountingConnectionHandler(state: state, requestHandler: Self.echoHandler()) + + try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + for _ in 1...2 { + let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestHTTP1Server(at: serverAddress) + try await client.executeThenClose { inbound, outbound in + try await outbound.write(.head(.init(method: .get, scheme: "http", authority: "", path: "/"))) + try await outbound.write(.end(nil)) + var iterator = inbound.makeAsyncIterator() + while let part = try await iterator.next() { + if case .end = part { break } + } + } + } + } + + #expect(state.connectionInvocations.withLockedValue { $0 } == 2) + let remotes = state.observedRemoteAddresses.withLockedValue { $0 } + let locals = state.observedLocalAddresses.withLockedValue { $0 } + #expect(remotes.count == 2) + #expect(locals.count == 2) + for remote in remotes { #expect(remote != nil) } + for local in locals { #expect(local != nil) } + } + + /// HTTP/1.1 keep-alive: two requests on the same connection result in a + /// single `handleConnection` invocation that runs the request handler twice. + @available(anyAppleOS 26.0, *) + @Test("HTTP/1.1 keep-alive — single connection-handler invocation, multiple requests", .timeLimit(.minutes(1))) + func testKeepAliveSingleInvocationMultipleRequests() async throws { + let server = try NIOHTTPServerTests.makePlaintextHTTP1Server(logger: Self.serverLogger) + let state = ConnectionLifecycleTestState() + let connectionHandler = CountingConnectionHandler(state: state, requestHandler: Self.echoHandler()) + + try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestHTTP1Server(at: serverAddress) + try await client.executeThenClose { inbound, outbound in + // Pipeline both requests up-front, then read both responses. + for path in ["/a", "/b"] { + try await outbound.write(.head(.init(method: .post, scheme: "http", authority: "", path: path))) + try await outbound.write(.body(ByteBuffer(string: "x"))) + try await outbound.write(.end(nil)) + } + + var iterator = inbound.makeAsyncIterator() + for _ in 0..<2 { + while let part = try await iterator.next() { + if case .end = part { break } + } + } + } + } + + #expect(state.connectionInvocations.withLockedValue { $0 } == 1) + #expect(state.requestInvocations.withLockedValue { $0 } == 2) + } + + /// HTTP/2: three concurrent streams on one connection result in one + /// `handleConnection` call and three request-handler calls. A user counter + /// held by the connection handler observes three after `handleRequests` + /// returns. + @available(anyAppleOS 26.0, *) + @Test("HTTP/2 — single connection-handler invocation, concurrent streams", .timeLimit(.minutes(1))) + func testHTTP2SingleInvocationConcurrentStreams() async throws { + let (server, serverChain) = try NIOHTTPServerTests.makeSecureUpgradeServer(logger: Self.serverLogger) + let elg: EventLoopGroup = .singletonMultiThreadedEventLoopGroup + let numStreams = 3 + let allRequestsReceived = elg.any().makePromise(of: Void.self) + let arrivedCounter = NIOLockedValueBox(0) + + let state = ConnectionLifecycleTestState() + + // Inner request handler that synchronises three concurrent streams via the + // promise so they all execute before any responds. + let synchronizingRequestHandler: + HTTPServerClosureRequestHandler< + NIOHTTPServer.RequestContext, + NIOHTTPServer.Reader, + NIOHTTPServer.ResponseSender + > = HTTPServerClosureRequestHandler { + request, + requestContext, + reader, + responseSender in + let arrived = arrivedCounter.withLockedValue { value -> Int in + value += 1 + return value + } + if arrived == numStreams { + allRequestsReceived.succeed() + } else { + try await allRequestsReceived.futureResult.get() + } + var buffer = UniqueArray(copying: []) + try await responseSender.sendAndFinish(.init(status: .ok), buffer: &buffer) + } + let connectionHandler = CountingConnectionHandler(state: state, requestHandler: synchronizingRequestHandler) + + try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + let clientChannel = try await ClientBootstrap(group: elg) + .connectToTestSecureUpgradeHTTPServer( + at: serverAddress, + trustRoots: serverChain.chain, + applicationProtocol: HTTPVersion.http2.alpnIdentifier + ) + guard case .http2(let streamManager) = clientChannel else { + Issue.record("Expected HTTP/2 channel, got \(clientChannel).") + return + } + + try await withThrowingTaskGroup { group in + for _ in 1...numStreams { + group.addTask { + let stream = try await streamManager.openStream() + try await stream.executeThenClose { inbound, outbound in + try await outbound.write( + .head(.init(method: .get, scheme: "https", authority: "", path: "/")) + ) + try await outbound.write(.end(nil)) + var iterator = inbound.makeAsyncIterator() + while let part = try await iterator.next() { + if case .end = part { break } + } + } + } + } + try await group.waitForAll() + } + } + + #expect(state.connectionInvocations.withLockedValue { $0 } == 1) + // handleConnection returns after the multiplexer's inbound iteration ends, + // which happens after the server task is cancelled at the end of `withServer`. + for _ in 0..<50 { + if state.observedFinalCounter.withLockedValue({ $0 }) != nil { break } + try await Task.sleep(for: .milliseconds(100)) + } + #expect(state.observedFinalCounter.withLockedValue { $0 } == numStreams) + } + + /// A throwing connection handler is logged at debug level by the server but + /// doesn't bring it down: a subsequent connection on the same server is + /// served normally. + @available(anyAppleOS 26.0, *) + @Test("Throwing connection handler doesn't bring down the server", .timeLimit(.minutes(1))) + func testThrowingConnectionHandlerDoesNotKillServer() async throws { + let server = try NIOHTTPServerTests.makePlaintextHTTP1Server(logger: Self.serverLogger) + let connectionInvocations = NIOLockedValueBox(0) + + let connectionHandler = ThrowingFirstConnectionHandler( + connectionInvocations: connectionInvocations + ) + + try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + // First connection: the handler throws after consuming the connection; + // we expect the channel to close without a response. + let firstClient = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestHTTP1Server(at: serverAddress) + try await firstClient.executeThenClose { inbound, _ in + var iterator = inbound.makeAsyncIterator() + let part = try await iterator.next() + #expect(part == nil) + } + + // Second connection: the handler runs the request loop normally. + let secondClient = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestHTTP1Server(at: serverAddress) + try await secondClient.executeThenClose { inbound, outbound in + try await outbound.write(.head(.init(method: .post, scheme: "http", authority: "", path: "/"))) + try await outbound.write(.body(ByteBuffer(string: "x"))) + try await outbound.write(.end(nil)) + var iterator = inbound.makeAsyncIterator() + var sawOK = false + while let part = try await iterator.next() { + if case .head(let response) = part { + #expect(response.status == .ok) + sawOK = true + } + if case .end = part { break } + } + #expect(sawOK, "Second connection should have been served normally.") + } + } + + #expect(connectionInvocations.withLockedValue { $0 } == 2) + } + + /// A connection handler that returns without calling `handleRequests` + /// effectively drops the connection: the channel closes immediately and + /// the client sees EOF without any response. + @available(anyAppleOS 26.0, *) + @Test("Connection handler returning without handleRequests drops the connection", .timeLimit(.minutes(1))) + func testConnectionHandlerEarlyReturn() async throws { + let server = try NIOHTTPServerTests.makePlaintextHTTP1Server(logger: Self.serverLogger) + let connectionInvocations = NIOLockedValueBox(0) + + let connectionHandler = NoOpConnectionHandler(connectionInvocations: connectionInvocations) + + try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestHTTP1Server(at: serverAddress) + try await client.executeThenClose { inbound, outbound in + // The server side dropped the connection immediately; trying to + // read either returns nil (clean EOF) or throws (peer reset). + // Either is valid evidence that the connection was dropped. + try? await outbound.write(.head(.init(method: .get, scheme: "http", authority: "", path: "/"))) + try? await outbound.write(.end(nil)) + var iterator = inbound.makeAsyncIterator() + var receivedAnyResponsePart = false + do { + while let part = try await iterator.next() { + if case .head = part { receivedAnyResponsePart = true } + } + } catch { + // Connection-reset / read errors are also valid evidence that + // the connection was dropped. + } + #expect(!receivedAnyResponsePart, "Expected no response head from a dropped connection.") + } + } + + #expect(connectionInvocations.withLockedValue { $0 } == 1) + } + + /// HTTP/2 counterpart: a connection handler that returns without calling + /// `handleRequests` still causes the underlying connection channel to be + /// closed by the dispatcher. Without the dispatcher's explicit close, the + /// multiplexer's underlying `NIOAsyncChannel` would deinit with an + /// unfinalized writer and trip the `NIOAsyncWriter` precondition — since + /// nothing else on our side references the channel in the early-return + /// path. + @available(anyAppleOS 26.0, *) + @Test("Connection handler returning without handleRequests drops the HTTP/2 connection", .timeLimit(.minutes(1))) + func testConnectionHandlerEarlyReturnHTTP2() async throws { + let (server, serverChain) = try NIOHTTPServerTests.makeSecureUpgradeServer(logger: Self.serverLogger) + let elg: EventLoopGroup = .singletonMultiThreadedEventLoopGroup + let connectionInvocations = NIOLockedValueBox(0) + + let connectionHandler = NoOpConnectionHandler(connectionInvocations: connectionInvocations) + + try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + let clientChannel = try await ClientBootstrap(group: elg) + .connectToTestSecureUpgradeHTTPServer( + at: serverAddress, + trustRoots: serverChain.chain, + applicationProtocol: HTTPVersion.http2.alpnIdentifier + ) + guard case .http2(let streamManager) = clientChannel else { + Issue.record("Expected HTTP/2 channel, got \(clientChannel).") + return + } + + // The server side drops the connection right after `handleConnection` + // returns. Any stream we try to open should either fail outright, or + // succeed transiently and then error/EOF once the server's close + // reaches the client. Both outcomes are valid evidence that the + // connection was closed. + var sawResponseHead = false + do { + let stream = try await streamManager.openStream() + try await stream.executeThenClose { inbound, outbound in + try? await outbound.write(.head(.init(method: .get, scheme: "https", authority: "", path: "/"))) + try? await outbound.write(.end(nil)) + var iterator = inbound.makeAsyncIterator() + while let part = try? await iterator.next() { + if case .head = part { sawResponseHead = true } + } + } + } catch { + // Stream open / write / read may throw — all valid outcomes. + } + #expect(!sawResponseHead, "Expected no response head from a dropped connection.") + } + + #expect(connectionInvocations.withLockedValue { $0 } == 1) + } + + /// `ConnectionContext.httpVersion` reflects the protocol negotiated for the + /// connection. Verified for plaintext HTTP/1.1, secure-upgrade-negotiated + /// HTTP/1.1, and secure-upgrade-negotiated HTTP/2. + @available(anyAppleOS 26.0, *) + @Test("ConnectionContext.httpVersion for plaintext HTTP/1.1", .timeLimit(.minutes(1))) + func testHTTPVersionPlaintextHTTP1_1() async throws { + let server = try NIOHTTPServerTests.makePlaintextHTTP1Server(logger: Self.serverLogger) + let observed = NIOLockedValueBox(nil) + + let connectionHandler = HTTPVersionRecordingConnectionHandler( + observed: observed, + wrappedHandler: Self.echoHandler() + ) + + try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestHTTP1Server(at: serverAddress) + try await client.executeThenClose { inbound, outbound in + try await outbound.write(.head(.init(method: .post, scheme: "http", authority: "", path: "/"))) + try await outbound.write(.body(ByteBuffer(string: "x"))) + try await outbound.write(.end(nil)) + var iterator = inbound.makeAsyncIterator() + while let part = try await iterator.next() { + if case .end = part { break } + } + } + } + + #expect(observed.withLockedValue { $0 } == .http1_1) + } + + @available(anyAppleOS 26.0, *) + @Test("ConnectionContext.httpVersion for secure-upgrade HTTP/1.1", .timeLimit(.minutes(1))) + func testHTTPVersionSecureUpgradeHTTP1_1() async throws { + let (server, serverChain) = try NIOHTTPServerTests.makeSecureUpgradeServer(logger: Self.serverLogger) + let observed = NIOLockedValueBox(nil) + + let connectionHandler = HTTPVersionRecordingConnectionHandler( + observed: observed, + wrappedHandler: Self.echoHandler() + ) + + try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + let clientChannel = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestSecureUpgradeHTTPServer( + at: serverAddress, + trustRoots: serverChain.chain, + applicationProtocol: HTTPVersion.http1_1.alpnIdentifier + ) + guard case .http1(let http1Channel) = clientChannel else { + Issue.record("Expected HTTP/1.1 negotiation, got \(clientChannel).") + return + } + try await http1Channel.executeThenClose { inbound, outbound in + try await outbound.write(.head(.init(method: .post, scheme: "https", authority: "", path: "/"))) + try await outbound.write(.body(ByteBuffer(string: "x"))) + try await outbound.write(.end(nil)) + var iterator = inbound.makeAsyncIterator() + while let part = try await iterator.next() { + if case .end = part { break } + } + } + } + + #expect(observed.withLockedValue { $0 } == .http1_1) + } + + @available(anyAppleOS 26.0, *) + @Test("ConnectionContext.httpVersion for secure-upgrade HTTP/2", .timeLimit(.minutes(1))) + func testHTTPVersionSecureUpgradeHTTP2() async throws { + let (server, serverChain) = try NIOHTTPServerTests.makeSecureUpgradeServer(logger: Self.serverLogger) + let observed = NIOLockedValueBox(nil) + + let connectionHandler = HTTPVersionRecordingConnectionHandler( + observed: observed, + wrappedHandler: Self.echoHandler() + ) + + try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + let clientChannel = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestSecureUpgradeHTTPServer( + at: serverAddress, + trustRoots: serverChain.chain, + applicationProtocol: HTTPVersion.http2.alpnIdentifier + ) + guard case .http2(let streamManager) = clientChannel else { + Issue.record("Expected HTTP/2 negotiation, got \(clientChannel).") + return + } + let stream = try await streamManager.openStream() + try await stream.executeThenClose { inbound, outbound in + try await outbound.write(.head(.init(method: .post, scheme: "https", authority: "", path: "/"))) + try await outbound.write(.body(ByteBuffer(string: "x"))) + try await outbound.write(.end(nil)) + var iterator = inbound.makeAsyncIterator() + while let part = try await iterator.next() { + if case .end = part { break } + } + } + } + + #expect(observed.withLockedValue { $0 } == .http2) + } + + /// Multiple HTTP/1.1 keep-alive connections in parallel each receive their + /// own `handleConnection` invocation and connection-scoped state isn't + /// shared between them — each connection's per-request counter only + /// reflects its own requests. + @available(anyAppleOS 26.0, *) + @Test("State isolation across keep-alive HTTP/1.1 connections", .timeLimit(.minutes(1))) + func testKeepAliveStateIsolationAcrossConnections() async throws { + let server = try NIOHTTPServerTests.makePlaintextHTTP1Server(logger: Self.serverLogger) + let perConnectionCounters = NIOLockedValueBox<[Int]>([]) + + let connectionHandler = PerConnectionCounterHandler( + perConnectionCounters: perConnectionCounters, + wrappedHandler: Self.echoHandler() + ) + + try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + await withThrowingTaskGroup(of: Void.self) { group in + // Connection A makes 3 requests; connection B makes 1. + for requestCount in [3, 1] { + group.addTask { + let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestHTTP1Server(at: serverAddress) + try await client.executeThenClose { inbound, outbound in + for i in 0.. + + func handleConnection( + connection: consuming sending NIOHTTPServer.Connection, + context: NIOHTTPServer.ConnectionContext + ) async throws { + let invocation = self.connectionInvocations.withLockedValue { value -> Int in + value += 1 + return value + } + if invocation == 1 { + throw TestError.intentional + } + await connection.handleRequests { request, _, reader, sender in + try await NIOHTTPServerTests.echoResponse(readUpTo: 1024, reader: reader, sender: sender) + } + } +} + +/// Connection handler that records the invocation count and returns without +/// calling `handleRequests`, dropping the connection. +@available(anyAppleOS 26.0, *) +struct NoOpConnectionHandler: NIOHTTPServerConnectionHandler { + let connectionInvocations: NIOLockedValueBox + + func handleConnection( + connection: consuming sending NIOHTTPServer.Connection, + context: NIOHTTPServer.ConnectionContext + ) async throws { + self.connectionInvocations.withLockedValue { $0 += 1 } + // Intentionally do not call `handleRequests` — the connection is + // dropped and the channel is closed on scope exit. + } +} + +/// Connection handler that records the negotiated HTTP version on its first +/// invocation and forwards every request to a wrapped handler. +@available(anyAppleOS 26.0, *) +struct HTTPVersionRecordingConnectionHandler: + NIOHTTPServerConnectionHandler +where + Wrapped.RequestContext == NIOHTTPServer.RequestContext, + Wrapped.Reader == NIOHTTPServer.Reader, + Wrapped.ResponseSender == NIOHTTPServer.ResponseSender +{ + let observed: NIOLockedValueBox + let wrappedHandler: Wrapped + + func handleConnection( + connection: consuming sending NIOHTTPServer.Connection, + context: NIOHTTPServer.ConnectionContext + ) async throws { + self.observed.withLockedValue { value in + if value == nil { value = context.httpVersion } + } + await connection.handleRequests(handler: self.wrappedHandler) + } +} + +/// Connection handler that counts requests on its own connection and appends +/// the final count to a shared array when the connection ends. +@available(anyAppleOS 26.0, *) +struct PerConnectionCounterHandler: NIOHTTPServerConnectionHandler +where + Wrapped.RequestContext == NIOHTTPServer.RequestContext, + Wrapped.Reader == NIOHTTPServer.Reader, + Wrapped.ResponseSender == NIOHTTPServer.ResponseSender +{ + let perConnectionCounters: NIOLockedValueBox<[Int]> + let wrappedHandler: Wrapped + + func handleConnection( + connection: consuming sending NIOHTTPServer.Connection, + context: NIOHTTPServer.ConnectionContext + ) async throws { + let counter = NIOLockedValueBox(0) + let countingHandler = ConnectionScopedRequestHandler( + wrappedHandler: self.wrappedHandler, + globalCounter: NIOLockedValueBox(0), + localCounter: counter + ) + await connection.handleRequests(handler: countingHandler) + let finalCount = counter.withLockedValue { $0 } + self.perConnectionCounters.withLockedValue { $0.append(finalCount) } + } +} + +/// Connection handler that uses the closure-overload of +/// ``NIOHTTPServer/Connection/handleRequests(handler:)-((@Sendable)``. +@available(anyAppleOS 26.0, *) +struct ClosureRequestHandlerConnectionHandler: NIOHTTPServerConnectionHandler { + func handleConnection( + connection: consuming sending NIOHTTPServer.Connection, + context: NIOHTTPServer.ConnectionContext + ) async throws { + await connection.handleRequests { request, _, reader, sender in + try await NIOHTTPServerTests.echoResponse(readUpTo: 1024, reader: reader, sender: sender) + } + } +} diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift index 2f01ff5..e9479c4 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift @@ -143,7 +143,7 @@ struct NIOHTTPServerTests { serverHandler: HTTPServerClosureRequestHandler { request, requestContext, reader, responseWriter in #expect(request == Self.makeRequest(method: .post, for: httpVersion)) - let peerChain = try #require(try await NIOHTTPServer.connectionContext.peerCertificateChain) + let peerChain = try #require(try await requestContext.peerCertificateChain) #expect(Array(peerChain) == [clientChain.leaf]) let (buffer, finalElement) = try await reader.collect(upTo: Self.bodyData.readableBytes + 1) { diff --git a/Tests/NIOHTTPServerTests/TestError.swift b/Tests/NIOHTTPServerTests/TestError.swift index 0874e0e..fe19b49 100644 --- a/Tests/NIOHTTPServerTests/TestError.swift +++ b/Tests/NIOHTTPServerTests/TestError.swift @@ -16,4 +16,5 @@ enum TestError: Error { case errorWhileReading case errorWhileWriting + case intentional } diff --git a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+HTTP1.swift b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+HTTP1.swift index 4621d12..9efd034 100644 --- a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+HTTP1.swift +++ b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+HTTP1.swift @@ -27,12 +27,9 @@ extension NIOHTTPServer { handler: Handler ) async throws where - Handler.RequestContext: ~Copyable, Handler.RequestContext == RequestContext, Handler.Reader == Reader, - Handler.Reader: ~Copyable, - Handler.ResponseSender == ResponseSender, - Handler.ResponseSender: ~Copyable + Handler.ResponseSender == ResponseSender { // The server requires a NIOAsyncChannel, so we create one from the test channel let serverTestAsyncChannel = try await testChannel.eventLoop.submit { @@ -48,6 +45,9 @@ extension NIOHTTPServer { try self.addressesBound([.init(ipAddress: "127.0.0.1", port: 8000)]) _ = try await self.listeningAddresses - try await self.serveInsecureHTTP1_1(serverChannel: serverTestAsyncChannel, handler: handler) + try await self.serveInsecureHTTP1_1( + serverChannel: serverTestAsyncChannel, + connectionHandler: NIOHTTPServerDefaultConnectionHandler(handler: handler) + ) } } diff --git a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+SecureUpgrade.swift b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+SecureUpgrade.swift index c51871d..7581294 100644 --- a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+SecureUpgrade.swift @@ -48,6 +48,9 @@ extension NIOHTTPServer { try self.addressesBound([.init(ipAddress: "127.0.0.1", port: 8000)]) _ = try await self.listeningAddresses - try await self.serveSecureUpgrade(serverChannel: testAsyncChannel, handler: handler) + try await self.serveSecureUpgrade( + serverChannel: testAsyncChannel, + connectionHandler: NIOHTTPServerDefaultConnectionHandler(handler: handler) + ) } } diff --git a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+HTTP1.swift b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+HTTP1.swift index 12aadb5..d6a8013 100644 --- a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+HTTP1.swift +++ b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+HTTP1.swift @@ -88,7 +88,7 @@ struct TestingChannelHTTP1Server { isSecure: false ).get() - // Write the connection channel to the server channel to simulate an incoming connection + // Write the child connection to the server channel to simulate an incoming connection try await self.serverTestChannel.writeInbound(serverAsyncConnectionChannel) let clientTestingChannel = try await NIOAsyncTestingChannel.createActiveChannel()