Skip to content

Commit

Permalink
Merge pull request #54 from guoye-zhang/parsing
Browse files Browse the repository at this point in the history
Add conveniences for modern HTTP parsers
  • Loading branch information
guoye-zhang authored Jun 20, 2024
2 parents 9bee2fd + b6db31d commit 1ddbea1
Show file tree
Hide file tree
Showing 5 changed files with 297 additions and 1 deletion.
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:
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 {
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

0 comments on commit 1ddbea1

Please sign in to comment.