diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index 37c0bf2..e5a9e69 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -204,7 +204,7 @@ public struct NIOHTTPServer: HTTPServer { } /// Creates and returns server channels based on the configured transport security. - private func makeServerChannels() async throws -> [ServerChannel] { + func makeServerChannels() async throws -> [ServerChannel] { // If transport security is `plaintext`, we can only create an HTTP/1.1 channel. if case .plaintext = self.configuration.transportSecurity.backing { let http1Channels = try await self.setupHTTP1_1ServerChannels(bindTargets: self.configuration.bindTargets) @@ -417,7 +417,7 @@ public struct NIOHTTPServer: HTTPServer { } /// Forcefully closes the server channels without waiting for existing connections to drain. - private func close(serverChannels: [ServerChannel]) { + func close(serverChannels: [ServerChannel]) { self.finishListeningAddressPromise() for serverChannel in serverChannels { diff --git a/Tests/NIOHTTPServerTests/ConnectionBackpressureEndToEndTests.swift b/Tests/NIOHTTPServerTests/ConnectionBackpressureEndToEndTests.swift index 3e7579c..41d85d4 100644 --- a/Tests/NIOHTTPServerTests/ConnectionBackpressureEndToEndTests.swift +++ b/Tests/NIOHTTPServerTests/ConnectionBackpressureEndToEndTests.swift @@ -23,181 +23,167 @@ import Testing @Suite("Connection Backpressure End-to-End") struct ConnectionBackpressureEndToEndTests { - let serverLogger = Logger(label: "ConnectionBackpressureE2ETests") + let serverLogger = Logger(label: "ConnectionBackpressureE2ETests.server") + let clientLogger = Logger(label: "ConnectionBackpressureE2ETests.client") @available(anyAppleOS 26.0, *) - @Test("Requests succeed under connection limit") - func requestsSucceedUnderConnectionLimit() async throws { - var configuration = try NIOHTTPServerConfiguration( - bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http1_1], - transportSecurity: .plaintext - ) - configuration.maxConnections = 2 - configuration.connectionTimeouts = .init(idle: nil, readHeader: nil, readBody: nil) - - let server = NIOHTTPServer( - logger: self.serverLogger, - configuration: configuration - ) + @Test( + "Requests succeed under connection limit", + arguments: [NIOHTTPServer.HTTPVersion.plaintextHTTP1_1, .http1_1, .http2] + ) + func requestsSucceedUnderConnectionLimit(httpVersion: NIOHTTPServer.HTTPVersion) async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: httpVersion, + clientLogger: self.clientLogger, + serverLogger: self.serverLogger + ) { configuration in + configuration.maxConnections = 2 + configuration.connectionTimeouts = .init(idle: nil, readHeader: nil, readBody: nil) + } try await confirmation(expectedCount: 2) { responseReceived in - try await NIOHTTPServerTests.withServer( + try await TestHelpers.withServer( server: server, serverHandler: HTTPServerClosureRequestHandler { _, _, reader, responseSender in - try await NIOHTTPServerTests.echoResponse( + try await TestHelpers.echoResponse( readUpTo: 1024, reader: reader, sender: responseSender ) - }, - body: { serverAddress in - try await withThrowingTaskGroup { group in - for _ in 0..<2 { - group.addTask { - 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)) - - try await NIOHTTPServerTests.validateResponse( - inbound, - expectedHead: [NIOHTTPServerTests.responseHead(status: .ok, for: .http1_1)], - expectedBody: [], - expectStreamEnd: false - ) - - responseReceived() - } + } + ) { serverAddress in + try await withThrowingTaskGroup { group in + for _ in 0..<2 { + group.addTask { + try await TestClientConnection.withConnectedRequestChannel( + configuration: clientConfiguration, + serverAddress: serverAddress + ) { inbound, outbound in + try await outbound.write(.testHead(method: .get, for: httpVersion)) + try await outbound.write(.end(nil)) + + try await TestHelpers.validateResponse( + inbound, + expectedHead: [.makeResponse(status: .ok, for: httpVersion)], + expectedBody: [], + expectStreamEnd: false + ) + + responseReceived() } } - - try await group.waitForAll() } + + try await group.waitForAll() } - ) + } } } @available(anyAppleOS 26.0, *) - @Test("More connections than maxConnections all eventually complete") - func moreConnectionsThanLimitAllComplete() async throws { - var configuration = try NIOHTTPServerConfiguration( - bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http1_1], - transportSecurity: .plaintext - ) - configuration.maxConnections = 2 - configuration.connectionTimeouts = .init(idle: nil, readHeader: nil, readBody: nil) - - let server = NIOHTTPServer( - logger: self.serverLogger, - configuration: configuration - ) + @Test( + "More connections than maxConnections all eventually complete", + arguments: [NIOHTTPServer.HTTPVersion.plaintextHTTP1_1, .http1_1, .http2] + ) + func moreConnectionsThanLimitAllComplete(httpVersion: NIOHTTPServer.HTTPVersion) async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: httpVersion, + clientLogger: self.clientLogger, + serverLogger: self.serverLogger + ) { configuration in + configuration.maxConnections = 2 + configuration.connectionTimeouts = .init(idle: nil, readHeader: nil, readBody: nil) + } // Open 5 connections with maxConnections: 2. All should eventually complete // as the connection limit handler releases slots when connections close. let numConnections = 5 try await confirmation(expectedCount: numConnections) { responseReceived in - try await NIOHTTPServerTests.withServer( + try await TestHelpers.withServer( server: server, serverHandler: HTTPServerClosureRequestHandler { _, _, reader, responseSender in - try await NIOHTTPServerTests.echoResponse( + try await TestHelpers.echoResponse( readUpTo: 1024, reader: reader, sender: responseSender ) }, - body: { serverAddress in - await withThrowingTaskGroup { group in - for _ in 0..( - 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() - } - } + static let serverLogger = Logger(label: "ConnectionLifecycleTests.server") + static let clientLogger = Logger(label: "ConnectionLifecycleTests.client") /// 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 @@ -140,25 +114,33 @@ struct ConnectionLifecycleTests { NIOHTTPServer.ResponseSender > { HTTPServerClosureRequestHandler { request, requestContext, reader, responseSender in - try await NIOHTTPServerTests.echoResponse(readUpTo: 1024, reader: reader, sender: responseSender) + try await TestHelpers.echoResponse(readUpTo: 1024, reader: reader, sender: responseSender) } } - /// HTTP/1.1: connecting twice results in two `handleConnection` invocations, - /// each with non-nil `remoteAddress` and `localAddress`. + /// 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) + @Test( + "Per-connection invocation count", + arguments: [NIOHTTPServer.HTTPVersion.plaintextHTTP1_1, .http1_1, .http2] + ) + func testPerConnectionInvocation(httpVersion: NIOHTTPServer.HTTPVersion) async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: httpVersion, + clientLogger: Self.clientLogger, + serverLogger: Self.serverLogger + ) let state = ConnectionLifecycleTestState() let connectionHandler = CountingConnectionHandler(state: state, requestHandler: Self.echoHandler()) - try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + try await TestHelpers.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 TestClientConnection.withConnectedRequestChannel( + configuration: clientConfiguration, + serverAddress: serverAddress + ) { inbound, outbound in + try await outbound.write(.testHead(method: .get, for: httpVersion)) try await outbound.write(.end(nil)) var iterator = inbound.makeAsyncIterator() while let part = try await iterator.next() { @@ -177,31 +159,39 @@ struct ConnectionLifecycleTests { 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. + /// 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) + @Test( + "HTTP/1.1 keep-alive: single connection-handler invocation, multiple requests", + arguments: [NIOHTTPServer.HTTPVersion.plaintextHTTP1_1, .http1_1] + ) + func testKeepAliveSingleInvocationMultipleRequests(httpVersion: NIOHTTPServer.HTTPVersion) async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: httpVersion, + clientLogger: Self.clientLogger, + serverLogger: 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)) - } + try await TestHelpers.withClientServerRequestChannel( + clientConfiguration: clientConfiguration, + server: server, + connectionHandler: connectionHandler + ) { _, inbound, outbound in + // Pipeline both requests up-front, then read both responses. + for path in ["/a", "/b"] { + try await outbound.write(.testHead(method: .post, path: path, for: httpVersion)) + 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 } - } + var iterator = inbound.makeAsyncIterator() + for _ in 0..<2 { + while let part = try await iterator.next() { + if case .end = part { break } } } } @@ -210,14 +200,18 @@ struct ConnectionLifecycleTests { #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` + /// 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))) + @Test("HTTP/2: single connection-handler invocation, concurrent streams") func testHTTP2SingleInvocationConcurrentStreams() async throws { - let (server, serverChain) = try NIOHTTPServerTests.makeSecureUpgradeServer(logger: Self.serverLogger) + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: .http2, + clientLogger: Self.clientLogger, + serverLogger: Self.serverLogger + ) + let elg: EventLoopGroup = .singletonMultiThreadedEventLoopGroup let numStreams = 3 let allRequestsReceived = elg.any().makePromise(of: Void.self) @@ -232,11 +226,7 @@ struct ConnectionLifecycleTests { NIOHTTPServer.RequestContext, NIOHTTPServer.Reader, NIOHTTPServer.ResponseSender - > = HTTPServerClosureRequestHandler { - request, - requestContext, - reader, - responseSender in + > = HTTPServerClosureRequestHandler { request, requestContext, reader, responseSender in let arrived = arrivedCounter.withLockedValue { value -> Int in value += 1 return value @@ -249,28 +239,18 @@ struct ConnectionLifecycleTests { 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 TestHelpers.withClientServerConnection( + clientConfiguration: clientConfiguration, + server: server, + connectionHandler: CountingConnectionHandler(state: state, requestHandler: synchronizingRequestHandler) + ) { serverAddress, clientConnection in try await withThrowingTaskGroup { group in for _ in 1...numStreams { group.addTask { - let stream = try await streamManager.openStream() + let stream = try await clientConnection.makeRequestChannel(expectedHTTPVersion: .http2) try await stream.executeThenClose { inbound, outbound in - try await outbound.write( - .head(.init(method: .get, scheme: "https", authority: "", path: "/")) - ) + try await outbound.write(.testHead(method: .get, for: .http2)) try await outbound.write(.end(nil)) var iterator = inbound.makeAsyncIterator() while let part = try await iterator.next() { @@ -297,31 +277,41 @@ struct ConnectionLifecycleTests { /// 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) + @Test( + "Throwing connection handler doesn't bring down the server", + arguments: [NIOHTTPServer.HTTPVersion.plaintextHTTP1_1, .http1_1, .http2] + ) + func testThrowingConnectionHandlerDoesNotKillServer(httpVersion: NIOHTTPServer.HTTPVersion) async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: httpVersion, + clientLogger: Self.clientLogger, + serverLogger: Self.serverLogger + ) + let connectionInvocations = NIOLockedValueBox(0) let connectionHandler = ThrowingFirstConnectionHandler( connectionInvocations: connectionInvocations ) - try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + try await TestHelpers.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 + try await TestClientConnection.withConnectedRequestChannel( + configuration: clientConfiguration, + serverAddress: serverAddress + ) { 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 TestClientConnection.withConnectedRequestChannel( + configuration: clientConfiguration, + serverAddress: serverAddress + ) { inbound, outbound in + try await outbound.write(.testHead(method: .post, for: httpVersion)) try await outbound.write(.body(ByteBuffer(string: "x"))) try await outbound.write(.end(nil)) var iterator = inbound.makeAsyncIterator() @@ -340,131 +330,68 @@ struct ConnectionLifecycleTests { #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. + /// 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 + @Test( + "Connection handler returning without handleRequests drops the connection", + arguments: [NIOHTTPServer.HTTPVersion.plaintextHTTP1_1, .http1_1, .http2] + ) + func testConnectionHandlerEarlyReturn(httpVersion: NIOHTTPServer.HTTPVersion) async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: httpVersion, + clientLogger: Self.clientLogger, + serverLogger: Self.serverLogger + ) 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 + try await TestHelpers.withClientServerRequestChannel( + clientConfiguration: clientConfiguration, + server: server, + connectionHandler: connectionHandler + ) { _, 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(.testHead(method: .get, for: httpVersion)) + try? await outbound.write(.end(nil)) + var iterator = inbound.makeAsyncIterator() + var receivedAnyResponsePart = 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 } - } + while let part = try await iterator.next() { + if case .head = part { receivedAnyResponsePart = true } } } catch { - // Stream open / write / read may throw — all valid outcomes. + // Connection-reset / read errors are also valid evidence that + // the connection was dropped. } - #expect(!sawResponseHead, "Expected no response head from a dropped connection.") + #expect(!receivedAnyResponsePart, "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. + /// `ConnectionContext.httpVersion` reflects the protocol negotiated for the connection. @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() + #if HTTP3 + @Test( + "ConnectionContext.httpVersion is correct", + arguments: [NIOHTTPServer.HTTPVersion.plaintextHTTP1_1, .http1_1, .http2, .http3] + ) + #else + @Test( + "ConnectionContext.httpVersion is correct", + arguments: [NIOHTTPServer.HTTPVersion.plaintextHTTP1_1, .http1_1, .http2] + ) + #endif + func testHTTPVersion(httpVersion: NIOHTTPServer.HTTPVersion) async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: httpVersion, + clientLogger: Self.clientLogger, + serverLogger: Self.serverLogger ) - 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 } == .plaintextHTTP1_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( @@ -472,76 +399,38 @@ struct ConnectionLifecycleTests { 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 } - } + try await TestHelpers.withClientServerRequestChannel( + clientConfiguration: clientConfiguration, + server: server, + connectionHandler: connectionHandler + ) { _, inbound, outbound in + try await outbound.write(.testHead(method: .post, for: httpVersion)) + 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) + #expect(observed.withLockedValue { $0 } == httpVersion) } + /// 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("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() + @Test( + "State isolation across keep-alive HTTP/1.1 connections", + arguments: [NIOHTTPServer.HTTPVersion.plaintextHTTP1_1, .http1_1] + ) + func testKeepAliveStateIsolationAcrossConnections(http1Variant: NIOHTTPServer.HTTPVersion) async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: http1Variant, + clientLogger: Self.clientLogger, + serverLogger: Self.serverLogger ) - 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( @@ -549,18 +438,17 @@ struct ConnectionLifecycleTests { wrappedHandler: Self.echoHandler() ) - try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + try await TestHelpers.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 + try await TestClientConnection.withConnectedRequestChannel( + configuration: clientConfiguration, + serverAddress: serverAddress + ) { inbound, outbound in for i in 0.. @@ -637,7 +521,7 @@ struct ThrowingFirstConnectionHandler: NIOHTTPServerConnectionHandler { throw TestError.intentional } await connection.handleRequests { request, _, reader, sender in - try await NIOHTTPServerTests.echoResponse(readUpTo: 1024, reader: reader, sender: sender) + try await TestHelpers.echoResponse(readUpTo: 1024, reader: reader, sender: sender) } } } @@ -719,7 +603,7 @@ struct ClosureRequestHandlerConnectionHandler: NIOHTTPServerConnectionHandler { context: NIOHTTPServer.ConnectionContext ) async throws { await connection.handleRequests { request, _, reader, sender in - try await NIOHTTPServerTests.echoResponse(readUpTo: 1024, reader: reader, sender: sender) + try await TestHelpers.echoResponse(readUpTo: 1024, reader: reader, sender: sender) } } } diff --git a/Tests/NIOHTTPServerTests/ConnectionLimitHandlerTests.swift b/Tests/NIOHTTPServerTests/ConnectionLimitHandlerTests.swift index 62e6dff..f807e95 100644 --- a/Tests/NIOHTTPServerTests/ConnectionLimitHandlerTests.swift +++ b/Tests/NIOHTTPServerTests/ConnectionLimitHandlerTests.swift @@ -33,7 +33,7 @@ struct ConnectionLimitHandlerTests { for _ in 0..<3 { let child = EmbeddedChannel() children.append(child) - try channel.writeInbound(child as Channel) + try channel.writeInbound(child) } // All 3 should have been forwarded @@ -56,8 +56,8 @@ struct ConnectionLimitHandlerTests { // Open 2 connections to fill the limit let child1 = EmbeddedChannel() let child2 = EmbeddedChannel() - try channel.writeInbound(child1 as Channel) - try channel.writeInbound(child2 as Channel) + try channel.writeInbound(child1) + try channel.writeInbound(child2) // Trigger a read while within the acceptable number of connections: it should be forwarded. channel.read() @@ -67,7 +67,7 @@ struct ConnectionLimitHandlerTests { // Open a third connection - this will be above the limit, so stop forwarding reads. let child3 = EmbeddedChannel() - try channel.writeInbound(child3 as Channel) + try channel.writeInbound(child3) // Now at capacity — a third read should be blocked channel.pipeline.read() @@ -82,7 +82,7 @@ struct ConnectionLimitHandlerTests { // Open 1 connection (at limit) let child1 = EmbeddedChannel() - try channel.writeInbound(child1 as Channel) + try channel.writeInbound(child1) _ = try channel.readInbound(as: Channel.self) // Close the child connection @@ -93,7 +93,7 @@ struct ConnectionLimitHandlerTests { // Now we should be able to accept a new connection let child2 = EmbeddedChannel() - try channel.writeInbound(child2 as Channel) + try channel.writeInbound(child2) let forwarded = try channel.readInbound(as: Channel.self) #expect(forwarded != nil) } diff --git a/Tests/NIOHTTPServerTests/HTTPKeepAliveHandlerTests.swift b/Tests/NIOHTTPServerTests/HTTPKeepAliveHandlerTests.swift index 540623f..77f7968 100644 --- a/Tests/NIOHTTPServerTests/HTTPKeepAliveHandlerTests.swift +++ b/Tests/NIOHTTPServerTests/HTTPKeepAliveHandlerTests.swift @@ -24,94 +24,89 @@ import Testing @Suite struct HTTPKeepAliveHandlerTests { - let serverLogger = Logger(label: "HTTPKeepAliveHandlerTests") + let clientLogger = Logger(label: "HTTPKeepAliveHandlerTests.client") + let serverLogger = Logger(label: "HTTPKeepAliveHandlerTests.server") /// Verifies the happy case: when a client pipelines multiple HTTP/1.1 requests /// on a single connection, all responses are returned in order and the connection /// stays alive (no `Connection: close`). @available(anyAppleOS 26.0, *) - @Test("Pipelined requests on a single connection all succeed") - func testPipelinedRequests() async throws { - let server = NIOHTTPServer( - logger: self.serverLogger, - configuration: try .init( - bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http1_1], - transportSecurity: .plaintext - ) + @Test( + "Pipelined requests on a single connection all succeed", + arguments: [NIOHTTPServer.HTTPVersion.plaintextHTTP1_1, .http1_1] + ) + func testPipelinedRequests(http1Variant: NIOHTTPServer.HTTPVersion) async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: http1Variant, + clientLogger: self.clientLogger, + serverLogger: self.serverLogger ) let requestCount = 5 - try await NIOHTTPServerTests.withServer( + try await TestHelpers.withClientServerRequestChannel( + clientConfiguration: clientConfiguration, server: server, serverHandler: HTTPServerClosureRequestHandler { request, _, reader, sender in - try await NIOHTTPServerTests.echoResponse(readUpTo: 1024, reader: reader, sender: sender) - }, - body: { serverAddress in - let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .connectToTestHTTP1Server(at: serverAddress) - - try await client.executeThenClose { inbound, outbound in - // Pipeline all requests up-front, then read all responses. - for i in 1...requestCount { - try await outbound.write( - .head(.init(method: .post, scheme: "http", authority: "", path: "/\(i)")) - ) - try await outbound.write(.body(ByteBuffer(string: "request-\(i)"))) - try await outbound.write(.end(nil)) - } + try await TestHelpers.echoResponse(readUpTo: 1024, reader: reader, sender: sender) + } + ) { _, inbound, outbound in + // Pipeline all requests up-front, then read all responses. + for i in 1...requestCount { + try await outbound.write(.testHead(method: .post, path: "/\(i)", for: http1Variant)) + try await outbound.write(.body(ByteBuffer(string: "request-\(i)"))) + try await outbound.write(.end(nil)) + } - var responseIterator = inbound.makeAsyncIterator() - for i in 1...requestCount { - let headPart = try await responseIterator.next() - guard case .head(let response) = headPart else { - Issue.record("Expected .head for request \(i), got \(String(describing: headPart))") - return - } - #expect(response.status == .ok) - // Connection should remain keep-alive — no Connection: close header. - #expect( - response.headerFields[.connection] != "close", - "Response \(i) unexpectedly had Connection: close: \(response.headerFields)" - ) - - // Drain body parts until .end. - var collectedBody = ByteBuffer() - while true { - let part = try await responseIterator.next() - if case .body(let buf) = part { - collectedBody.writeImmutableBuffer(buf) - } else if case .end = part { - break - } else { - Issue.record("Unexpected part for request \(i): \(String(describing: part))") - return - } - } - #expect(collectedBody == ByteBuffer(string: "request-\(i)")) + var responseIterator = inbound.makeAsyncIterator() + for i in 1...requestCount { + let headPart = try await responseIterator.next() + guard case .head(let response) = headPart else { + Issue.record("Expected .head for request \(i), got \(String(describing: headPart))") + return + } + #expect(response.status == .ok) + // Connection should remain keep-alive — no Connection: close header. + #expect( + response.headerFields[.connection] != "close", + "Response \(i) unexpectedly had Connection: close: \(response.headerFields)" + ) + + // Drain body parts until .end. + var collectedBody = ByteBuffer() + while true { + let part = try await responseIterator.next() + if case .body(let buf) = part { + collectedBody.writeImmutableBuffer(buf) + } else if case .end = part { + break + } else { + Issue.record("Unexpected part for request \(i): \(String(describing: part))") + return } } + #expect(collectedBody == ByteBuffer(string: "request-\(i)")) } - ) + } } /// Verifies that when the handler writes a short response (head + end, no body) /// before the request `.end` has arrived, the response head includes a /// `Connection: close` header and the server closes the connection. @available(anyAppleOS 26.0, *) - @Test("Server sends head+end (no body) before request .end — Connection: close in header") - func testShortResponseBeforeRequestEnd() async throws { - let server = NIOHTTPServer( - logger: self.serverLogger, - configuration: try .init( - bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http1_1], - transportSecurity: .plaintext - ) + @Test( + "Server sends head+end (no body) before request .end — Connection: close in header", + arguments: [NIOHTTPServer.HTTPVersion.plaintextHTTP1_1, .http1_1] + ) + func testShortResponseBeforeRequestEnd(http1Variant: NIOHTTPServer.HTTPVersion) async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: http1Variant, + clientLogger: self.clientLogger, + serverLogger: self.serverLogger ) - try await NIOHTTPServerTests.withServer( + try await TestHelpers.withClientServerRequestChannel( + clientConfiguration: clientConfiguration, server: server, serverHandler: HTTPServerClosureRequestHandler { _, _, reader, sender in var reader = reader @@ -124,52 +119,44 @@ struct HTTPKeepAliveHandlerTests { .init(status: .ok, headerFields: [.contentLength: "0"]) ) }, - body: { 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"))) - - // Read the response: should have Connection: close in the head. - var responseIterator = inbound.makeAsyncIterator() - let headPart = try await responseIterator.next() - guard case .head(let response) = headPart else { - Issue.record("Expected .head, got \(String(describing: headPart))") - return - } - #expect(response.status == .ok) - #expect( - response.headerFields[.connection] == "close", - "Expected Connection: close, got headers: \(response.headerFields)" - ) - - // Drain until .end, then verify channel closed. - var sawEnd = false - while !sawEnd { - let part = try await responseIterator.next() - switch part { - case .body: - continue - case .end: - sawEnd = true - case .none: - Issue.record("Stream ended before response .end") - return - case .head: - Issue.record("Unexpected second .head: \(String(describing: part))") - return - } - } + ) { _, inbound, outbound in + try await outbound.write(.testHead(method: .post, for: http1Variant)) + try await outbound.write(.body(ByteBuffer(string: "x"))) + + // Read the response: should have Connection: close in the head. + var responseIterator = inbound.makeAsyncIterator() + let headPart = try await responseIterator.next() + guard case .head(let response) = headPart else { + Issue.record("Expected .head, got \(String(describing: headPart))") + return + } + #expect(response.status == .ok) + #expect( + response.headerFields[.connection] == "close", + "Expected Connection: close, got headers: \(response.headerFields)" + ) - let next = try await responseIterator.next() - #expect(next == nil, "Expected channel to be closed; got \(String(describing: next))") + // Drain until .end, then verify channel closed. + var sawEnd = false + while !sawEnd { + let part = try await responseIterator.next() + switch part { + case .body: + continue + case .end: + sawEnd = true + case .none: + Issue.record("Stream ended before response .end") + return + case .head: + Issue.record("Unexpected second .head: \(String(describing: part))") + return } } - ) + + let next = try await responseIterator.next() + #expect(next == nil, "Expected channel to be closed; got \(String(describing: next))") + } } /// Verifies that informational (1xx) responses pass through the keep-alive handler @@ -178,18 +165,19 @@ struct HTTPKeepAliveHandlerTests { /// response immediately (without waiting for request `.end`), and the connection /// must remain alive after the final response. @available(anyAppleOS 26.0, *) - @Test("Informational (1xx) responses pass through without buffering or closing") - func testInformationalResponsePassesThrough() async throws { - let server = NIOHTTPServer( - logger: self.serverLogger, - configuration: try .init( - bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http1_1], - transportSecurity: .plaintext - ) + @Test( + "Informational (1xx) responses pass through without buffering or closing", + arguments: [NIOHTTPServer.HTTPVersion.plaintextHTTP1_1, .http1_1] + ) + func testInformationalResponsePassesThrough(http1Variant: NIOHTTPServer.HTTPVersion) async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: http1Variant, + clientLogger: self.clientLogger, + serverLogger: self.serverLogger ) - try await NIOHTTPServerTests.withServer( + try await TestHelpers.withClientServerRequestChannel( + clientConfiguration: clientConfiguration, server: server, serverHandler: HTTPServerClosureRequestHandler { request, _, reader, sender in var sender = sender @@ -210,77 +198,69 @@ struct HTTPKeepAliveHandlerTests { .init(status: .ok, headerFields: [.contentLength: "5"]), buffer: &buffer ) - }, - body: { 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: "/")) - ) - - // Read the 100 Continue before sending the request body — this - // only works if the informational response was forwarded without - // being buffered by the keep-alive handler. - var responseIterator = inbound.makeAsyncIterator() - let informationalPart = try await responseIterator.next() - guard case .head(let informational) = informationalPart else { - Issue.record("Expected informational .head, got \(String(describing: informationalPart))") - return - } - #expect(informational.status == .continue) - - // Now send the body and end so the server can write the final - // response. - try await outbound.write(.body(ByteBuffer(string: "hello"))) - try await outbound.write(.end(nil)) + } + ) { _, inbound, outbound in + try await outbound.write(.testHead(method: .post, for: http1Variant)) + + // Read the 100 Continue before sending the request body — this + // only works if the informational response was forwarded without + // being buffered by the keep-alive handler. + var responseIterator = inbound.makeAsyncIterator() + let informationalPart = try await responseIterator.next() + guard case .head(let informational) = informationalPart else { + Issue.record("Expected informational .head, got \(String(describing: informationalPart))") + return + } + #expect(informational.status == .continue) + + // Now send the body and end so the server can write the final + // response. + try await outbound.write(.body(ByteBuffer(string: "hello"))) + try await outbound.write(.end(nil)) + + // Read the final 200 OK response. + let finalHeadPart = try await responseIterator.next() + guard case .head(let response) = finalHeadPart else { + Issue.record("Expected final .head, got \(String(describing: finalHeadPart))") + return + } + #expect(response.status == .ok) + #expect( + response.headerFields[.connection] != "close", + "Expected keep-alive after informational flow; got headers: \(response.headerFields)" + ) - // Read the final 200 OK response. - let finalHeadPart = try await responseIterator.next() - guard case .head(let response) = finalHeadPart else { - Issue.record("Expected final .head, got \(String(describing: finalHeadPart))") - return - } - #expect(response.status == .ok) - #expect( - response.headerFields[.connection] != "close", - "Expected keep-alive after informational flow; got headers: \(response.headerFields)" - ) - - // Drain body and end. - var sawEnd = false - while !sawEnd { - let part = try await responseIterator.next() - switch part { - case .body: - continue - case .end: - sawEnd = true - case .none: - Issue.record("Stream ended before response .end") - return - case .head: - Issue.record("Unexpected .head: \(String(describing: part))") - return - } - } + // Drain body and end. + var sawEnd = false + while !sawEnd { + let part = try await responseIterator.next() + switch part { + case .body: + continue + case .end: + sawEnd = true + case .none: + Issue.record("Stream ended before response .end") + return + case .head: + Issue.record("Unexpected .head: \(String(describing: part))") + return + } + } - // Pipeline a second request to verify keep-alive actually works. - try await outbound.write( - .head(.init(method: .get, scheme: "http", authority: "", path: "/second")) - ) - try await outbound.write(.end(nil)) + // Pipeline a second request to verify keep-alive actually works. + try await outbound.write( + .head(.init(method: .get, scheme: "http", authority: "", path: "/second")) + ) + try await outbound.write(.end(nil)) - let secondHead = try await responseIterator.next() - guard case .head(let secondResponse) = secondHead else { - Issue.record("Expected second .head, got \(String(describing: secondHead))") - return - } - #expect(secondResponse.status == .ok) - } + let secondHead = try await responseIterator.next() + guard case .head(let secondResponse) = secondHead else { + Issue.record("Expected second .head, got \(String(describing: secondHead))") + return } - ) + #expect(secondResponse.status == .ok) + } } /// Verifies bidirectional streaming over HTTP/1.1: the handler writes the @@ -292,79 +272,72 @@ struct HTTPKeepAliveHandlerTests { /// request `.end` arrives, the response carries `Connection: close` and the /// server closes the connection after writing response `.end`. @available(anyAppleOS 26.0, *) - @Test("Bidirectional streaming works — head is flushed (with Connection: close) when a body part is written") - func testBidirectionalStreamingOverHTTP1() async throws { - let server = NIOHTTPServer( - logger: self.serverLogger, - configuration: try .init( - bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http1_1], - transportSecurity: .plaintext - ) + @Test( + "Bidirectional streaming works — head is flushed (with Connection: close) when a body part is written", + arguments: [NIOHTTPServer.HTTPVersion.plaintextHTTP1_1, .http1_1] + ) + func testBidirectionalStreamingOverHTTP1(http1Variant: NIOHTTPServer.HTTPVersion) async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: http1Variant, + clientLogger: self.clientLogger, + serverLogger: self.serverLogger ) - try await NIOHTTPServerTests.withServer( + try await TestHelpers.withClientServerRequestChannel( + clientConfiguration: clientConfiguration, server: server, serverHandler: HTTPServerClosureRequestHandler { _, _, reader, sender in // Echo request body parts back as response body parts, concurrently // with reading from the request body. let writer = try await sender.send(.init(status: .ok)) try await reader.pipe(into: writer) - }, - body: { 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: "/")) - ) - // Write the first body byte before reading the response head, so - // the server has something to echo — this unblocks the buffered - // head in the keep-alive handler. This mirrors how real - // bidirectional clients (like the conformance `/echo` test) work. - let chunkCount = 5 - let firstByte = ByteBuffer(bytes: [UInt8(ascii: "A")]) - try await outbound.write(.body(firstByte)) - - var responseIterator = inbound.makeAsyncIterator() - let headPart = try await responseIterator.next() - guard case .head(let response) = headPart else { - Issue.record("Expected .head, got \(String(describing: headPart))") - return - } - #expect(response.status == .ok) - // The head was flushed because a body part was written before - // request `.end` arrived, so it carries `Connection: close`. - #expect( - response.headerFields[.connection] == "close", - "Expected Connection: close on bidirectional flow; got \(response.headerFields)" - ) - - // Read the echo of the first byte. - let firstEcho = try await responseIterator.next() - #expect(firstEcho == .body(firstByte)) - - // Ping-pong: write a byte, read its echo. - for i in 1...makeStream() let (handlerCanFinishStream, handlerCanFinish) = AsyncStream.makeStream() - try await NIOHTTPServerTests.withServer( + try await TestHelpers.withClientServerRequestChannel( + clientConfiguration: clientConfiguration, server: server, serverHandler: HTTPServerClosureRequestHandler { _, _, reader, sender in // Write the response head before reading anything. The keep-alive @@ -413,149 +387,61 @@ struct HTTPKeepAliveHandlerTests { requestBody.reserveCapacity(1024) _ = try await reader.collect(into: &requestBody) var buffer = UniqueArray(copying: "hello".utf8) - try await writer.finish(buffer: &buffer, finalElement: nil) - }, - body: { serverAddress in - let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .connectToTestHTTP1Server(at: serverAddress) - - try await client.executeThenClose { inbound, outbound in - // Send only the head. - try await outbound.write( - .head(.init(method: .post, scheme: "http", authority: "", path: "/")) - ) - - // Wait for the handler to write the response head. - var signalIterator = responseHeadWrittenStream.makeAsyncIterator() - _ = await signalIterator.next() - - // Send a single body byte, WITHOUT request `.end`. The server - // will see this as its own read cycle that ends with the - // request `.end` still missing — triggering the - // `Connection: close` amendment. - try await outbound.write(.body(ByteBuffer(string: "x"))) - - // Read the response head. It must carry `Connection: close`. - var responseIterator = inbound.makeAsyncIterator() - let headPart = try await responseIterator.next() - guard case .head(let response) = headPart else { - Issue.record("Expected .head, got \(String(describing: headPart))") - return - } - #expect(response.status == .ok) - #expect( - response.headerFields[.connection] == "close", - "Expected Connection: close after read cycle ended without request .end; got \(response.headerFields)" - ) - - // Let the handler finish and send the rest of the request. - handlerCanFinish.yield() - handlerCanFinish.finish() - try await outbound.write(.end(nil)) - - // Drain the response body + end. - var sawEnd = false - while !sawEnd { - let part = try await responseIterator.next() - switch part { - case .body: - continue - case .end: - sawEnd = true - case .none: - Issue.record("Stream ended before response .end") - return - case .head: - Issue.record("Unexpected second .head: \(String(describing: part))") - return - } - } - - // The server should have closed the connection. - let next = try await responseIterator.next() - #expect(next == nil, "Expected channel close after response; got \(String(describing: next))") - } + try await writer.finish(buffer: &buffer) } - ) - } - - /// Verifies that the keep-alive handler is also present on the secure upgrade - /// HTTP/1.1 pipeline. This mirrors `testShortResponseBeforeRequestEnd` but runs - /// over TLS: if the keep-alive handler isn't wired into the secure pipeline, - /// the response head will be flushed without `Connection: close` and this test - /// will fail. - @available(anyAppleOS 26.0, *) - @Test("Server sends head+end before request .end over TLS — Connection: close in header") - func testShortResponseBeforeRequestEndOverTLS() async throws { - let serverChain = try TestCA.makeSelfSignedChain() - let server = NIOHTTPServer( - logger: self.serverLogger, - configuration: try .init( - bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http1_1], - transportSecurity: .tls( - credentials: .x509(.certificates(chain: serverChain.chain, privateKey: serverChain.privateKey)) - ) + ) { _, inbound, outbound in + // Send only the head. + try await outbound.write(.testHead(method: .post, for: http1Variant)) + + // Wait for the handler to write the response head. + var signalIterator = responseHeadWrittenStream.makeAsyncIterator() + _ = await signalIterator.next() + + // Send a single body byte, WITHOUT request `.end`. The server + // will see this as its own read cycle that ends with the + // request `.end` still missing — triggering the + // `Connection: close` amendment. + try await outbound.write(.body(ByteBuffer(string: "x"))) + + // Read the response head. It must carry `Connection: close`. + var responseIterator = inbound.makeAsyncIterator() + let headPart = try await responseIterator.next() + guard case .head(let response) = headPart else { + Issue.record("Expected .head, got \(String(describing: headPart))") + return + } + #expect(response.status == .ok) + #expect( + response.headerFields[.connection] == "close", + "Expected Connection: close after read cycle ended without request .end; got \(response.headerFields)" ) - ) - try await NIOHTTPServerTests.withServer( - server: server, - serverHandler: HTTPServerClosureRequestHandler { _, _, reader, sender in - var reader = reader - try await reader.read { _, _ in } - try await sender.sendAndFinish( - .init(status: .ok, headerFields: [.contentLength: "0"]) - ) - }, - body: { serverAddress in - let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .connectToTestSecureUpgradeHTTPServer( - at: serverAddress, - trustRoots: serverChain.chain, - applicationProtocol: HTTPVersion.http1_1.alpnIdentifier - ) - .unwrapChannel(expectedHTTPVersion: .http1_1) - - try await client.executeThenClose { inbound, outbound in - try await outbound.write( - .head(.init(method: .post, scheme: "https", authority: "", path: "/")) - ) - try await outbound.write(.body(ByteBuffer(string: "x"))) - - var responseIterator = inbound.makeAsyncIterator() - let headPart = try await responseIterator.next() - guard case .head(let response) = headPart else { - Issue.record("Expected .head, got \(String(describing: headPart))") - return - } - #expect(response.status == .ok) - #expect( - response.headerFields[.connection] == "close", - "Expected Connection: close, got headers: \(response.headerFields)" - ) - - var sawEnd = false - while !sawEnd { - let part = try await responseIterator.next() - switch part { - case .body: - continue - case .end: - sawEnd = true - case .none: - Issue.record("Stream ended before response .end") - return - case .head: - Issue.record("Unexpected second .head: \(String(describing: part))") - return - } - } - - let next = try await responseIterator.next() - #expect(next == nil, "Expected channel to be closed; got \(String(describing: next))") + // Let the handler finish and send the rest of the request. + handlerCanFinish.yield() + handlerCanFinish.finish() + try await outbound.write(.end(nil)) + + // Drain the response body + end. + var sawEnd = false + while !sawEnd { + let part = try await responseIterator.next() + switch part { + case .body: + continue + case .end: + sawEnd = true + case .none: + Issue.record("Stream ended before response .end") + return + case .head: + Issue.record("Unexpected second .head: \(String(describing: part))") + return } } - ) + + // The server should have closed the connection. + let next = try await responseIterator.next() + #expect(next == nil, "Expected channel close after response; got \(String(describing: next))") + } } } diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift index 5f0891b..8a280c5 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift @@ -12,7 +12,9 @@ // //===----------------------------------------------------------------------===// +import AsyncStreaming import BasicContainers +import HTTPTypes import Logging import NIOConcurrencyHelpers import NIOCore @@ -25,24 +27,29 @@ import Testing @testable import NIOHTTPServer +#if HTTP3 +import HTTP3 +@_spi(HTTP3AsyncInterface) import NIOHTTP3 +import NIOQUIC +#endif + @Suite struct NIOHTTPServiceLifecycleTests { - static let reqHead = HTTPRequestPart.head(.init(method: .post, scheme: "http", authority: "", path: "/")) - static let bodyData = ByteBuffer(repeating: 5, count: 100) - static let reqBody = HTTPRequestPart.body(Self.bodyData) - static let trailer: HTTPFields = [.trailer: "test_trailer"] - static let reqEnd = HTTPRequestPart.end(trailer) - - let serverLogger = Logger(label: "NIOHTTPServiceLifecycleTests") - let serviceGroupLogger = Logger(label: "NIOHTTPServiceLifecycleTests_ServiceGroup") + let clientLogger = Logger(label: "NIOHTTPServiceLifecycleTests.client") + let serverLogger = Logger(label: "NIOHTTPServiceLifecycleTests.server") + let serviceGroupLogger = Logger(label: "NIOHTTPServiceLifecycleTests.serviceGroup") @Test( "Active connection completes when graceful shutdown triggered", - arguments: [HTTPVersion.http1_1, HTTPVersion.http2] + arguments: [NIOHTTPServer.HTTPVersion.http1_1, .http2] ) @available(anyAppleOS 26.0, *) - func activeConnectionCanCompleteWhenGracefullyShutdown(httpVersion: HTTPVersion) async throws { - let (server, serverChain) = try NIOHTTPServerTests.makeSecureUpgradeServer(logger: self.serverLogger) + func activeConnectionCanCompleteWhenGracefullyShutdown(httpVersion: NIOHTTPServer.HTTPVersion) async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: httpVersion, + clientLogger: self.clientLogger, + serverLogger: self.serverLogger + ) // This promise will be fulfilled when the server receives the first part of the body. Once this happens, we can // initiate the graceful shutdown and then send the remaining body. If graceful shutdown is respected, we should @@ -75,19 +82,14 @@ struct NIOHTTPServiceLifecycleTests { let serverAddress = try await server.listeningAddresses.first! - let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .connectToTestSecureUpgradeHTTPServer( - at: serverAddress, - trustRoots: serverChain.chain, - applicationProtocol: httpVersion.alpnIdentifier - ) - .unwrapChannel(expectedHTTPVersion: httpVersion) - - try await client.executeThenClose { inbound, outbound in - try await outbound.write(Self.reqHead) + try await TestClientConnection.withConnectedRequestChannel( + configuration: clientConfiguration, + serverAddress: serverAddress + ) { inbound, outbound in + try await outbound.write(.testHead(method: .post, for: httpVersion)) // Write the first body part. - try await outbound.write(Self.reqBody) + try await outbound.write(.testBody) // Wait until the server has received the first body part. try await firstChunkReadPromise.futureResult.get() @@ -96,8 +98,8 @@ struct NIOHTTPServiceLifecycleTests { trigger.triggerGracefulShutdown() // We should be able to complete our request. - try await outbound.write(Self.reqBody) - try await outbound.write(Self.reqEnd) + try await outbound.write(.testBody) + try await outbound.write(.testEnd) for try await response in inbound { switch response { @@ -122,11 +124,15 @@ struct NIOHTTPServiceLifecycleTests { @Test( "Server closes active connection upon forceful shutdown", - arguments: [HTTPVersion.http1_1, HTTPVersion.http2] + arguments: [NIOHTTPServer.HTTPVersion.http1_1, .http2] ) @available(anyAppleOS 26.0, *) - func testServerClosesActiveConnectionOnForcefulShutdown(httpVersion: HTTPVersion) async throws { - let (server, serverChain) = try NIOHTTPServerTests.makeSecureUpgradeServer(logger: self.serverLogger) + func testServerClosesActiveConnectionOnForcefulShutdown(httpVersion: NIOHTTPServer.HTTPVersion) async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: httpVersion, + clientLogger: self.clientLogger, + serverLogger: self.serverLogger + ) // This promise will be fulfilled when the server receives the first part of the request body. Once this // happens, we cancel the server task and test whether the client's socket channel has closed. @@ -158,60 +164,50 @@ struct NIOHTTPServiceLifecycleTests { let serverAddress = try await server.listeningAddresses.first! - let tlsConfig = try TLSConfiguration.makeTestClientConfiguration( - trustRoots: .certificates(serverChain.chain), - applicationProtocol: httpVersion.alpnIdentifier - ) - - let (clientConnectionChannel, alpnResultFuture) = - try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup).connect( - to: try .init(ipAddress: serverAddress.host, port: serverAddress.port) - ) { socketChannel in - socketChannel.configureTestClientSSLPipeline(tlsConfig: tlsConfig).flatMap { - socketChannel.configureTestSecureUpgradeClientPipeline().map { connectionChannel in - (socketChannel, connectionChannel) - } - } - } - - let alpnResult = try await alpnResultFuture.get() - let clientRequestChannel = try await NegotiatedClientConnection(negotiationResult: alpnResult) - .unwrapChannel(expectedHTTPVersion: httpVersion) + try await TestClientConnection.withConnection( + configuration: clientConfiguration, + serverAddress: serverAddress + ) { connection in + let clientRequestChannel = try await connection.makeRequestChannel(expectedHTTPVersion: httpVersion) - try await clientRequestChannel.executeThenClose { inbound, outbound in - try await outbound.write(Self.reqHead) + try await clientRequestChannel.executeThenClose { inbound, outbound in + try await outbound.write(.testHead(method: .post, for: httpVersion)) - // Write the first body part. - try await outbound.write(Self.reqBody) + // Write the first body part. + try await outbound.write(.testBody) - // Wait until the server has received the first body part. - try await firstChunkReadPromise.futureResult.get() + // Wait until the server has received the first body part. + try await firstChunkReadPromise.futureResult.get() - // Cancel the server task. - group.cancelAll() - // Wait for the server to shut down. - try await group.waitForAll() + // Cancel the server task. + group.cancelAll() + // Wait for the server to shut down. + try await group.waitForAll() - // Wait for the client channel to be fully closed. - try await clientRequestChannel.channel.closeFuture.get() + // Wait for the client channel to be fully closed. + try await clientRequestChannel.channel.closeFuture.get() - // We shouldn't be able to complete our request; the server should have shut down. - await #expect(throws: ChannelError.ioOnClosedChannel) { - try await outbound.write(Self.reqBody) + // We shouldn't be able to complete our request; the server should have shut down. + await #expect(throws: ChannelError.ioOnClosedChannel) { + try await outbound.write(.testBody) + } } } - - try await clientConnectionChannel.closeFuture.get() } } @Test( "Active connection forcefully shutdown when server task cancelled", - arguments: [HTTPVersion.http1_1, HTTPVersion.http2] + arguments: [NIOHTTPServer.HTTPVersion.http1_1, .http2] ) @available(anyAppleOS 26.0, *) - func activeConnectionForcefullyShutdownWhenServerTaskCancelled(httpVersion: HTTPVersion) async throws { - let (server, serverChain) = try NIOHTTPServerTests.makeSecureUpgradeServer(logger: self.serverLogger) + func activeConnectionForcefullyShutdownWhenServerTaskCancelled(httpVersion: NIOHTTPServer.HTTPVersion) async throws + { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: httpVersion, + clientLogger: self.clientLogger, + serverLogger: self.serverLogger + ) // This promise will be fulfilled when the server receives the first part of the request body. Once this // happens, we cancel the server task and test whether the in-flight request's connection was forcefully shut. @@ -245,38 +241,36 @@ struct NIOHTTPServiceLifecycleTests { let serverAddress = try await server.listeningAddresses.first! - let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .connectToTestSecureUpgradeHTTPServer( - at: serverAddress, - trustRoots: serverChain.chain, - applicationProtocol: httpVersion.alpnIdentifier - ) - .unwrapChannel(expectedHTTPVersion: httpVersion) + try await TestClientConnection.withConnection( + configuration: clientConfiguration, + serverAddress: serverAddress + ) { clientConnection in + let requestChannel = try await clientConnection.makeRequestChannel(expectedHTTPVersion: httpVersion) - try await client.executeThenClose { inbound, outbound in - try await outbound.write(Self.reqHead) + try await requestChannel.executeThenClose { inbound, outbound in + try await outbound.write(.testHead(method: .post, for: httpVersion)) - // Write the first body part. - try await outbound.write(Self.reqBody) + // Write the first body part. + try await outbound.write(.testBody) - // Wait until the server has received the first body part. - try await firstChunkReadPromise.futureResult.get() + // Wait until the server has received the first body part. + try await firstChunkReadPromise.futureResult.get() - // Cancel the server task. - group.cancelAll() - // Wait for the server to shut down. - try await group.waitForAll() + // Cancel the server task. + group.cancelAll() + // Wait for the server to shut down. + try await group.waitForAll() - // Wait for the client channel to be fully closed. The server has closed - // its side of the connection, but the client's event loop may not have - // processed the TCP FIN/RST yet. closeFuture completes only once the - // channel is fully inactive, which is a stronger guarantee than just - // draining inbound (which may return while the channel is half-closed). - try await client.channel.closeFuture.get() + // Wait for the client channel to be fully closed. The server has closed its side of the + // connection, but the client's event loop may not have processed the TCP FIN/RST yet. + // closeFuture completes only once the channel is fully inactive, which is a stronger guarantee + // than just draining inbound (which may return while the channel is half-closed). + try await requestChannel.channel.closeFuture.get() - // We shouldn't be able to complete our request; the server should have shut down. - await #expect(throws: ChannelError.ioOnClosedChannel) { - try await outbound.write(Self.reqBody) + // We shouldn't be able to complete our request; the server should have shut down. + await #expect(throws: ChannelError.ioOnClosedChannel) { + try await outbound.write(.testBody) + } } connectionForcefullyClosed() @@ -288,7 +282,7 @@ struct NIOHTTPServiceLifecycleTests { @Test("Active HTTP/2 connection is forcefully shut down upon graceful shutdown timeout") @available(anyAppleOS 26.0, *) func testActiveHTTP2ConnectionIsShutDownAfterGraceTimeout() async throws { - let serverChain = try TestCA.makeSelfSignedChain() + let (leafPath, caPath, keyPath) = try TestCA.makeSelfSignedChain().writeToDisk() let server = NIOHTTPServer( logger: self.serverLogger, @@ -299,7 +293,7 @@ struct NIOHTTPServiceLifecycleTests { .http2(config: .init(gracefulShutdown: .init(maximumGracefulShutdownDuration: .milliseconds(500)))), ], transportSecurity: .tls( - credentials: .x509(.certificates(chain: serverChain.chain, privateKey: serverChain.privateKey)) + credentials: .x509(.pemFile(certificateChainPath: leafPath, privateKeyPath: keyPath)) ) ) ) @@ -335,41 +329,34 @@ struct NIOHTTPServiceLifecycleTests { let serverAddress = try await server.listeningAddresses.first! - let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .connectToTestSecureUpgradeHTTPServer( - at: serverAddress, - trustRoots: [serverChain.ca], - applicationProtocol: HTTPVersion.http2.alpnIdentifier - ) - - switch client { - case .http1: - Issue.record("Unexpectedly negotiated a HTTP/2 connection") - - case .http2(let streamManager): - let streamChannel = try await streamManager.openStream() - try await streamChannel.executeThenClose { inbound, outbound in - try await outbound.write(Self.reqHead) - try await outbound.write(Self.reqBody) - - // Wait until the server has received the request. - try await firstChunkReadPromise.futureResult.get() - - // Now trigger graceful shutdown. This should propagate down to the server. The server will - // start the 500ms grace timer after which all connections that are still open will be - // forcefully closed. - trigger.triggerGracefulShutdown() + try await TestClientConnection.withConnectedRequestChannel( + configuration: .init( + logger: self.clientLogger, + httpVersion: .http2, + trustRootsPEMPath: caPath + ), + serverAddress: serverAddress + ) { inbound, outbound in + try await outbound.write(.testHead(method: .post, for: .http2)) + try await outbound.write(.testBody) + + // Wait until the server has received the request. + try await firstChunkReadPromise.futureResult.get() - // The server should shut down after 500ms. Wait for this. - try await group.waitForAll() + // Now trigger graceful shutdown. This should propagate down to the server. The server will + // start the 500ms grace timer after which all connections that are still open will be + // forcefully closed. + trigger.triggerGracefulShutdown() - // The connection should have been closed: we should get an `ioOnClosedChannel` error. - await #expect(throws: ChannelError.ioOnClosedChannel) { - try await outbound.write(Self.reqEnd) - } + // The server should shut down after 500ms. Wait for this. + try await group.waitForAll() - connectionForcefullyShutdown() + // The connection should have been closed: we should get an `ioOnClosedChannel` error. + await #expect(throws: ChannelError.ioOnClosedChannel) { + try await outbound.write(.testEnd) } + + connectionForcefullyShutdown() } } } @@ -379,26 +366,23 @@ struct NIOHTTPServiceLifecycleTests { @Test( "Active connections across different listeners can complete when graceful shutdown triggered", arguments: [ - (HTTPVersion.http1_1, HTTPVersion.http1_1), - (HTTPVersion.http1_1, HTTPVersion.http2), - (HTTPVersion.http2, HTTPVersion.http1_1), - (HTTPVersion.http2, HTTPVersion.http2), + (NIOHTTPServer.HTTPVersion.http1_1, NIOHTTPServer.HTTPVersion.http1_1), + (.http1_1, .http2), + (.http2, .http1_1), + (.http2, .http2), ] ) @available(anyAppleOS 26.0, *) func activeConnectionsAcrossDifferentListenersCanCompleteWhenGracefullyShutdown( - firstClientHTTPVersion: HTTPVersion, - secondClientHTTPVersion: HTTPVersion + firstClientHTTPVersion: NIOHTTPServer.HTTPVersion, + secondClientHTTPVersion: NIOHTTPServer.HTTPVersion ) async throws { - let (server, serverChain) = try NIOHTTPServerTests.makeSecureUpgradeServer( - bindTargets: [ - // Configure two bind targets. We want to test whether graceful shutdown works independently on each - // bind target. - .hostAndPort(host: "127.0.0.1", port: 0), - .hostAndPort(host: "127.0.0.1", port: 0), - ], - logger: self.serverLogger + // Configure two listeners. We want to test whether graceful shutdown works independently on each listener. + let (serverConfiguration, trustRootsPEMPath) = try TestHelpers.makeSecureUpgradeServerConfiguration( + supportedHTTPVersions: [.http1_1, .http2(config: .defaults)], + concurrentListeners: 2 ) + let server = NIOHTTPServer(logger: self.serverLogger, configuration: serverConfiguration) // The test needs both clients to have an active in-flight request before triggering graceful shutdown. To // express this, we create two promises (one for each bind target), which will be fulfilled by the server's @@ -448,32 +432,30 @@ struct NIOHTTPServiceLifecycleTests { let firstServerAddress = try await server.listeningAddresses[0] let secondServerAddress = try await server.listeningAddresses[1] - let firstClient = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .connectToTestSecureUpgradeHTTPServer( - at: firstServerAddress, - trustRoots: serverChain.chain, - applicationProtocol: firstClientHTTPVersion.alpnIdentifier - ) - .unwrapChannel(expectedHTTPVersion: firstClientHTTPVersion) - - try await firstClient.executeThenClose { firstInbound, firstOutbound in - try await firstOutbound.write(Self.reqHead) - try await firstOutbound.write(Self.reqBody) + try await TestClientConnection.withConnectedRequestChannel( + configuration: TestHelpers.ClientConfiguration( + logger: self.clientLogger, + httpVersion: firstClientHTTPVersion, + trustRootsPEMPath: trustRootsPEMPath + ), + serverAddress: firstServerAddress + ) { firstInbound, firstOutbound in + try await firstOutbound.write(.testHead(method: .post, for: firstClientHTTPVersion)) + try await firstOutbound.write(.testBody) // Wait until the server has received the body part. try await firstTargetRequestStartedPromise.futureResult.get() - let secondClient = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .connectToTestSecureUpgradeHTTPServer( - at: secondServerAddress, - trustRoots: serverChain.chain, - applicationProtocol: secondClientHTTPVersion.alpnIdentifier - ) - .unwrapChannel(expectedHTTPVersion: secondClientHTTPVersion) - - try await secondClient.executeThenClose { secondInbound, secondOutbound in - try await secondOutbound.write(Self.reqHead) - try await secondOutbound.write(Self.reqBody) + try await TestClientConnection.withConnectedRequestChannel( + configuration: TestHelpers.ClientConfiguration( + logger: self.clientLogger, + httpVersion: secondClientHTTPVersion, + trustRootsPEMPath: trustRootsPEMPath + ), + serverAddress: secondServerAddress + ) { secondInbound, secondOutbound in + try await secondOutbound.write(.testHead(method: .post, for: secondClientHTTPVersion)) + try await secondOutbound.write(.testBody) // Wait until the server has received the body part. try await secondTargetRequestStartedPromise.futureResult.get() @@ -482,8 +464,8 @@ struct NIOHTTPServiceLifecycleTests { trigger.triggerGracefulShutdown() // The second client should be able to complete its request. - try await secondOutbound.write(Self.reqBody) - try await secondOutbound.write(Self.reqEnd) + try await secondOutbound.write(.testBody) + try await secondOutbound.write(.testEnd) for try await response in secondInbound { switch response { @@ -500,8 +482,8 @@ struct NIOHTTPServiceLifecycleTests { } // And so should the first client. - try await firstOutbound.write(Self.reqBody) - try await firstOutbound.write(Self.reqEnd) + try await firstOutbound.write(.testBody) + try await firstOutbound.write(.testEnd) for try await response in firstInbound { switch response { diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift index d05c7d4..d91ef34 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift @@ -36,29 +36,13 @@ struct NIOHTTPServerEndToEndTests { try await outbound.write(.head(.init(method: .get, scheme: "", authority: "", path: "/"))) try await outbound.write(.end(nil)) - var inboundIterator = inbound.makeAsyncIterator() - - let head = try await inboundIterator.next() - guard case .head(let responseHead) = head else { - Issue.record("Expected response head but received \(head).") - return - } - #expect(responseHead.status == 200) - #expect(responseHead.headerFields == [.transferEncoding: "chunked"]) - - let body = try await inboundIterator.next() - guard case .body(let responseBody) = body else { - Issue.record("Expected response body but received \(body).") - return - } - #expect(responseBody == .init([1, 2])) - - let end = try await inboundIterator.next() - guard case .end(let responseEnd) = end else { - Issue.record("Expected response end but received \(end).") - return - } - #expect(responseEnd == [.serverTiming: "test"]) + try await TestHelpers.validateResponse( + inbound, + expectedHead: [.makeResponse(status: .ok, for: .http1_1)], + expectedBody: [.init([1, 2])], + expectedTrailers: [.serverTiming: "test"], + expectStreamEnd: false + ) } } } @@ -84,42 +68,21 @@ struct NIOHTTPServerEndToEndTests { try await resSender.sendAndFinish(.init(status: .ok), buffer: &buffer, trailer: [.serverTiming: "test"]) } ) { server in - try await server.withConnectedClient(clientTLSConfig: clientTLSConfig) { negotiatedConnectionChannel in - switch negotiatedConnectionChannel { - case .http1(_): - Issue.record("Failed to negotiate HTTP/2 despite the client requiring HTTP/2.") - - case .http2(let http2StreamManager): - let http2AsyncChannel = try await http2StreamManager.openStream() - - try await http2AsyncChannel.executeThenClose { inbound, outbound in + try await server.withConnectedClient(clientTLSConfig: clientTLSConfig) { negotiatedConnection in + try await negotiatedConnection + .makeRequestChannel(expectedHTTPVersion: .http2) + .executeThenClose { inbound, outbound in try await outbound.write(.head(.init(method: .get, scheme: "", authority: "", path: "/"))) try await outbound.write(.end(nil)) - var inboundIterator = inbound.makeAsyncIterator() - - let head = try await inboundIterator.next() - guard case .head(let responseHead) = head else { - Issue.record("Expected response head but received \(head).") - return - } - #expect(responseHead.status == 200) - - let body = try await inboundIterator.next() - guard case .body(let responseBody) = body else { - Issue.record("Expected response body but received \(body).") - return - } - #expect(responseBody == .init([1, 2])) - - let end = try await inboundIterator.next() - guard case .end(let responseEnd) = end else { - Issue.record("Expected response end but received \(end).") - return - } - #expect(responseEnd == [.serverTiming: "test"]) + try await TestHelpers.validateResponse( + inbound, + expectedHead: [.makeResponse(status: .ok, for: .http2)], + expectedBody: [.init([1, 2])], + expectedTrailers: [.serverTiming: "test"], + expectStreamEnd: true + ) } - } } } } diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerReaderTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerReaderTests.swift index 2546664..331fcd3 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerReaderTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerReaderTests.swift @@ -155,10 +155,10 @@ struct NIOHTTPServerReaderTests { ) // Check that the read error is propagated - await #expect(throws: TestError.errorWhileReading) { + await #expect(throws: TestError.intentional) { do { try await requestReader.read { _, _ throws(TestError) in - throw TestError.errorWhileReading + throw TestError.intentional } } catch let eitherError as EitherError { try eitherError.unwrap() diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift index de89986..bda62e8 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift @@ -18,11 +18,14 @@ import NIOCore import NIOEmbedded import NIOHTTP1 import NIOHTTP2 +@_spi(HTTP3AsyncInterface) import NIOHTTP3 import NIOHTTPTypes import NIOHTTPTypesHTTP1 import NIOHTTPTypesHTTP2 import NIOPosix +import NIOQUIC import NIOSSL +import SwiftASN1 import Synchronization import Testing import X509 @@ -31,7 +34,8 @@ import X509 @Suite struct NIOHTTPServerTests { - let serverLogger = Logger(label: "NIOHTTPServerTests") + let clientLogger = Logger(label: "NIOHTTPServerTests.client") + let serverLogger = Logger(label: "NIOHTTPServerTests.server") @available(anyAppleOS 26.0, *) @Test("Obtain the listening address correctly") @@ -45,7 +49,7 @@ struct NIOHTTPServerTests { ) ) - try await Self.withServer( + try await TestHelpers.withServer( server: server, serverHandler: HTTPServerClosureRequestHandler { _, _, _, _ in }, body: { serverAddress in @@ -61,250 +65,245 @@ struct NIOHTTPServerTests { } } - @Test("Plaintext request-response") @available(anyAppleOS 26.0, *) - func testPlaintext() async throws { - let server = NIOHTTPServer( - logger: self.serverLogger, - configuration: try .init( - bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http1_1], - transportSecurity: .plaintext - ) + #if HTTP3 + @Test( + "Request-response", + arguments: [NIOHTTPServer.HTTPVersion.plaintextHTTP1_1, .http1_1, .http2, .http3] + ) + #else + @Test( + "Request-response", + arguments: [NIOHTTPServer.HTTPVersion.plaintextHTTP1_1, .http1_1, .http2] + ) + #endif + func testRequestResponse(httpVersion: NIOHTTPServer.HTTPVersion) async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: httpVersion, + clientLogger: self.clientLogger, + serverLogger: self.serverLogger ) - try await Self.withServer( - server: server, - serverHandler: HTTPServerClosureRequestHandler { request, requestContext, reader, responseWriter in - #expect(request == Self.makeRequest(method: .post, scheme: "http", for: .http1_1)) - - var collected = UniqueArray() - collected.reserveCapacity(Self.bodyData.readableBytes + 1) - let finalElement = try await reader.collect(into: &collected) - var buffer = ByteBuffer() - buffer.writeBytes(collected.span.bytes) - #expect(buffer == Self.bodyData) - #expect(finalElement == 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) - .connectToTestHTTP1Server(at: serverAddress) + try await confirmation { responseReceived in + try await TestHelpers.withClientServerRequestChannel( + clientConfiguration: clientConfiguration, + server: server, + serverHandler: HTTPServerClosureRequestHandler { request, requestContext, reader, responseWriter in + #expect(request == .makeRequest(method: .post, for: httpVersion)) - try await client.executeThenClose { inbound, outbound in - try await outbound.write(.head(.init(method: .post, scheme: "http", authority: "", path: "/"))) - try await outbound.write(Self.reqBody) - try await outbound.write(Self.reqEnd) + let testData = ByteBuffer.testData + var collected = UniqueArray() + collected.reserveCapacity(testData.readableBytes + 1) + let finalElement = try await reader.collect(into: &collected) + var buffer = ByteBuffer() + buffer.writeBytes(collected.span.bytes) + #expect(buffer == testData) + #expect(finalElement == .testTrailer) - try await Self.validateResponse( - inbound, - expectedHead: [Self.responseHead(status: .ok, for: .http1_1)], - expectedBody: [Self.bodyData], - expectedTrailers: Self.trailer, - expectStreamEnd: false + var responseBody = UniqueArray(copying: testData.readableBytesUInt8Span) + try await responseWriter.sendAndFinish( + .init(status: .ok), + buffer: &responseBody, + trailer: .testTrailer ) } + ) { _, inbound, outbound in + try await outbound.write(.testHead(method: .post, for: httpVersion)) + try await outbound.write(.testBody) + try await outbound.write(.testEnd) + + try await TestHelpers.validateResponse( + inbound, + expectedHead: [.makeResponse(status: .ok, for: httpVersion)], + expectedBody: [.testData], + expectedTrailers: .testTrailer, + expectStreamEnd: httpVersion != .plaintextHTTP1_1 && httpVersion != .http1_1 + ) + + responseReceived() } - ) + } } @available(anyAppleOS 26.0, *) @Test( "mTLS request-response with custom verification callback returning peer certificates", - arguments: [HTTPVersion.http1_1, HTTPVersion.http2] + arguments: [NIOHTTPServer.HTTPVersion.http1_1, .http2] ) - func testMTLS(httpVersion: HTTPVersion) async throws { - let serverChain = try TestCA.makeSelfSignedChain() - let clientChain = try TestCA.makeSelfSignedChain() - - let server = NIOHTTPServer( - logger: self.serverLogger, - configuration: try .init( - bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http1_1, .http2(config: .init())], - transportSecurity: .mTLS( - credentials: .x509( - .certificates( - chain: [serverChain.leaf], - privateKey: serverChain.privateKey, - ) - ), - trustConfiguration: .init( - .customCertificateVerificationCallback { certificates in - // Return the peer's certificate chain; this must then be accessible in the request handler - .certificateVerified(.init(.init(uncheckedCertificateChain: certificates))) - } - ) - ) + func testMTLS(httpVersion: NIOHTTPServer.HTTPVersion) async throws { + let (server, clientConfiguration) = try TestHelpers.makeMTLSServerAndClientConfiguration( + for: httpVersion, + clientLogger: self.clientLogger, + serverLogger: self.serverLogger, + serverTrustConfiguration: .init( + .customCertificateVerificationCallback { certificates in + // Return the peer's certificate chain; this must then be accessible in the request handler. + .certificateVerified(.init(.init(uncheckedCertificateChain: certificates))) + } ) ) + let clientLeaf = try #require(clientConfiguration.clientChain?.leaf) try await confirmation { responseReceived in - try await Self.withServer( + try await TestHelpers.withClientServerRequestChannel( + clientConfiguration: clientConfiguration, server: server, serverHandler: HTTPServerClosureRequestHandler { request, requestContext, reader, responseWriter in - #expect(request == Self.makeRequest(method: .post, for: httpVersion)) + #expect(request == .makeRequest(method: .post, for: httpVersion)) let peerChain = try #require(try await requestContext.peerCertificateChain) - #expect(Array(peerChain) == [clientChain.leaf]) + #expect(Array(peerChain) == [clientLeaf]) + let testData = ByteBuffer.testData var collected = UniqueArray() - collected.reserveCapacity(Self.bodyData.readableBytes + 1) + collected.reserveCapacity(testData.readableBytes + 1) let finalElement = try await reader.collect(into: &collected) var buffer = ByteBuffer() buffer.writeBytes(collected.span.bytes) - #expect(buffer == Self.bodyData) - #expect(finalElement == Self.trailer) + #expect(buffer == testData) + #expect(finalElement == .testTrailer) - var responseBody = UniqueArray(copying: Self.bodyData.readableBytesUInt8Span) + var responseBody = UniqueArray(copying: testData.readableBytesUInt8Span) try await responseWriter.sendAndFinish( .init(status: .ok), buffer: &responseBody, - trailer: Self.trailer + trailer: .testTrailer ) - }, - body: { serverAddress in - let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .connectToTestSecureUpgradeHTTPServerOverMTLS( - at: serverAddress, - clientChain: clientChain, - trustRoots: [serverChain.ca], - applicationProtocol: httpVersion.alpnIdentifier - ) - .unwrapChannel(expectedHTTPVersion: httpVersion) - - try await client.executeThenClose { inbound, outbound in - try await outbound.write(.head(.init(method: .post, scheme: "https", authority: "", path: "/"))) - try await outbound.write(Self.reqBody) - try await outbound.write(Self.reqEnd) - - try await Self.validateResponse( - inbound, - expectedHead: [Self.responseHead(status: .ok, for: httpVersion)], - expectedBody: [Self.bodyData], - expectedTrailers: Self.trailer, - expectStreamEnd: httpVersion == .http2 - ) - - responseReceived() - } } - ) + ) { _, inbound, outbound in + try await outbound.write(.testHead(method: .post, for: httpVersion)) + try await outbound.write(.testBody) + try await outbound.write(.testEnd) + + try await TestHelpers.validateResponse( + inbound, + expectedHead: [.makeResponse(status: .ok, for: httpVersion)], + expectedBody: [.testData], + expectedTrailers: .testTrailer, + expectStreamEnd: httpVersion == .http2 + ) + + responseReceived() + } } } @available(anyAppleOS 26.0, *) - @Test("Multiple informational response headers", arguments: [HTTPVersion.http1_1, HTTPVersion.http2]) - func testMultipleInformationalResponseHeaders(httpVersion: HTTPVersion) async throws { - let (server, serverChain) = try NIOHTTPServerTests.makeSecureUpgradeServer(logger: self.serverLogger) + #if HTTP3 + @Test( + "Multiple informational response headers", + arguments: [NIOHTTPServer.HTTPVersion.plaintextHTTP1_1, .http1_1, .http2, .http3] + ) + #else + @Test( + "Multiple informational response headers", + arguments: [NIOHTTPServer.HTTPVersion.plaintextHTTP1_1, .http1_1, .http2] + ) + #endif + func testMultipleInformationalResponseHeaders(httpVersion: NIOHTTPServer.HTTPVersion) async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: httpVersion, + clientLogger: self.clientLogger, + serverLogger: self.serverLogger + ) try await confirmation { responseReceived in - try await Self.withServer( + try await TestHelpers.withClientServerRequestChannel( + clientConfiguration: clientConfiguration, server: server, - serverHandler: HTTPServerClosureRequestHandler { request, requestContext, reader, responseSender in + serverHandler: HTTPServerClosureRequestHandler { request, _, reader, responseSender in var responseSender = responseSender try await responseSender.sendInformational(.init(status: .continue)) try await responseSender.sendInformational(.init(status: .earlyHints)) - 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) - .connectToTestSecureUpgradeHTTPServer( - at: serverAddress, - trustRoots: serverChain.chain, - applicationProtocol: httpVersion.alpnIdentifier - ) - .unwrapChannel(expectedHTTPVersion: httpVersion) - - try await client.executeThenClose { inbound, outbound in - try await outbound.write(.head(.init(method: .get, scheme: "https", authority: "", path: "/"))) - try await outbound.write(.end(nil)) - - try await Self.validateResponse( - inbound, - expectedHead: [ - .init(status: .continue), - .init(status: .earlyHints), - Self.responseHead(status: .ok, for: httpVersion), - ], - expectedBody: [Self.bodyData], - expectedTrailers: Self.trailer, - expectStreamEnd: httpVersion == .http2 - ) - responseReceived() - } + let testData = ByteBuffer.testData + var buffer = UniqueArray(copying: testData.readableBytesUInt8Span) + try await responseSender.sendAndFinish(.init(status: .ok), buffer: &buffer, trailer: .testTrailer) } - ) + ) { _, inbound, outbound in + try await outbound.write(.testHead(method: .get, for: httpVersion)) + try await outbound.write(.end(nil)) + + try await TestHelpers.validateResponse( + inbound, + expectedHead: [ + .init(status: .continue), + .init(status: .earlyHints), + .makeResponse(status: .ok, for: httpVersion), + ], + expectedBody: [.testData], + expectedTrailers: .testTrailer, + expectStreamEnd: httpVersion != .plaintextHTTP1_1 && httpVersion != .http1_1 + ) + + responseReceived() + } } } @available(anyAppleOS 26.0, *) - @Test("Client closes stream without sending end part", arguments: [HTTPVersion.http1_1, HTTPVersion.http2]) - func testRequestWithoutEndPart(httpVersion: HTTPVersion) async throws { - let (server, serverChain) = try NIOHTTPServerTests.makeSecureUpgradeServer(logger: self.serverLogger) + @Test( + "Client closes stream without sending end part", + arguments: [NIOHTTPServer.HTTPVersion.http1_1, .http2] + ) + func testRequestWithoutEndPart(httpVersion: NIOHTTPServer.HTTPVersion) async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: httpVersion, + clientLogger: self.clientLogger, + serverLogger: self.serverLogger + ) let elg: EventLoopGroup = .singletonMultiThreadedEventLoopGroup let requestReadPromise = elg.any().makePromise(of: Void.self) try await confirmation { responseReceived in - try await Self.withServer( + try await TestHelpers.withClientServerRequestChannel( + clientConfiguration: clientConfiguration, server: server, serverHandler: HTTPServerClosureRequestHandler { request, requestContext, reader, responseSender in var reader = reader - #expect(request == Self.makeRequest(method: .post, for: httpVersion)) + #expect(request == .makeRequest(method: .post, for: httpVersion)) // 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: + if case .http1_1 = httpVersion { #expect(throws: HTTPParserError.invalidEOFState) { try error.unwrap() } - - case .http2: + } else if case .http2 = httpVersion { 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) - .connectToTestSecureUpgradeHTTPServer( - at: serverAddress, - trustRoots: serverChain.chain, - applicationProtocol: httpVersion.alpnIdentifier - ) - .unwrapChannel(expectedHTTPVersion: httpVersion) - - try await client.executeThenClose { inbound, outbound in - // Only send a request head; finish the stream immediately afterwards. - try await outbound.write(.head(.init(method: .post, scheme: "https", authority: "", path: "/"))) - outbound.finish() - } + } + ) { _, inbound, outbound in + // Only send a request head; finish the stream immediately afterwards. + try await outbound.write(.testHead(method: .post, for: httpVersion)) + outbound.finish() - // Wait for the server to handle the (partial) request before closing. - try await requestReadPromise.futureResult.get() + // Wait for the server to handle the (partial) request before closing. + try await requestReadPromise.futureResult.get() - responseReceived() - } - ) + responseReceived() + } } } @available(anyAppleOS 26.0, *) - @Test("Bi-directional streaming", arguments: [HTTPVersion.http1_1, HTTPVersion.http2]) - func testBidirectionalStreaming(httpVersion: HTTPVersion) async throws { - let (server, serverChain) = try NIOHTTPServerTests.makeSecureUpgradeServer(logger: self.serverLogger) + @Test("Bi-directional streaming", arguments: [NIOHTTPServer.HTTPVersion.http1_1, .http2]) + func testBidirectionalStreaming(httpVersion: NIOHTTPServer.HTTPVersion) async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: httpVersion, + clientLogger: self.clientLogger, + serverLogger: self.serverLogger + ) - try await Self.withServer( + try await TestHelpers.withClientServerRequestChannel( + clientConfiguration: clientConfiguration, server: server, serverHandler: HTTPServerClosureRequestHandler { request, requestContext, requestReader, responseSender in - #expect(request == Self.makeRequest(method: .post, for: httpVersion)) + #expect(request == .makeRequest(method: .post, for: httpVersion)) var responseBodyWriter = try await responseSender.send(HTTPResponse(status: .ok)) @@ -319,106 +318,102 @@ struct NIOHTTPServerTests { 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) - .connectToTestSecureUpgradeHTTPServer( - at: serverAddress, - trustRoots: serverChain.chain, - applicationProtocol: httpVersion.alpnIdentifier - ) - .unwrapChannel(expectedHTTPVersion: httpVersion) - - try await client.executeThenClose { inbound, outbound in - try await outbound.write(.head(.init(method: .post, scheme: "https", authority: "", path: "/"))) - var responseIterator = inbound.makeAsyncIterator() - - // For HTTP/1.1, the keep-alive handler flushes the response head with - // `Connection: close` because a body part is written before the request - // `.end` arrives. HTTP/2 has no equivalent header. - var expectedHead = Self.responseHead(status: .ok, for: httpVersion) - if httpVersion == .http1_1 { - expectedHead.headerFields[.connection] = "close" - } - let head = try await responseIterator.next() - #expect(head == .head(expectedHead)) + #expect(finalElement == .testTrailer) - for i in 1...5 { - let body = ByteBuffer(bytes: [UInt8(i)]) - try await outbound.write(.body(body)) + try await responseBodyWriter.finish(trailer: .testTrailer) + } + ) { _, inbound, outbound in + try await outbound.write(.testHead(method: .post, for: httpVersion)) + var responseIterator = inbound.makeAsyncIterator() + + // For HTTP/1.1, the keep-alive handler flushes the response head with + // `Connection: close` because a body part is written before the request + // `.end` arrives. HTTP/2 has no equivalent header. + var expectedHead = HTTPResponse.makeResponse(status: .ok, for: httpVersion) + if httpVersion == .http1_1 { + expectedHead.headerFields[.connection] = "close" + } + let head = try await responseIterator.next() + #expect(head == .head(expectedHead)) - let response = try await responseIterator.next() - #expect(response == .body(body)) - } + for i in 1...5 { + let body = ByteBuffer(bytes: [UInt8(i)]) + try await outbound.write(.body(body)) - try await outbound.write(.end(Self.trailer)) - #expect(try await responseIterator.next() == .end(Self.trailer)) - } + let response = try await responseIterator.next() + #expect(response == .body(body)) } - ) + + try await outbound.write(.end(.testTrailer)) + #expect(try await responseIterator.next() == .end(.testTrailer)) + } } @available(anyAppleOS 26.0, *) - @Test("Multiple serial HTTP/1.1 requests on the same connection") - func testMultipleSerialHTTP1Requests() async throws { - let server = NIOHTTPServer( - logger: self.serverLogger, - configuration: try .init( - bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http1_1], - transportSecurity: .plaintext - ) + @Test( + "Multiple serial HTTP/1.1 requests on the same connection", + arguments: [NIOHTTPServer.HTTPVersion.plaintextHTTP1_1, .http1_1] + ) + func testMultipleSerialHTTP1Requests(http1Variant: NIOHTTPServer.HTTPVersion) async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: http1Variant, + clientLogger: self.clientLogger, + serverLogger: self.serverLogger ) let requestCount = 3 try await confirmation(expectedCount: requestCount) { responseReceived in - try await Self.withServer( + try await TestHelpers.withClientServerRequestChannel( + clientConfiguration: clientConfiguration, server: server, serverHandler: HTTPServerClosureRequestHandler { request, requestContext, reader, responseWriter in // Echo the request body back as the response body. - try await Self.echoResponse(readUpTo: 1024, reader: reader, sender: responseWriter) - }, - body: { serverAddress in - let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .connectToTestHTTP1Server(at: serverAddress) - - try await client.executeThenClose { inbound, outbound in - var responseIterator = inbound.makeAsyncIterator() - - for i in 1...requestCount { - // Send request - try await outbound.write( - .head(.init(method: .post, scheme: "http", authority: "", path: "/\(i)")) - ) - try await outbound.write(Self.reqBody) - try await outbound.write(.end(nil)) - - // Read response - let headPart = try await responseIterator.next() - #expect(headPart == .head(Self.responseHead(status: .ok, for: .http1_1))) - - let bodyPart = try await responseIterator.next() - #expect(bodyPart == .body(Self.bodyData)) - - let endPart = try await responseIterator.next() - #expect(endPart == .end(nil)) - - responseReceived() - } - } + try await TestHelpers.echoResponse(readUpTo: 1024, reader: reader, sender: responseWriter) } - ) + ) { _, inbound, outbound in + var responseIterator = inbound.makeAsyncIterator() + + for i in 1...requestCount { + // Send request + try await outbound.write(.testHead(method: .post, path: "/\(i)", for: http1Variant)) + try await outbound.write(.testBody) + try await outbound.write(.end(nil)) + + // Read response + let headPart = try await responseIterator.next() + #expect(headPart == .head(.makeResponse(status: .ok, for: http1Variant))) + + let bodyPart = try await responseIterator.next() + #expect(bodyPart == .body(.testData)) + + let endPart = try await responseIterator.next() + #expect(endPart == .end(nil)) + + responseReceived() + } + } } } @available(anyAppleOS 26.0, *) - @Test("Multiple concurrent connections", arguments: [HTTPVersion.http1_1, HTTPVersion.http2]) - func testMultipleConcurrentConnections(httpVersion: HTTPVersion) async throws { - let (server, serverChain) = try NIOHTTPServerTests.makeSecureUpgradeServer(logger: self.serverLogger) + #if HTTP3 + @Test( + "Multiple concurrent connections", + arguments: [NIOHTTPServer.HTTPVersion.plaintextHTTP1_1, .http1_1, .http2, .http3] + ) + #else + @Test( + "Multiple concurrent connections", + arguments: [NIOHTTPServer.HTTPVersion.plaintextHTTP1_1, .http1_1, .http2] + ) + #endif + func testMultipleConcurrentConnections(httpVersion: NIOHTTPServer.HTTPVersion) async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: httpVersion, + clientLogger: self.clientLogger, + serverLogger: self.serverLogger + ) // We will create 10 connections and send a request from each connection. The server will fulfill the // `allOtherRequestsCanProceedPromise` promise after seeing the 10th request. All other requests will be blocked @@ -429,7 +424,7 @@ struct NIOHTTPServerTests { let allOtherRequestsCanProceedPromise = elg.any().makePromise(of: Void.self) try await confirmation(expectedCount: numConnections) { responseReceived in - try await Self.withServer( + try await TestHelpers.withServer( server: server, serverHandler: HTTPServerClosureRequestHandler { request, context, requestReader, responseSender in let requestNumber = requestCounter.withLock { counter in @@ -444,48 +439,48 @@ struct NIOHTTPServerTests { try await allOtherRequestsCanProceedPromise.futureResult.get() } - try await Self.echoResponse(readUpTo: 1024, reader: requestReader, sender: responseSender) - }, - body: { serverAddress in - await withThrowingTaskGroup { group in - for _ in 1...numConnections { - group.addTask { - let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .connectToTestSecureUpgradeHTTPServer( - at: serverAddress, - trustRoots: serverChain.chain, - applicationProtocol: httpVersion.alpnIdentifier - ) - .unwrapChannel(expectedHTTPVersion: httpVersion) - - try await client.executeThenClose { inbound, outbound in - try await outbound.write( - .head(.init(method: .post, scheme: "https", authority: "", path: "/")) - ) - try await outbound.write(Self.reqBody) - try await outbound.write(.end(nil)) - - try await Self.validateResponse( - inbound, - expectedHead: [Self.responseHead(status: .ok, for: httpVersion)], - expectedBody: [Self.bodyData], - expectStreamEnd: httpVersion == .http2 - ) - - responseReceived() - } + try await TestHelpers.echoResponse(readUpTo: 1024, reader: requestReader, sender: responseSender) + } + ) { (serverAddress: NIOHTTPServer.SocketAddress) in + await withThrowingTaskGroup { group in + for _ in 1...numConnections { + group.addTask { + try await TestClientConnection.withConnectedRequestChannel( + configuration: clientConfiguration, + serverAddress: serverAddress + ) { inbound, outbound in + try await outbound.write(.testHead(method: .post, for: httpVersion)) + try await outbound.write(.testBody) + try await outbound.write(.end(nil)) + + try await TestHelpers.validateResponse( + inbound, + expectedHead: [.makeResponse(status: .ok, for: httpVersion)], + expectedBody: [.testData], + expectStreamEnd: httpVersion != .plaintextHTTP1_1 && httpVersion != .http1_1 + ) + + responseReceived() } } } } - ) + } } } @available(anyAppleOS 26.0, *) - @Test("Multiple concurrent HTTP/2 streams") - func testMultipleConcurrentHTTP2Streams() async throws { - let (server, serverChain) = try NIOHTTPServerTests.makeSecureUpgradeServer(logger: self.serverLogger) + #if HTTP3 + @Test("Multiple concurrent streams over single connection", arguments: [NIOHTTPServer.HTTPVersion.http2, .http3]) + #else + @Test("Multiple concurrent streams over single connection", arguments: [NIOHTTPServer.HTTPVersion.http2]) + #endif + func testMultipleConcurrentStreams(httpVersion: NIOHTTPServer.HTTPVersion) async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: httpVersion, + clientLogger: self.clientLogger, + serverLogger: self.serverLogger + ) let numStreams = 10 let requestCounter = Mutex(0) @@ -493,7 +488,8 @@ struct NIOHTTPServerTests { let allOtherRequestsCanProceedPromise = elg.any().makePromise(of: Void.self) try await confirmation(expectedCount: numStreams) { responseReceived in - try await Self.withServer( + try await TestHelpers.withClientServerConnection( + clientConfiguration: clientConfiguration, server: server, serverHandler: HTTPServerClosureRequestHandler { request, context, requestReader, responseSender in let requestNumber = requestCounter.withLock { counter in @@ -508,64 +504,54 @@ struct NIOHTTPServerTests { try await allOtherRequestsCanProceedPromise.futureResult.get() } - try await Self.echoResponse(readUpTo: 1024, reader: requestReader, sender: responseSender) - }, - body: { serverAddress in - await withThrowingTaskGroup { group in - for _ in 1...numStreams { - group.addTask { - let clientChannel = try await ClientBootstrap( - group: .singletonMultiThreadedEventLoopGroup - ) - .connectToTestSecureUpgradeHTTPServer( - at: serverAddress, - trustRoots: serverChain.chain, - applicationProtocol: HTTPVersion.http2.alpnIdentifier + try await TestHelpers.echoResponse(readUpTo: 1024, reader: requestReader, sender: responseSender) + } + ) { _, connection in + await withThrowingTaskGroup { group in + for _ in 1...numStreams { + group.addTask { + let stream = try await connection.makeRequestChannel(expectedHTTPVersion: httpVersion) + try await stream.executeThenClose { inbound, outbound in + try await outbound.write(.testHead(method: .post, for: httpVersion)) + try await outbound.write(.testBody) + try await outbound.write(.end(nil)) + + try await TestHelpers.validateResponse( + inbound, + expectedHead: [.makeResponse(status: .ok, for: httpVersion)], + expectedBody: [.testData] ) - guard case .http2(let streamManager) = clientChannel else { - Issue.record("Expected a HTTP/2 channel but 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(Self.reqBody) - try await outbound.write(.end(nil)) - - try await Self.validateResponse( - inbound, - expectedHead: [Self.responseHead(status: .ok, for: .http2)], - expectedBody: [Self.bodyData] - ) - - responseReceived() - } + responseReceived() } } } } - ) + } } } @available(anyAppleOS 26.0, *) - @Test("Server can still process other connections despite one failing") - func testServerCanContinueDespiteFailedConnection() async throws { - let server = try NIOHTTPServerTests.makePlaintextHTTP1Server(logger: self.serverLogger) + @Test( + "Server can still process other connections despite one failing", + arguments: [NIOHTTPServer.HTTPVersion.plaintextHTTP1_1, .http1_1, .http2] + ) + func testServerCanContinueDespiteFailedConnection(httpVersion: NIOHTTPServer.HTTPVersion) async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: httpVersion, + clientLogger: self.clientLogger, + serverLogger: self.serverLogger + ) let elg: EventLoopGroup = .singletonMultiThreadedEventLoopGroup let firstRequestErrorCaught = elg.any().makePromise(of: Void.self) - try await Self.withServer( + try await TestHelpers.withServer( server: server, serverHandler: HTTPServerClosureRequestHandler { request, context, requestReader, responseSender in do { - try await Self.echoResponse( - readUpTo: Self.bodyData.readableBytes, + try await TestHelpers.echoResponse( + readUpTo: ByteBuffer.testData.readableBytes, reader: requestReader, sender: responseSender ) @@ -576,39 +562,38 @@ struct NIOHTTPServerTests { // Propagate the error upwards throw error } - }, - body: { serverAddress in - try await confirmation { responseReceived in - let firstClientChannel = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .connectToTestHTTP1Server(at: serverAddress) - - try await firstClientChannel.executeThenClose { inbound, outbound in - // Only send a request head; finish the stream immediately afterwards. - try await outbound.write(.head(.init(method: .post, scheme: "http", authority: "", path: "/"))) - } - - try await firstRequestErrorCaught.futureResult.get() + } + ) { serverAddress in + try await confirmation { responseReceived in + try await TestClientConnection.withConnectedRequestChannel( + configuration: clientConfiguration, + serverAddress: serverAddress + ) { inbound, outbound in + // Only send a request head; finish the stream immediately afterwards. + try await outbound.write(.testHead(method: .post, for: httpVersion)) + } - let secondClientChannel = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .connectToTestHTTP1Server(at: serverAddress) + try await firstRequestErrorCaught.futureResult.get() - try await secondClientChannel.executeThenClose { inbound, outbound in - try await outbound.write(.head(.init(method: .post, scheme: "http", authority: "", path: "/"))) - try await outbound.write(.body(Self.bodyData)) - try await outbound.write(.end(nil)) + try await TestClientConnection.withConnectedRequestChannel( + configuration: clientConfiguration, + serverAddress: serverAddress + ) { inbound, outbound in + try await outbound.write(.testHead(method: .post, for: httpVersion)) + try await outbound.write(.testBody) + try await outbound.write(.end(nil)) - try await Self.validateResponse( - inbound, - expectedHead: [Self.responseHead(status: .ok, for: .http1_1)], - expectedBody: [Self.bodyData], - expectStreamEnd: false - ) + try await TestHelpers.validateResponse( + inbound, + expectedHead: [.makeResponse(status: .ok, for: httpVersion)], + expectedBody: [.testData], + expectStreamEnd: httpVersion == .http2 + ) - responseReceived() - } + responseReceived() } } - ) + } } @available(anyAppleOS 26.0, *) @@ -626,10 +611,10 @@ struct NIOHTTPServerTests { ) ) - try await Self.withServer( + try await TestHelpers.withServer( server: server, serverHandler: HTTPServerClosureRequestHandler { _, _, _, _ in }, - body: { (addresses: [NIOHTTPServer.SocketAddress]) in + body: { addresses in #expect(addresses.count == 2) #expect(addresses[0].port != addresses[1].port) } @@ -637,66 +622,54 @@ struct NIOHTTPServerTests { } @available(anyAppleOS 26.0, *) - @Test("Serve requests on multiple addresses independently") - func testServeOnMultipleAddresses() async throws { - let server = NIOHTTPServer( - logger: self.serverLogger, - configuration: try .init( - bindTargets: [ - .hostAndPort(host: "127.0.0.1", port: 0), - .hostAndPort(host: "127.0.0.1", port: 0), - ], - supportedHTTPVersions: [.http1_1], - transportSecurity: .plaintext - ) + #if HTTP3 + @Test( + "Serve requests on multiple addresses independently", + arguments: [NIOHTTPServer.HTTPVersion.plaintextHTTP1_1, .http1_1, .http2, .http3] + ) + #else + @Test( + "Serve requests on multiple addresses independently", + arguments: [NIOHTTPServer.HTTPVersion.plaintextHTTP1_1, .http1_1, .http2] + ) + #endif + func testServeOnMultipleAddresses(httpVersion: NIOHTTPServer.HTTPVersion) async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: httpVersion, + clientLogger: self.clientLogger, + serverLogger: self.serverLogger, + concurrentListeners: 2 ) - try await Self.withServer( + try await TestHelpers.withServer( server: server, serverHandler: HTTPServerClosureRequestHandler { request, context, requestReader, responseSender in - try await Self.echoResponse( - readUpTo: Self.bodyData.readableBytes, + try await TestHelpers.echoResponse( + readUpTo: ByteBuffer.testData.readableBytes, reader: requestReader, sender: responseSender ) }, - body: { (addresses: [NIOHTTPServer.SocketAddress]) in + body: { addresses in #expect(addresses.count == 2) - // Send a request to the first address - let firstClient = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .connectToTestHTTP1Server(at: addresses[0]) - - try await firstClient.executeThenClose { inbound, outbound in - try await outbound.write(.head(.init(method: .post, scheme: "http", authority: "", path: "/"))) - try await outbound.write(Self.reqBody) - try await outbound.write(Self.reqEnd) - - try await Self.validateResponse( - inbound, - expectedHead: [Self.responseHead(status: .ok, for: .http1_1)], - expectedBody: [Self.bodyData], - expectedTrailers: Self.trailer, - expectStreamEnd: false - ) - } - - // Send a request to the second address - let secondClient = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .connectToTestHTTP1Server(at: addresses[1]) - - try await secondClient.executeThenClose { inbound, outbound in - try await outbound.write(.head(.init(method: .post, scheme: "http", authority: "", path: "/"))) - try await outbound.write(Self.reqBody) - try await outbound.write(Self.reqEnd) + for address in addresses { + try await TestClientConnection.withConnectedRequestChannel( + configuration: clientConfiguration, + serverAddress: address + ) { inbound, outbound in + try await outbound.write(.testHead(method: .post, for: httpVersion)) + try await outbound.write(.testBody) + try await outbound.write(.testEnd) - try await Self.validateResponse( - inbound, - expectedHead: [Self.responseHead(status: .ok, for: .http1_1)], - expectedBody: [Self.bodyData], - expectedTrailers: Self.trailer, - expectStreamEnd: false - ) + try await TestHelpers.validateResponse( + inbound, + expectedHead: [.makeResponse(status: .ok, for: httpVersion)], + expectedBody: [.testData], + expectedTrailers: .testTrailer, + expectStreamEnd: httpVersion != .plaintextHTTP1_1 && httpVersion != .http1_1 + ) + } } } ) @@ -707,25 +680,30 @@ struct NIOHTTPServerTests { /// ``ListeningAddressError/serverClosed``. No subset of addresses continues serving after the server /// has stopped. @available(anyAppleOS 26.0, *) - @Test("All addresses stop together and listeningAddresses throws after server stops") - func testAllAddressesStopTogether() async throws { - let server = NIOHTTPServer( - logger: self.serverLogger, - configuration: try .init( - bindTargets: [ - .hostAndPort(host: "127.0.0.1", port: 0), - .hostAndPort(host: "127.0.0.1", port: 0), - ], - supportedHTTPVersions: [.http1_1], - transportSecurity: .plaintext - ) + #if HTTP3 + @Test( + "All addresses stop together and listeningAddresses throws after server stops", + arguments: [NIOHTTPServer.HTTPVersion.plaintextHTTP1_1, .http1_1, .http2, .http3] + ) + #else + @Test( + "All addresses stop together and listeningAddresses throws after server stops", + arguments: [NIOHTTPServer.HTTPVersion.plaintextHTTP1_1, .http1_1, .http2] + ) + #endif + func testAllAddressesStopTogether(httpVersion: NIOHTTPServer.HTTPVersion) async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: httpVersion, + clientLogger: self.clientLogger, + serverLogger: self.serverLogger, + concurrentListeners: 2 ) - try await Self.withServer( + try await TestHelpers.withServer( server: server, serverHandler: HTTPServerClosureRequestHandler { request, context, requestReader, responseSender in - try await Self.echoResponse( - readUpTo: Self.bodyData.readableBytes, + try await TestHelpers.echoResponse( + readUpTo: ByteBuffer.testData.readableBytes, reader: requestReader, sender: responseSender ) @@ -734,19 +712,20 @@ struct NIOHTTPServerTests { #expect(addresses.count == 2) // Verify both addresses are serving - for addr in addresses { - let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .connectToTestHTTP1Server(at: addr) - try await client.executeThenClose { inbound, outbound in - try await outbound.write(.head(.init(method: .post, scheme: "http", authority: "", path: "/"))) - try await outbound.write(Self.reqBody) - try await outbound.write(Self.reqEnd) - try await Self.validateResponse( + for address in addresses { + try await TestClientConnection.withConnectedRequestChannel( + configuration: clientConfiguration, + serverAddress: address + ) { inbound, outbound in + try await outbound.write(.testHead(method: .post, for: httpVersion)) + try await outbound.write(.testBody) + try await outbound.write(.testEnd) + try await TestHelpers.validateResponse( inbound, - expectedHead: [Self.responseHead(status: .ok, for: .http1_1)], - expectedBody: [Self.bodyData], - expectedTrailers: Self.trailer, - expectStreamEnd: false + expectedHead: [.makeResponse(status: .ok, for: httpVersion)], + expectedBody: [.testData], + expectedTrailers: .testTrailer, + expectStreamEnd: httpVersion != .plaintextHTTP1_1 && httpVersion != .http1_1 ) } } @@ -829,167 +808,3 @@ struct NIOHTTPServerTests { try await rebindAttempt.channel.close() } } - -extension NIOHTTPServerTests { - static let bodyData = ByteBuffer(repeating: 5, count: 100) - static let reqBody = HTTPRequestPart.body(Self.bodyData) - - static let trailer: HTTPFields = [.trailer: "test_trailer"] - static let reqEnd = HTTPRequestPart.end(trailer) - - @available(anyAppleOS 26.0, *) - static func makePlaintextHTTP1Server(logger: Logger) throws -> NIOHTTPServer { - let server = NIOHTTPServer( - logger: logger, - configuration: try .init( - bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http1_1], - transportSecurity: .plaintext - ) - ) - - return server - } - - @available(anyAppleOS 26.0, *) - static func makeSecureUpgradeServer( - bindTargets: [NIOHTTPServerConfiguration.BindTarget] = [.hostAndPort(host: "127.0.0.1", port: 0)], - logger: Logger - ) throws -> (NIOHTTPServer, ChainPrivateKeyPair) { - let serverChain = try TestCA.makeSelfSignedChain() - - let server = NIOHTTPServer( - logger: logger, - configuration: try .init( - bindTargets: bindTargets, - supportedHTTPVersions: [.http1_1, .http2(config: .defaults)], - transportSecurity: .tls( - credentials: .x509(.certificates(chain: serverChain.chain, privateKey: serverChain.privateKey)) - ) - ) - ) - - return (server, serverChain) - } - - /// Reads from `responseStream` and asserts each part matches the expected head, body, and trailers in order. - static func validateResponse( - _ responseStream: NIOAsyncChannelInboundStream, - expectedHead: [HTTPResponse], - expectedBody: [ByteBuffer], - expectedTrailers: HTTPFields? = nil, - expectStreamEnd: Bool = true, - sourceLocation: SourceLocation = #_sourceLocation - ) async throws { - var responseIterator = responseStream.makeAsyncIterator() - - for expectedHeadPart in expectedHead { - let headResponsePart = try await responseIterator.next() - #expect(headResponsePart == .head(expectedHeadPart), sourceLocation: sourceLocation) - } - - for expectedBodyBuffer in expectedBody { - let bodyResponsePart = try await responseIterator.next() - #expect(bodyResponsePart == .body(expectedBodyBuffer), sourceLocation: sourceLocation) - } - - let endResponsePart = try await responseIterator.next() - #expect(endResponsePart == .end(expectedTrailers), sourceLocation: sourceLocation) - - if expectStreamEnd { - #expect( - try await responseIterator.next() == nil, - "Received another response part when the response stream should have finished.", - sourceLocation: sourceLocation - ) - } - } - - /// Returns the body encoding header fields required for the given HTTP version. - static func makeBodyEncodingHeaders(for httpVersion: HTTPVersion) -> HTTPFields { - switch httpVersion { - case .http1_1: - [.transferEncoding: "chunked"] - case .http2: - [:] - } - } - - /// Creates an ``HTTPRequest`` with the appropriate headers for the given `httpVersion`. - static func makeRequest( - method: HTTPRequest.Method, - scheme: String = "https", - authority: String = "", - path: String = "/", - for httpVersion: HTTPVersion - ) -> HTTPRequest { - let headers = self.makeBodyEncodingHeaders(for: httpVersion) - return HTTPRequest(method: method, scheme: scheme, authority: authority, path: path, headerFields: headers) - } - - /// Creates an ``HTTPResponse`` with the given status and the appropriate headers for the given `httpVersion`. - static func responseHead(status: HTTPResponse.Status, for httpVersion: HTTPVersion) -> HTTPResponse { - let headers = self.makeBodyEncodingHeaders(for: httpVersion) - return HTTPResponse(status: status, headerFields: headers) - } - - /// Starts `server` with `serverHandler`, waits for it to begin listening, runs `body` with the first - /// listening address, then cancels the server task. - @available(anyAppleOS 26.0, *) - static func withServer( - server: NIOHTTPServer, - serverHandler: some HTTPServerRequestHandler< - NIOHTTPServer.RequestContext, - NIOHTTPServer.Reader, - NIOHTTPServer.ResponseSender - >, - body: (NIOHTTPServer.SocketAddress) async throws -> Void - ) async throws { - try await self.withServer(server: server, serverHandler: serverHandler) { - (addresses: [NIOHTTPServer.SocketAddress]) in - let address = try #require(addresses.first) - try await body(address) - } - } - - /// Starts `server` with `serverHandler`, waits for it to begin listening, runs `body` with all listening - /// addresses, then cancels the server task. - @available(anyAppleOS 26.0, *) - static func withServer( - server: NIOHTTPServer, - serverHandler: some HTTPServerRequestHandler< - NIOHTTPServer.RequestContext, - NIOHTTPServer.Reader, - NIOHTTPServer.ResponseSender - >, - body: ([NIOHTTPServer.SocketAddress]) async throws -> Void - ) async throws { - try await withThrowingTaskGroup { group in - group.addTask { - try await server.serve(handler: serverHandler) - } - - let listeningAddresses = try await server.listeningAddresses - - try await body(listeningAddresses) - - group.cancelAll() - } - } - - /// Reads the full request body and trailers from `reader`, then sends a `200 OK` response echoing them back. - @available(anyAppleOS 26.0, *) - static func echoResponse( - readUpTo limit: Int, - reader: consuming NIOHTTPServer.Reader, - sender: consuming NIOHTTPServer.ResponseSender - ) async throws { - var buffer = UniqueArray() - // Reserve one extra byte beyond the limit: `collect(into:)` stops as soon as the buffer's - // free capacity is exhausted, so an exact fit would drop the trailing fields delivered in - // the terminal chunk. - buffer.reserveCapacity(limit + 1) - let trailer = try await reader.collect(into: &buffer) - try await sender.sendAndFinish(.init(status: .ok), buffer: &buffer, trailer: trailer) - } -} diff --git a/Tests/NIOHTTPServerTests/ServerChannelTests.swift b/Tests/NIOHTTPServerTests/ServerChannelTests.swift new file mode 100644 index 0000000..130bffb --- /dev/null +++ b/Tests/NIOHTTPServerTests/ServerChannelTests.swift @@ -0,0 +1,235 @@ +//===----------------------------------------------------------------------===// +// +// 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 Logging +import NIOCore +import Testing + +@testable import NIOHTTPServer + +@Suite +struct ServerChannelTests { + let logger = Logger(label: "ServerChannelTests") + + @available(anyAppleOS 26.0, *) + @Test("transport: plaintext, versions: {HTTP/1.1} -> plaintext channel") + func plaintextHTTP1_1() async throws { + let server = NIOHTTPServer( + logger: self.logger, + configuration: try .init( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext + ) + ) + + let channels = try await server.makeServerChannels() + defer { server.close(serverChannels: channels) } + + #expect(channels.count == 1) + #expect(channels[0].isPlaintextHTTP1_1) + } + + @available(anyAppleOS 26.0, *) + @Test("transport: TLS, versions: {HTTP/1.1} -> secure upgrade channel") + func tlsHTTP1_1() async throws { + let chain = try TestCA.makeSelfSignedChain() + + let server = NIOHTTPServer( + logger: self.logger, + configuration: try .init( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1], + transportSecurity: .tls( + credentials: .x509(.certificates(chain: chain.chain, privateKey: chain.privateKey)) + ) + ) + ) + + let channels = try await server.makeServerChannels() + defer { server.close(serverChannels: channels) } + + #expect(channels.count == 1) + #expect(channels[0].isSecureUpgrade) + } + + @available(anyAppleOS 26.0, *) + @Test("transport: TLS, versions: {HTTP/1.1, HTTP/2} -> secure upgrade channel") + func tlsHTTP1_1AndHTTP2() async throws { + let chain = try TestCA.makeSelfSignedChain() + + let server = NIOHTTPServer( + logger: self.logger, + configuration: try .init( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1, .http2(config: .defaults)], + transportSecurity: .tls( + credentials: .x509(.certificates(chain: chain.chain, privateKey: chain.privateKey)) + ) + ) + ) + + let channels = try await server.makeServerChannels() + defer { server.close(serverChannels: channels) } + + #expect(channels.count == 1) + #expect(channels[0].isSecureUpgrade) + } + + #if HTTP3 + @available(anyAppleOS 26.0, *) + @Test("transport: TLS, versions: {HTTP/3} -> HTTP/3 channel") + func http3Only() async throws { + let chain = try TestCA.makeSelfSignedChain() + let (leafPath, _, keyPath) = try chain.writeToDisk() + + let server = NIOHTTPServer( + logger: self.logger, + configuration: try .init( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http3(config: .defaults)], + transportSecurity: .tls( + credentials: .x509(.pemFile(certificateChainPath: leafPath, privateKeyPath: keyPath)) + ) + ) + ) + + let channels = try await server.makeServerChannels() + defer { server.close(serverChannels: channels) } + + #expect(channels.count == 1) + #expect(channels[0].isHTTP3) + } + + @available(anyAppleOS 26.0, *) + @Test( + "transport: TLS, versions: {HTTP/1.1 and/or HTTP/2} + {HTTP/3} -> HTTP/3 and secure upgrade channels", + arguments: [ + [Self.http1_1, Self.http3], + [Self.http2, Self.http3], + [Self.http1_1, Self.http2, Self.http3], + ] + ) + func tlsCombinationOfSecureUpgradeAndHTTP3( + supportedHTTPVersions: Set + ) async throws { + let chain = try TestCA.makeSelfSignedChain() + let (leafPath, _, keyPath) = try chain.writeToDisk() + + let server = NIOHTTPServer( + logger: self.logger, + configuration: try .init( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: supportedHTTPVersions, + transportSecurity: .tls( + credentials: .x509(.pemFile(certificateChainPath: leafPath, privateKeyPath: keyPath)) + ) + ) + ) + + let channels = try await server.makeServerChannels() + defer { server.close(serverChannels: channels) } + + #expect(channels.count == 2) + #expect(channels[0].isHTTP3) + #expect(channels[1].isSecureUpgrade) + + // Check whether both channels share the same port + let http3Address = try #require(channels[0].localAddress) + let secureUpgradeAddress = try #require(channels[1].localAddress) + #expect(http3Address.port == secureUpgradeAddress.port) + } + + @available(anyAppleOS 26.0, *) + @Test( + "transport: plaintext, versions: HTTP/2 and/or HTTP/3 -> rejected", + arguments: [ + [Self.http2], + [Self.http3], + [Self.http2, Self.http3], + // Even when HTTP/1.1 is specified, the presence of HTTP/2 and/or HTTP/3 should make the config invalid + [Self.http1_1, Self.http2], + [Self.http1_1, Self.http3], + [Self.http1_1, Self.http2, Self.http3], + ] + ) + func plaintextNotSupportedForHTTP2OrHTTP3(supportedHTTPVersions: Set) { + #expect(throws: NIOHTTPServerConfigurationError.incompatibleTransportSecurity) { + try NIOHTTPServerConfiguration( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: supportedHTTPVersions, + transportSecurity: .plaintext + ) + } + } + #endif // HTTP3 +} + +@available(anyAppleOS 26.0, *) +extension ServerChannelTests { + private static let http1_1 = NIOHTTPServerConfiguration.HTTPVersion.http1_1 + + private static let http2 = NIOHTTPServerConfiguration.HTTPVersion.http2(config: .defaults) + + #if HTTP3 + private static let http3 = NIOHTTPServerConfiguration.HTTPVersion.http3(config: .defaults) + #endif +} + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServer.ServerChannel { + var isPlaintextHTTP1_1: Bool { + switch self { + case .plaintextHTTP1_1: + true + default: + false + } + } + + var isSecureUpgrade: Bool { + switch self { + case .secureUpgrade: + true + default: + false + } + } + + #if HTTP3 + var isHTTP3: Bool { + switch self { + case .http3: + true + default: + false + } + } + #endif + + var localAddress: NIOCore.SocketAddress? { + switch self { + case .plaintextHTTP1_1(let serverChannel, _): + serverChannel.channel.localAddress + + case .secureUpgrade(let serverChannel, _): + serverChannel.channel.localAddress + + #if HTTP3 + case .http3(let serverChannel, _): + serverChannel.localAddress + #endif + } + } +} diff --git a/Tests/NIOHTTPServerTests/TestError.swift b/Tests/NIOHTTPServerTests/TestError.swift index fe19b49..f2f7317 100644 --- a/Tests/NIOHTTPServerTests/TestError.swift +++ b/Tests/NIOHTTPServerTests/TestError.swift @@ -12,9 +12,11 @@ // //===----------------------------------------------------------------------===// -// An error type for use in tests +// An error type for use in tests. enum TestError: Error { - case errorWhileReading - case errorWhileWriting + /// Thrown deliberately by a test handler to exercise a failure path. case intentional + + /// Thrown when the client configuration is invalid for the configured HTTP version. + case invalidClientConfiguration } diff --git a/Tests/NIOHTTPServerTests/Utilities/Certificates.swift b/Tests/NIOHTTPServerTests/Utilities/Certificates.swift index aca219c..42562d9 100644 --- a/Tests/NIOHTTPServerTests/Utilities/Certificates.swift +++ b/Tests/NIOHTTPServerTests/Utilities/Certificates.swift @@ -14,6 +14,7 @@ import Crypto import Foundation +import SwiftASN1 import X509 struct ChainPrivateKeyPair { @@ -31,10 +32,23 @@ struct ChainPrivateKeyPair { return certs.joined(separator: "\n") } } + + func writeToDisk() throws -> (leafPath: String, caPath: String, keyPath: String) { + let uuid = UUID().uuidString + let leafPath = FileManager.default.temporaryDirectory.appendingPathComponent("leaf-\(uuid).pem") + let caPath = FileManager.default.temporaryDirectory.appendingPathComponent("ca-\(uuid).pem") + let keyPath = FileManager.default.temporaryDirectory.appendingPathComponent("key-\(uuid).pem") + + try self.leaf.serializeAsPEM().pemString.data(using: .utf8)!.write(to: leafPath) + try self.ca.serializeAsPEM().pemString.data(using: .utf8)!.write(to: caPath) + try self.privateKey.serializeAsPEM().pemString.data(using: .utf8)!.write(to: keyPath) + + return (leafPath.path, caPath.path, keyPath.path) + } } struct TestCA { - static func makeSelfSignedChain() throws -> ChainPrivateKeyPair { + static func makeSelfSignedChain(leafExtensions: Certificate.Extensions = .init()) throws -> ChainPrivateKeyPair { let caKey = P384.Signing.PrivateKey() let caName = try DistinguishedName { OrganizationName("Test CA") } let ca = try makeCA(name: caName, privateKey: caKey) @@ -47,7 +61,7 @@ struct TestCA { issuerKey: .init(caKey), publicKey: .init(leafKey.publicKey), subject: leafName, - extensions: .init() + extensions: leafExtensions ) return ChainPrivateKeyPair(leaf: leaf, ca: ca, privateKey: .init(leafKey)) @@ -85,4 +99,22 @@ struct TestCA { issuerPrivateKey: issuerKey ) } + + /// Creates a self-signed certificate chain with a SAN for the leaf certificate. + static func makeSelfSignedChainWithSAN( + leafSAN: SubjectAlternativeNames = SubjectAlternativeNames([ + .dnsName("127.0.0.1"), + .ipAddress(ASN1OctetString(contentBytes: [127, 0, 0, 1])), + ]) + ) throws -> ChainPrivateKeyPair { + try TestCA.makeSelfSignedChain( + leafExtensions: try Certificate.Extensions { + BasicConstraints.notCertificateAuthority + + try ExtendedKeyUsage([.serverAuth]) + + leafSAN + } + ) + } } diff --git a/Tests/NIOHTTPServerTests/Utilities/HTTPVersion.swift b/Tests/NIOHTTPServerTests/Utilities/HTTPVersion.swift index 3feb3e4..0a94da4 100644 --- a/Tests/NIOHTTPServerTests/Utilities/HTTPVersion.swift +++ b/Tests/NIOHTTPServerTests/Utilities/HTTPVersion.swift @@ -12,19 +12,46 @@ // //===----------------------------------------------------------------------===// -enum HTTPVersion { - case http1_1 - case http2 +import NIOHTTPServer +@available(anyAppleOS 26.0, *) +extension NIOHTTPServer.HTTPVersion { /// The ALPN protocol identifier. /// /// - SeeAlso: https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml#alpn-protocol-ids var alpnIdentifier: String { switch self { + case .plaintextHTTP1_1: + fatalError("Not applicable") + case .http1_1: "http/1.1" + case .http2: "h2" + + #if HTTP3 + case .http3: + "h3" + #endif + } + } +} + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServerConfiguration.HTTPVersion { + init(_ version: NIOHTTPServer.HTTPVersion) { + switch version { + case .plaintextHTTP1_1, .http1_1: + self = .http1_1 + + case .http2: + self = .http2(config: .defaults) + + #if HTTP3 + case .http3: + self = .http3(config: .defaults) + #endif } } } diff --git a/Tests/NIOHTTPServerTests/Utilities/Helpers.swift b/Tests/NIOHTTPServerTests/Utilities/Helpers.swift index d558378..4ddab65 100644 --- a/Tests/NIOHTTPServerTests/Utilities/Helpers.swift +++ b/Tests/NIOHTTPServerTests/Utilities/Helpers.swift @@ -12,9 +12,17 @@ // //===----------------------------------------------------------------------===// +import BasicContainers +import HTTPAPIs +import HTTPTypes +import Logging import NIOCore import NIOEmbedded +import NIOHTTPTypes +import NIOPosix +import NIOQUIC import NIOSSL +import Testing import X509 @testable import NIOHTTPServer @@ -81,11 +89,11 @@ extension TLSConfiguration { /// Creates a client `TLSConfiguration` that trusts `testTrustRoots` and advertises the `applicationProtocol` ALPN /// identifier. static func makeTestClientConfiguration( - trustRoots: NIOSSLTrustRoots, + testTrustRoots: NIOSSLTrustRoots, applicationProtocol: String ) throws -> TLSConfiguration { var clientTLSConfig = TLSConfiguration.makeClientConfiguration() - clientTLSConfig.trustRoots = trustRoots + clientTLSConfig.trustRoots = testTrustRoots clientTLSConfig.certificateVerification = .noHostnameVerification clientTLSConfig.applicationProtocols = [applicationProtocol] @@ -94,17 +102,471 @@ extension TLSConfiguration { /// Like ``makeTestClientConfiguration``, but with mTLS. static func makeTestClientMTLSConfiguration( - trustRoots: NIOSSLTrustRoots, - clientCredentials: ChainPrivateKeyPair, + testTrustRoots: NIOSSLTrustRoots, + clientChain: ChainPrivateKeyPair, applicationProtocol: String ) throws -> TLSConfiguration { var mTLSConfig = try TLSConfiguration.makeTestClientConfiguration( - trustRoots: trustRoots, + testTrustRoots: testTrustRoots, applicationProtocol: applicationProtocol ) - mTLSConfig.certificateChain = [try NIOSSLCertificateSource(clientCredentials.leaf)] - mTLSConfig.privateKey = .privateKey(try .init(clientCredentials.privateKey)) + mTLSConfig.certificateChain = [try NIOSSLCertificateSource(clientChain.leaf)] + mTLSConfig.privateKey = .privateKey(try .init(clientChain.privateKey)) return mTLSConfig } } + +#if HTTP3 +@available(anyAppleOS 26.0, *) +extension QUICConfiguration { + /// Creates a client QUIC configuration. + /// + /// - Parameter caPath: The filepath of the client's trusted roots. + static func makeClientQUICConfig(caPath: String?) -> QUICConfiguration { + QUICConfiguration.client( + verificationConfiguration: .x509Certificates(trustRootsFilePath: caPath), + applicationProtocols: [NIOHTTPServer.HTTPVersion.http3.alpnIdentifier] + ) + } +} +#endif + +@available(anyAppleOS 26.0, *) +struct TestHelpers { + /// Starts `server` with `serverHandler`, waits for it to begin listening, runs `body` with the first + /// listening address, then cancels the server task. + static func withServer( + server: NIOHTTPServer, + serverHandler: some HTTPServerRequestHandler< + NIOHTTPServer.RequestContext, + NIOHTTPServer.Reader, + NIOHTTPServer.ResponseSender + >, + body: (NIOHTTPServer.SocketAddress) async throws -> Void + ) async throws { + try await self.withServer(server: server, serverHandler: serverHandler) { addresses in + let address = try #require(addresses.first) + try await body(address) + } + } + + /// Starts `server` with `serverHandler`, waits for it to begin listening, runs `body` with the first + /// listening address, then cancels the server task. + static func withServer( + server: NIOHTTPServer, + connectionHandler: Handler, + body: (NIOHTTPServer.SocketAddress) async throws -> Void + ) async throws { + try await self.withServer(server: server, connectionHandler: connectionHandler) { addresses in + let address = try #require(addresses.first) + try await body(address) + } + } + + /// Starts `server` with `serverHandler`, waits for it to begin listening, runs `body` with all listening + /// addresses, then cancels the server task. + static func withServer( + server: NIOHTTPServer, + serverHandler: some HTTPServerRequestHandler< + NIOHTTPServer.RequestContext, + NIOHTTPServer.Reader, + NIOHTTPServer.ResponseSender + >, + body: ([NIOHTTPServer.SocketAddress]) async throws -> Void + ) async throws { + try await withThrowingTaskGroup { group in + group.addTask { + try await server.serve(handler: serverHandler) + } + + let listeningAddresses = try await server.listeningAddresses + + try await body(listeningAddresses) + + group.cancelAll() + } + } + + /// Starts `server` with `connectionHandler`, waits for it to begin listening, runs `body` with all listening + /// addresses, then cancels the server task. + 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 + + try await body(listeningAddresses) + + group.cancelAll() + } + } + + /// The information needed to establish a test client connection to a ``NIOHTTPServer``. + struct ClientConfiguration { + let logger: Logger + let httpVersion: NIOHTTPServer.HTTPVersion + let trustRootsPEMPath: String? + var clientChain: ChainPrivateKeyPair? = nil + } + + /// Starts `server` with `serverHandler`, establishes a client connection described by `clientConfiguration`, + /// then runs `body` with the server's listening address and the resulting ``TestClientConnection``. + /// + /// The client connection is closed and the server task is cancelled when `body` returns. + static func withClientServerConnection( + clientConfiguration: ClientConfiguration, + server: NIOHTTPServer, + serverHandler: some HTTPServerRequestHandler< + NIOHTTPServer.RequestContext, + NIOHTTPServer.Reader, + NIOHTTPServer.ResponseSender + >, + body: (NIOHTTPServer.SocketAddress, TestClientConnection) async throws -> Void + ) async throws { + try await Self.withServer(server: server, serverHandler: serverHandler) { serverAddress in + try await TestClientConnection.withConnection( + configuration: clientConfiguration, + serverAddress: serverAddress + ) { clientConnection in + try await body(serverAddress, clientConnection) + } + } + } + + /// Starts `server` with `connectionHandler`, establishes a client connection described by `clientConfiguration`, + /// then runs `body` with the server's listening address and the resulting ``TestClientConnection``. + /// + /// The client connection is closed and the server task is cancelled when `body` returns. + static func withClientServerConnection( + clientConfiguration: ClientConfiguration, + server: NIOHTTPServer, + connectionHandler: Handler, + body: (NIOHTTPServer.SocketAddress, TestClientConnection) async throws -> Void + ) async throws { + try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + try await TestClientConnection.withConnection( + configuration: clientConfiguration, + serverAddress: serverAddress + ) { clientConnection in + try await body(serverAddress, clientConnection) + } + } + } + + /// Starts `server` with `serverHandler`, establishes a client connection described by `clientConfiguration`, + /// opens a request stream on it, then runs `body` with the server's listening address and the request stream's + /// inbound and outbound halves. + /// + /// The request stream, the client connection, and the server task are all torn down when `body` returns. + static func withClientServerRequestChannel( + clientConfiguration: ClientConfiguration, + server: NIOHTTPServer, + serverHandler: some HTTPServerRequestHandler< + NIOHTTPServer.RequestContext, + NIOHTTPServer.Reader, + NIOHTTPServer.ResponseSender + >, + body: ( + NIOHTTPServer.SocketAddress, + NIOAsyncChannelInboundStream, + NIOAsyncChannelOutboundWriter + ) async throws -> Void + ) async throws { + try await Self.withServer(server: server, serverHandler: serverHandler) { serverAddress in + try await TestClientConnection.withConnectedRequestChannel( + configuration: clientConfiguration, + serverAddress: serverAddress + ) { inbound, outbound in + try await body(serverAddress, inbound, outbound) + } + } + } + + /// Starts `server` with `connectionHandler`, establishes a client connection described by `clientConfiguration`, + /// opens a request stream on it, then runs `body` with the server's listening address and the request stream's + /// inbound and outbound halves. + /// + /// The request stream, the client connection, and the server task are all torn down when `body` returns. + static func withClientServerRequestChannel( + clientConfiguration: ClientConfiguration, + server: NIOHTTPServer, + connectionHandler: Handler, + body: ( + NIOHTTPServer.SocketAddress, + NIOAsyncChannelInboundStream, + NIOAsyncChannelOutboundWriter + ) async throws -> Void + ) async throws { + try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + try await TestClientConnection.withConnectedRequestChannel( + configuration: clientConfiguration, + serverAddress: serverAddress + ) { inbound, outbound in + try await body(serverAddress, inbound, outbound) + } + } + } + + /// Reads from `responseStream` and asserts each part matches the expected head, body, and trailers in order. + static func validateResponse( + _ responseStream: NIOAsyncChannelInboundStream, + expectedHead: [HTTPResponse], + expectedBody: [ByteBuffer], + expectedTrailers: HTTPFields? = nil, + expectStreamEnd: Bool = true, + sourceLocation: SourceLocation = #_sourceLocation + ) async throws { + var responseIterator = responseStream.makeAsyncIterator() + + for expectedHeadPart in expectedHead { + let headResponsePart = try await responseIterator.next() + try #require(headResponsePart == .head(expectedHeadPart), sourceLocation: sourceLocation) + } + + for expectedBodyBuffer in expectedBody { + let bodyResponsePart = try await responseIterator.next() + try #require(bodyResponsePart == .body(expectedBodyBuffer), sourceLocation: sourceLocation) + } + + let endResponsePart = try await responseIterator.next() + try #require(endResponsePart == .end(expectedTrailers), sourceLocation: sourceLocation) + + if expectStreamEnd { + try #require( + try await responseIterator.next() == nil, + "Received another response part when the response stream should have finished.", + sourceLocation: sourceLocation + ) + } + } + + /// Reads the full request body and trailers from `reader`, then sends a `200 OK` response echoing them back. + static func echoResponse( + readUpTo limit: Int, + reader: consuming NIOHTTPServer.Reader, + sender: consuming NIOHTTPServer.ResponseSender + ) async throws { + var buffer = UniqueArray() + // Reserve one extra byte beyond the limit: `collect(into:)` stops as soon as the buffer's + // free capacity is exhausted, so an exact fit would drop the trailing fields delivered in + // the terminal chunk. + buffer.reserveCapacity(limit + 1) + let trailer = try await reader.collect(into: &buffer) + try await sender.sendAndFinish(.init(status: .ok), buffer: &buffer, trailer: trailer) + } +} + +@available(anyAppleOS 26.0, *) +extension TestHelpers { + static func makeSecureUpgradeServerConfiguration( + supportedHTTPVersions: Set = [ + .http1_1, + .http2(config: .defaults), + ], + concurrentListeners: Int = 1 + ) throws -> (NIOHTTPServerConfiguration, String) { + let (leafPath, caPath, privateKeyPath) = try TestCA.makeSelfSignedChainWithSAN().writeToDisk() + + let bindTargets = (0.. Void)? = nil + ) throws -> (NIOHTTPServer, ClientConfiguration) { + let bindTargets = (0.. (NIOHTTPServer, ClientConfiguration) { + guard version != .plaintextHTTP1_1 else { + throw NIOHTTPServerConfigurationError.incompatibleTransportSecurity + } + #if HTTP3 + guard version != .http3 else { + throw NIOHTTPServerConfigurationError.mTLSNotCurrentlySupportedOverHTTP3 + } + #endif + + let serverChain = try TestCA.makeSelfSignedChainWithSAN() + let clientChain = try TestCA.makeSelfSignedChain() + let (serverLeafPath, serverCAPath, serverKeyPath) = try serverChain.writeToDisk() + + let bindTargets = (0.. HTTPFields { + switch httpVersion { + case .plaintextHTTP1_1, .http1_1: + [.transferEncoding: "chunked"] + + case .http2: + [:] + + #if HTTP3 + case .http3: + [:] + #endif + } + } +} + +@available(anyAppleOS 26.0, *) +extension String { + static func makeScheme(for httpVersion: NIOHTTPServer.HTTPVersion) -> String { + switch httpVersion { + case .plaintextHTTP1_1: + "http" + + case .http1_1, .http2: + "https" + + #if HTTP3 + case .http3: + "https" + #endif + } + } +} + +@available(anyAppleOS 26.0, *) +extension HTTPRequest { + /// Creates an ``HTTPRequest`` with the appropriate headers for the given `httpVersion`. + static func makeRequest( + method: HTTPRequest.Method, + authority: String = "test", + path: String = "/", + for httpVersion: NIOHTTPServer.HTTPVersion + ) -> HTTPRequest { + HTTPRequest( + method: method, + scheme: .makeScheme(for: httpVersion), + authority: authority, + path: path, + headerFields: .makeBodyEncodingHeaders(for: httpVersion) + ) + } +} + +@available(anyAppleOS 26.0, *) +extension HTTPResponse { + /// Creates an ``HTTPResponse`` with the given status and the appropriate headers for the given `httpVersion`. + static func makeResponse( + status: HTTPResponse.Status, + for httpVersion: NIOHTTPServer.HTTPVersion + ) -> HTTPResponse { + HTTPResponse( + status: status, + headerFields: .makeBodyEncodingHeaders(for: httpVersion) + ) + } +} + +@available(anyAppleOS 26.0, *) +extension HTTPRequestPart { + static func testHead( + method: HTTPRequest.Method, + authority: String = "test", + path: String = "/", + for version: NIOHTTPServer.HTTPVersion + ) -> HTTPRequestPart { + .head(HTTPRequest(method: method, scheme: .makeScheme(for: version), authority: authority, path: path)) + } + + static let testBody = HTTPRequestPart.body(.testData) + + static let testEnd = HTTPRequestPart.end(.testTrailer) +} + +extension ByteBuffer { + static let testData = ByteBuffer(repeating: 5, count: 100) +} + +extension HTTPFields { + static let testTrailer: HTTPFields = [.trailer: "test_trailer"] +} diff --git a/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+HTTP1.swift b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+HTTP1.swift index a4dadf7..756242e 100644 --- a/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+HTTP1.swift +++ b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+HTTP1.swift @@ -50,14 +50,21 @@ extension Channel { @available(anyAppleOS 26.0, *) extension ClientBootstrap { - /// Connects to the provided `serverAddress` and provides a `NIOAsyncChannel`. With this ``NIOAsyncChannel``, one - /// can write `HTTPRequestPart`s to the server and observe `HTTPResponsePart`s from the inbound stream of the - /// channel. + /// Connects to the provided `serverAddress` over plaintext HTTP/1.1 and returns a ``TestClientConnection`` + /// wrapping the established connection. Use ``TestClientConnection/makeRequestChannel()`` to obtain a + /// `NIOAsyncChannel` for writing `HTTPRequestPart`s to the server and observing `HTTPResponsePart`s from its + /// inbound stream. func connectToTestHTTP1Server( at serverAddress: NIOHTTPServer.SocketAddress - ) async throws -> NIOAsyncChannel { - try await self.connect(to: try .init(ipAddress: serverAddress.host, port: serverAddress.port)) { channel in - channel.configureTestHTTP1ClientPipeline() - } + ) async throws -> TestClientConnection { + .init( + connectionProtocol: .http1( + connectionChannel: try await self.connect( + to: try .init(ipAddress: serverAddress.host, port: serverAddress.port) + ) { channel in + channel.configureTestHTTP1ClientPipeline() + } + ) + ) } } diff --git a/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+HTTP3.swift b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+HTTP3.swift new file mode 100644 index 0000000..bb00b18 --- /dev/null +++ b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+HTTP3.swift @@ -0,0 +1,156 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +#if HTTP3 + +import HTTP3 +import Logging +import NIOCore +@_spi(HTTP3AsyncInterface) import NIOHTTP3 +import NIOHTTPServer +import NIOHTTPTypes +import NIOHTTPTypesHTTP1 +import NIOPosix +import NIOQUIC + +@available(anyAppleOS 26.0, *) +struct QUICConnectionCreator: HTTP3ConnectionCreator { + let quicHandler: QUICHandler + let connectionInitializer: @Sendable (any Channel, NIOQUIC.QUICStreamCreator) -> EventLoopFuture + let inboundStreamInitializer: @Sendable (any Channel) -> EventLoopFuture + + func createNewConnection( + serverName: String, + remoteAddress: SocketAddress, + connectionInitializer h3ConnectionInitializer: @escaping @Sendable (any Channel) -> EventLoopFuture + ) -> EventLoopFuture { + self.quicHandler.createOutboundConnection( + serverName: serverName, + remoteAddress: remoteAddress, + connectionInitializer: { [connectionInitializer] connectionChannel, streamCreator in + connectionInitializer(connectionChannel, streamCreator).flatMap { newConnectionChannel in + h3ConnectionInitializer(newConnectionChannel) + } + }, + inboundStreamInitializer: self.inboundStreamInitializer + ) + } +} + +@available(anyAppleOS 26.0, *) +extension Channel { + /// Adds HTTP/3 client handlers to the pipeline. + func configureTestHTTP3ClientPipeline( + logger: Logger, + settings: HTTP3Settings, + configuration: HTTP3ClientConfiguration, + quicConfiguration: QUICConfiguration, + asyncVerifier: NIOQUIC.AsyncVerifier + ) throws -> ( + any Channel, + HTTP3ClientConnectionMultiplexer + ) { + let quicHandler = QUICHandler( + channel: self, + quicConfiguration: quicConfiguration, + asyncVerifier: asyncVerifier, + authenticator: nil, + logger: logger, + inboundConnectionInitializer: { _, _ in fatalError() }, + inboundStreamInitializer: { _ in fatalError() }, + noMoreConnections: {} + ) + try self.pipeline.syncOperations.addHandler(quicHandler) + + let connectionMultiplexer = HTTP3ClientConnectionMultiplexer( + eventLoop: self.eventLoop, + createNewConnection: NIOLoopBound( + QUICConnectionCreator( + quicHandler: quicHandler, + connectionInitializer: { connectionChannel, streamCreator in + connectionChannel.eventLoop.makeCompletedFuture { + let h3Handler = HTTP3ConnectionHandler.client( + eventLoop: connectionChannel.eventLoop, + configuration: configuration, + settings: settings, + streamCreator: streamCreator, + logger: logger, + inboundPushStreamInitializer: { _ in fatalError() } + ) + try connectionChannel.pipeline.syncOperations.addHandler(h3Handler) + return connectionChannel + } + }, + inboundStreamInitializer: { streamChannel in + streamChannel.parent!.pipeline.handler(type: HTTP3ConnectionHandler.self) + .flatMap { http3Handler in + http3Handler.inboundStreamReceived(streamChannel) + } + } + ), + eventLoop: self.eventLoop + ) + ) + + return (self, connectionMultiplexer) + } +} + +@available(anyAppleOS 26.0, *) +extension DatagramBootstrap { + /// Sets up a test HTTP/3 client and returns the QUIC connection channel and the connection multiplexer. + func setupTestHTTP3Client( + logger: Logger, + trustRootsPath: String + ) async throws -> ( + any Channel, + HTTP3ClientConnectionMultiplexer + ) { + try await self.channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .bind(host: "127.0.0.1", port: 0) { channel in + channel.eventLoop.makeCompletedFuture { + try channel.configureTestHTTP3ClientPipeline( + logger: logger, + settings: .init(), + configuration: .defaults, + quicConfiguration: .makeClientQUICConfig(caPath: trustRootsPath), + asyncVerifier: try! .init( + trustRootsPath: trustRootsPath, + certificateVerification: .noHostnameVerification, + eventLoop: channel.eventLoop + ) + ) + } + } + } +} + +@available(anyAppleOS 26.0, *) +extension HTTP3ClientConnection { + /// Opens a single request stream on this connection wrapped in a `NIOAsyncChannel`. The stream is closed by the + /// caller using `executeThenClose`. + func makeRequestStream() async throws -> NIOAsyncChannel { + try await self.concurrencyView.createRequestStream { + let streamChannel = $0.channel + return streamChannel.eventLoop.makeCompletedFuture { + try NIOAsyncChannel( + wrappingChannelSynchronously: streamChannel, + configuration: .init(isOutboundHalfClosureEnabled: true) + ) + } + } + } +} + +#endif // HTTP3 diff --git a/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift index 72518ac..8c30e1b 100644 --- a/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift +++ b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift @@ -17,7 +17,6 @@ import NIOHTTP2 import NIOHTTPTypes import NIOPosix import NIOSSL -import X509 @testable import NIOHTTPServer @@ -59,52 +58,20 @@ extension ClientBootstrap { func connectToTestSecureUpgradeHTTPServer( at serverAddress: NIOHTTPServer.SocketAddress, tlsConfig: TLSConfiguration - ) async throws -> NegotiatedClientConnection { - let clientNegotiatedChannel = try await self.connect( + ) async throws -> TestClientConnection { + let (connectionChannel, alpnResultFuture) = try await self.connect( to: try .init(ipAddress: serverAddress.host, port: serverAddress.port) ) { channel in channel.configureTestClientSSLPipeline(tlsConfig: tlsConfig).flatMap { - channel.configureTestSecureUpgradeClientPipeline() + channel.configureTestSecureUpgradeClientPipeline().map { alpnResultFuture in + (channel, alpnResultFuture) + } } - }.get() - - switch clientNegotiatedChannel { - case .http1_1(let http1Channel): - return .http1(http1Channel) - - case .http2(let http2Channel): - return .http2(.init(http2StreamMultiplexer: http2Channel)) } - } - /// Creates and connects a TLS-enabled client to the specified address. - func connectToTestSecureUpgradeHTTPServer( - at serverAddress: NIOHTTPServer.SocketAddress, - trustRoots: [Certificate], - applicationProtocol: String - ) async throws -> NegotiatedClientConnection { - let tlsConfig = try TLSConfiguration.makeTestClientConfiguration( - trustRoots: .certificates(try trustRoots.map { try NIOSSLCertificate($0) }), - applicationProtocol: applicationProtocol + return try await TestClientConnection( + alpnNegotiationResult: try await alpnResultFuture.get(), + connectionChannel: connectionChannel ) - - return try await self.connectToTestSecureUpgradeHTTPServer(at: serverAddress, tlsConfig: tlsConfig) - } - - /// Exactly like ``connectToTestSecureUpgradeHTTPServerOverMTLS(at:trustRoots:applicationProtocol:)`` but over mTLS - /// instead. - func connectToTestSecureUpgradeHTTPServerOverMTLS( - at serverAddress: NIOHTTPServer.SocketAddress, - clientChain: ChainPrivateKeyPair, - trustRoots: [Certificate], - applicationProtocol: String - ) async throws -> NegotiatedClientConnection { - let mTLSConfig = try TLSConfiguration.makeTestClientMTLSConfiguration( - trustRoots: .certificates(try trustRoots.map { try NIOSSLCertificate($0) }), - clientCredentials: clientChain, - applicationProtocol: applicationProtocol - ) - - return try await self.connectToTestSecureUpgradeHTTPServer(at: serverAddress, tlsConfig: mTLSConfig) } } diff --git a/Tests/NIOHTTPServerTests/Utilities/NegotiatedClientConnection.swift b/Tests/NIOHTTPServerTests/Utilities/NegotiatedClientConnection.swift deleted file mode 100644 index 9e438b0..0000000 --- a/Tests/NIOHTTPServerTests/Utilities/NegotiatedClientConnection.swift +++ /dev/null @@ -1,91 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// 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 -import NIOHTTPTypesHTTP2 -import Testing - -/// A testing utility that wraps the result of ALPN negotiation for HTTP/1.1 or HTTP/2 client connections. -/// -/// - If HTTP/1.1 is negotiated, this type vends the underlying client connection channel. -/// - If HTTP/2 is negotiated, this type vends a ``HTTP2StreamManager``. In tests, you can then call the -/// ``HTTP2StreamManager/openStream()`` method, which will create a stream channel, set it up with a channel handler, -/// and return a ``NIOAsyncChannel`` from which you can send/observe requests/responses in terms of HTTP types. -enum NegotiatedClientConnection { - case http1(NIOAsyncChannel) - case http2(HTTP2StreamManager) - - init( - negotiationResult: NIONegotiatedHTTPVersion< - NIOAsyncChannel, NIOHTTP2Handler.AsyncStreamMultiplexer - > - ) async throws { - switch negotiationResult { - case .http1_1(let http1AsyncChannel): - self = .http1(http1AsyncChannel) - - case .http2(let http2StreamMultiplexer): - self = .http2(.init(http2StreamMultiplexer: http2StreamMultiplexer)) - } - } - - /// Provides utilities for managing HTTP/2 streams. - struct HTTP2StreamManager { - let http2StreamMultiplexer: NIOHTTP2Handler.AsyncStreamMultiplexer - - /// A wrapper over `NIOHTTP2Handler/AsyncStreamMultiplexer/openStream(_:)` that first initializes the stream - /// channel with the `HTTP2FramePayloadToHTTPClientCodec` channel handler, and wraps it in a `NIOAsyncChannel` - /// (with outbound half closure enabled). - func openStream() async throws -> NIOAsyncChannel { - try await self.http2StreamMultiplexer.openStream { channel in - channel.eventLoop.makeCompletedFuture { - try channel.pipeline.syncOperations.addHandler(HTTP2FramePayloadToHTTPClientCodec()) - return try NIOAsyncChannel( - wrappingChannelSynchronously: channel, - configuration: .init(isOutboundHalfClosureEnabled: true) - ) - } - } - } - } -} - -extension NegotiatedClientConnection { - /// Unwraps a negotiated channel, asserting it matches the expected `httpVersion`. For HTTP/2, opens and returns a - /// new stream channel. - func unwrapChannel( - expectedHTTPVersion: HTTPVersion, - sourceLocation: SourceLocation = #_sourceLocation - ) async throws -> NIOAsyncChannel { - switch self { - case .http1(let http1Channel): - #expect( - expectedHTTPVersion == .http1_1, - "Unexpectedly established an HTTP/1 connection.", - sourceLocation: sourceLocation - ) - return http1Channel - - case .http2(let http2StreamManager): - #expect( - expectedHTTPVersion == .http2, - "Unexpectedly established an HTTP/2 connection.", - sourceLocation: sourceLocation - ) - return try await http2StreamManager.openStream() - } - } -} diff --git a/Tests/NIOHTTPServerTests/Utilities/TestClientConnection.swift b/Tests/NIOHTTPServerTests/Utilities/TestClientConnection.swift new file mode 100644 index 0000000..23cb526 --- /dev/null +++ b/Tests/NIOHTTPServerTests/Utilities/TestClientConnection.swift @@ -0,0 +1,237 @@ +//===----------------------------------------------------------------------===// +// +// 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 HTTPTypes +import Logging +import NIOCore +import NIOHTTP2 +import NIOHTTPTypes +import NIOHTTPTypesHTTP2 +import NIOPosix +import NIOQUIC +import NIOSSL +import Testing + +@testable import NIOHTTPServer + +#if HTTP3 +@_spi(HTTP3AsyncInterface) import NIOHTTP3 +#endif + +/// A testing utility that wraps an established HTTP/1.1, HTTP/2, or HTTP/3 client connection and provides an opaque +/// interface for creating request streams. +@available(anyAppleOS 26.0, *) +struct TestClientConnection { + enum ConnectionProtocol { + case http1(connectionChannel: NIOAsyncChannel) + + case http2( + connectionChannel: any Channel, + streamMultiplexer: NIOHTTP2Handler.AsyncStreamMultiplexer + ) + + #if HTTP3 + case http3(connection: HTTP3ClientConnection, quicChannel: any Channel) + #endif + } + + let connectionProtocol: ConnectionProtocol + + /// Asserts the negotiated protocol matches `expectedHTTPVersion`, then returns a request stream. + func makeRequestChannel( + expectedHTTPVersion: NIOHTTPServer.HTTPVersion, + sourceLocation: SourceLocation = #_sourceLocation + ) async throws -> NIOAsyncChannel { + switch self.connectionProtocol { + case .http1(let http1Channel): + try #require( + expectedHTTPVersion == .plaintextHTTP1_1 || expectedHTTPVersion == .http1_1, + "Unexpectedly established an HTTP/1 connection.", + sourceLocation: sourceLocation + ) + return http1Channel + + case .http2(_, let streamMultiplexer): + try #require( + expectedHTTPVersion == .http2, + "Unexpectedly established an HTTP/2 connection.", + sourceLocation: sourceLocation + ) + return try await streamMultiplexer.makeRequestStream() + + #if HTTP3 + case .http3(let http3Connection, _): + try #require( + expectedHTTPVersion == .http3, + "Unexpectedly established an HTTP/3 connection.", + sourceLocation: sourceLocation + ) + return try await http3Connection.makeRequestStream() + #endif + } + } + + /// Closes the underlying connection. + func close() async throws { + switch self.connectionProtocol { + case .http1(let asyncChannel): + do { + try await asyncChannel.channel.close() + } catch ChannelError.alreadyClosed { + () + } + + case .http2(let channel, _): + do { + try await channel.close() + } catch ChannelError.alreadyClosed { + () + } + + #if HTTP3 + case .http3(_, let channel): + do { + try await channel.close() + } catch ChannelError.alreadyClosed { + () + } + #endif + } + } +} + +@available(anyAppleOS 26.0, *) +extension TestClientConnection { + init( + alpnNegotiationResult: NIONegotiatedHTTPVersion< + NIOAsyncChannel, + NIOHTTP2Handler.AsyncStreamMultiplexer + >, + connectionChannel: any Channel + ) async throws { + switch alpnNegotiationResult { + case .http1_1(let http1AsyncChannel): + self.init(connectionProtocol: .http1(connectionChannel: http1AsyncChannel)) + + case .http2(let http2StreamMultiplexer): + self.init( + connectionProtocol: .http2( + connectionChannel: connectionChannel, + streamMultiplexer: http2StreamMultiplexer + ) + ) + } + } +} + +@available(anyAppleOS 26.0, *) +extension TestClientConnection { + /// Establishes a client connection to `serverAddress` based on the provided `httpVersion`, runs `body` with the + /// resulting ``TestClientConnection``. The stream and the underlying connection are closed when `body` returns. + static func withConnection( + configuration: TestHelpers.ClientConfiguration, + serverAddress: NIOHTTPServer.SocketAddress, + body: (TestClientConnection) async throws -> Void + ) async throws { + let connection: TestClientConnection + + switch (configuration.httpVersion, configuration.trustRootsPEMPath) { + case (.plaintextHTTP1_1, .none): + connection = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestHTTP1Server(at: serverAddress) + + case (.http1_1, .some(let trustRootsPEMPath)), (.http2, .some(let trustRootsPEMPath)): + let tlsConfiguration = + if let clientChain = configuration.clientChain { + try TLSConfiguration.makeTestClientMTLSConfiguration( + testTrustRoots: .file(trustRootsPEMPath), + clientChain: clientChain, + applicationProtocol: configuration.httpVersion.alpnIdentifier + ) + } else { + try TLSConfiguration.makeTestClientConfiguration( + testTrustRoots: .file(trustRootsPEMPath), + applicationProtocol: configuration.httpVersion.alpnIdentifier + ) + } + + connection = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestSecureUpgradeHTTPServer(at: serverAddress, tlsConfig: tlsConfiguration) + + #if HTTP3 + case (.http3, .some(let trustRootsPEMPath)): + let (quicChannel, multiplexer) = try await DatagramBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .setupTestHTTP3Client(logger: configuration.logger, trustRootsPath: trustRootsPEMPath) + + do { + let h3Connection = try await multiplexer.concurrencyView.createConnection( + serverName: "127.0.0.1", + remoteAddress: .init(ipAddress: serverAddress.host, port: serverAddress.port), + inboundPushStreamInitializer: { _ in fatalError("Push streams not supported") } + ) + connection = TestClientConnection( + connectionProtocol: .http3(connection: h3Connection, quicChannel: quicChannel) + ) + } catch { + try? await quicChannel.close() + throw error + } + #endif + + default: + throw TestError.invalidClientConfiguration + } + + do { + try await body(connection) + try await connection.close() + } catch { + try? await connection.close() + throw error + } + } + + /// Establishes a client connection to `serverAddress`, opens a request stream on it, and runs the `body` closure. + /// The stream and the underlying connection are closed when `body` returns. + static func withConnectedRequestChannel( + configuration: TestHelpers.ClientConfiguration, + serverAddress: NIOHTTPServer.SocketAddress, + body: ( + NIOAsyncChannelInboundStream, + NIOAsyncChannelOutboundWriter + ) async throws -> Void + ) async throws { + try await Self.withConnection(configuration: configuration, serverAddress: serverAddress) { connection in + try await connection.makeRequestChannel(expectedHTTPVersion: configuration.httpVersion) + .executeThenClose(body) + } + } +} + +extension NIOHTTP2Handler.AsyncStreamMultiplexer { + /// A wrapper over `openStream(_:)` that first initializes the stream channel with the + /// `HTTP2FramePayloadToHTTPClientCodec` channel handler, and wraps it in a `NIOAsyncChannel` (with outbound half + /// closure enabled). + func makeRequestStream() async throws -> NIOAsyncChannel { + try await self.openStream { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler(HTTP2FramePayloadToHTTPClientCodec()) + return try NIOAsyncChannel( + wrappingChannelSynchronously: channel, + configuration: .init(isOutboundHalfClosureEnabled: true) + ) + } + } + } +} diff --git a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+SecureUpgrade.swift b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+SecureUpgrade.swift index 19b1d33..846cbb9 100644 --- a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+SecureUpgrade.swift +++ b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+SecureUpgrade.swift @@ -61,6 +61,8 @@ struct TestingChannelSecureUpgradeServer { try await server.serveSecureUpgradeWithTestChannel(testChannel: serverTestChannel, handler: handler) } + _ = try await server.listeningAddresses + // Execute the provided closure. try await body(Self(server: server, serverTestChannel: serverTestChannel)) @@ -72,7 +74,7 @@ struct TestingChannelSecureUpgradeServer { /// with the negotiated ALPN result as an argument. func withConnectedClient( clientTLSConfig: TLSConfiguration, - body: (_ negotiatedConnectionChannel: NegotiatedClientConnection) async throws -> Void + body: (_ negotiatedConnectionChannel: TestClientConnection) async throws -> Void ) async throws { // Create a connection channel: we will write this to the server channel to simulate an incoming connection. let serverTestConnectionChannel = try await NIOAsyncTestingChannel.createActiveChannel() @@ -105,7 +107,12 @@ struct TestingChannelSecureUpgradeServer { // We must forward all client outbound writes to the server and vice-versa. group.addTask { try await clientTestingChannel.glueTo(serverTestConnectionChannel) } - try await body(.init(negotiationResult: try await clientNegotiatedConnectionFuture.get())) + try await body( + .init( + alpnNegotiationResult: try await clientNegotiatedConnectionFuture.get(), + connectionChannel: clientTestingChannel + ) + ) try await serverTestConnectionChannel.close() }