Skip to content

Commit

Permalink
Added helper to configure HTTP1 or H2 accordingly to the negotiated p…
Browse files Browse the repository at this point in the history
…rotocol (apple#193)

Motivation:

Applications might want to support H2 (when available) and HTTP1 (as a fallback).
The application logic should not change between the two versions of the protocol
and the code to configure support for both the protocols is boilerplate and can
be provided as an helper function.

Modifications:

This PR adds an helper function to configure HTTP1 or H2 pipelines according to
the protocol negotiated by the TLS handler.

Result:

Applications that need to support clients using both HTTP1 and H2 can
configure their channels by calling `configureCommonHTTPServerPipeline`,
which should reduce the boilerplate they have to write.

Co-Authored-By: Cory Benfield <lukasa@apple.com>
  • Loading branch information
mariosangiorgio and Lukasa authored Mar 17, 2020
1 parent fbfe24c commit 798c19b
Show file tree
Hide file tree
Showing 3 changed files with 214 additions and 7 deletions.
87 changes: 87 additions & 0 deletions Sources/NIOHTTP2/HTTP2PipelineHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public extension ChannelPipeline {
/// negotiated, or if no protocol was negotiated. Must return a future that completes when the
/// pipeline has been fully mutated.
/// - returns: An `EventLoopFuture<Void>` that completes when the pipeline is ready to negotiate.
@available(*, deprecated, renamed: "Channel.configureHTTP2SecureUpgrade(h2ChannelConfigurator:http1ChannelConfigurator:)")
func configureHTTP2SecureUpgrade(h2PipelineConfigurator: @escaping (ChannelPipeline) -> EventLoopFuture<Void>,
http1PipelineConfigurator: @escaping (ChannelPipeline) -> EventLoopFuture<Void>) -> EventLoopFuture<Void> {
let alpnHandler = ApplicationProtocolNegotiationHandler { result in
Expand Down Expand Up @@ -104,4 +105,90 @@ extension Channel {

return self.pipeline.addHandlers(handlers, position: position).map { multiplexer }
}

/// Configures a channel to perform a HTTP/2 secure upgrade.
///
/// HTTP/2 secure upgrade uses the Application Layer Protocol Negotiation TLS extension to
/// negotiate the inner protocol as part of the TLS handshake. For this reason, until the TLS
/// handshake is complete, the ultimate configuration of the channel pipeline cannot be known.
///
/// This function configures the channel with a pair of callbacks that will handle the result
/// of the negotiation. It explicitly **does not** configure a TLS handler to actually attempt
/// to negotiate ALPN. The supported ALPN protocols are provided in
/// `NIOHTTP2SupportedALPNProtocols`: please ensure that the TLS handler you are using for your
/// pipeline is appropriately configured to perform this protocol negotiation.
///
/// If negotiation results in an unexpected protocol, the pipeline will close the connection
/// and no callback will fire.
///
/// This configuration is acceptable for use on both client and server channel pipelines.
///
/// - parameters:
/// - h2ChannelConfigurator: A callback that will be invoked if HTTP/2 has been negogiated, and that
/// should configure the channel for HTTP/2 use. Must return a future that completes when the
/// channel has been fully mutated.
/// - http1ChannelConfigurator: A callback that will be invoked if HTTP/1.1 has been explicitly
/// negotiated, or if no protocol was negotiated. Must return a future that completes when the
/// channel has been fully mutated.
/// - returns: An `EventLoopFuture<Void>` that completes when the channel is ready to negotiate.
func configureHTTP2SecureUpgrade(h2ChannelConfigurator: @escaping (Channel) -> EventLoopFuture<Void>,
http1ChannelConfigurator: @escaping (Channel) -> EventLoopFuture<Void>) -> EventLoopFuture<Void> {
let alpnHandler = ApplicationProtocolNegotiationHandler { result in
switch result {
case .negotiated("h2"):
// Successful upgrade to HTTP/2. Let the user configure the pipeline.
return h2ChannelConfigurator(self)
case .negotiated("http/1.1"), .fallback:
// Explicit or implicit HTTP/1.1 choice.
return http1ChannelConfigurator(self)
case .negotiated:
// We negotiated something that isn't HTTP/1.1. This is a bad scene, and is a good indication
// of a user configuration error. We're going to close the connection directly.
return self.close().flatMap { self.eventLoop.makeFailedFuture(NIOHTTP2Errors.InvalidALPNToken()) }
}
}

return self.pipeline.addHandler(alpnHandler)
}

/// Configures a `ChannelPipeline` to speak either HTTP or HTTP/2 according to what can be negotiated with the client.
///
/// This helper takes care of configuring the server pipeline such that it negotiates whether to
/// use HTTP/1.1 or HTTP/2. Once the protocol to use for the channel has been negotiated, the
/// provided callback will configure the application-specific handlers in a protocol-agnostic way.
///
/// This function doesn't configure the TLS handler. Callers of this function need to add a TLS
/// handler appropriately configured to perform protocol negotiation.
///
/// - parameters:
/// - h2ConnectionChannelConfigurator: An optional callback that will be invoked only
/// when the negotiated protocol is H2 to configure the connection channel.
/// - configurator: A callback that will be invoked after a protocol has been negotiated.
/// The callback only needs to add application-specific handlers and must return a future
/// that completes when the channel has been fully mutated.
/// - returns: `EventLoopFuture<Void>` that completes when the channel is ready.
public func configureCommonHTTPServerPipeline(
h2ConnectionChannelConfigurator: ((Channel) -> EventLoopFuture<Void>)? = nil,
_ configurator: @escaping (Channel) -> EventLoopFuture<Void>) -> EventLoopFuture<Void> {
let h2ChannelConfigurator = { (channel: Channel) -> EventLoopFuture<Void> in
channel.configureHTTP2Pipeline(mode: .server) { (streamChannel, streamID) -> EventLoopFuture<Void> in
streamChannel.pipeline.addHandler(HTTP2ToHTTP1ServerCodec(streamID: streamID)).flatMap { () -> EventLoopFuture<Void> in
configurator(streamChannel)
}
}.flatMap { (_: HTTP2StreamMultiplexer) in
if let h2ConnectionChannelConfigurator = h2ConnectionChannelConfigurator {
return h2ConnectionChannelConfigurator(channel)
} else {
return channel.eventLoop.makeSucceededFuture(())
}
}
}
let http1ChannelConfigurator = { (channel: Channel) -> EventLoopFuture<Void> in
channel.pipeline.configureHTTPServerPipeline().flatMap { _ in
configurator(channel)
}
}
return self.configureHTTP2SecureUpgrade(h2ChannelConfigurator: h2ChannelConfigurator,
http1ChannelConfigurator: http1ChannelConfigurator)
}
}
2 changes: 2 additions & 0 deletions Tests/NIOHTTP2Tests/ConfiguringPipelineTests+XCTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ extension ConfiguringPipelineTests {
("testPipelineRespectsPositionRequest", testPipelineRespectsPositionRequest),
("testPreambleGetsWrittenOnce", testPreambleGetsWrittenOnce),
("testClosingParentChannelClosesStreamChannel", testClosingParentChannelClosesStreamChannel),
("testNegotiatedHTTP2BasicPipelineCommunicates", testNegotiatedHTTP2BasicPipelineCommunicates),
("testNegotiatedHTTP1BasicPipelineCommunicates", testNegotiatedHTTP1BasicPipelineCommunicates),
]
}
}
Expand Down
132 changes: 125 additions & 7 deletions Tests/NIOHTTP2Tests/ConfiguringPipelineTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ import XCTest

import NIO
import NIOHPACK
import NIOHTTP1
import NIOHTTP2
import NIOTLS

/// A simple channel handler that can be inserted in a pipeline to ensure that it never sees a write.
///
Expand Down Expand Up @@ -69,13 +71,8 @@ class ConfiguringPipelineTests: XCTestCase {
(self.clientChannel.eventLoop as! EmbeddedEventLoop).run()
self.interactInMemory(self.clientChannel, self.serverChannel)
(self.clientChannel.eventLoop as! EmbeddedEventLoop).run()
do {
try requestPromise.futureResult.wait()
XCTFail("Did not throw")
} catch is NIOHTTP2Errors.StreamClosed {
// ok
} catch {
XCTFail("Unexpected error: \(error)")
XCTAssertThrowsError(try requestPromise.futureResult.wait()) { error in
XCTAssertTrue(error is NIOHTTP2Errors.StreamClosed)
}

// We should have received a HEADERS and a RST_STREAM frame.
Expand Down Expand Up @@ -181,4 +178,125 @@ class ConfiguringPipelineTests: XCTestCase {
}
XCTAssertNoThrow(try self.serverChannel.finish())
}

/// A simple channel handler that records inbound frames.
class HTTP1ServerRequestRecorderHandler: ChannelInboundHandler {
typealias InboundIn = HTTPServerRequestPart

var receivedParts: [HTTPServerRequestPart] = []

func channelRead(context: ChannelHandlerContext, data: NIOAny) {
self.receivedParts.append(self.unwrapInboundIn(data))
}
}

func testNegotiatedHTTP2BasicPipelineCommunicates() throws {
final class ErrorHandler: ChannelInboundHandler {
typealias InboundIn = Never

func errorCaught(context: ChannelHandlerContext, error: Error) {
context.close(promise: nil)
}
}

let serverRecorder = HTTP1ServerRequestRecorderHandler()
let clientHandler = try assertNoThrowWithValue(self.clientChannel.configureHTTP2Pipeline(mode: .client).wait())

XCTAssertNoThrow(try self.serverChannel.configureCommonHTTPServerPipeline(h2ConnectionChannelConfigurator: { $0.pipeline.addHandler(ErrorHandler()) }) { channel in
return channel.pipeline.addHandler(serverRecorder)
}.wait())

// Let's pretent the TLS handler did protocol negotiation for us
self.serverChannel.pipeline.fireUserInboundEventTriggered (TLSUserEvent.handshakeCompleted(negotiatedProtocol: "h2"))

XCTAssertNoThrow(try self.assertDoHandshake(client: self.clientChannel, server: self.serverChannel))

// Let's try sending an h2 request.
let requestPromise = self.clientChannel.eventLoop.makePromise(of: Void.self)
let reqFrame = HTTP2Frame(streamID: 1, payload: .headers(.init(headers: HPACKHeaders([(":method", "GET"), (":authority", "localhost"), (":scheme", "https"), (":path", "/testH2toHTTP1")]), endStream: true)))

clientHandler.createStreamChannel(promise: nil) { channel, streamID in
XCTAssertEqual(streamID, HTTP2StreamID(1))
channel.writeAndFlush(reqFrame).whenComplete { _ in channel.close(promise: requestPromise) }
return channel.eventLoop.makeSucceededFuture(())
}

// In addition to interacting in memory, we need two loop spins. The first is to execute `createStreamChannel`.
// The second is to execute the close promise callback, after the interaction is complete.
(self.clientChannel.eventLoop as! EmbeddedEventLoop).run()
self.interactInMemory(self.clientChannel, self.serverChannel)
(self.clientChannel.eventLoop as! EmbeddedEventLoop).run()
XCTAssertThrowsError(try requestPromise.futureResult.wait()) { error in
XCTAssertTrue(error is NIOHTTP2Errors.StreamClosed)
}

// Assert that the user-provided handler received the
// HTTP1 parts corresponding to the H2 message sent
XCTAssertEqual(2, serverRecorder.receivedParts.count)
if case .some(.head(let head)) = serverRecorder.receivedParts.first {
XCTAssertEqual(1, head.headers["host"].count)
XCTAssertEqual("localhost", head.headers["host"].first)
XCTAssertEqual(.GET, head.method)
XCTAssertEqual("/testH2toHTTP1", head.uri)
} else {
XCTFail("Expected head")
}
if case .some(.end(_)) = serverRecorder.receivedParts.last {
} else {
XCTFail("Expected end")
}
self.clientChannel.assertNoFramesReceived()
self.serverChannel.assertNoFramesReceived()

XCTAssertNoThrow(try self.clientChannel.finish())
XCTAssertNoThrow(try self.serverChannel.finish())
}

func testNegotiatedHTTP1BasicPipelineCommunicates() throws {
final class ErrorHandler: ChannelInboundHandler {
typealias InboundIn = Never

func errorCaught(context: ChannelHandlerContext, error: Error) {
context.close(promise: nil)
}
}
let serverRecorder = HTTP1ServerRequestRecorderHandler()
XCTAssertNoThrow(try self.clientChannel.pipeline.addHTTPClientHandlers().wait())

XCTAssertNoThrow(try self.serverChannel.configureCommonHTTPServerPipeline(h2ConnectionChannelConfigurator: { $0.pipeline.addHandler(ErrorHandler()) }) { channel in
return channel.pipeline.addHandler(serverRecorder)
}.wait())

// Let's pretent the TLS handler did protocol negotiation for us
self.serverChannel.pipeline.fireUserInboundEventTriggered (TLSUserEvent.handshakeCompleted(negotiatedProtocol: "http/1.1"))

// Let's try sending an http/1.1 request.
let requestPromise = self.clientChannel.eventLoop.makePromise(of: Void.self)

XCTAssertNoThrow(try self.clientChannel.writeOutbound(HTTPClientRequestPart.head(HTTPRequestHead(version: .init(major: 1, minor: 1), method: .GET, uri: "/testHTTP1"))))
self.clientChannel.writeAndFlush(HTTPClientRequestPart.end(nil),
promise: requestPromise)
XCTAssertNoThrow(try requestPromise.futureResult.wait())

self.interactInMemory(self.clientChannel, self.serverChannel)

// Assert that the user-provided handler received the
// HTTP1 parts corresponding to the H2 message sent
XCTAssertEqual(2, serverRecorder.receivedParts.count)
if case .some(.head(let head)) = serverRecorder.receivedParts.first {
XCTAssertEqual(.GET, head.method)
XCTAssertEqual("/testHTTP1", head.uri)
} else {
XCTFail("Expected head")
}
if case .some(.end(_)) = serverRecorder.receivedParts.last {
} else {
XCTFail("Expected end")
}
self.clientChannel.assertNoFramesReceived()
self.serverChannel.assertNoFramesReceived()

XCTAssertNoThrow(try self.clientChannel.finish())
XCTAssertNoThrow(try self.serverChannel.finish())
}
}

0 comments on commit 798c19b

Please # to comment.