Skip to content

Commit 1ddbea1

Browse files
authored
Merge pull request #54 from guoye-zhang/parsing
Add conveniences for modern HTTP parsers
2 parents 9bee2fd + b6db31d commit 1ddbea1

File tree

5 files changed

+297
-1
lines changed

5 files changed

+297
-1
lines changed

Benchmarks/Benchmarks/HTTPFieldsBenchmarks/Benchmarks.swift

+14
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift open source project
4+
//
5+
// Copyright (c) 2024 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
115
import Benchmark
216
import HTTPTypes
317

Sources/HTTPTypes/HTTPFieldName.swift

+34
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,40 @@ extension HTTPField {
4848
self.canonicalName = name.lowercased()
4949
}
5050

51+
/// Create an HTTP field name from a string produced by HPACK or QPACK decoders used in
52+
/// modern HTTP versions.
53+
///
54+
/// - Warning: Do not use directly with the `HTTPFields` struct which does not allow pseudo
55+
/// header fields.
56+
///
57+
/// - Parameter name: The name of the HTTP field or the HTTP pseudo header field. It must
58+
/// be lowercased.
59+
public init?(parsed name: String) {
60+
guard !name.isEmpty else {
61+
return nil
62+
}
63+
let token: Substring
64+
if name.hasPrefix(":") {
65+
token = name.dropFirst()
66+
} else {
67+
token = Substring(name)
68+
}
69+
guard token.utf8.allSatisfy({
70+
switch $0 {
71+
case 0x21, 0x23, 0x24, 0x25, 0x26, 0x27, 0x2A, 0x2B, 0x2D, 0x2E, 0x5E, 0x5F, 0x60, 0x7C, 0x7E:
72+
return true
73+
case 0x30 ... 0x39, 0x61 ... 0x7A: // DIGHT, ALPHA
74+
return true
75+
default:
76+
return false
77+
}
78+
}) else {
79+
return nil
80+
}
81+
self.rawName = name
82+
self.canonicalName = name
83+
}
84+
5185
private init(rawName: String, canonicalName: String) {
5286
self.rawName = rawName
5387
self.canonicalName = canonicalName
+212
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift open source project
4+
//
5+
// Copyright (c) 2024 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
struct HTTPParsedFields {
16+
private var method: ISOLatin1String?
17+
private var scheme: ISOLatin1String?
18+
private var authority: ISOLatin1String?
19+
private var path: ISOLatin1String?
20+
private var extendedConnectProtocol: ISOLatin1String?
21+
private var status: ISOLatin1String?
22+
private var fields: HTTPFields = .init()
23+
24+
enum ParsingError: Error {
25+
case invalidName
26+
case invalidPseudoName
27+
case invalidPseudoValue
28+
case multiplePseudo
29+
case pseudoNotFirst
30+
31+
case requestWithoutMethod
32+
case invalidMethod
33+
case requestWithResponsePseudo
34+
35+
case responseWithoutStatus
36+
case invalidStatus
37+
case responseWithRequestPseudo
38+
39+
case trailersWithPseudo
40+
41+
case multipleContentLength
42+
case multipleContentDisposition
43+
case multipleLocation
44+
}
45+
46+
mutating func add(field: HTTPField) throws {
47+
if field.name.isPseudo {
48+
if !self.fields.isEmpty {
49+
throw ParsingError.pseudoNotFirst
50+
}
51+
switch field.name {
52+
case .method:
53+
if self.method != nil {
54+
throw ParsingError.multiplePseudo
55+
}
56+
self.method = field.rawValue
57+
case .scheme:
58+
if self.scheme != nil {
59+
throw ParsingError.multiplePseudo
60+
}
61+
self.scheme = field.rawValue
62+
case .authority:
63+
if self.authority != nil {
64+
throw ParsingError.multiplePseudo
65+
}
66+
self.authority = field.rawValue
67+
case .path:
68+
if self.path != nil {
69+
throw ParsingError.multiplePseudo
70+
}
71+
self.path = field.rawValue
72+
case .protocol:
73+
if self.extendedConnectProtocol != nil {
74+
throw ParsingError.multiplePseudo
75+
}
76+
self.extendedConnectProtocol = field.rawValue
77+
case .status:
78+
if self.status != nil {
79+
throw ParsingError.multiplePseudo
80+
}
81+
self.status = field.rawValue
82+
default:
83+
throw ParsingError.invalidPseudoName
84+
}
85+
} else {
86+
self.fields.append(field)
87+
}
88+
}
89+
90+
private func validateFields() throws {
91+
guard self.fields[values: .contentLength].allElementsSame else {
92+
throw ParsingError.multipleContentLength
93+
}
94+
guard self.fields[values: .contentDisposition].allElementsSame else {
95+
throw ParsingError.multipleContentDisposition
96+
}
97+
guard self.fields[values: .location].allElementsSame else {
98+
throw ParsingError.multipleLocation
99+
}
100+
}
101+
102+
var request: HTTPRequest {
103+
get throws {
104+
guard let method = self.method else {
105+
throw ParsingError.requestWithoutMethod
106+
}
107+
guard let requestMethod = HTTPRequest.Method(method._storage) else {
108+
throw ParsingError.invalidMethod
109+
}
110+
if self.status != nil {
111+
throw ParsingError.requestWithResponsePseudo
112+
}
113+
try validateFields()
114+
var request = HTTPRequest(method: requestMethod, scheme: self.scheme, authority: self.authority, path: self.path, headerFields: self.fields)
115+
if let extendedConnectProtocol = self.extendedConnectProtocol {
116+
request.pseudoHeaderFields.extendedConnectProtocol = HTTPField(name: .protocol, uncheckedValue: extendedConnectProtocol)
117+
}
118+
return request
119+
}
120+
}
121+
122+
var response: HTTPResponse {
123+
get throws {
124+
guard let statusString = self.status?._storage else {
125+
throw ParsingError.responseWithoutStatus
126+
}
127+
if self.method != nil || self.scheme != nil || self.authority != nil || self.path != nil || self.extendedConnectProtocol != nil {
128+
throw ParsingError.responseWithRequestPseudo
129+
}
130+
if !HTTPResponse.Status.isValidStatus(statusString) {
131+
throw ParsingError.invalidStatus
132+
}
133+
try validateFields()
134+
return HTTPResponse(status: .init(code: Int(statusString)!), headerFields: self.fields)
135+
}
136+
}
137+
138+
var trailerFields: HTTPFields {
139+
get throws {
140+
if self.method != nil || self.scheme != nil || self.authority != nil || self.path != nil || self.extendedConnectProtocol != nil || self.status != nil {
141+
throw ParsingError.responseWithRequestPseudo
142+
}
143+
try validateFields()
144+
return self.fields
145+
}
146+
}
147+
}
148+
149+
extension HTTPRequest {
150+
fileprivate init(method: Method, scheme: ISOLatin1String?, authority: ISOLatin1String?, path: ISOLatin1String?, headerFields: HTTPFields) {
151+
let methodField = HTTPField(name: .method, uncheckedValue: ISOLatin1String(unchecked: method.rawValue))
152+
let schemeField = scheme.map { HTTPField(name: .scheme, uncheckedValue: $0) }
153+
let authorityField = authority.map { HTTPField(name: .authority, uncheckedValue: $0) }
154+
let pathField = path.map { HTTPField(name: .path, uncheckedValue: $0) }
155+
self.pseudoHeaderFields = .init(method: methodField, scheme: schemeField, authority: authorityField, path: pathField)
156+
self.headerFields = headerFields
157+
}
158+
}
159+
160+
extension Array where Element: Equatable {
161+
fileprivate var allElementsSame: Bool {
162+
guard let first = self.first else {
163+
return true
164+
}
165+
return dropFirst().allSatisfy { $0 == first }
166+
}
167+
}
168+
169+
extension HTTPRequest {
170+
/// Create an HTTP request with an array of parsed `HTTPField`. The fields must include the
171+
/// necessary request pseudo header fields.
172+
///
173+
/// - Parameter fields: The array of parsed `HTTPField` produced by HPACK or QPACK decoders
174+
/// used in modern HTTP versions.
175+
public init(parsed fields: [HTTPField]) throws {
176+
var parsedFields = HTTPParsedFields()
177+
for field in fields {
178+
try parsedFields.add(field: field)
179+
}
180+
self = try parsedFields.request
181+
}
182+
}
183+
184+
extension HTTPResponse {
185+
/// Create an HTTP response with an array of parsed `HTTPField`. The fields must include the
186+
/// necessary response pseudo header fields.
187+
///
188+
/// - Parameter fields: The array of parsed `HTTPField` produced by HPACK or QPACK decoders
189+
/// used in modern HTTP versions.
190+
public init(parsed fields: [HTTPField]) throws {
191+
var parsedFields = HTTPParsedFields()
192+
for field in fields {
193+
try parsedFields.add(field: field)
194+
}
195+
self = try parsedFields.response
196+
}
197+
}
198+
199+
extension HTTPFields {
200+
/// Create an HTTP trailer fields with an array of parsed `HTTPField`. The fields must not
201+
/// include any pseudo header fields.
202+
///
203+
/// - Parameter fields: The array of parsed `HTTPField` produced by HPACK or QPACK decoders
204+
/// used in modern HTTP versions.
205+
public init(parsedTrailerFields fields: [HTTPField]) throws {
206+
var parsedFields = HTTPParsedFields()
207+
for field in fields {
208+
try parsedFields.add(field: field)
209+
}
210+
self = try parsedFields.trailerFields
211+
}
212+
}

Tests/HTTPTypesTests/HTTPTypesTests.swift

+36
Original file line numberDiff line numberDiff line change
@@ -192,4 +192,40 @@ final class HTTPTypesTests: XCTestCase {
192192
let decoded = try JSONDecoder().decode(HTTPResponse.self, from: encoded)
193193
XCTAssertEqual(response, decoded)
194194
}
195+
196+
func testRequestParsing() throws {
197+
let fields = [
198+
HTTPField(name: HTTPField.Name(parsed: ":method")!, lenientValue: "PUT".utf8),
199+
HTTPField(name: HTTPField.Name(parsed: ":scheme")!, lenientValue: "https".utf8),
200+
HTTPField(name: HTTPField.Name(parsed: ":authority")!, lenientValue: "www.example.com".utf8),
201+
HTTPField(name: HTTPField.Name(parsed: ":path")!, lenientValue: "/upload".utf8),
202+
HTTPField(name: HTTPField.Name(parsed: "content-length")!, lenientValue: "1024".utf8),
203+
]
204+
let request = try HTTPRequest(parsed: fields)
205+
XCTAssertEqual(request.method, .put)
206+
XCTAssertEqual(request.scheme, "https")
207+
XCTAssertEqual(request.authority, "www.example.com")
208+
XCTAssertEqual(request.path, "/upload")
209+
XCTAssertEqual(request.headerFields[.contentLength], "1024")
210+
}
211+
212+
func testResponseParsing() throws {
213+
let fields = [
214+
HTTPField(name: HTTPField.Name(parsed: ":status")!, lenientValue: "204".utf8),
215+
HTTPField(name: HTTPField.Name(parsed: "server")!, lenientValue: "HTTPServer/1.0".utf8),
216+
]
217+
let response = try HTTPResponse(parsed: fields)
218+
XCTAssertEqual(response.status, .noContent)
219+
XCTAssertEqual(response.headerFields[.server], "HTTPServer/1.0")
220+
}
221+
222+
func testTrailerFieldsParsing() throws {
223+
let fields = [
224+
HTTPField(name: HTTPField.Name(parsed: "trailer1")!, lenientValue: "value1".utf8),
225+
HTTPField(name: HTTPField.Name(parsed: "trailer2")!, lenientValue: "value2".utf8),
226+
]
227+
let trailerFields = try HTTPFields(parsedTrailerFields: fields)
228+
XCTAssertEqual(trailerFields[HTTPField.Name("trailer1")!], "value1")
229+
XCTAssertEqual(trailerFields[HTTPField.Name("trailer2")!], "value2")
230+
}
195231
}

scripts/soundness.sh

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ here="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
1818

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

2424
printf "=> Checking for unacceptable language... "

0 commit comments

Comments
 (0)