Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add conveniences for modern HTTP parsers #54

Merged
merged 2 commits into from
Jun 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Benchmarks/Benchmarks/HTTPFieldsBenchmarks/Benchmarks.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift open source project
//
// Copyright (c) 2024 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of Swift project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import Benchmark
import HTTPTypes

Expand Down
34 changes: 34 additions & 0 deletions Sources/HTTPTypes/HTTPFieldName.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,40 @@ extension HTTPField {
self.canonicalName = name.lowercased()
}

/// Create an HTTP field name from a string produced by HPACK or QPACK decoders used in
/// modern HTTP versions.
///
/// - Warning: Do not use directly with the `HTTPFields` struct which does not allow pseudo
/// header fields.
///
/// - Parameter name: The name of the HTTP field or the HTTP pseudo header field. It must
/// be lowercased.
public init?(parsed name: String) {
guard !name.isEmpty else {
return nil
}
let token: Substring
if name.hasPrefix(":") {
token = name.dropFirst()
} else {
token = Substring(name)
}
guard token.utf8.allSatisfy({
switch $0 {
case 0x21, 0x23, 0x24, 0x25, 0x26, 0x27, 0x2A, 0x2B, 0x2D, 0x2E, 0x5E, 0x5F, 0x60, 0x7C, 0x7E:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we define these somewhere so that it can match against human-readable names to reduce risk of missing something?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is HTTPField.isValidToken minus capital letters. I can't think of an easy way to share without incurring additional performance overhead. My original change was name == name.lowercased() but that could require allocation.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use UInt8(ascii: "") for each one. It'll get a bit more verbose, mind.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can make a separate change to switch everything to that after landing this.

return true
case 0x30 ... 0x39, 0x61 ... 0x7A: // DIGHT, ALPHA
return true
default:
return false
}
}) else {
return nil
}
self.rawName = name
self.canonicalName = name
}

private init(rawName: String, canonicalName: String) {
self.rawName = rawName
self.canonicalName = canonicalName
Expand Down
212 changes: 212 additions & 0 deletions Sources/HTTPTypes/HTTPParsedFields.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift open source project
//
// Copyright (c) 2024 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of Swift project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

struct HTTPParsedFields {
private var method: ISOLatin1String?
private var scheme: ISOLatin1String?
private var authority: ISOLatin1String?
private var path: ISOLatin1String?
private var extendedConnectProtocol: ISOLatin1String?
private var status: ISOLatin1String?
private var fields: HTTPFields = .init()

enum ParsingError: Error {
case invalidName
case invalidPseudoName
case invalidPseudoValue
case multiplePseudo
case pseudoNotFirst

case requestWithoutMethod
case invalidMethod
case requestWithResponsePseudo

case responseWithoutStatus
case invalidStatus
case responseWithRequestPseudo

case trailersWithPseudo

case multipleContentLength
case multipleContentDisposition
case multipleLocation
}

mutating func add(field: HTTPField) throws {
if field.name.isPseudo {
if !self.fields.isEmpty {
throw ParsingError.pseudoNotFirst
}
switch field.name {
case .method:
if self.method != nil {
throw ParsingError.multiplePseudo
}
self.method = field.rawValue
case .scheme:
if self.scheme != nil {
throw ParsingError.multiplePseudo
}
self.scheme = field.rawValue
case .authority:
if self.authority != nil {
throw ParsingError.multiplePseudo
}
self.authority = field.rawValue
case .path:
if self.path != nil {
throw ParsingError.multiplePseudo
}
self.path = field.rawValue
case .protocol:
if self.extendedConnectProtocol != nil {
throw ParsingError.multiplePseudo
}
self.extendedConnectProtocol = field.rawValue
case .status:
if self.status != nil {
throw ParsingError.multiplePseudo
}
self.status = field.rawValue
default:
throw ParsingError.invalidPseudoName
}
} else {
self.fields.append(field)
}
}

private func validateFields() throws {
guard self.fields[values: .contentLength].allElementsSame else {
throw ParsingError.multipleContentLength
}
guard self.fields[values: .contentDisposition].allElementsSame else {
throw ParsingError.multipleContentDisposition
}
guard self.fields[values: .location].allElementsSame else {
throw ParsingError.multipleLocation
}
}

var request: HTTPRequest {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: We often write these as inits on the types and do the logic in the init instead of making them "conversion" vars. This follows how Swift defines type conversions

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ultimate APIs are inits, but I thought they are better as computed properties here so we can keep all the state private.

get throws {
guard let method = self.method else {
throw ParsingError.requestWithoutMethod
}
guard let requestMethod = HTTPRequest.Method(method._storage) else {
throw ParsingError.invalidMethod
}
if self.status != nil {
throw ParsingError.requestWithResponsePseudo
}
try validateFields()
var request = HTTPRequest(method: requestMethod, scheme: self.scheme, authority: self.authority, path: self.path, headerFields: self.fields)
if let extendedConnectProtocol = self.extendedConnectProtocol {
request.pseudoHeaderFields.extendedConnectProtocol = HTTPField(name: .protocol, uncheckedValue: extendedConnectProtocol)
}
return request
}
}

var response: HTTPResponse {
get throws {
guard let statusString = self.status?._storage else {
throw ParsingError.responseWithoutStatus
}
if self.method != nil || self.scheme != nil || self.authority != nil || self.path != nil || self.extendedConnectProtocol != nil {
throw ParsingError.responseWithRequestPseudo
}
if !HTTPResponse.Status.isValidStatus(statusString) {
throw ParsingError.invalidStatus
}
try validateFields()
return HTTPResponse(status: .init(code: Int(statusString)!), headerFields: self.fields)
}
}

var trailerFields: HTTPFields {
get throws {
if self.method != nil || self.scheme != nil || self.authority != nil || self.path != nil || self.extendedConnectProtocol != nil || self.status != nil {
throw ParsingError.responseWithRequestPseudo
}
try validateFields()
return self.fields
}
}
}

extension HTTPRequest {
fileprivate init(method: Method, scheme: ISOLatin1String?, authority: ISOLatin1String?, path: ISOLatin1String?, headerFields: HTTPFields) {
let methodField = HTTPField(name: .method, uncheckedValue: ISOLatin1String(unchecked: method.rawValue))
let schemeField = scheme.map { HTTPField(name: .scheme, uncheckedValue: $0) }
let authorityField = authority.map { HTTPField(name: .authority, uncheckedValue: $0) }
let pathField = path.map { HTTPField(name: .path, uncheckedValue: $0) }
self.pseudoHeaderFields = .init(method: methodField, scheme: schemeField, authority: authorityField, path: pathField)
self.headerFields = headerFields
}
}

extension Array where Element: Equatable {
fileprivate var allElementsSame: Bool {
guard let first = self.first else {
return true
}
return dropFirst().allSatisfy { $0 == first }
}
}

extension HTTPRequest {
/// Create an HTTP request with an array of parsed `HTTPField`. The fields must include the
/// necessary request pseudo header fields.
///
/// - Parameter fields: The array of parsed `HTTPField` produced by HPACK or QPACK decoders
/// used in modern HTTP versions.
public init(parsed fields: [HTTPField]) throws {
var parsedFields = HTTPParsedFields()
for field in fields {
try parsedFields.add(field: field)
}
self = try parsedFields.request
}
}

extension HTTPResponse {
/// Create an HTTP response with an array of parsed `HTTPField`. The fields must include the
/// necessary response pseudo header fields.
///
/// - Parameter fields: The array of parsed `HTTPField` produced by HPACK or QPACK decoders
/// used in modern HTTP versions.
public init(parsed fields: [HTTPField]) throws {
var parsedFields = HTTPParsedFields()
for field in fields {
try parsedFields.add(field: field)
}
self = try parsedFields.response
}
}

extension HTTPFields {
/// Create an HTTP trailer fields with an array of parsed `HTTPField`. The fields must not
/// include any pseudo header fields.
///
/// - Parameter fields: The array of parsed `HTTPField` produced by HPACK or QPACK decoders
/// used in modern HTTP versions.
public init(parsedTrailerFields fields: [HTTPField]) throws {
var parsedFields = HTTPParsedFields()
for field in fields {
try parsedFields.add(field: field)
}
self = try parsedFields.trailerFields
}
}
36 changes: 36 additions & 0 deletions Tests/HTTPTypesTests/HTTPTypesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,4 +192,40 @@ final class HTTPTypesTests: XCTestCase {
let decoded = try JSONDecoder().decode(HTTPResponse.self, from: encoded)
XCTAssertEqual(response, decoded)
}

func testRequestParsing() throws {
let fields = [
HTTPField(name: HTTPField.Name(parsed: ":method")!, lenientValue: "PUT".utf8),
HTTPField(name: HTTPField.Name(parsed: ":scheme")!, lenientValue: "https".utf8),
HTTPField(name: HTTPField.Name(parsed: ":authority")!, lenientValue: "www.example.com".utf8),
HTTPField(name: HTTPField.Name(parsed: ":path")!, lenientValue: "/upload".utf8),
HTTPField(name: HTTPField.Name(parsed: "content-length")!, lenientValue: "1024".utf8),
]
let request = try HTTPRequest(parsed: fields)
XCTAssertEqual(request.method, .put)
XCTAssertEqual(request.scheme, "https")
XCTAssertEqual(request.authority, "www.example.com")
XCTAssertEqual(request.path, "/upload")
XCTAssertEqual(request.headerFields[.contentLength], "1024")
}

func testResponseParsing() throws {
let fields = [
HTTPField(name: HTTPField.Name(parsed: ":status")!, lenientValue: "204".utf8),
HTTPField(name: HTTPField.Name(parsed: "server")!, lenientValue: "HTTPServer/1.0".utf8),
]
let response = try HTTPResponse(parsed: fields)
XCTAssertEqual(response.status, .noContent)
XCTAssertEqual(response.headerFields[.server], "HTTPServer/1.0")
}

func testTrailerFieldsParsing() throws {
let fields = [
HTTPField(name: HTTPField.Name(parsed: "trailer1")!, lenientValue: "value1".utf8),
HTTPField(name: HTTPField.Name(parsed: "trailer2")!, lenientValue: "value2".utf8),
]
let trailerFields = try HTTPFields(parsedTrailerFields: fields)
XCTAssertEqual(trailerFields[HTTPField.Name("trailer1")!], "value1")
XCTAssertEqual(trailerFields[HTTPField.Name("trailer2")!], "value2")
}
}
2 changes: 1 addition & 1 deletion scripts/soundness.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ here="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"

function replace_acceptable_years() {
# this needs to replace all acceptable forms with 'YEARS'
sed -e 's/20[12][7890123]-20[12][890123]/YEARS/' -e 's/20[12][890123]/YEARS/'
sed -e 's/20[12][78901234]-20[12][8901234]/YEARS/' -e 's/20[12][8901234]/YEARS/'
}

printf "=> Checking for unacceptable language... "
Expand Down