diff --git a/.spi.yml b/.spi.yml index b8df20cf..b65d0fd9 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 9f9d9c9a..461184a8 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.7 +// swift-tools-version:5.7.1 //===----------------------------------------------------------------------===// // // This source file is part of the SwiftNIO open source project @@ -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: "1.0.0"), ], targets: targets ) diff --git a/README.md b/README.md index 6fe5cb0d..8f88dbd3 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ All code will go through code review like in the other repositories related to t `swift-nio-extras` part of the SwiftNIO 2 family of repositories and depends on the following: - [`swift-nio`](https://github.com/apple/swift-nio), version 2.30.0 or better. -- Swift 5.7 +- Swift 5.7.1 - `zlib` and its development headers installed on the system. But don't worry, you'll find `zlib` on pretty much any UNIX system that can compile any sort of code. To depend on `swift-nio-extras`, put the following in the `dependencies` of your `Package.swift`: @@ -25,7 +25,7 @@ To depend on `swift-nio-extras`, put the following in the `dependencies` of your ### Support for older Swift versions -The most recent versions of SwiftNIO Extras support Swift 5.7 and newer. The minimum Swift version supported by SwiftNIO Extras releases are detailed below: +The most recent versions of SwiftNIO Extras support Swift 5.7.1 and newer. The minimum Swift version supported by SwiftNIO Extras releases are detailed below: SwiftNIO Extras | Minimum Swift Version --------------------|---------------------- @@ -34,7 +34,7 @@ SwiftNIO Extras | Minimum Swift Version `1.11.0 ..< 1.14.0` | 5.4 `1.14.0 ..< 1.19.0` | 5.5.2 `1.19.0 ..< 1.20.0` | 5.6 -`1.20.0 ...` | 5.7 +`1.20.0 ...` | 5.7.1 On the [`nio-extras-0.1`](https://github.com/apple/swift-nio-extras/tree/nio-extras-0.1) branch, you can find the `swift-nio-extras` version for the SwiftNIO 1 family. It requires Swift 4.1 or better. @@ -51,3 +51,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 00000000..63ad3614 --- /dev/null +++ b/Sources/NIOHTTPTypes/NIOHTTPTypes.swift @@ -0,0 +1,42 @@ +//===----------------------------------------------------------------------===// +// +// 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 HTTPTypes +import NIOCore + +/// The parts of a complete HTTP request. +/// +/// An HTTP request message is made up of a request 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 HTTPRequestPart: Sendable, Hashable { + case head(HTTPRequest) + case body(ByteBuffer) + case end(HTTPFields?) +} + +/// The parts of a complete HTTP response. +/// +/// An HTTP response message is made up of 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 HTTPResponsePart: Sendable, Hashable { + case head(HTTPResponse) + case body(ByteBuffer) + case end(HTTPFields?) +} diff --git a/Sources/NIOHTTPTypesHTTP1/HTTP1ToHTTPCodec.swift b/Sources/NIOHTTPTypesHTTP1/HTTP1ToHTTPCodec.swift new file mode 100644 index 00000000..cfde0f31 --- /dev/null +++ b/Sources/NIOHTTPTypesHTTP1/HTTP1ToHTTPCodec.swift @@ -0,0 +1,117 @@ +//===----------------------------------------------------------------------===// +// +// 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 HTTPTypes +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: ChannelDuplexHandler { + public typealias InboundIn = HTTPClientResponsePart + public typealias InboundOut = HTTPResponsePart + + public typealias OutboundIn = HTTPRequestPart + public typealias OutboundOut = HTTPClientRequestPart + + /// Initializes a `HTTP1ToHTTPClientCodec`. + public init() {} + + public func channelRead(context: ChannelHandlerContext, data: NIOAny) { + switch self.unwrapInboundIn(data) { + case .head(let head): + do { + let newResponse = try HTTPResponse(head) + context.fireChannelRead(self.wrapInboundOut(.head(newResponse))) + } catch { + context.fireErrorCaught(error) + } + case .body(let body): + context.fireChannelRead(self.wrapInboundOut(.body(body))) + case .end(let trailers): + let newTrailers = trailers.map { HTTPFields($0, splitCookie: false) } + context.fireChannelRead(self.wrapInboundOut(.end(newTrailers))) + } + } + + public func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + switch self.unwrapOutboundIn(data) { + case .head(let request): + do { + let oldRequest = try HTTPRequestHead(request) + context.write(self.wrapOutboundOut(.head(oldRequest)), promise: promise) + } catch { + context.fireErrorCaught(error) + promise?.fail(error) + } + case .body(let body): + context.write(self.wrapOutboundOut(.body(.byteBuffer(body))), promise: promise) + case .end(let trailers): + context.write(self.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: ChannelDuplexHandler { + public typealias InboundIn = HTTPServerRequestPart + public typealias InboundOut = HTTPRequestPart + + public typealias OutboundIn = HTTPResponsePart + 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 self.unwrapInboundIn(data) { + case .head(let head): + do { + let newRequest = try HTTPRequest(head, secure: self.secure, splitCookie: self.splitCookie) + context.fireChannelRead(self.wrapInboundOut(.head(newRequest))) + } catch { + context.fireErrorCaught(error) + } + case .body(let body): + context.fireChannelRead(self.wrapInboundOut(.body(body))) + case .end(let trailers): + let newTrailers = trailers.map { HTTPFields($0, splitCookie: false) } + context.fireChannelRead(self.wrapInboundOut(.end(newTrailers))) + } + } + + public func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + switch self.unwrapOutboundIn(data) { + case .head(let response): + let oldResponse = HTTPResponseHead(response) + context.write(self.wrapOutboundOut(.head(oldResponse)), promise: promise) + case .body(let body): + context.write(self.wrapOutboundOut(.body(.byteBuffer(body))), promise: promise) + case .end(let trailers): + context.write(self.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 00000000..f1f21ffa --- /dev/null +++ b/Sources/NIOHTTPTypesHTTP1/HTTPToHTTP1Codec.swift @@ -0,0 +1,134 @@ +//===----------------------------------------------------------------------===// +// +// 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 HTTPTypes +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: ChannelDuplexHandler { + public typealias InboundIn = HTTPResponsePart + public typealias InboundOut = HTTPClientResponsePart + + public typealias OutboundIn = HTTPClientRequestPart + public typealias OutboundOut = HTTPRequestPart + + 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. Splitting the `Cookie` + /// header field improves the performance of HTTP/2 and + /// HTTP/3 clients by allowing individual cookies to be + /// indexed separately in the dynamic table. It has no + /// effects in HTTP/1. Defaults to true. + public init(secure: Bool, splitCookie: Bool = true) { + self.secure = secure + self.splitCookie = splitCookie + } + + public func channelRead(context: ChannelHandlerContext, data: NIOAny) { + switch self.unwrapInboundIn(data) { + case .head(let head): + let oldResponse = HTTPResponseHead(head) + context.fireChannelRead(self.wrapInboundOut(.head(oldResponse))) + case .body(let body): + context.fireChannelRead(self.wrapInboundOut(.body(body))) + case .end(let trailers): + context.fireChannelRead(self.wrapInboundOut(.end(trailers.map(HTTPHeaders.init)))) + } + } + + public func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + switch self.unwrapOutboundIn(data) { + case .head(let request): + do { + let newRequest = try HTTPRequest(request, secure: self.secure, splitCookie: self.splitCookie) + context.write(self.wrapOutboundOut(.head(newRequest)), promise: promise) + } catch { + context.fireErrorCaught(error) + promise?.fail(error) + } + case .body(.byteBuffer(let body)): + context.write(self.wrapOutboundOut(.body(body)), promise: promise) + case .body: + fatalError("File region not supported") + case .end(let trailers): + let newTrailers = trailers.map { HTTPFields($0, splitCookie: false) } + context.write(self.wrapOutboundOut(.end(newTrailers)), 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: ChannelDuplexHandler { + public typealias InboundIn = HTTPRequestPart + public typealias InboundOut = HTTPServerRequestPart + + public typealias OutboundIn = HTTPServerResponsePart + public typealias OutboundOut = HTTPResponsePart + + /// Initializes a `HTTPToHTTP1ServerCodec`. + public init() {} + + public func channelRead(context: ChannelHandlerContext, data: NIOAny) { + switch self.unwrapInboundIn(data) { + case .head(let head): + do { + let oldRequest = try HTTPRequestHead(head) + context.fireChannelRead(self.wrapInboundOut(.head(oldRequest))) + } catch { + context.fireErrorCaught(error) + } + case .body(let body): + context.fireChannelRead(self.wrapInboundOut(.body(body))) + case .end(let trailers): + context.fireChannelRead(self.wrapInboundOut(.end(trailers.map(HTTPHeaders.init)))) + } + } + + public func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + switch self.unwrapOutboundIn(data) { + case .head(let response): + do { + let newResponse = try HTTPResponse(response) + context.write(self.wrapOutboundOut(.head(newResponse)), promise: promise) + } catch { + context.fireErrorCaught(error) + promise?.fail(error) + } + case .body(.byteBuffer(let body)): + context.write(self.wrapOutboundOut(.body(body)), promise: promise) + case .body: + fatalError("File region not supported") + case .end(let trailers): + let newTrailers = trailers.map { HTTPFields($0, splitCookie: false) } + context.write(self.wrapOutboundOut(.end(newTrailers)), promise: promise) + } + } +} diff --git a/Sources/NIOHTTPTypesHTTP1/HTTPTypeConversion.swift b/Sources/NIOHTTPTypesHTTP1/HTTPTypeConversion.swift new file mode 100644 index 00000000..f8e2c4b7 --- /dev/null +++ b/Sources/NIOHTTPTypesHTTP1/HTTPTypeConversion.swift @@ -0,0 +1,212 @@ +//===----------------------------------------------------------------------===// +// +// 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 HTTPTypes +import NIOHTTP1 + +private enum HTTP1TypeConversionError: Error { + case invalidMethod + case missingPath + case invalidStatusCode +} + +extension HTTPMethod { + init(_ newMethod: HTTPRequest.Method) { + switch newMethod { + case .get: self = .GET + case .head: self = .HEAD + case .post: self = .POST + case .put: self = .PUT + case .delete: self = .DELETE + case .connect: self = .CONNECT + case .options: self = .OPTIONS + case .trace: self = .TRACE + case .patch: self = .PATCH + default: + let rawValue = newMethod.rawValue + switch rawValue { + case "ACL": self = .ACL + case "COPY": self = .COPY + case "LOCK": self = .LOCK + case "MOVE": self = .MOVE + case "BIND": self = .BIND + case "LINK": self = .LINK + 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 "UNLINK": self = .UNLINK + case "MSEARCH": self = .MSEARCH + 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: rawValue) + } + } + } +} + +extension HTTPRequest.Method { + init(_ oldMethod: HTTPMethod) throws { + switch oldMethod { + case .GET: self = .get + case .PUT: self = .put + case .ACL: self = .init("ACL")! + case .HEAD: self = .head + case .POST: self = .post + case .COPY: self = .init("COPY")! + case .LOCK: self = .init("LOCK")! + case .MOVE: self = .init("MOVE")! + case .BIND: self = .init("BIND")! + case .LINK: self = .init("LINK")! + case .PATCH: self = .patch + case .TRACE: self = .trace + case .MKCOL: self = .init("MKCOL")! + case .MERGE: self = .init("MERGE")! + case .PURGE: self = .init("PURGE")! + case .NOTIFY: self = .init("NOTIFY")! + case .SEARCH: self = .init("SEARCH")! + case .UNLOCK: self = .init("UNLOCK")! + case .REBIND: self = .init("REBIND")! + case .UNBIND: self = .init("UNBIND")! + case .REPORT: self = .init("REPORT")! + case .DELETE: self = .delete + case .UNLINK: self = .init("UNLINK")! + case .CONNECT: self = .connect + case .MSEARCH: self = .init("MSEARCH")! + case .OPTIONS: self = .options + case .PROPFIND: self = .init("PROPFIND")! + case .CHECKOUT: self = .init("CHECKOUT")! + case .PROPPATCH: self = .init("PROPPATCH")! + case .SUBSCRIBE: self = .init("SUBSCRIBE")! + case .MKCALENDAR: self = .init("MKCALENDAR")! + case .MKACTIVITY: self = .init("MKACTIVITY")! + case .UNSUBSCRIBE: self = .init("UNSUBSCRIBE")! + case .SOURCE: self = .init("SOURCE")! + case .RAW(value: let value): + guard let method = HTTPRequest.Method(value) else { + throw HTTP1TypeConversionError.invalidMethod + } + self = method + } + } +} + +extension HTTPHeaders { + init(_ newFields: HTTPFields) { + let fields = newFields.map { ($0.name.rawName, $0.value) } + self.init(fields) + } +} + +extension HTTPFields { + init(_ oldHeaders: HTTPHeaders, splitCookie: Bool) { + self.init() + self.reserveCapacity(count) + var firstHost = true + for field in oldHeaders { + if firstHost && field.name.lowercased() == "host" { + firstHost = false + 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, *) { + self.append(contentsOf: field.value.split(separator: "; ", omittingEmptySubsequences: false).map { + HTTPField(name: name, value: String($0)) + }) + } else { + self.append(HTTPField(name: name, value: field.value)) + } + } + } + } +} + +extension HTTPRequestHead { + init(_ newRequest: HTTPRequest) throws { + guard let path = newRequest.method == .connect ? newRequest.authority : newRequest.path else { + throw HTTP1TypeConversionError.missingPath + } + var headers = HTTPHeaders() + headers.reserveCapacity(newRequest.headerFields.count + 1) + if let authority = newRequest.authority { + headers.add(name: "Host", value: authority) + } + 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: path, + headers: headers + ) + } +} + +extension HTTPRequest { + init(_ oldRequest: HTTPRequestHead, secure: Bool, splitCookie: Bool) throws { + let method = try Method(oldRequest.method) + let scheme = secure ? "https" : "http" + let authority = oldRequest.headers["Host"].first + self.init( + method: method, + scheme: scheme, + authority: authority, + path: oldRequest.uri, + headerFields: HTTPFields(oldRequest.headers, 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) + ) + } +} + +extension HTTPResponse { + init(_ oldResponse: HTTPResponseHead) throws { + guard oldResponse.status.code <= 999 else { + throw HTTP1TypeConversionError.invalidStatusCode + } + let status = HTTPResponse.Status(code: Int(oldResponse.status.code), reasonPhrase: oldResponse.status.reasonPhrase) + self.init(status: status, headerFields: HTTPFields(oldResponse.headers, splitCookie: false)) + } +} diff --git a/Sources/NIOHTTPTypesHTTP2/HTTP2HeadersStateMachine.swift b/Sources/NIOHTTPTypesHTTP2/HTTP2HeadersStateMachine.swift new file mode 100644 index 00000000..64a88a98 --- /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 NIOHPACK +import NIOHTTP2 + +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 { + 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 { + 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 00000000..6d57448c --- /dev/null +++ b/Sources/NIOHTTPTypesHTTP2/HTTP2ToHTTPCodec.swift @@ -0,0 +1,247 @@ +//===----------------------------------------------------------------------===// +// +// 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 HTTPTypes +import NIOCore +import NIOHPACK +import NIOHTTP2 +import NIOHTTPTypes + +// MARK: - Client + +private struct BaseClientCodec { + private var headerStateMachine: HTTP2HeadersStateMachine = .init(mode: .client) + + private var outgoingHTTP1RequestHead: HTTPRequest? + + mutating func processInboundData(_ data: HTTP2Frame.FramePayload) throws -> (first: HTTPResponsePart?, second: HTTPResponsePart?) { + switch data { + case .headers(let headerContent): + switch try self.headerStateMachine.newHeaders(block: headerContent.headers) { + case .trailer: + let newTrailers = try HTTPFields(trailers: headerContent.headers) + return (first: .end(newTrailers), second: nil) + + case .informationalResponseHead: + let newResponse = try HTTPResponse(headerContent.headers) + return (first: .head(newResponse), second: nil) + + case .finalResponseHead: + guard self.outgoingHTTP1RequestHead != nil else { + preconditionFailure("Expected not to get a response without having sent a request") + } + self.outgoingHTTP1RequestHead = nil + let newResponse = try HTTPResponse(headerContent.headers) + let first = HTTPResponsePart.head(newResponse) + var second: HTTPResponsePart? + 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 = HTTPResponsePart.body(b) + var second: HTTPResponsePart? + 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: HTTPRequestPart, 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: .byteBuffer(body))) + case .end(let trailers): + if let 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: ChannelDuplexHandler { + public typealias InboundIn = HTTP2Frame.FramePayload + public typealias InboundOut = HTTPResponsePart + + public typealias OutboundIn = HTTPRequestPart + 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 { + context.fireChannelRead(self.wrapInboundOut(first)) + } + if let 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 = .init(mode: .server) + + mutating func processInboundData(_ data: HTTP2Frame.FramePayload) throws -> (first: HTTPRequestPart?, second: HTTPRequestPart?) { + switch data { + case .headers(let headerContent): + if case .trailer = try self.headerStateMachine.newHeaders(block: headerContent.headers) { + let newTrailers = try HTTPFields(trailers: headerContent.headers) + return (first: .end(newTrailers), second: nil) + } else { + let newRequest = try HTTPRequest(headerContent.headers) + let first = HTTPRequestPart.head(newRequest) + var second: HTTPRequestPart? + 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 = HTTPRequestPart.body(b) + var second: HTTPRequestPart? + 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: HTTPResponsePart, 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: .byteBuffer(body)) + return .data(payload) + case .end(let trailers): + if let 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: ChannelDuplexHandler { + public typealias InboundIn = HTTP2Frame.FramePayload + public typealias InboundOut = HTTPRequestPart + + public typealias OutboundIn = HTTPResponsePart + 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 { + context.fireChannelRead(self.wrapInboundOut(first)) + } + if let 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 00000000..202facb6 --- /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 HTTPTypes +import NIOHPACK + +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 +} + +private extension HPACKIndexing { + init(_ newIndexingStrategy: HTTPField.DynamicTableIndexingStrategy) { + switch newIndexingStrategy { + case .avoid: self = .nonIndexable + case .disallow: self = .neverIndexed + default: self = .indexable + } + } +} + +private extension HTTPField.DynamicTableIndexingStrategy { + init(_ oldIndexing: HPACKIndexing) { + switch oldIndexing { + case .indexable: self = .automatic + case .nonIndexable: self = .avoid + case .neverIndexed: self = .disallow + } + } +} + +extension HPACKHeaders { + private mutating func add(newField field: HTTPField) { + self.add(name: field.name.canonicalName, value: field.value, indexing: HPACKIndexing(field.indexingStrategy)) + } + + init(_ newRequest: HTTPRequest) { + self.init() + self.reserveCapacity(newRequest.headerFields.count + 5) + + self.add(newField: newRequest.pseudoHeaderFields.method) + if let field = newRequest.pseudoHeaderFields.scheme { + self.add(newField: field) + } + if let field = newRequest.pseudoHeaderFields.authority { + self.add(newField: field) + } + if let field = newRequest.pseudoHeaderFields.path { + self.add(newField: field) + } + if let field = newRequest.pseudoHeaderFields.extendedConnectProtocol { + self.add(newField: field) + } + for field in newRequest.headerFields { + self.add(newField: field) + } + } + + init(_ newResponse: HTTPResponse) { + self.init() + self.reserveCapacity(newResponse.headerFields.count + 1) + + self.add(newField: newResponse.pseudoHeaderFields.status) + for field in newResponse.headerFields { + self.add(newField: field) + } + } + + init(_ newTrailers: HTTPFields) { + self.init() + self.reserveCapacity(newTrailers.count) + + for field in newTrailers { + self.add(newField: field) + } + } +} + +extension HTTPRequest { + init(_ hpack: HPACKHeaders) 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 = hpack.startIndex + while i != hpack.endIndex { + let (name, value, indexable) = hpack[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 = hpack.index(after: i) + } + + guard let methodString else { + throw HTTP2TypeConversionError.missingMethod + } + guard let method = HTTPRequest.Method(methodString) else { + throw HTTP2TypeConversionError.invalidMethod + } + + self.init( + method: method, + scheme: schemeString, + authority: authorityString, + path: pathString + ) + self.pseudoHeaderFields.method.indexingStrategy = .init(methodIndexable) + self.pseudoHeaderFields.scheme?.indexingStrategy = .init(schemeIndexable) + self.pseudoHeaderFields.authority?.indexingStrategy = .init(authorityIndexable) + self.pseudoHeaderFields.path?.indexingStrategy = .init(pathIndexable) + if let protocolString { + self.extendedConnectProtocol = protocolString + self.pseudoHeaderFields.extendedConnectProtocol?.indexingStrategy = .init(protocolIndexable) + } + + self.headerFields.reserveCapacity(hpack.count) + while i != hpack.endIndex { + let (name, value, indexable) = hpack[i] + if name.hasPrefix(":") { + throw HTTP2TypeConversionError.pseudoFieldNotFirst + } + if let fieldName = HTTPField.Name(name) { + var field = HTTPField(name: fieldName, value: value) + field.indexingStrategy = .init(indexable) + self.headerFields.append(field) + } + i = hpack.index(after: i) + } + } +} + +extension HTTPResponse { + init(_ hpack: HPACKHeaders) throws { + var statusString: String? = nil + var statusIndexable: HPACKIndexing = .indexable + + var i = hpack.startIndex + while i != hpack.endIndex { + let (name, value, indexable) = hpack[i] + if !name.hasPrefix(":") { + break + } + switch name { + case ":status": + if statusString != nil { + throw HTTP2TypeConversionError.multipleStatus + } + statusString = value + statusIndexable = indexable + default: + continue + } + i = hpack.index(after: i) + } + + guard let statusString else { + throw HTTP2TypeConversionError.missingStatus + } + guard let status = Int(statusString), + (0 ... 999).contains(status) else { + throw HTTP2TypeConversionError.invalidStatus + } + + self.init(status: HTTPResponse.Status(code: status)) + self.pseudoHeaderFields.status.indexingStrategy = .init(statusIndexable) + + self.headerFields.reserveCapacity(hpack.count) + while i != hpack.endIndex { + let (name, value, indexable) = hpack[i] + if name.hasPrefix(":") { + throw HTTP2TypeConversionError.pseudoFieldNotFirst + } + if let fieldName = HTTPField.Name(name) { + var field = HTTPField(name: fieldName, value: value) + field.indexingStrategy = .init(indexable) + self.headerFields.append(field) + } + i = hpack.index(after: i) + } + } +} + +extension HTTPFields { + init(trailers: HPACKHeaders) throws { + self.init() + self.reserveCapacity(trailers.count) + + for (name, value, indexable) in trailers { + if name.hasPrefix(":") { + throw HTTP2TypeConversionError.pseudoFieldInTrailers + } + if let fieldName = HTTPField.Name(name) { + var field = HTTPField(name: fieldName, value: value) + field.indexingStrategy = .init(indexable) + self.append(field) + } + } + } +} diff --git a/Tests/NIOHTTPTypesHTTP1Tests/NIOHTTPTypesHTTP1Tests.swift b/Tests/NIOHTTPTypesHTTP1Tests/NIOHTTPTypesHTTP1Tests.swift new file mode 100644 index 00000000..1baec754 --- /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 HTTPTypes +import NIOCore +import NIOEmbedded +import NIOHTTP1 +import NIOHTTPTypes +import NIOHTTPTypesHTTP1 +import XCTest + +/// 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)) + } +} + +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(HTTPRequestPart.head(Self.request)) + try self.channel.writeOutbound(HTTPRequestPart.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 self.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(HTTPResponsePart.head(Self.response)) + try self.channel.writeOutbound(HTTPResponsePart.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 self.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: HTTPRequestPart.self), .head(Self.request)) + XCTAssertEqual(try self.channel.readOutbound(as: HTTPRequestPart.self), .end(Self.trailers)) + + try self.channel.writeInbound(HTTPResponsePart.head(Self.response)) + try self.channel.writeInbound(HTTPResponsePart.end(Self.trailers)) + + XCTAssertEqual(recorder.receivedFrames[0], .head(Self.oldResponse)) + XCTAssertEqual(recorder.receivedFrames[1], .end(Self.oldTrailers)) + + XCTAssertTrue(try self.channel.finish().isClean) + } + + func testServerHTTPToHTTP1() throws { + let recorder = InboundRecorder() + + try self.channel.pipeline.addHandlers(HTTPToHTTP1ServerCodec(), recorder).wait() + + try self.channel.writeInbound(HTTPRequestPart.head(Self.request)) + try self.channel.writeInbound(HTTPRequestPart.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: HTTPResponsePart.self), .head(Self.response)) + XCTAssertEqual(try self.channel.readOutbound(as: HTTPResponsePart.self), .end(Self.trailers)) + + XCTAssertTrue(try self.channel.finish().isClean) + } +} diff --git a/Tests/NIOHTTPTypesHTTP2Tests/NIOHTTPTypesHTTP2Tests.swift b/Tests/NIOHTTPTypesHTTP2Tests/NIOHTTPTypesHTTP2Tests.swift new file mode 100644 index 00000000..830768d7 --- /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 HTTPTypes +import NIOCore +import NIOEmbedded +import NIOHPACK +import NIOHTTP2 +import NIOHTTPTypes +import NIOHTTPTypesHTTP2 +import XCTest + +/// 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)) + } +} + +extension HTTPField.Name { + static let xFoo = Self("X-Foo")! +} + +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(HTTPRequestPart.head(Self.request)) + try self.channel.writeOutbound(HTTPRequestPart.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 self.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(HTTPResponsePart.head(Self.response)) + try self.channel.writeOutbound(HTTPResponsePart.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 self.channel.finish().isClean) + } +}