diff --git a/Sources/HTTPTypes/HTTPField.swift b/Sources/HTTPTypes/HTTPField.swift index 8645aa9..4bec849 100644 --- a/Sources/HTTPTypes/HTTPField.swift +++ b/Sources/HTTPTypes/HTTPField.swift @@ -78,6 +78,51 @@ public struct HTTPField: Sendable, Hashable { self.rawValue = Self.lenientLegalizeValue(ISOLatin1String(lenientValue)) } + /// Create an HTTP field from a name and a value, skipping the validation of the value. + /// + /// This is useful when the field value is already known to be valid, for example because it + /// was produced by an HTTP parser that has already validated it, and the cost of validating it + /// again is not desirable. + /// + /// - Warning: The caller must ensure that the value only contains the bytes allowed in RFC + /// 9110, otherwise the behavior is undefined. The validity is checked with a debug + /// assertion only. Prefer the validating `init(name:value:)` initializer if the + /// value comes from an untrusted source. + /// + /// https://www.rfc-editor.org/rfc/rfc9110.html#name-field-values + /// + /// - Parameters: + /// - name: The HTTP field name. + /// - uncheckedValue: The HTTP field value, assumed to be valid. It is initialized from the + /// UTF-8 encoded bytes of the string. + public init(name: Name, uncheckedValue: String) { + assert(Self.isValidValue(uncheckedValue), "Invalid HTTP field value: \(uncheckedValue)") + self.name = name + self.rawValue = ISOLatin1String(uncheckedValue) + } + + /// Create an HTTP field from a name and a value, skipping the validation of the value. + /// + /// This is useful when the field value is already known to be valid, for example because it + /// was produced by an HTTP parser that has already validated it, and the cost of validating it + /// again is not desirable. + /// + /// - Warning: The caller must ensure that the value only contains the bytes allowed in RFC + /// 9110, otherwise the behavior is undefined. The validity is checked with a debug + /// assertion only. Prefer the validating `init(name:value:)` initializer if the + /// value comes from an untrusted source. + /// + /// https://www.rfc-editor.org/rfc/rfc9110.html#name-field-values + /// + /// - Parameters: + /// - name: The HTTP field name. + /// - uncheckedValue: The HTTP field value, assumed to be valid. + public init(name: Name, uncheckedValue: some Collection) { + assert(Self.isValidValue(uncheckedValue), "Invalid HTTP field value") + self.name = name + self.rawValue = ISOLatin1String(uncheckedValue) + } + init(name: Name, uncheckedValue: ISOLatin1String) { self.name = name self.rawValue = uncheckedValue diff --git a/Sources/HTTPTypes/HTTPFieldName.swift b/Sources/HTTPTypes/HTTPFieldName.swift index a14a97d..7844d2b 100644 --- a/Sources/HTTPTypes/HTTPFieldName.swift +++ b/Sources/HTTPTypes/HTTPFieldName.swift @@ -84,6 +84,27 @@ extension HTTPField { self.canonicalName = name } + /// Create an HTTP field name from a string, skipping the validation of the characters. + /// + /// This is useful when the field name is already known to be valid, for example because it + /// was produced by an HTTP parser that has already validated it, and the cost of validating + /// it again is not desirable. + /// + /// - Warning: The caller must ensure that the name only contains the characters allowed in + /// RFC 9110, otherwise the behavior is undefined. The validity is checked with a + /// debug assertion only. Prefer ``init(_:)`` if the name comes from an untrusted + /// source. + /// + /// https://www.rfc-editor.org/rfc/rfc9110.html#name-field-names + /// + /// - Parameter name: The name of the HTTP field. It can be accessed from the `rawName` + /// property. It must not be empty and must only contain valid characters. + public init(unchecked name: String) { + assert(HTTPField.isValidToken(name), "Invalid HTTP field name: \(name)") + self.rawName = name + self.canonicalName = name.lowercased() + } + private init(rawName: String, canonicalName: String) { self.rawName = rawName self.canonicalName = canonicalName diff --git a/Tests/HTTPTypesTests/HTTPTypesTests.swift b/Tests/HTTPTypesTests/HTTPTypesTests.swift index b2b3db3..7e90af3 100644 --- a/Tests/HTTPTypesTests/HTTPTypesTests.swift +++ b/Tests/HTTPTypesTests/HTTPTypesTests.swift @@ -249,6 +249,41 @@ final class HTTPTypesTests: XCTestCase { XCTAssertEqual(trailerFields[HTTPField.Name("trailer2")!], "value2") } + func testUncheckedFieldName() { + let unchecked = HTTPField.Name(unchecked: "Accept-Encoding") + // Skips validation but still produces the same name as the validated initializer. + XCTAssertEqual(unchecked, HTTPField.Name("Accept-Encoding")!) + XCTAssertEqual(unchecked, .acceptEncoding) + XCTAssertEqual(unchecked.rawName, "Accept-Encoding") + // The canonical name is lowercased so hashing and comparison stay case-insensitive. + XCTAssertEqual(unchecked.canonicalName, "accept-encoding") + XCTAssertEqual(HTTPField.Name(unchecked: "ACCEPT-ENCODING"), unchecked) + XCTAssertEqual(HTTPField.Name(unchecked: "X-Custom").rawName, "X-Custom") + } + + func testUncheckedFieldValue() { + let fromString = HTTPField(name: .accept, uncheckedValue: "text/html") + XCTAssertEqual(fromString, HTTPField(name: .accept, value: "text/html")) + XCTAssertEqual(fromString.value, "text/html") + + let fromBytes = HTTPField(name: .accept, uncheckedValue: "text/html".utf8) + XCTAssertEqual(fromBytes, fromString) + XCTAssertEqual(fromBytes.value, "text/html") + + // Unlike the validating initializer, the unchecked one performs no legalization, so a + // value that is already valid is stored verbatim. + let field = HTTPField(name: .accept, uncheckedValue: "a 😀 b") + XCTAssertEqual(field.value, "a 😀 b") + } + + func testUncheckedFieldInRequest() { + var request = HTTPRequest(method: .get, scheme: "https", authority: "www.example.com", path: "/") + request.headerFields.append( + HTTPField(name: HTTPField.Name(unchecked: "X-Trace-ID"), uncheckedValue: "abc123") + ) + XCTAssertEqual(request.headerFields[HTTPField.Name("x-trace-id")!], "abc123") + } + func testTypeLayoutSize() { XCTAssertEqual(MemoryLayout.size, MemoryLayout.size * 2) XCTAssertEqual(MemoryLayout.size, MemoryLayout.size * 2)