diff --git a/.spi.yml b/.spi.yml index b8df20c..b65d0fd 100644 --- a/.spi.yml +++ b/.spi.yml @@ -1,4 +1,4 @@ version: 1 builder: configs: - - documentation_targets: [NIOExtras, NIOHTTPCompression, NIOSOCKS] + - documentation_targets: [NIOExtras, NIOHTTPCompression, NIOSOCKS, NIOHTTPTypes, NIOHTTPTypesHTTP1, NIOHTTPTypesHTTP2] diff --git a/Package.swift b/Package.swift index 0715732..a38a220 100644 --- a/Package.swift +++ b/Package.swift @@ -123,6 +123,34 @@ var targets: [PackageDescription.Target] = [ .product(name: "NIOEmbedded", package: "swift-nio"), .product(name: "NIOTestUtils", package: "swift-nio"), ]), + .target( + name: "NIOHTTPTypes", + dependencies: [ + .product(name: "HTTPTypes", package: "swift-http-types"), + .product(name: "NIOCore", package: "swift-nio"), + ]), + .target( + name: "NIOHTTPTypesHTTP1", + dependencies: [ + "NIOHTTPTypes", + .product(name: "NIOHTTP1", package: "swift-nio"), + ]), + .target( + name: "NIOHTTPTypesHTTP2", + dependencies: [ + "NIOHTTPTypes", + .product(name: "NIOHTTP2", package: "swift-nio-http2"), + ]), + .testTarget( + name: "NIOHTTPTypesHTTP1Tests", + dependencies: [ + "NIOHTTPTypesHTTP1", + ]), + .testTarget( + name: "NIOHTTPTypesHTTP2Tests", + dependencies: [ + "NIOHTTPTypesHTTP2", + ]), ] let package = Package( @@ -131,10 +159,15 @@ let package = Package( .library(name: "NIOExtras", targets: ["NIOExtras"]), .library(name: "NIOSOCKS", targets: ["NIOSOCKS"]), .library(name: "NIOHTTPCompression", targets: ["NIOHTTPCompression"]), + .library(name: "NIOHTTPTypes", targets: ["NIOHTTPTypes"]), + .library(name: "NIOHTTPTypesHTTP1", targets: ["NIOHTTPTypesHTTP1"]), + .library(name: "NIOHTTPTypesHTTP2", targets: ["NIOHTTPTypesHTTP2"]), ], dependencies: [ .package(url: "https://github.com/apple/swift-nio.git", from: "2.42.0"), + .package(url: "https://github.com/apple/swift-nio-http2.git", from: "1.27.0"), .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-http-types", from: "0.1.0"), ], targets: targets ) diff --git a/README.md b/README.md index 9ec63b1..e7f945d 100644 --- a/README.md +++ b/README.md @@ -50,3 +50,9 @@ On the [`nio-extras-0.1`](https://github.com/apple/swift-nio-extras/tree/nio-ext - [`DebugInboundsEventHandler`](Sources/NIOExtras/DebugInboundEventsHandler.swift) Prints out all inbound events that travel through the `ChannelPipeline`. - [`DebugOutboundsEventHandler`](Sources/NIOExtras/DebugOutboundEventsHandler.swift) Prints out all outbound events that travel through the `ChannelPipeline`. - [`WritePCAPHandler`](Sources/NIOExtras/WritePCAPHandler.swift) A `ChannelHandler` that writes `.pcap` containing the traffic of the `ChannelPipeline` that you can inspect with Wireshark/tcpdump. +- [`HTTP1ToHTTPClientCodec`](Sources/NIOHTTPTypesHTTP1/HTTP1ToHTTPCodec.swift) A `ChannelHandler` that translates HTTP/1 messages into shared HTTP types for the client side. +- [`HTTP1ToHTTPServerCodec`](Sources/NIOHTTPTypesHTTP1/HTTP1ToHTTPCodec.swift) A `ChannelHandler` that translates HTTP/1 messages into shared HTTP types for the server side. +- [`HTTPToHTTP1ClientCodec`](Sources/NIOHTTPTypesHTTP1/HTTPToHTTP1Codec.swift) A `ChannelHandler` that translates shared HTTP types into HTTP/1 messages for the client side for compatibility purposes. +- [`HTTPToHTTP1ServerCodec`](Sources/NIOHTTPTypesHTTP1/HTTPToHTTP1Codec.swift) A `ChannelHandler` that translates shared HTTP types into HTTP/1 messages for the server side for compatibility purposes. +- [`HTTP2FramePayloadToHTTPClientCodec`](Sources/NIOHTTPTypesHTTP2/HTTP2ToHTTPCodec.swift) A `ChannelHandler` that translates HTTP/2 concepts into shared HTTP types for the client side. +- [`HTTP2FramePayloadToHTTPServerCodec`](Sources/NIOHTTPTypesHTTP2/HTTP2ToHTTPCodec.swift) A `ChannelHandler` that translates HTTP/2 concepts into shared HTTP types for the server side. diff --git a/Sources/NIOHTTPTypes/NIOHTTPTypes.swift b/Sources/NIOHTTPTypes/NIOHTTPTypes.swift new file mode 100644 index 0000000..b52399f --- /dev/null +++ b/Sources/NIOHTTPTypes/NIOHTTPTypes.swift @@ -0,0 +1,44 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore +import HTTPTypes + +/// The parts of a complete HTTP message, either request or response. +/// +/// An HTTP message is made up of a request, or one or more response headers, +/// encoded by `.head`, zero or more body parts, and optionally some trailers. To +/// indicate that a complete HTTP message has been sent or received, we use `.end`, +/// which may also contain any trailers that make up the message. +public enum HTTPTypePart { + case head(HeadT) + case body(BodyT) + case end(HTTPFields?) +} + +extension HTTPTypePart: Sendable where HeadT: Sendable, BodyT: Sendable {} + +extension HTTPTypePart: Equatable {} + +/// The components of an HTTP request from the view of an HTTP client. +public typealias HTTPTypeClientRequestPart = HTTPTypePart + +/// The components of an HTTP request from the view of an HTTP server. +public typealias HTTPTypeServerRequestPart = HTTPTypePart + +/// The components of an HTTP response from the view of an HTTP client. +public typealias HTTPTypeClientResponsePart = HTTPTypePart + +/// The components of an HTTP response from the view of an HTTP server. +public typealias HTTPTypeServerResponsePart = HTTPTypePart diff --git a/Sources/NIOHTTPTypesHTTP1/HTTP1ToHTTPCodec.swift b/Sources/NIOHTTPTypesHTTP1/HTTP1ToHTTPCodec.swift new file mode 100644 index 0000000..338b104 --- /dev/null +++ b/Sources/NIOHTTPTypesHTTP1/HTTP1ToHTTPCodec.swift @@ -0,0 +1,110 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore +import NIOHTTP1 +import NIOHTTPTypes + +/// A simple channel handler that translates HTTP/1 messages into shared HTTP types, +/// and vice versa, for use on the client side. +public final class HTTP1ToHTTPClientCodec: ChannelInboundHandler, ChannelOutboundHandler { + public typealias InboundIn = HTTPClientResponsePart + public typealias InboundOut = HTTPTypeClientResponsePart + + public typealias OutboundIn = HTTPTypeClientRequestPart + public typealias OutboundOut = HTTPClientRequestPart + + /// Initializes a `HTTP1ToHTTPClientCodec`. + public init() { + } + + public func channelRead(context: ChannelHandlerContext, data: NIOAny) { + switch unwrapInboundIn(data) { + case .head(let head): + do { + context.fireChannelRead(wrapInboundOut(.head(try head.newResponse))) + } catch { + context.fireErrorCaught(error) + } + case .body(let body): + context.fireChannelRead(wrapInboundOut(.body(body))) + case .end(let trailers): + context.fireChannelRead(wrapInboundOut(.end(trailers?.newFields(splitCookie: false)))) + } + } + + public func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + switch unwrapOutboundIn(data) { + case .head(let request): + do { + context.write(wrapOutboundOut(.head(try HTTPRequestHead(request))), promise: promise) + } catch { + context.fireErrorCaught(error) + } + case .body(let body): + context.write(wrapOutboundOut(.body(body)), promise: promise) + case .end(let trailers): + context.write(wrapOutboundOut(.end(trailers.map(HTTPHeaders.init))), promise: promise) + } + } +} + +/// A simple channel handler that translates HTTP/1 messages into shared HTTP types, +/// and vice versa, for use on the server side. +public final class HTTP1ToHTTPServerCodec: ChannelInboundHandler, ChannelOutboundHandler { + public typealias InboundIn = HTTPServerRequestPart + public typealias InboundOut = HTTPTypeServerRequestPart + + public typealias OutboundIn = HTTPTypeServerResponsePart + public typealias OutboundOut = HTTPServerResponsePart + + private let secure: Bool + private let splitCookie: Bool + + /// Initializes a `HTTP1ToHTTPServerCodec`. + /// - Parameters: + /// - secure: Whether "https" or "http" is used. + /// - splitCookie: Whether the cookies received from the server should be split + /// into multiple header fields. Defaults to false. + public init(secure: Bool, splitCookie: Bool = false) { + self.secure = secure + self.splitCookie = splitCookie + } + + public func channelRead(context: ChannelHandlerContext, data: NIOAny) { + switch unwrapInboundIn(data) { + case .head(let head): + do { + context.fireChannelRead(wrapInboundOut(.head(try head.newRequest(secure: secure, splitCookie: splitCookie)))) + } catch { + context.fireErrorCaught(error) + } + case .body(let body): + context.fireChannelRead(wrapInboundOut(.body(body))) + case .end(let trailers): + context.fireChannelRead(wrapInboundOut(.end(trailers?.newFields(splitCookie: false)))) + } + } + + public func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + switch unwrapOutboundIn(data) { + case .head(let response): + context.write(wrapOutboundOut(.head(HTTPResponseHead(response))), promise: promise) + case .body(let body): + context.write(wrapOutboundOut(.body(body)), promise: promise) + case .end(let trailers): + context.write(wrapOutboundOut(.end(trailers.map(HTTPHeaders.init))), promise: promise) + } + } +} diff --git a/Sources/NIOHTTPTypesHTTP1/HTTPToHTTP1Codec.swift b/Sources/NIOHTTPTypesHTTP1/HTTPToHTTP1Codec.swift new file mode 100644 index 0000000..2ec3313 --- /dev/null +++ b/Sources/NIOHTTPTypesHTTP1/HTTPToHTTP1Codec.swift @@ -0,0 +1,118 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore +import NIOHTTP1 +import NIOHTTPTypes + +/// A simple channel handler that translates shared HTTP types into HTTP/1 messages, +/// and vice versa, for use on the client side. +/// +/// This is intended for compatibility purposes where a channel handler working with +/// HTTP/1 messages needs to work on top of the new version-independent HTTP types +/// abstraction. +public final class HTTPToHTTP1ClientCodec: ChannelInboundHandler, ChannelOutboundHandler { + public typealias InboundIn = HTTPTypeClientResponsePart + public typealias InboundOut = HTTPClientResponsePart + + public typealias OutboundIn = HTTPClientRequestPart + public typealias OutboundOut = HTTPTypeClientRequestPart + + private let secure: Bool + private let splitCookie: Bool + + /// Initializes a `HTTPToHTTP1ClientCodec`. + /// - Parameters: + /// - secure: Whether "https" or "http" is used. + /// - splitCookie: Whether the cookies sent by the client should be split + /// into multiple header fields. Defaults to true. + public init(secure: Bool, splitCookie: Bool = true) { + self.secure = secure + self.splitCookie = splitCookie + } + + public func channelRead(context: ChannelHandlerContext, data: NIOAny) { + switch unwrapInboundIn(data) { + case .head(let head): + context.fireChannelRead(wrapInboundOut(.head(HTTPResponseHead(head)))) + case .body(let body): + context.fireChannelRead(wrapInboundOut(.body(body))) + case .end(let trailers): + context.fireChannelRead(wrapInboundOut(.end(trailers.map(HTTPHeaders.init)))) + } + } + + public func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + switch unwrapOutboundIn(data) { + case .head(let request): + do { + context.write(wrapOutboundOut(.head(try request.newRequest(secure: secure, splitCookie: splitCookie))), promise: promise) + } catch { + context.fireErrorCaught(error) + } + case .body(let body): + context.write(wrapOutboundOut(.body(body)), promise: promise) + case .end(let trailers): + context.write(wrapOutboundOut(.end(trailers?.newFields(splitCookie: false))), promise: promise) + } + } +} + +/// A simple channel handler that translates shared HTTP types into HTTP/1 messages, +/// and vice versa, for use on the server side. +/// +/// This is intended for compatibility purposes where a channel handler working with +/// HTTP/1 messages needs to work on top of the new version-independent HTTP types +/// abstraction. +public final class HTTPToHTTP1ServerCodec: ChannelInboundHandler, ChannelOutboundHandler { + public typealias InboundIn = HTTPTypeServerRequestPart + public typealias InboundOut = HTTPServerRequestPart + + public typealias OutboundIn = HTTPServerResponsePart + public typealias OutboundOut = HTTPTypeServerResponsePart + + /// Initializes a `HTTPToHTTP1ServerCodec`. + public init() { + } + + public func channelRead(context: ChannelHandlerContext, data: NIOAny) { + switch unwrapInboundIn(data) { + case .head(let head): + do { + context.fireChannelRead(wrapInboundOut(.head(try HTTPRequestHead(head)))) + } catch { + context.fireErrorCaught(error) + } + case .body(let body): + context.fireChannelRead(wrapInboundOut(.body(body))) + case .end(let trailers): + context.fireChannelRead(wrapInboundOut(.end(trailers.map(HTTPHeaders.init)))) + } + } + + public func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + switch unwrapOutboundIn(data) { + case .head(let response): + do { + context.write(wrapOutboundOut(.head(try response.newResponse)), promise: promise) + } catch { + context.fireErrorCaught(error) + } + case .body(let body): + context.write(wrapOutboundOut(.body(body)), promise: promise) + case .end(let trailers): + context.write(wrapOutboundOut(.end(trailers?.newFields(splitCookie: false))), promise: promise) + } + } +} diff --git a/Sources/NIOHTTPTypesHTTP1/HTTPTypeConversion.swift b/Sources/NIOHTTPTypesHTTP1/HTTPTypeConversion.swift new file mode 100644 index 0000000..79ed394 --- /dev/null +++ b/Sources/NIOHTTPTypesHTTP1/HTTPTypeConversion.swift @@ -0,0 +1,195 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOHTTP1 +import HTTPTypes + +private enum HTTP1TypeConversionError: Error { + case invalidMethod + case missingPath + case invalidStatusCode +} + +extension HTTPMethod { + init(_ newMethod: HTTPRequest.Method) { + switch newMethod { + case .get: self = .GET + case .put: self = .PUT + case .acl: self = .ACL + case .head: self = .HEAD + case .post: self = .POST + case .copy: self = .COPY + case .lock: self = .LOCK + case .move: self = .MOVE + case .bind: self = .BIND + case .link: self = .LINK + case .patch: self = .PATCH + case .trace: self = .TRACE + case .mkcol: self = .MKCOL + case .merge: self = .MERGE + case .purge: self = .PURGE + case .notify: self = .NOTIFY + case .search: self = .SEARCH + case .unlock: self = .UNLOCK + case .rebind: self = .REBIND + case .unbind: self = .UNBIND + case .report: self = .REPORT + case .delete: self = .DELETE + case .unlink: self = .UNLINK + case .connect: self = .CONNECT + case .msearch: self = .MSEARCH + case .options: self = .OPTIONS + case .propfind: self = .PROPFIND + case .checkout: self = .CHECKOUT + case .proppatch: self = .PROPPATCH + case .subscribe: self = .SUBSCRIBE + case .mkcalendar: self = .MKCALENDAR + case .mkactivity: self = .MKACTIVITY + case .unsubscribe: self = .UNSUBSCRIBE + case .source: self = .SOURCE + default: self = .RAW(value: newMethod.rawValue) + } + } + + var newMethod: HTTPRequest.Method { + get throws { + switch self { + case .GET: return .get + case .PUT: return .put + case .ACL: return .acl + case .HEAD: return .head + case .POST: return .post + case .COPY: return .copy + case .LOCK: return .lock + case .MOVE: return .move + case .BIND: return .bind + case .LINK: return .link + case .PATCH: return .patch + case .TRACE: return .trace + case .MKCOL: return .mkcol + case .MERGE: return .merge + case .PURGE: return .purge + case .NOTIFY: return .notify + case .SEARCH: return .search + case .UNLOCK: return .unlock + case .REBIND: return .rebind + case .UNBIND: return .unbind + case .REPORT: return .report + case .DELETE: return .delete + case .UNLINK: return .unlink + case .CONNECT: return .connect + case .MSEARCH: return .msearch + case .OPTIONS: return .options + case .PROPFIND: return .propfind + case .CHECKOUT: return .checkout + case .PROPPATCH: return .proppatch + case .SUBSCRIBE: return .subscribe + case .MKCALENDAR: return .mkcalendar + case .MKACTIVITY: return .mkactivity + case .UNSUBSCRIBE: return .unsubscribe + case .SOURCE: return .source + case .RAW(value: let value): + guard let method = HTTPRequest.Method(value) else { + throw HTTP1TypeConversionError.invalidMethod + } + return method + } + } + } +} + +extension HTTPHeaders { + init(_ newFields: HTTPFields) { + let fields = newFields.map { ($0.name.rawName, $0.value) } + self.init(fields) + } + + func newFields(splitCookie: Bool) -> HTTPFields { + var fields = HTTPFields() + fields.reserveCapacity(count) + for (index, field) in enumerated() { + if index == 0 && field.name.lowercased() == "host" { + continue + } + if let name = HTTPField.Name(field.name) { + if splitCookie && name == .cookie, #available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) { + fields.append(contentsOf: field.value.split(separator: "; ", omittingEmptySubsequences: false).map { + HTTPField(name: name, value: String($0)) + }) + } else { + fields.append(HTTPField(name: name, value: field.value)) + } + } + } + return fields + } +} + +extension HTTPRequestHead { + init(_ newRequest: HTTPRequest) throws { + guard let pathField = newRequest.pseudoHeaderFields.path else { + throw HTTP1TypeConversionError.missingPath + } + var headers = HTTPHeaders() + headers.reserveCapacity(newRequest.headerFields.count + 1) + if let authorityField = newRequest.pseudoHeaderFields.authority { + headers.add(name: "Host", value: authorityField.value) + } + var firstCookie = true + for field in newRequest.headerFields { + if field.name == .cookie { + if firstCookie { + firstCookie = false + headers.add(name: field.name.rawName, value: newRequest.headerFields[.cookie]!) + } + } else { + headers.add(name: field.name.rawName, value: field.value) + } + } + self.init(version: .http1_1, + method: HTTPMethod(newRequest.method), + uri: pathField.value, + headers: headers) + } + + func newRequest(secure: Bool, splitCookie: Bool) throws -> HTTPRequest { + let method = try method.newMethod + let scheme = secure ? "https" : "http" + let authority = headers.first.flatMap { $0.name.lowercased() == "host" ? $0.value : nil } + return HTTPRequest(method: method, + scheme: scheme, + authority: authority, + path: uri, + headerFields: headers.newFields(splitCookie: splitCookie)) + } +} + +extension HTTPResponseHead { + init(_ newResponse: HTTPResponse) { + self.init(version: .http1_1, + status: HTTPResponseStatus(statusCode: newResponse.status.code, + reasonPhrase: newResponse.status.reasonPhrase), + headers: HTTPHeaders(newResponse.headerFields)) + } + + var newResponse: HTTPResponse { + get throws { + guard status.code <= 999 else { + throw HTTP1TypeConversionError.invalidStatusCode + } + let status = HTTPResponse.Status(code: Int(status.code), reasonPhrase: status.reasonPhrase) + return HTTPResponse(status: status, headerFields: headers.newFields(splitCookie: false)) + } + } +} diff --git a/Sources/NIOHTTPTypesHTTP2/HTTP2HeadersStateMachine.swift b/Sources/NIOHTTPTypesHTTP2/HTTP2HeadersStateMachine.swift new file mode 100644 index 0000000..47a4e97 --- /dev/null +++ b/Sources/NIOHTTPTypesHTTP2/HTTP2HeadersStateMachine.swift @@ -0,0 +1,118 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOHTTP2 +import NIOHPACK + +extension HPACKHeaders { + /// Whether this `HTTPHeaders` corresponds to a final response or not. + /// + /// This function is only valid if called on a response header block. If the :status header + /// is not present, this will throw. + fileprivate func isInformationalResponse() throws -> Bool { + return try self.peekPseudoHeader(name: ":status").first! == "1" + } + + /// Grabs a pseudo-header from a header block. Does not remove it. + /// + /// - Parameters: + /// - name: The header name to find. + /// - Returns: The value for this pseudo-header. + /// - Throws: `NIOHTTP2Errors` if there is no such header, or multiple. + internal func peekPseudoHeader(name: String) throws -> String { + // This could be done with .lazy.filter.map but that generates way more ARC traffic. + var headerValue: String? = nil + + for (fieldName, fieldValue, _) in self { + if name == fieldName { + guard headerValue == nil else { + throw NIOHTTP2Errors.duplicatePseudoHeader(name) + } + headerValue = fieldValue + } + } + + if let headerValue = headerValue { + return headerValue + } else { + throw NIOHTTP2Errors.missingPseudoHeader(name) + } + } +} + +/// A state machine that keeps track of the header blocks sent or received and that determines the type of any +/// new header block. +struct HTTP2HeadersStateMachine { + /// The list of possible header frame types. + /// + /// This is used in combination with introspection of the HTTP header blocks to determine what HTTP header block + /// a certain HTTP header is. + enum HeaderType { + /// A request header block. + case requestHead + + /// An informational response header block. These can be sent zero or more times. + case informationalResponseHead + + /// A final response header block. + case finalResponseHead + + /// A trailer block. Once this is sent no further header blocks are acceptable. + case trailer + } + + /// The previous header block. + private var previousHeader: HeaderType? + + /// The mode of this connection: client or server. + private let mode: NIOHTTP2Handler.ParserMode + + init(mode: NIOHTTP2Handler.ParserMode) { + self.mode = mode + } + + /// Called when about to process an HTTP headers block to determine its type. + mutating func newHeaders(block: HPACKHeaders) throws -> HeaderType { + let newType: HeaderType + + switch (self.mode, self.previousHeader) { + case (.server, .none): + // The first header block received on a server mode stream must be a request block. + newType = .requestHead + case (.client, .none), + (.client, .some(.informationalResponseHead)): + // The first header block received on a client mode stream may be either informational or final, + // depending on the value of the :status pseudo-header. Alternatively, if the previous + // header block was informational, the same possibilities apply. + newType = try block.isInformationalResponse() ? .informationalResponseHead : .finalResponseHead + case (.server, .some(.requestHead)), + (.client, .some(.finalResponseHead)): + // If the server has already received a request head, or the client has already received a final response, + // this is a trailer block. + newType = .trailer + case (.server, .some(.informationalResponseHead)), + (.server, .some(.finalResponseHead)), + (.client, .some(.requestHead)): + // These states should not be reachable! + preconditionFailure("Invalid internal state!") + case (.server, .some(.trailer)), + (.client, .some(.trailer)): + // TODO(cory): This should probably throw, as this can happen in malformed programs without the world ending. + preconditionFailure("Sending too many header blocks.") + } + + self.previousHeader = newType + return newType + } +} diff --git a/Sources/NIOHTTPTypesHTTP2/HTTP2ToHTTPCodec.swift b/Sources/NIOHTTPTypesHTTP2/HTTP2ToHTTPCodec.swift new file mode 100644 index 0000000..7794fea --- /dev/null +++ b/Sources/NIOHTTPTypesHTTP2/HTTP2ToHTTPCodec.swift @@ -0,0 +1,244 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore +import NIOHTTP2 +import NIOHPACK +import HTTPTypes +import NIOHTTPTypes + +// MARK: - Client + +private struct BaseClientCodec { + private var headerStateMachine: HTTP2HeadersStateMachine = HTTP2HeadersStateMachine(mode: .client) + + private var outgoingHTTP1RequestHead: HTTPRequest? + + mutating func processInboundData(_ data: HTTP2Frame.FramePayload) throws -> (first: HTTPTypeClientResponsePart?, second: HTTPTypeClientResponsePart?) { + switch data { + case .headers(let headerContent): + switch try self.headerStateMachine.newHeaders(block: headerContent.headers) { + case .trailer: + return (first: .end(try headerContent.headers.newTrailers), second: nil) + + case .informationalResponseHead: + return (first: .head(try headerContent.headers.newResponse), second: nil) + + case .finalResponseHead: + guard outgoingHTTP1RequestHead != nil else { + preconditionFailure("Expected not to get a response without having sent a request") + } + self.outgoingHTTP1RequestHead = nil + let respHead = try headerContent.headers.newResponse + let first = HTTPTypeClientResponsePart.head(respHead) + var second: HTTPTypeClientResponsePart? = nil + if headerContent.endStream { + second = .end(nil) + } + return (first: first, second: second) + + case .requestHead: + preconditionFailure("A client can not receive request heads") + } + case .data(let content): + guard case .byteBuffer(let b) = content.data else { + preconditionFailure("Received DATA frame with non-bytebuffer IOData") + } + + var first = HTTPTypeClientResponsePart.body(b) + var second: HTTPTypeClientResponsePart? = nil + if content.endStream { + if b.readableBytes == 0 { + first = .end(nil) + } else { + second = .end(nil) + } + } + return (first: first, second: second) + case .alternativeService, .rstStream, .priority, .windowUpdate, .settings, .pushPromise, .ping, .goAway, .origin: + // These are not meaningful in HTTP messaging, so drop them. + return (first: nil, second: nil) + } + } + + mutating func processOutboundData(_ data: HTTPTypeClientRequestPart, allocator: ByteBufferAllocator) throws -> HTTP2Frame.FramePayload { + switch data { + case .head(let head): + precondition(self.outgoingHTTP1RequestHead == nil, "Only a single HTTP request allowed per HTTP2 stream") + self.outgoingHTTP1RequestHead = head + let headerContent = HTTP2Frame.FramePayload.Headers(headers: HPACKHeaders(head)) + return .headers(headerContent) + case .body(let body): + return .data(HTTP2Frame.FramePayload.Data(data: body)) + case .end(let trailers): + if let trailers = trailers { + return .headers(.init(headers: HPACKHeaders(trailers), + endStream: true)) + } else { + return .data(.init(data: .byteBuffer(allocator.buffer(capacity: 0)), endStream: true)) + } + } + } +} + +/// A simple channel handler that translates HTTP/2 concepts into shared HTTP types, +/// and vice versa, for use on the client side. +/// +/// Use this channel handler alongside the `HTTP2StreamMultiplexer` to +/// help provide an HTTP transaction-level abstraction on top of an HTTP/2 multiplexed +/// connection. +/// +/// This handler uses `HTTP2Frame.FramePayload` as its HTTP/2 currency type. +public final class HTTP2FramePayloadToHTTPClientCodec: ChannelInboundHandler, ChannelOutboundHandler { + public typealias InboundIn = HTTP2Frame.FramePayload + public typealias InboundOut = HTTPTypeClientResponsePart + + public typealias OutboundIn = HTTPTypeClientRequestPart + public typealias OutboundOut = HTTP2Frame.FramePayload + + private var baseCodec: BaseClientCodec = .init() + + public init() { + } + + public func channelRead(context: ChannelHandlerContext, data: NIOAny) { + let payload = self.unwrapInboundIn(data) + do { + let (first, second) = try self.baseCodec.processInboundData(payload) + if let first = first { + context.fireChannelRead(self.wrapInboundOut(first)) + } + if let second = second { + context.fireChannelRead(self.wrapInboundOut(second)) + } + } catch { + context.fireErrorCaught(error) + } + } + + public func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + let requestPart = self.unwrapOutboundIn(data) + + do { + let transformedPayload = try self.baseCodec.processOutboundData(requestPart, allocator: context.channel.allocator) + context.write(self.wrapOutboundOut(transformedPayload), promise: promise) + } catch { + promise?.fail(error) + context.fireErrorCaught(error) + } + } +} + +// MARK: - Server + +private struct BaseServerCodec { + private var headerStateMachine: HTTP2HeadersStateMachine = HTTP2HeadersStateMachine(mode: .server) + + mutating func processInboundData(_ data: HTTP2Frame.FramePayload) throws -> (first: HTTPTypeServerRequestPart?, second: HTTPTypeServerRequestPart?) { + switch data { + case .headers(let headerContent): + if case .trailer = try self.headerStateMachine.newHeaders(block: headerContent.headers) { + return (first: .end(try headerContent.headers.newTrailers), second: nil) + } else { + let reqHead = try headerContent.headers.newRequest + + let first = HTTPTypeServerRequestPart.head(reqHead) + var second: HTTPTypeServerRequestPart? = nil + if headerContent.endStream { + second = .end(nil) + } + return (first: first, second: second) + } + case .data(let dataContent): + guard case .byteBuffer(let b) = dataContent.data else { + preconditionFailure("Received non-byteBuffer IOData from network") + } + var first = HTTPTypeServerRequestPart.body(b) + var second: HTTPTypeServerRequestPart? = nil + if dataContent.endStream { + if b.readableBytes == 0 { + first = .end(nil) + } else { + second = .end(nil) + } + } + return (first: first, second: second) + default: + // Any other frame type is ignored. + return (first: nil, second: nil) + } + } + + mutating func processOutboundData(_ data: HTTPTypeServerResponsePart, allocator: ByteBufferAllocator) -> HTTP2Frame.FramePayload { + switch data { + case .head(let head): + let payload = HTTP2Frame.FramePayload.Headers(headers: HPACKHeaders(head)) + return .headers(payload) + case .body(let body): + let payload = HTTP2Frame.FramePayload.Data(data: body) + return .data(payload) + case .end(let trailers): + if let trailers = trailers { + return .headers(.init(headers: HPACKHeaders(trailers), + endStream: true)) + } else { + return .data(.init(data: .byteBuffer(allocator.buffer(capacity: 0)), endStream: true)) + } + } + } +} + + +/// A simple channel handler that translates HTTP/2 concepts into shared HTTP types, +/// and vice versa, for use on the server side. +/// +/// Use this channel handler alongside the `HTTP2StreamMultiplexer` to +/// help provide an HTTP transaction-level abstraction on top of an HTTP/2 multiplexed +/// connection. +/// +/// This handler uses `HTTP2Frame.FramePayload` as its HTTP/2 currency type. +public final class HTTP2FramePayloadToHTTPServerCodec: ChannelInboundHandler, ChannelOutboundHandler { + public typealias InboundIn = HTTP2Frame.FramePayload + public typealias InboundOut = HTTPTypeServerRequestPart + + public typealias OutboundIn = HTTPTypeServerResponsePart + public typealias OutboundOut = HTTP2Frame.FramePayload + + private var baseCodec: BaseServerCodec = .init() + + public init() { + } + + public func channelRead(context: ChannelHandlerContext, data: NIOAny) { + let payload = self.unwrapInboundIn(data) + + do { + let (first, second) = try self.baseCodec.processInboundData(payload) + if let first = first { + context.fireChannelRead(self.wrapInboundOut(first)) + } + if let second = second { + context.fireChannelRead(self.wrapInboundOut(second)) + } + } catch { + context.fireErrorCaught(error) + } + } + + public func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + let responsePart = self.unwrapOutboundIn(data) + let transformedPayload = self.baseCodec.processOutboundData(responsePart, allocator: context.channel.allocator) + context.write(self.wrapOutboundOut(transformedPayload), promise: promise) + } +} diff --git a/Sources/NIOHTTPTypesHTTP2/HTTPTypeConversion.swift b/Sources/NIOHTTPTypesHTTP2/HTTPTypeConversion.swift new file mode 100644 index 0000000..fd23663 --- /dev/null +++ b/Sources/NIOHTTPTypesHTTP2/HTTPTypeConversion.swift @@ -0,0 +1,263 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOHPACK +import HTTPTypes + +private enum HTTP2TypeConversionError: Error { + case multipleMethod + case multipleScheme + case multipleAuthority + case multiplePath + case multipleProtocol + case missingMethod + case invalidMethod + + case multipleStatus + case missingStatus + case invalidStatus + + case pseudoFieldNotFirst + case pseudoFieldInTrailers +} + +extension HPACKIndexing { + init(_ newIndexingStrategy: HTTPField.DynamicTableIndexingStrategy) { + switch newIndexingStrategy { + case .avoid: self = .nonIndexable + case .disallow: self = .neverIndexed + default: self = .indexable + } + } + + var newIndexingStrategy: HTTPField.DynamicTableIndexingStrategy { + switch self { + case .indexable: return .automatic + case .nonIndexable: return .avoid + case .neverIndexed: return .disallow + } + } +} + +extension HPACKHeaders { + mutating func add(newField field: HTTPField) { + add(name: field.name.canonicalName, value: field.value, indexing: HPACKIndexing(field.indexingStrategy)) + } + + init(_ newRequest: HTTPRequest) { + var headers = HPACKHeaders() + headers.reserveCapacity(newRequest.headerFields.count + 5) + + headers.add(newField: newRequest.pseudoHeaderFields.method) + if let field = newRequest.pseudoHeaderFields.scheme { + headers.add(newField: field) + } + if let field = newRequest.pseudoHeaderFields.authority { + headers.add(newField: field) + } + if let field = newRequest.pseudoHeaderFields.path { + headers.add(newField: field) + } + if let field = newRequest.pseudoHeaderFields.extendedConnectProtocol { + headers.add(newField: field) + } + for field in newRequest.headerFields { + headers.add(newField: field) + } + self = headers + } + + init(_ newResponse: HTTPResponse) { + var headers = HPACKHeaders() + headers.reserveCapacity(newResponse.headerFields.count + 1) + + headers.add(newField: newResponse.pseudoHeaderFields.status) + for field in newResponse.headerFields { + headers.add(newField: field) + } + self = headers + } + + init(_ newTrailers: HTTPFields) { + var headers = HPACKHeaders() + headers.reserveCapacity(newTrailers.count) + for field in newTrailers { + headers.add(newField: field) + } + self = headers + } + + var newRequest: HTTPRequest { + get throws { + var methodString: String? = nil + var methodIndexable: HPACKIndexing = .indexable + var schemeString: String? = nil + var schemeIndexable: HPACKIndexing = .indexable + var authorityString: String? = nil + var authorityIndexable: HPACKIndexing = .indexable + var pathString: String? = nil + var pathIndexable: HPACKIndexing = .indexable + var protocolString: String? = nil + var protocolIndexable: HPACKIndexing = .indexable + + var i = startIndex + while i != endIndex { + let (name, value, indexable) = self[i] + if !name.hasPrefix(":") { + break + } + switch name { + case ":method": + if methodString != nil { + throw HTTP2TypeConversionError.multipleMethod + } + methodString = value + methodIndexable = indexable + case ":scheme": + if schemeString != nil { + throw HTTP2TypeConversionError.multipleScheme + } + schemeString = value + schemeIndexable = indexable + case ":authority": + if authorityString != nil { + throw HTTP2TypeConversionError.multipleAuthority + } + authorityString = value + authorityIndexable = indexable + case ":path": + if pathString != nil { + throw HTTP2TypeConversionError.multiplePath + } + pathString = value + pathIndexable = indexable + case ":protocol": + if protocolString != nil { + throw HTTP2TypeConversionError.multipleProtocol + } + protocolString = value + protocolIndexable = indexable + default: + continue + } + i = index(after: i) + } + + guard let methodString else { + throw HTTP2TypeConversionError.missingMethod + } + guard let method = HTTPRequest.Method(methodString) else { + throw HTTP2TypeConversionError.invalidMethod + } + + var request = HTTPRequest(method: method, + scheme: schemeString, + authority: authorityString, + path: pathString) + request.pseudoHeaderFields.method.indexingStrategy = methodIndexable.newIndexingStrategy + request.pseudoHeaderFields.scheme?.indexingStrategy = schemeIndexable.newIndexingStrategy + request.pseudoHeaderFields.authority?.indexingStrategy = authorityIndexable.newIndexingStrategy + request.pseudoHeaderFields.path?.indexingStrategy = pathIndexable.newIndexingStrategy + if let protocolString { + request.extendedConnectProtocol = protocolString + request.pseudoHeaderFields.extendedConnectProtocol?.indexingStrategy = protocolIndexable.newIndexingStrategy + } + + request.headerFields.reserveCapacity(count) + while i != endIndex { + let (name, value, indexable) = self[i] + if name.hasPrefix(":") { + throw HTTP2TypeConversionError.pseudoFieldNotFirst + } + if let fieldName = HTTPField.Name(name) { + var field = HTTPField(name: fieldName, value: value) + field.indexingStrategy = indexable.newIndexingStrategy + request.headerFields.append(field) + } + i = index(after: i) + } + return request + } + } + + var newResponse: HTTPResponse { + get throws { + var statusString: String? = nil + var statusIndexable: HPACKIndexing = .indexable + + var i = startIndex + while i != endIndex { + let (name, value, indexable) = self[i] + if !name.hasPrefix(":") { + break + } + switch name { + case ":status": + if statusString != nil { + throw HTTP2TypeConversionError.multipleStatus + } + statusString = value + statusIndexable = indexable + default: + continue + } + i = index(after: i) + } + + guard let statusString else { + throw HTTP2TypeConversionError.missingStatus + } + guard let status = Int(statusString), + (0...999).contains(status) else { + throw HTTP2TypeConversionError.invalidStatus + } + + var response = HTTPResponse(status: HTTPResponse.Status(code: status)) + response.pseudoHeaderFields.status.indexingStrategy = statusIndexable.newIndexingStrategy + + response.headerFields.reserveCapacity(count) + while i != endIndex { + let (name, value, indexable) = self[i] + if name.hasPrefix(":") { + throw HTTP2TypeConversionError.pseudoFieldNotFirst + } + if let fieldName = HTTPField.Name(name) { + var field = HTTPField(name: fieldName, value: value) + field.indexingStrategy = indexable.newIndexingStrategy + response.headerFields.append(field) + } + i = index(after: i) + } + return response + } + } + + var newTrailers: HTTPFields { + get throws { + var fields = HTTPFields() + fields.reserveCapacity(count) + for (name, value, indexable) in self { + if name.hasPrefix(":") { + throw HTTP2TypeConversionError.pseudoFieldInTrailers + } + if let fieldName = HTTPField.Name(name) { + var field = HTTPField(name: fieldName, value: value) + field.indexingStrategy = indexable.newIndexingStrategy + fields.append(field) + } + } + return fields + } + } +} diff --git a/Tests/NIOHTTPTypesHTTP1Tests/NIOHTTPTypesHTTP1Tests.swift b/Tests/NIOHTTPTypesHTTP1Tests/NIOHTTPTypesHTTP1Tests.swift new file mode 100644 index 0000000..68dbdb2 --- /dev/null +++ b/Tests/NIOHTTPTypesHTTP1Tests/NIOHTTPTypesHTTP1Tests.swift @@ -0,0 +1,170 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import XCTest +import NIOCore +import NIOEmbedded +import NIOHTTP1 +import HTTPTypes +import NIOHTTPTypes +import NIOHTTPTypesHTTP1 + +/// A handler that keeps track of all reads made on a channel. +private final class InboundRecorder: ChannelInboundHandler { + typealias InboundIn = Frame + + var receivedFrames: [Frame] = [] + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + self.receivedFrames.append(self.unwrapInboundIn(data)) + } +} + +private extension HTTPField.Name { + static let xFoo = Self("X-Foo")! +} + +final class NIOHTTPTypesHTTP1Tests: XCTestCase { + var channel: EmbeddedChannel! + + override func setUp() { + super.setUp() + self.channel = EmbeddedChannel() + } + + override func tearDown() { + self.channel = nil + super.tearDown() + } + + static let request = HTTPRequest(method: .get, scheme: "https", authority: "www.example.com", path: "/", headerFields: [ + .accept: "*/*", + .acceptEncoding: "gzip", + .acceptEncoding: "br", + .cookie: "a=b", + .cookie: "c=d", + .trailer: "X-Foo", + ]) + + static let requestNoSplitCookie = HTTPRequest(method: .get, scheme: "https", authority: "www.example.com", path: "/", headerFields: [ + .accept: "*/*", + .acceptEncoding: "gzip", + .acceptEncoding: "br", + .cookie: "a=b; c=d", + .trailer: "X-Foo", + ]) + + static let oldRequest = HTTPRequestHead(version: .http1_1, method: .GET, uri: "/", headers: [ + "Host": "www.example.com", + "Accept": "*/*", + "Accept-Encoding": "gzip", + "Accept-Encoding": "br", + "Cookie": "a=b; c=d", + "Trailer": "X-Foo", + ]) + + static let response = HTTPResponse(status: .ok, headerFields: [ + .server: "HTTPServer/1.0", + .trailer: "X-Foo", + ]) + + static let oldResponse = HTTPResponseHead(version: .http1_1, status: .ok, headers: [ + "Server": "HTTPServer/1.0", + "Trailer": "X-Foo", + ]) + + static let trailers: HTTPFields = [.xFoo: "Bar"] + + static let oldTrailers: HTTPHeaders = ["X-Foo": "Bar"] + + func testClientHTTP1ToHTTP() throws { + let recorder = InboundRecorder() + + try self.channel.pipeline.addHandlers(HTTP1ToHTTPClientCodec(), recorder).wait() + + try self.channel.writeOutbound(HTTPTypeClientRequestPart.head(Self.request)) + try self.channel.writeOutbound(HTTPTypeClientRequestPart.end(Self.trailers)) + + XCTAssertEqual(try self.channel.readOutbound(as: HTTPClientRequestPart.self), .head(Self.oldRequest)) + XCTAssertEqual(try self.channel.readOutbound(as: HTTPClientRequestPart.self), .end(Self.oldTrailers)) + + try self.channel.writeInbound(HTTPClientResponsePart.head(Self.oldResponse)) + try self.channel.writeInbound(HTTPClientResponsePart.end(Self.oldTrailers)) + + XCTAssertEqual(recorder.receivedFrames[0], .head(Self.response)) + XCTAssertEqual(recorder.receivedFrames[1], .end(Self.trailers)) + + XCTAssertTrue(try channel.finish().isClean) + } + + func testServerHTTP1ToHTTP() throws { + let recorder = InboundRecorder() + + try self.channel.pipeline.addHandlers(HTTP1ToHTTPServerCodec(secure: true), recorder).wait() + + try self.channel.writeInbound(HTTPServerRequestPart.head(Self.oldRequest)) + try self.channel.writeInbound(HTTPServerRequestPart.end(Self.oldTrailers)) + + XCTAssertEqual(recorder.receivedFrames[0], .head(Self.requestNoSplitCookie)) + XCTAssertEqual(recorder.receivedFrames[1], .end(Self.trailers)) + + try self.channel.writeOutbound(HTTPTypeServerResponsePart.head(Self.response)) + try self.channel.writeOutbound(HTTPTypeServerResponsePart.end(Self.trailers)) + + XCTAssertEqual(try self.channel.readOutbound(as: HTTPServerResponsePart.self), .head(Self.oldResponse)) + XCTAssertEqual(try self.channel.readOutbound(as: HTTPServerResponsePart.self), .end(Self.oldTrailers)) + + XCTAssertTrue(try channel.finish().isClean) + } + + func testClientHTTPToHTTP1() throws { + let recorder = InboundRecorder() + + try self.channel.pipeline.addHandlers(HTTPToHTTP1ClientCodec(secure: true), recorder).wait() + + try self.channel.writeOutbound(HTTPClientRequestPart.head(Self.oldRequest)) + try self.channel.writeOutbound(HTTPClientRequestPart.end(Self.oldTrailers)) + + XCTAssertEqual(try self.channel.readOutbound(as: HTTPTypeClientRequestPart.self), .head(Self.request)) + XCTAssertEqual(try self.channel.readOutbound(as: HTTPTypeClientRequestPart.self), .end(Self.trailers)) + + try self.channel.writeInbound(HTTPTypeClientResponsePart.head(Self.response)) + try self.channel.writeInbound(HTTPTypeClientResponsePart.end(Self.trailers)) + + XCTAssertEqual(recorder.receivedFrames[0], .head(Self.oldResponse)) + XCTAssertEqual(recorder.receivedFrames[1], .end(Self.oldTrailers)) + + XCTAssertTrue(try channel.finish().isClean) + } + + func testServerHTTPToHTTP1() throws { + let recorder = InboundRecorder() + + try self.channel.pipeline.addHandlers(HTTPToHTTP1ServerCodec(), recorder).wait() + + try self.channel.writeInbound(HTTPTypeServerRequestPart.head(Self.request)) + try self.channel.writeInbound(HTTPTypeServerRequestPart.end(Self.trailers)) + + XCTAssertEqual(recorder.receivedFrames[0], .head(Self.oldRequest)) + XCTAssertEqual(recorder.receivedFrames[1], .end(Self.oldTrailers)) + + try self.channel.writeOutbound(HTTPServerResponsePart.head(Self.oldResponse)) + try self.channel.writeOutbound(HTTPServerResponsePart.end(Self.oldTrailers)) + + XCTAssertEqual(try self.channel.readOutbound(as: HTTPTypeServerResponsePart.self), .head(Self.response)) + XCTAssertEqual(try self.channel.readOutbound(as: HTTPTypeServerResponsePart.self), .end(Self.trailers)) + + XCTAssertTrue(try channel.finish().isClean) + } +} diff --git a/Tests/NIOHTTPTypesHTTP2Tests/NIOHTTPTypesHTTP2Tests.swift b/Tests/NIOHTTPTypesHTTP2Tests/NIOHTTPTypesHTTP2Tests.swift new file mode 100644 index 0000000..a338b34 --- /dev/null +++ b/Tests/NIOHTTPTypesHTTP2Tests/NIOHTTPTypesHTTP2Tests.swift @@ -0,0 +1,142 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import XCTest +import NIOCore +import NIOEmbedded +import NIOHTTP2 +import NIOHPACK +import HTTPTypes +import NIOHTTPTypes +import NIOHTTPTypesHTTP2 + +/// A handler that keeps track of all reads made on a channel. +private final class InboundRecorder: ChannelInboundHandler { + typealias InboundIn = Frame + + var receivedFrames: [Frame] = [] + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + self.receivedFrames.append(self.unwrapInboundIn(data)) + } +} + +private extension HTTPField.Name { + static let xFoo = Self("X-Foo")! +} + +private extension HTTP2Frame.FramePayload { + var headers: HPACKHeaders? { + if case .headers(let headers) = self { + return headers.headers + } else { + return nil + } + } + + init(headers: HPACKHeaders) { + self = .headers(.init(headers: headers)) + } +} + +final class NIOHTTPTypesHTTP2Tests: XCTestCase { + var channel: EmbeddedChannel! + + override func setUp() { + super.setUp() + self.channel = EmbeddedChannel() + } + + override func tearDown() { + self.channel = nil + super.tearDown() + } + + static let request = HTTPRequest(method: .get, scheme: "https", authority: "www.example.com", path: "/", headerFields: [ + .accept: "*/*", + .acceptEncoding: "gzip", + .acceptEncoding: "br", + .trailer: "X-Foo", + .cookie: "a=b", + .cookie: "c=d", + ]) + + static let oldRequest: HPACKHeaders = [ + ":method": "GET", + ":scheme": "https", + ":authority": "www.example.com", + ":path": "/", + "accept": "*/*", + "accept-encoding": "gzip", + "accept-encoding": "br", + "trailer": "X-Foo", + "cookie": "a=b", + "cookie": "c=d", + ] + + static let response = HTTPResponse(status: .ok, headerFields: [ + .server: "HTTPServer/1.0", + .trailer: "X-Foo", + ]) + + static let oldResponse: HPACKHeaders = [ + ":status": "200", + "server": "HTTPServer/1.0", + "trailer": "X-Foo", + ] + + static let trailers: HTTPFields = [.xFoo: "Bar"] + + static let oldTrailers: HPACKHeaders = ["x-foo": "Bar"] + + func testClientHTTP2ToHTTP() throws { + let recorder = InboundRecorder() + + try self.channel.pipeline.addHandlers(HTTP2FramePayloadToHTTPClientCodec(), recorder).wait() + + try self.channel.writeOutbound(HTTPTypeClientRequestPart.head(Self.request)) + try self.channel.writeOutbound(HTTPTypeClientRequestPart.end(Self.trailers)) + + XCTAssertEqual(try self.channel.readOutbound(as: HTTP2Frame.FramePayload.self)?.headers, Self.oldRequest) + XCTAssertEqual(try self.channel.readOutbound(as: HTTP2Frame.FramePayload.self)?.headers, Self.oldTrailers) + + try self.channel.writeInbound(HTTP2Frame.FramePayload(headers: Self.oldResponse)) + try self.channel.writeInbound(HTTP2Frame.FramePayload(headers: Self.oldTrailers)) + + XCTAssertEqual(recorder.receivedFrames[0], .head(Self.response)) + XCTAssertEqual(recorder.receivedFrames[1], .end(Self.trailers)) + + XCTAssertTrue(try channel.finish().isClean) + } + + func testServerHTTP2ToHTTP() throws { + let recorder = InboundRecorder() + + try self.channel.pipeline.addHandlers(HTTP2FramePayloadToHTTPServerCodec(), recorder).wait() + + try self.channel.writeInbound(HTTP2Frame.FramePayload(headers: Self.oldRequest)) + try self.channel.writeInbound(HTTP2Frame.FramePayload(headers: Self.oldTrailers)) + + XCTAssertEqual(recorder.receivedFrames[0], .head(Self.request)) + XCTAssertEqual(recorder.receivedFrames[1], .end(Self.trailers)) + + try self.channel.writeOutbound(HTTPTypeServerResponsePart.head(Self.response)) + try self.channel.writeOutbound(HTTPTypeServerResponsePart.end(Self.trailers)) + + XCTAssertEqual(try self.channel.readOutbound(as: HTTP2Frame.FramePayload.self)?.headers, Self.oldResponse) + XCTAssertEqual(try self.channel.readOutbound(as: HTTP2Frame.FramePayload.self)?.headers, Self.oldTrailers) + + XCTAssertTrue(try channel.finish().isClean) + } +}