Repository: apple/swift-nio-http2 (swift)

Issue:
HTTP2to1ServerCodec: should we be more tolerant with the headers the user provides
- we should probably lower-case the header names automatically
- we should probably just ignore `connection: keep-alive/close`
- we should probably drop `transfer-encoding: chunked`

Pull request:
Motivation:

HTTP/2 is mostly just HTTP(/1) semantics with streams on a new wire
protocol. However, there are certain things that are legal in HTTP/1 but
which are illegal in HTTP/2.

Modification:

Accept more of those messages and transform them into their HTTP/2
equivalent.

Result:

- Easier to write HTTP/2 clients & servers in NIO.
- fixes #152

Patch:
diff --git a/Sources/NIOHPACK/HPACKHeader.swift b/Sources/NIOHPACK/HPACKHeader.swift
index f5f90973..768bacc8 100644
--- a/Sources/NIOHPACK/HPACKHeader.swift
+++ b/Sources/NIOHPACK/HPACKHeader.swift
@@ -19,10 +19,31 @@ import NIOHTTP1
 /// information.
 public struct HPACKHeaders {
     internal var headers: [HPACKHeader]
-    
+
+    // see 8.1.2.2. Connection-Specific Header Fields in RFC 7540
+    static let illegalHeaders: [String] = ["connection", "keep-alive", "proxy-connection",
+                                           "transfer-encoding", "upgrade"]
+
+    /// Constructor that can be used to map between `HTTPHeaders` and `HPACKHeaders` types.
+    public init(httpHeaders: HTTPHeaders, normalizeHTTPHeaders: Bool) {
+        if normalizeHTTPHeaders {
+            self.headers = httpHeaders.map { HPACKHeader(name: $0.name.lowercased(), value: $0.value) }
+
+            let connectionHeaderValue = httpHeaders[canonicalForm: "connection"]
+            if !connectionHeaderValue.isEmpty {
+                self.headers.removeAll { header in
+                    return HPACKHeaders.illegalHeaders.contains(header.name) ||
+                        connectionHeaderValue.contains(header.name[...])
+                }
+            }
+        } else {
+            self.headers = httpHeaders.map { HPACKHeader(name: $0.name, value: $0.value) }
+        }
+    }
+
     /// Constructor that can be used to map between `HTTPHeaders` and `HPACKHeaders` types.
     public init(httpHeaders: HTTPHeaders) {
-        self.headers = httpHeaders.map { HPACKHeader(name: $0.name, value: $0.value) }
+        self.init(httpHeaders: httpHeaders, normalizeHTTPHeaders: true)
     }
     
     /// Construct a `HPACKHeaders` structure.
@@ -226,8 +247,8 @@ extension HPACKHeaders: Equatable {
         guard lhs.headers.count == rhs.headers.count else {
             return false
         }
-        let lhsNames = Set(lhs.headers.map { $0.name.lowercased() })
-        let rhsNames = Set(rhs.headers.map { $0.name.lowercased() })
+        let lhsNames = Set(lhs.headers.map { $0.name })
+        let rhsNames = Set(rhs.headers.map { $0.name })
         guard lhsNames == rhsNames else {
             return false
         }
diff --git a/Sources/NIOHTTP2/HTTP2ToHTTP1Codec.swift b/Sources/NIOHTTP2/HTTP2ToHTTP1Codec.swift
index 8228d28a..befea1ec 100644
--- a/Sources/NIOHTTP2/HTTP2ToHTTP1Codec.swift
+++ b/Sources/NIOHTTP2/HTTP2ToHTTP1Codec.swift
@@ -38,11 +38,23 @@ public final class HTTP2ToHTTP1ClientCodec: ChannelInboundHandler, ChannelOutbou
 
     private let streamID: HTTP2StreamID
     private let protocolString: String
+    private let normalizeHTTPHeaders: Bool
 
     private var headerStateMachine: HTTP2HeadersStateMachine = HTTP2HeadersStateMachine(mode: .client)
 
-    public init(streamID: HTTP2StreamID, httpProtocol: HTTPProtocol) {
+    /// Initializes a `HTTP2ToHTTP1ClientCodec` for the given `HTTP2StreamID`.
+    ///
+    /// - parameters:
+    ///    - streamID: The HTTP/2 stream ID this `HTTP2ToHTTP1ClientCodec` will be used for
+    ///    - httpProtocol: The protocol (usually `"http"` or `"https"` that is used).
+    ///    - normalizeHTTPHeaders: Whether to automatically normalize the HTTP headers to be suitable for HTTP/2.
+    ///                            The normalization will for example lower-case all heder names (as required by the
+    ///                            HTTP/2 spec) and remove headers that are unsuitable for HTTP/2 such as
+    ///                            headers related to HTTP/1's keep-alive behaviour. Unless you are sure that all your
+    ///                            headers conform to the HTTP/2 spec, you should leave this parameter set to `true`.
+    public init(streamID: HTTP2StreamID, httpProtocol: HTTPProtocol, normalizeHTTPHeaders: Bool) {
         self.streamID = streamID
+        self.normalizeHTTPHeaders = normalizeHTTPHeaders
 
         switch httpProtocol {
         case .http:
@@ -52,6 +64,15 @@ public final class HTTP2ToHTTP1ClientCodec: ChannelInboundHandler, ChannelOutbou
         }
     }
 
+    /// Initializes a `HTTP2ToHTTP1ClientCodec` for the given `HTTP2StreamID`.
+    ///
+    /// - parameters:
+    ///    - streamID: The HTTP/2 stream ID this `HTTP2ToHTTP1ClientCodec` will be used for
+    ///    - httpProtocol: The protocol (usually `"http"` or `"https"` that is used).
+    public convenience init(streamID: HTTP2StreamID, httpProtocol: HTTPProtocol) {
+        self.init(streamID: streamID, httpProtocol: httpProtocol, normalizeHTTPHeaders: true)
+    }
+
     public func channelRead(context: ChannelHandlerContext, data: NIOAny) {
         let frame = self.unwrapInboundIn(data)
 
@@ -91,7 +112,8 @@ public final class HTTP2ToHTTP1ClientCodec: ChannelInboundHandler, ChannelOutbou
         case .head(let head):
             do {
                 let h1Headers = try HTTPHeaders(requestHead: head, protocolString: self.protocolString)
-                let headerContent = HTTP2Frame.FramePayload.Headers(headers: HPACKHeaders(httpHeaders: h1Headers))
+                let headerContent = HTTP2Frame.FramePayload.Headers(headers: HPACKHeaders(httpHeaders: h1Headers,
+                                                                                          normalizeHTTPHeaders: self.normalizeHTTPHeaders))
                 let frame = HTTP2Frame(streamID: self.streamID, payload: .headers(headerContent))
                 context.write(self.wrapOutboundOut(frame), promise: promise)
             } catch {
@@ -105,7 +127,9 @@ public final class HTTP2ToHTTP1ClientCodec: ChannelInboundHandler, ChannelOutbou
         case .end(let trailers):
             let payload: HTTP2Frame.FramePayload
             if let trailers = trailers {
-                payload = .headers(.init(headers: HPACKHeaders(httpHeaders: trailers), endStream: true))
+                payload = .headers(.init(headers: HPACKHeaders(httpHeaders: trailers,
+                                                               normalizeHTTPHeaders: self.normalizeHTTPHeaders),
+                                         endStream: true))
             } else {
                 payload = .data(.init(data: .byteBuffer(context.channel.allocator.buffer(capacity: 0)), endStream: true))
             }
@@ -131,11 +155,26 @@ public final class HTTP2ToHTTP1ServerCodec: ChannelInboundHandler, ChannelOutbou
     public typealias OutboundOut = HTTP2Frame
 
     private let streamID: HTTP2StreamID
+    private let normalizeHTTPHeaders: Bool
 
     private var headerStateMachine: HTTP2HeadersStateMachine = HTTP2HeadersStateMachine(mode: .server)
 
-    public init(streamID: HTTP2StreamID) {
+    /// Initializes a `HTTP2ToHTTP1ServerCodec` for the given `HTTP2StreamID`.
+    ///
+    /// - parameters:
+    ///    - streamID: The HTTP/2 stream ID this `HTTP2ToHTTP1ServerCodec` will be used for
+    ///    - normalizeHTTPHeaders: Whether to automatically normalize the HTTP headers to be suitable for HTTP/2.
+    ///                            The normalization will for example lower-case all heder names (as required by the
+    ///                            HTTP/2 spec) and remove headers that are unsuitable for HTTP/2 such as
+    ///                            headers related to HTTP/1's keep-alive behaviour. Unless you are sure that all your
+    ///                            headers conform to the HTTP/2 spec, you should leave this parameter set to `true`.
+    public init(streamID: HTTP2StreamID, normalizeHTTPHeaders: Bool) {
         self.streamID = streamID
+        self.normalizeHTTPHeaders = normalizeHTTPHeaders
+    }
+
+    public convenience init(streamID: HTTP2StreamID) {
+        self.init(streamID: streamID, normalizeHTTPHeaders: true)
     }
 
     public func channelRead(context: ChannelHandlerContext, data: NIOAny) {
@@ -175,7 +214,8 @@ public final class HTTP2ToHTTP1ServerCodec: ChannelInboundHandler, ChannelOutbou
         switch responsePart {
         case .head(let head):
             let h1 = HTTPHeaders(responseHead: head)
-            let payload = HTTP2Frame.FramePayload.Headers(headers: HPACKHeaders(httpHeaders: h1))
+            let payload = HTTP2Frame.FramePayload.Headers(headers: HPACKHeaders(httpHeaders: h1,
+                                                                                normalizeHTTPHeaders: self.normalizeHTTPHeaders))
             let frame = HTTP2Frame(streamID: self.streamID, payload: .headers(payload))
             context.write(self.wrapOutboundOut(frame), promise: promise)
         case .body(let body):
@@ -186,7 +226,9 @@ public final class HTTP2ToHTTP1ServerCodec: ChannelInboundHandler, ChannelOutbou
             let payload: HTTP2Frame.FramePayload
 
             if let trailers = trailers {
-                payload = .headers(.init(headers: HPACKHeaders(httpHeaders: trailers), endStream: true))
+                payload = .headers(.init(headers: HPACKHeaders(httpHeaders: trailers,
+                                                               normalizeHTTPHeaders: self.normalizeHTTPHeaders),
+                                         endStream: true))
             } else {
                 payload = .data(.init(data: .byteBuffer(context.channel.allocator.buffer(capacity: 0)), endStream: true))
             }


Test patch:
diff --git a/Tests/NIOHPACKTests/HPACKCodingTests+XCTest.swift b/Tests/NIOHPACKTests/HPACKCodingTests+XCTest.swift
index c7db9d1d..011c7d6a 100644
--- a/Tests/NIOHPACKTests/HPACKCodingTests+XCTest.swift
+++ b/Tests/NIOHPACKTests/HPACKCodingTests+XCTest.swift
@@ -36,6 +36,7 @@ extension HPACKCodingTests {
                 ("testHPACKHeadersSubscript", testHPACKHeadersSubscript),
                 ("testHPACKHeadersWithZeroIndex", testHPACKHeadersWithZeroIndex),
                 ("testHPACKDecoderRespectsMaxHeaderListSize", testHPACKDecoderRespectsMaxHeaderListSize),
+                ("testDifferentlyCasedHPACKHeadersAreNotEqual", testDifferentlyCasedHPACKHeadersAreNotEqual),
            ]
    }
 }
diff --git a/Tests/NIOHPACKTests/HPACKCodingTests.swift b/Tests/NIOHPACKTests/HPACKCodingTests.swift
index c6ed4ad4..6be046a2 100644
--- a/Tests/NIOHPACKTests/HPACKCodingTests.swift
+++ b/Tests/NIOHPACKTests/HPACKCodingTests.swift
@@ -608,4 +608,18 @@ class HPACKCodingTests: XCTestCase {
         let decoded = try decoder.decodeHeaders(from: &request)
         XCTAssertEqual(decoded, HPACKHeaders(Array(repeatElement((":method", "GET"), count: 1000))))
     }
+
+    func testDifferentlyCasedHPACKHeadersAreNotEqual() {
+        let variants: [HPACKHeaders] = [HPACKHeaders([("foo", "foox")]), HPACKHeaders([("Foo", "foo")]),
+                                        HPACKHeaders([("foo", "Foo")]), HPACKHeaders([("Foo", "Foo")])]
+
+        for v1 in variants.indices {
+            for v2 in variants.indices {
+                guard v1 != v2 else {
+                    continue
+                }
+                XCTAssertNotEqual(variants[v1], variants[v2], "indices \(v1) and \(v2)")
+            }
+        }
+    }
 }
diff --git a/Tests/NIOHTTP2Tests/HTTP2ToHTTP1CodecTests+XCTest.swift b/Tests/NIOHTTP2Tests/HTTP2ToHTTP1CodecTests+XCTest.swift
index 916b35d7..d49508b9 100644
--- a/Tests/NIOHTTP2Tests/HTTP2ToHTTP1CodecTests+XCTest.swift
+++ b/Tests/NIOHTTP2Tests/HTTP2ToHTTP1CodecTests+XCTest.swift
@@ -54,6 +54,12 @@ extension HTTP2ToHTTP1CodecTests {
                 ("testSendRequestWithoutHost", testSendRequestWithoutHost),
                 ("testSendRequestWithDuplicateHost", testSendRequestWithDuplicateHost),
                 ("testFramesWithoutHTTP1EquivalentAreIgnored", testFramesWithoutHTTP1EquivalentAreIgnored),
+                ("testWeTolerateUpperCasedHTTP1HeadersForRequests", testWeTolerateUpperCasedHTTP1HeadersForRequests),
+                ("testWeTolerateUpperCasedHTTP1HeadersForResponses", testWeTolerateUpperCasedHTTP1HeadersForResponses),
+                ("testWeDoNotNormalizeHeadersIfUserAskedUsNotToForRequests", testWeDoNotNormalizeHeadersIfUserAskedUsNotToForRequests),
+                ("testWeDoNotNormalizeHeadersIfUserAskedUsNotToForResponses", testWeDoNotNormalizeHeadersIfUserAskedUsNotToForResponses),
+                ("testWeStripIllegalHeadersAsWellAsTheHeadersNominatedByTheConnectionHeaderForRequests", testWeStripIllegalHeadersAsWellAsTheHeadersNominatedByTheConnectionHeaderForRequests),
+                ("testWeStripIllegalHeadersAsWellAsTheHeadersNominatedByTheConnectionHeaderForResponses", testWeStripIllegalHeadersAsWellAsTheHeadersNominatedByTheConnectionHeaderForResponses),
            ]
    }
 }
diff --git a/Tests/NIOHTTP2Tests/HTTP2ToHTTP1CodecTests.swift b/Tests/NIOHTTP2Tests/HTTP2ToHTTP1CodecTests.swift
index 3966e53d..28ae6a2e 100644
--- a/Tests/NIOHTTP2Tests/HTTP2ToHTTP1CodecTests.swift
+++ b/Tests/NIOHTTP2Tests/HTTP2ToHTTP1CodecTests.swift
@@ -176,7 +176,7 @@ final class HTTP2ToHTTP1CodecTests: XCTestCase {
         writeRecorder.flushedWrites[1].assertDataFrame(endStream: false, streamID: 1, payload: bodyData)
 
         // Now trailers.
-        let trailers = HPACKHeaders([("a trailer", "yes"), ("another trailer", "still yes")])
+        let trailers = HPACKHeaders([("a-trailer", "yes"), ("another-trailer", "still yes")])
         self.channel.writeAndFlush(HTTPServerResponsePart.end(HTTPHeaders(regularHeadersFrom: trailers)), promise: nil)
         XCTAssertEqual(writeRecorder.flushedWrites.count, 3)
         writeRecorder.flushedWrites[2].assertHeadersFrame(endStream: true, streamID: 1, headers: trailers)
@@ -359,7 +359,7 @@ final class HTTP2ToHTTP1CodecTests: XCTestCase {
         writeRecorder.flushedWrites[1].assertDataFrame(endStream: false, streamID: 1, payload: bodyData)
 
         // Now trailers.
-        let trailers = HPACKHeaders([("a trailer", "yes"), ("another trailer", "still yes")])
+        let trailers = HPACKHeaders([("a-trailer", "yes"), ("another-trailer", "still yes")])
         self.channel.writeAndFlush(HTTPClientRequestPart.end(HTTPHeaders(regularHeadersFrom: trailers)), promise: nil)
         XCTAssertEqual(writeRecorder.flushedWrites.count, 3)
         writeRecorder.flushedWrites[2].assertHeadersFrame(endStream: true, streamID: 1, headers: trailers)
@@ -662,4 +662,124 @@ final class HTTP2ToHTTP1CodecTests: XCTestCase {
         }
         XCTAssertNoThrow(XCTAssertTrue(try self.channel.finish().isClean))
     }
+
+    func testWeTolerateUpperCasedHTTP1HeadersForRequests() throws {
+        let streamID = HTTP2StreamID(1)
+        let writeRecorder = FrameWriteRecorder()
+        XCTAssertNoThrow(try self.channel.pipeline.addHandler(writeRecorder).wait())
+        XCTAssertNoThrow(try self.channel.pipeline.addHandler(HTTP2ToHTTP1ClientCodec(streamID: streamID, httpProtocol: .https)).wait())
+
+        // A basic request.
+        var requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "/post")
+        requestHead.headers = HTTPHeaders([("host", "example.org"), ("UpperCased", "Header")])
+        self.channel.writeAndFlush(HTTPClientRequestPart.head(requestHead), promise: nil)
+
+        let expectedRequestHeaders = HPACKHeaders([(":path", "/post"), (":method", "POST"), (":scheme", "https"), (":authority", "example.org"), ("uppercased", "Header")])
+        XCTAssertEqual(writeRecorder.flushedWrites.count, 1)
+        writeRecorder.flushedWrites[0].assertHeadersFrame(endStream: false,
+                                                          streamID: 1,
+                                                          headers: expectedRequestHeaders,
+                                                          type: .request)
+    }
+
+    func testWeTolerateUpperCasedHTTP1HeadersForResponses() throws {
+        let streamID = HTTP2StreamID(1)
+        let writeRecorder = FrameWriteRecorder()
+        XCTAssertNoThrow(try self.channel.pipeline.addHandler(writeRecorder).wait())
+        XCTAssertNoThrow(try self.channel.pipeline.addHandler(HTTP2ToHTTP1ServerCodec(streamID: streamID)).wait())
+
+        var responseHead = HTTPResponseHead(version: .init(major: 2, minor: 0), status: .ok)
+        responseHead.headers = HTTPHeaders([("UpperCased", "Header")])
+        self.channel.writeAndFlush(HTTPServerResponsePart.head(responseHead), promise: nil)
+
+        let expectedRequestHeaders = HPACKHeaders([(":status", "200"), ("uppercased", "Header")])
+        XCTAssertEqual(writeRecorder.flushedWrites.count, 1)
+        writeRecorder.flushedWrites[0].assertHeadersFrame(endStream: false,
+                                                          streamID: 1,
+                                                          headers: expectedRequestHeaders,
+                                                          type: .response)
+    }
+
+    func testWeDoNotNormalizeHeadersIfUserAskedUsNotToForRequests() throws {
+        let streamID = HTTP2StreamID(1)
+        let writeRecorder = FrameWriteRecorder()
+        XCTAssertNoThrow(try self.channel.pipeline.addHandler(writeRecorder).wait())
+        XCTAssertNoThrow(try self.channel.pipeline.addHandler(HTTP2ToHTTP1ClientCodec(streamID: streamID,
+                                                                                      httpProtocol: .https,
+                                                                                      normalizeHTTPHeaders: false)).wait())
+
+        // A basic request.
+        var requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "/post")
+        requestHead.headers = HTTPHeaders([("host", "example.org"), ("UpperCased", "Header")])
+        self.channel.writeAndFlush(HTTPClientRequestPart.head(requestHead), promise: nil)
+
+        let expectedRequestHeaders = HPACKHeaders([(":path", "/post"), (":method", "POST"), (":scheme", "https"),
+                                                   (":authority", "example.org"), ("UpperCased", "Header")])
+        XCTAssertEqual(writeRecorder.flushedWrites.count, 1)
+        writeRecorder.flushedWrites[0].assertHeadersFrame(endStream: false,
+                                                          streamID: 1,
+                                                          headers: expectedRequestHeaders,
+                                                          type: .doNotValidate)
+    }
+
+    func testWeDoNotNormalizeHeadersIfUserAskedUsNotToForResponses() throws {
+        let streamID = HTTP2StreamID(1)
+        let writeRecorder = FrameWriteRecorder()
+        XCTAssertNoThrow(try self.channel.pipeline.addHandler(writeRecorder).wait())
+        XCTAssertNoThrow(try self.channel.pipeline.addHandler(HTTP2ToHTTP1ServerCodec(streamID: streamID,
+                                                                                      normalizeHTTPHeaders: false)).wait())
+
+        var responseHead = HTTPResponseHead(version: .init(major: 2, minor: 0), status: .ok)
+        responseHead.headers = HTTPHeaders([("UpperCased", "Header")])
+        self.channel.writeAndFlush(HTTPServerResponsePart.head(responseHead), promise: nil)
+
+        let expectedRequestHeaders = HPACKHeaders([(":status", "200"), ("UpperCased", "Header")])
+        XCTAssertEqual(writeRecorder.flushedWrites.count, 1)
+        writeRecorder.flushedWrites[0].assertHeadersFrame(endStream: false,
+                                                          streamID: 1,
+                                                          headers: expectedRequestHeaders,
+                                                          type: .doNotValidate)
+    }
+
+    func testWeStripIllegalHeadersAsWellAsTheHeadersNominatedByTheConnectionHeaderForRequests() throws {
+        let streamID = HTTP2StreamID(1)
+        let writeRecorder = FrameWriteRecorder()
+        XCTAssertNoThrow(try self.channel.pipeline.addHandler(writeRecorder).wait())
+        XCTAssertNoThrow(try self.channel.pipeline.addHandler(HTTP2ToHTTP1ClientCodec(streamID: streamID, httpProtocol: .https)).wait())
+
+        // A basic request.
+        var requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "/post")
+        requestHead.headers = HTTPHeaders([("host", "example.org"), ("connection", "keep-alive, also-to-be-removed"),
+                                           ("keep-alive", "foo"), ("also-to-be-removed", "yes"), ("should", "stay"),
+                                           ("Proxy-Connection", "bad"), ("Transfer-Encoding", "also bad")])
+        self.channel.writeAndFlush(HTTPClientRequestPart.head(requestHead), promise: nil)
+
+        let expectedRequestHeaders = HPACKHeaders([(":path", "/post"), (":method", "POST"), (":scheme", "https"),
+                                                   (":authority", "example.org"), ("should", "stay")])
+        XCTAssertEqual(writeRecorder.flushedWrites.count, 1)
+        writeRecorder.flushedWrites[0].assertHeadersFrame(endStream: false,
+                                                          streamID: 1,
+                                                          headers: expectedRequestHeaders,
+                                                          type: .request)
+    }
+
+    func testWeStripIllegalHeadersAsWellAsTheHeadersNominatedByTheConnectionHeaderForResponses() throws {
+        let streamID = HTTP2StreamID(1)
+        let writeRecorder = FrameWriteRecorder()
+        XCTAssertNoThrow(try self.channel.pipeline.addHandler(writeRecorder).wait())
+        XCTAssertNoThrow(try self.channel.pipeline.addHandler(HTTP2ToHTTP1ServerCodec(streamID: streamID)).wait())
+
+        var responseHead = HTTPResponseHead(version: .init(major: 2, minor: 0), status: .ok)
+        responseHead.headers = HTTPHeaders([("connection", "keep-alive, also-to-be-removed"),
+                                            ("keep-alive", "foo"), ("also-to-be-removed", "yes"), ("should", "stay"),
+                                            ("Proxy-Connection", "bad"), ("Transfer-Encoding", "also bad")])
+        self.channel.writeAndFlush(HTTPServerResponsePart.head(responseHead), promise: nil)
+
+        let expectedRequestHeaders = HPACKHeaders([(":status", "200"), ("should", "stay")])
+        XCTAssertEqual(writeRecorder.flushedWrites.count, 1)
+        writeRecorder.flushedWrites[0].assertHeadersFrame(endStream: false,
+                                                          streamID: 1,
+                                                          headers: expectedRequestHeaders,
+                                                          type: .response)
+    }
 }
diff --git a/Tests/NIOHTTP2Tests/TestUtilities.swift b/Tests/NIOHTTP2Tests/TestUtilities.swift
index 023c5136..8764fab4 100644
--- a/Tests/NIOHTTP2Tests/TestUtilities.swift
+++ b/Tests/NIOHTTP2Tests/TestUtilities.swift
@@ -22,7 +22,7 @@ import Glibc
 import XCTest
 import NIO
 import NIOHTTP1
-import NIOHTTP2
+@testable import NIOHTTP2
 import NIOHPACK
 
 struct NoFrameReceived: Error { }
@@ -252,9 +252,17 @@ extension HTTP2Frame {
                                 line: line)
     }
 
+    enum HeadersType {
+        case request
+        case response
+        case trailers
+        case doNotValidate
+    }
+
     /// Asserts the given frame is a HEADERS frame.
     func assertHeadersFrame(endStream: Bool, streamID: HTTP2StreamID, headers: HPACKHeaders,
                             priority: HTTP2Frame.StreamPriorityData? = nil,
+                            type: HeadersType? = nil,
                             file: StaticString = #file, line: UInt = #line) {
         guard case .headers(let actualPayload) = self.payload else {
             XCTFail("Expected HEADERS frame, got \(self.payload) instead", file: file, line: line)
@@ -267,6 +275,26 @@ extension HTTP2Frame {
                        "Unexpected streamID: expected \(streamID), got \(self.streamID)", file: file, line: line)
         XCTAssertEqual(headers, actualPayload.headers, "Non-equal headers: expected \(headers), got \(actualPayload.headers)", file: file, line: line)
         XCTAssertEqual(priority, actualPayload.priorityData, "Non-equal priorities: expected \(String(describing: priority)), got \(String(describing: actualPayload.priorityData))", file: file, line: line)
+
+        switch type {
+        case .some(.request):
+            XCTAssertNoThrow(try actualPayload.headers.validateRequestBlock(),
+                             "\(actualPayload.headers) not a valid \(type!) headers block", file: file, line: line)
+        case .some(.response):
+            XCTAssertNoThrow(try actualPayload.headers.validateResponseBlock(),
+                             "\(actualPayload.headers) not a valid \(type!) headers block", file: file, line: line)
+        case .some(.trailers):
+            XCTAssertNoThrow(try actualPayload.headers.validateTrailersBlock(),
+                             "\(actualPayload.headers) not a valid \(type!) headers block", file: file, line: line)
+        case .some(.doNotValidate):
+            () // alright, let's not validate then
+        case .none:
+            XCTAssertTrue((try? actualPayload.headers.validateRequestBlock()) != nil ||
+                (try? actualPayload.headers.validateResponseBlock()) != nil ||
+                (try? actualPayload.headers.validateTrailersBlock()) != nil,
+                          "\(actualPayload.headers) not a valid request/response/trailers header block",
+                file: file, line: line)
+        }
     }
 
     /// Asserts that a given frame is a DATA frame matching this one.