I'm currently working on a simple HTTP2 client in Swift using SwiftNIO and the SwiftNIOHTTP2 beta. My implementation looks like this:
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
let bootstrap = ClientBootstrap(group: group)
.channelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1)
.channelInitializer { channel in
channel.pipeline.add(handler: HTTP2Parser(mode: .client)).then {
let multiplexer = HTTP2StreamMultiplexer { (channel, streamID) -> EventLoopFuture<Void> in
return channel.pipeline.add(handler: HTTP2ToHTTP1ClientCodec(streamID: streamID, httpProtocol: .https))
}
return channel.pipeline.add(handler: multiplexer)
}
}
defer {
try! group.syncShutdownGracefully()
}
let url = URL(string: "https://strnmn.me")!
_ = try bootstrap.connect(host: url.host!, port: url.port ?? 443)
.wait()
Unfortunately the connection always fails with an error:
nghttp2 error: Remote peer returned unexpected data while we expected SETTINGS frame. Perhaps, peer does not support HTTP/2 properly.
However, connecting and issuing a simple request using nghttp2 from the command line works fine.
$ nghttp -vn https://strnmn.me
[ 0.048] Connected
The negotiated protocol: h2
[ 0.110] recv SETTINGS frame <length=18, flags=0x00, stream_id=0>
(niv=3)
[SETTINGS_MAX_CONCURRENT_STREAMS(0x03):128]
[SETTINGS_INITIAL_WINDOW_SIZE(0x04):65536]
[SETTINGS_MAX_FRAME_SIZE(0x05):16777215]
[ 0.110] recv WINDOW_UPDATE frame <length=4, flags=0x00, stream_id=0>
(window_size_increment=2147418112)
[ 0.110] send SETTINGS frame <length=12, flags=0x00, stream_id=0>
(niv=2)
[SETTINGS_MAX_CONCURRENT_STREAMS(0x03):100]
[SETTINGS_INITIAL_WINDOW_SIZE(0x04):65535]
[ 0.110] send SETTINGS frame <length=0, flags=0x01, stream_id=0>
; ACK
(niv=0)
[ 0.110] send PRIORITY frame <length=5, flags=0x00, stream_id=3>
(dep_stream_id=0, weight=201, exclusive=0)
[ 0.110] send PRIORITY frame <length=5, flags=0x00, stream_id=5>
(dep_stream_id=0, weight=101, exclusive=0)
[ 0.110] send PRIORITY frame <length=5, flags=0x00, stream_id=7>
(dep_stream_id=0, weight=1, exclusive=0)
[ 0.110] send PRIORITY frame <length=5, flags=0x00, stream_id=9>
(dep_stream_id=7, weight=1, exclusive=0)
[ 0.110] send PRIORITY frame <length=5, flags=0x00, stream_id=11>
(dep_stream_id=3, weight=1, exclusive=0)
[ 0.111] send HEADERS frame <length=35, flags=0x25, stream_id=13>
; END_STREAM | END_HEADERS | PRIORITY
(padlen=0, dep_stream_id=11, weight=16, exclusive=0)
; Open new stream
:method: GET
:path: /
:scheme: https
:authority: strnmn.me
accept: */*
accept-encoding: gzip, deflate
user-agent: nghttp2/1.34.0
[ 0.141] recv SETTINGS frame <length=0, flags=0x01, stream_id=0>
; ACK
(niv=0)
[ 0.141] recv (stream_id=13) :status: 200
[ 0.141] recv (stream_id=13) server: nginx
[ 0.141] recv (stream_id=13) date: Sat, 24 Nov 2018 16:29:13 GMT
[ 0.141] recv (stream_id=13) content-type: text/html
[ 0.141] recv (stream_id=13) last-modified: Sat, 01 Jul 2017 20:23:11 GMT
[ 0.141] recv (stream_id=13) vary: Accept-Encoding
[ 0.141] recv (stream_id=13) etag: W/"595804af-8a"
[ 0.141] recv (stream_id=13) expires: Sat, 24 Nov 2018 16:39:13 GMT
[ 0.141] recv (stream_id=13) cache-control: max-age=600
[ 0.141] recv (stream_id=13) x-frame-options: SAMEORIGIN
[ 0.141] recv (stream_id=13) content-encoding: gzip
[ 0.141] recv HEADERS frame <length=185, flags=0x04, stream_id=13>
; END_HEADERS
(padlen=0)
; First response header
[ 0.142] recv DATA frame <length=114, flags=0x01, stream_id=13>
; END_STREAM
[ 0.142] send GOAWAY frame <length=8, flags=0x00, stream_id=0>
(last_stream_id=0, error_code=NO_ERROR(0x00), opaque_data(0)=[])
How can I establish a session and issue a GET request using SwiftNIOHTTP2?
That's a very good question! Let's first analyse why this is more complicated than sending a HTTP/1.x request. Broadly speaking these issues fall into two categories:
swift-nio-ssl
and swift-nio-http2
on http://docs.swiftnio.io .I'll focus on the necessary complexity (2) here and will file bugs/fixes for (1). Let's check what tools we need from the NIO toolbox to get this working:
443
) so we need to tell the server that we want to speak HTTP/2 because for backwards compatibility the default remains HTTP/1. We can do this using a mechanism called ALPN (Application-layer Protocol Negotiation), the other option would be to perform a HTTP/1 upgrade to HTTP2 but that's both more complicated and less performant so let's not do this hereThe code in your question contains the most important bits, namely 3b and 3c of the above list. But we need to add 1, 2 and 3a so let's do this :)
Let's start with 2) ALPN:
let tlsConfig = TLSConfiguration.forClient(applicationProtocols: ["h2"])
let sslContext = try SSLContext(configuration: tlsConfig)
This is an SSL configuration with the "h2"
ALPN protocol identifier there which will tell the server that we want to speak HTTP/2 as documented in the HTTP/2 spec.
Ok, let's add TLS with the sslContext
set up before:
let sslHandler = try! OpenSSLClientHandler(context: sslContext, serverHostname: hostname)
It's also important that we tell the OpenSSLClientHandler
the server's hostname so it can validate the certificate properly.
Lastly we need to do 3a (creating a new HTTP/2 stream to issue our request on) which can be easily done using a ChannelHandler
:
/// Creates a new HTTP/2 stream when our channel is active and adds the `SendAGETRequestHandler` so a request is sent.
final class CreateRequestStreamHandler: ChannelInboundHandler {
typealias InboundIn = Never
private let multiplexer: HTTP2StreamMultiplexer
private let responseReceivedPromise: EventLoopPromise<[HTTPClientResponsePart]>
init(multiplexer: HTTP2StreamMultiplexer, responseReceivedPromise: EventLoopPromise<[HTTPClientResponsePart]>) {
self.multiplexer = multiplexer
self.responseReceivedPromise = responseReceivedPromise
}
func channelActive(ctx: ChannelHandlerContext) {
func requestStreamInitializer(channel: Channel, streamID: HTTP2StreamID) -> EventLoopFuture<Void> {
return channel.pipeline.addHandlers([HTTP2ToHTTP1ClientCodec(streamID: streamID, httpProtocol: .https),
SendAGETRequestHandler(responseReceivedPromise: self.responseReceivedPromise)],
first: false)
}
// this is the most important line: When the channel is active we add the `HTTP2ToHTTP1ClientCodec` to deal in HTTP/1 messages as well as the `SendAGETRequestHandler` which will send a request.
self.multiplexer.createStreamChannel(promise: nil, requestStreamInitializer)
}
}
Okay, that's the scaffolding done. The SendAGETRequestHandler
is the last part which is a handler that will be added as soon as the new HTTP/2 stream that we have opened before has been opened successfully. To see the full response, I also implemented accumulating all bits of the response together into a promise:
/// Fires off a GET request when our stream is active and collects all response parts into a promise.
///
/// - warning: This will read the whole response into memory and delivers it into a promise.
final class SendAGETRequestHandler: ChannelInboundHandler {
typealias InboundIn = HTTPClientResponsePart
typealias OutboundOut = HTTPClientRequestPart
private let responseReceivedPromise: EventLoopPromise<[HTTPClientResponsePart]>
private var responsePartAccumulator: [HTTPClientResponsePart] = []
init(responseReceivedPromise: EventLoopPromise<[HTTPClientResponsePart]>) {
self.responseReceivedPromise = responseReceivedPromise
}
func channelActive(ctx: ChannelHandlerContext) {
assert(ctx.channel.parent!.isActive)
var reqHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .GET, uri: "/")
reqHead.headers.add(name: "Host", value: hostname)
ctx.write(self.wrapOutboundOut(.head(reqHead)), promise: nil)
ctx.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: nil)
}
func channelRead(ctx: ChannelHandlerContext, data: NIOAny) {
let resPart = self.unwrapInboundIn(data)
self.responsePartAccumulator.append(resPart)
if case .end = resPart {
self.responseReceivedPromise.succeed(result: self.responsePartAccumulator)
}
}
}
To finish it up, let's set up the client's channel pipeline:
let bootstrap = ClientBootstrap(group: group)
.channelInitializer { channel in
let myEventLoop = channel.eventLoop
let sslHandler = try! OpenSSLClientHandler(context: sslContext, serverHostname: hostname)
let http2Parser = HTTP2Parser(mode: .client)
let http2Multiplexer = HTTP2StreamMultiplexer { (channel, streamID) -> EventLoopFuture<Void> in
return myEventLoop.newSucceededFuture(result: ())
}
return channel.pipeline.addHandlers([sslHandler,
http2Parser,
http2Multiplexer,
CreateRequestStreamHandler(multiplexer: http2Multiplexer,
responseReceivedPromise: responseReceivedPromise),
CollectErrorsAndCloseStreamHandler(responseReceivedPromise: responseReceivedPromise)],
first: false).map {
}
}
To see a fully working example, I put something together a PR for swift-nio-examples/http2-client
.
Oh, and the reason that NIO was claiming that the other end isn't speaking HTTP/2 properly was the lack of TLS. There was no OpenSSLHandler
so NIO was speaking plaintext HTTP/2 to a remote end which was speaking TLS and then the two peers don't understand each other :).
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With