Skip to content

Commit 0344a0d

Browse files
committed
multiple HTTPHeaders
1 parent 7e5974e commit 0344a0d

17 files changed

+241
-55
lines changed

FlyingFox/Sources/HTTPEncoder.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,18 @@ struct HTTPEncoder {
4646
httpHeaders.addValue(encoding, for: .transferEncoding)
4747
}
4848

49-
let headers = httpHeaders.map { "\($0.key.rawValue): \($0.value)" }
50-
49+
var headers = [String]()
50+
51+
for (header, values) in httpHeaders.storage {
52+
if HTTPHeaders.canCombineValues(for: header) {
53+
let joinedValues = values.joined(separator: ", ")
54+
headers.append("\(header.rawValue): \(joinedValues)")
55+
} else {
56+
headers.append(
57+
contentsOf: values.map { "\(header.rawValue): \($0)" }
58+
)
59+
}
60+
}
5161
return [status] + headers + ["\r\n"]
5262
}
5363

FlyingFox/Sources/HTTPHeader.swift

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -52,37 +52,23 @@ public struct HTTPHeader: Sendable, RawRepresentable, Hashable {
5252
public extension HTTPHeader {
5353
static let acceptRanges = HTTPHeader("Accept-Ranges")
5454
static let authorization = HTTPHeader("Authorization")
55+
static let cookie = HTTPHeader("Cookie")
5556
static let connection = HTTPHeader("Connection")
5657
static let contentDisposition = HTTPHeader("Content-Disposition")
5758
static let contentEncoding = HTTPHeader("Content-Encoding")
5859
static let contentLength = HTTPHeader("Content-Length")
5960
static let contentRange = HTTPHeader("Content-Range")
6061
static let contentType = HTTPHeader("Content-Type")
62+
static let date = HTTPHeader("Date")
63+
static let eTag = HTTPHeader("ETag")
6164
static let host = HTTPHeader("Host")
6265
static let location = HTTPHeader("Location")
6366
static let range = HTTPHeader("Range")
67+
static let setCookie = HTTPHeader("Set-Cookie")
6468
static let transferEncoding = HTTPHeader("Transfer-Encoding")
6569
static let upgrade = HTTPHeader("Upgrade")
6670
static let webSocketAccept = HTTPHeader("Sec-WebSocket-Accept")
6771
static let webSocketKey = HTTPHeader("Sec-WebSocket-Key")
6872
static let webSocketVersion = HTTPHeader("Sec-WebSocket-Version")
6973
static let xForwardedFor = HTTPHeader("X-Forwarded-For")
7074
}
71-
72-
public extension [HTTPHeader: String] {
73-
74-
func values(for header: HTTPHeader) -> [String] {
75-
let value = self[header] ?? ""
76-
return value
77-
.split(separator: ",", omittingEmptySubsequences: true)
78-
.map { String($0.trimmingCharacters(in: .whitespaces)) }
79-
}
80-
81-
mutating func setValues(_ values: [String], for header: HTTPHeader) {
82-
self[header] = values.joined(separator: ", ")
83-
}
84-
85-
mutating func addValue(_ value: String, for header: HTTPHeader) {
86-
setValues(values(for: header) + [value], for: header)
87-
}
88-
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
//
2+
// HTTPHeaders.swift
3+
// FlyingFox
4+
//
5+
// Created by Simon Whitty on 13/09/2025.
6+
// Copyright © 2025 Simon Whitty. All rights reserved.
7+
//
8+
// Distributed under the permissive MIT license
9+
// Get the latest version from here:
10+
//
11+
// https://github.com/swhitty/FlyingFox
12+
//
13+
// Permission is hereby granted, free of charge, to any person obtaining a copy
14+
// of this software and associated documentation files (the "Software"), to deal
15+
// in the Software without restriction, including without limitation the rights
16+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17+
// copies of the Software, and to permit persons to whom the Software is
18+
// furnished to do so, subject to the following conditions:
19+
//
20+
// The above copyright notice and this permission notice shall be included in all
21+
// copies or substantial portions of the Software.
22+
//
23+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29+
// SOFTWARE.
30+
//
31+
32+
public struct HTTPHeaders: Hashable, Sendable, Sequence, ExpressibleByDictionaryLiteral {
33+
var storage: [HTTPHeader: [String]] = [:]
34+
35+
public init() { }
36+
37+
public init(_ headers: [HTTPHeader: String]) {
38+
self.storage = headers.mapValues { [$0] }
39+
}
40+
41+
public init(dictionaryLiteral elements: (HTTPHeader, String)...) {
42+
for (header, value) in elements {
43+
if HTTPHeaders.canCombineValues(for: header) {
44+
let values = value
45+
.split(separator: ",", omittingEmptySubsequences: true)
46+
.map { String($0.trimmingCharacters(in: .whitespaces)) }
47+
storage[header, default: []].append(contentsOf: values)
48+
} else {
49+
storage[header, default: []].append(value)
50+
}
51+
}
52+
}
53+
54+
public subscript(header: HTTPHeader) -> String? {
55+
get {
56+
guard let values = storage[header] else { return nil }
57+
if HTTPHeaders.canCombineValues(for: header) {
58+
return values.joined(separator: ", ")
59+
} else {
60+
return values.first
61+
}
62+
}
63+
set {
64+
if let newValue {
65+
if storage[header] != nil {
66+
storage[header]?[0] = newValue
67+
} else {
68+
storage[header] = [newValue]
69+
}
70+
} else {
71+
storage.removeValue(forKey: header)
72+
}
73+
}
74+
}
75+
76+
public var keys: some Collection<HTTPHeader> {
77+
storage.keys
78+
}
79+
80+
public var values: some Collection<[String]> {
81+
storage.values
82+
}
83+
84+
public func values(for header: HTTPHeader) -> [String] {
85+
storage[header] ?? []
86+
}
87+
88+
public mutating func addValue(_ value: String, for header: HTTPHeader) {
89+
storage[header, default: []].append(value)
90+
}
91+
92+
public mutating func setValues(_ values: [String], for header: HTTPHeader) {
93+
storage[header] = values
94+
}
95+
96+
public mutating func removeValue(_ header: HTTPHeader) {
97+
storage.removeValue(forKey: header)
98+
}
99+
100+
public func makeIterator() -> some IteratorProtocol<(key: HTTPHeader, value: String)> {
101+
storage.lazy
102+
.flatMap { (key, values) in values.lazy.map { (key, $0) } }
103+
.makeIterator()
104+
}
105+
}
106+
107+
package extension HTTPHeaders {
108+
109+
private static let singleValueHeaders: Set<HTTPHeader> = [
110+
.cookie, .setCookie, .date, .eTag, .contentLength, .contentType, .authorization, .host
111+
]
112+
113+
static func canCombineValues(for header: HTTPHeader) -> Bool {
114+
!singleValueHeaders.contains(header)
115+
}
116+
}

FlyingFox/Sources/HTTPRequest.swift

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public struct HTTPRequest: Sendable {
3636
public var version: HTTPVersion
3737
public var path: String
3838
public var query: [QueryItem]
39-
public var headers: [HTTPHeader: String]
39+
public var headers: HTTPHeaders
4040
public var bodySequence: HTTPBodySequence
4141
public var remoteAddress: Address?
4242

@@ -62,7 +62,7 @@ public struct HTTPRequest: Sendable {
6262
version: HTTPVersion,
6363
path: String,
6464
query: [QueryItem],
65-
headers: [HTTPHeader: String],
65+
headers: HTTPHeaders,
6666
body: HTTPBodySequence,
6767
remoteAddress: Address? = nil) {
6868
self.method = method
@@ -84,12 +84,33 @@ public struct HTTPRequest: Sendable {
8484
self.version = version
8585
self.path = path
8686
self.query = query
87-
self.headers = headers
87+
self.headers = HTTPHeaders(headers)
8888
self.bodySequence = HTTPBodySequence(data: body)
8989
self.remoteAddress = nil
9090
}
9191
}
9292

93+
@available(*, deprecated, message: "Use ``HTTPHeaders`` instead of [HTTPHeader: String]")
94+
public extension HTTPRequest {
95+
96+
init(method: HTTPMethod,
97+
version: HTTPVersion,
98+
path: String,
99+
query: [QueryItem],
100+
headers: [HTTPHeader: String],
101+
body: HTTPBodySequence
102+
) {
103+
self.init(
104+
method: method,
105+
version: version,
106+
path: path,
107+
query: query,
108+
headers: HTTPHeaders(headers),
109+
body: body
110+
)
111+
}
112+
}
113+
93114
extension HTTPRequest {
94115
var shouldKeepAlive: Bool {
95116
headers[.connection]?.caseInsensitiveCompare("keep-alive") == .orderedSame

FlyingFox/Sources/HTTPResponse.swift

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import Foundation
3434
public struct HTTPResponse: Sendable {
3535
public var version: HTTPVersion
3636
public var statusCode: HTTPStatusCode
37-
public var headers: [HTTPHeader: String]
37+
public var headers: HTTPHeaders
3838
public var payload: Payload
3939

4040
public enum Payload: @unchecked Sendable {
@@ -65,7 +65,7 @@ public struct HTTPResponse: Sendable {
6565

6666
public init(version: HTTPVersion = .http11,
6767
statusCode: HTTPStatusCode,
68-
headers: [HTTPHeader: String] = [:],
68+
headers: HTTPHeaders = [:],
6969
body: Data = Data()) {
7070
self.version = version
7171
self.statusCode = statusCode
@@ -75,15 +75,15 @@ public struct HTTPResponse: Sendable {
7575

7676
public init(version: HTTPVersion = .http11,
7777
statusCode: HTTPStatusCode,
78-
headers: [HTTPHeader: String] = [:],
78+
headers: HTTPHeaders = [:],
7979
body: HTTPBodySequence) {
8080
self.version = version
8181
self.statusCode = statusCode
8282
self.headers = headers
8383
self.payload = .httpBody(body)
8484
}
8585

86-
public init(headers: [HTTPHeader: String] = [:],
86+
public init(headers: HTTPHeaders = [:],
8787
webSocket handler: some WSHandler) {
8888
self.version = .http11
8989
self.statusCode = .switchingProtocols
@@ -92,6 +92,42 @@ public struct HTTPResponse: Sendable {
9292
}
9393
}
9494

95+
@available(*, deprecated, message: "Use ``HTTPHeaders`` instead of [HTTPHeader: String]")
96+
public extension HTTPResponse {
97+
98+
init(
99+
version: HTTPVersion = .http11,
100+
statusCode: HTTPStatusCode,
101+
headers: [HTTPHeader: String],
102+
body: Data = Data()
103+
) {
104+
self.init(
105+
version: version,
106+
statusCode: statusCode,
107+
headers: HTTPHeaders(headers),
108+
body: body
109+
)
110+
}
111+
112+
init(
113+
version: HTTPVersion = .http11,
114+
statusCode: HTTPStatusCode,
115+
headers: [HTTPHeader: String],
116+
body: HTTPBodySequence
117+
) {
118+
self.init(
119+
version: version,
120+
statusCode: statusCode,
121+
headers: HTTPHeaders(headers),
122+
body: body
123+
)
124+
}
125+
126+
init(headers: [HTTPHeader: String], webSocket handler: some WSHandler) {
127+
self.init(headers: HTTPHeaders(headers), webSocket: handler)
128+
}
129+
}
130+
95131
extension HTTPResponse {
96132
var shouldKeepAlive: Bool {
97133
headers[.connection]?.caseInsensitiveCompare("keep-alive") == .orderedSame

FlyingFox/Sources/HTTPRoute.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ private extension HTTPRoute {
259259
return true
260260
}
261261

262-
func patternMatch(headers request: [HTTPHeader: String]) -> Bool {
262+
func patternMatch(headers request: HTTPHeaders) -> Bool {
263263
return headers.allSatisfy { header, value in
264264
value ~= request.values(for: header)
265265
}

FlyingFox/Sources/Handlers/FileHTTPHandler.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ public struct FileHTTPHandler: HTTPHandler {
153153
}
154154
}
155155

156-
static func makePartialRange(for headers: [HTTPHeader: String], fileSize: Int) -> ClosedRange<Int>? {
156+
static func makePartialRange(for headers: HTTPHeaders, fileSize: Int) -> ClosedRange<Int>? {
157157
guard let headerValue = headers[.range] else { return nil }
158158
let scanner = Scanner(string: headerValue)
159159
guard scanner.scanString("bytes") != nil,

FlyingFox/Sources/Handlers/WebSocketHTTPHandler.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ public struct WebSocketHTTPHandler: HTTPHandler, Sendable {
7777
/// - Parameter headers: The headers of the request to verify.
7878
/// - Returns: The request's key.
7979
/// - Throws: An ``WSInvalidHandshakeError`` if the headers are invalid.
80-
static func verifyHandshakeRequestHeaders(_ headers: [HTTPHeader: String]) throws -> String {
80+
static func verifyHandshakeRequestHeaders(_ headers: HTTPHeaders) throws -> String {
8181
// Verify the headers according to RFC 6455 section 4.2.1 (https://datatracker.ietf.org/doc/html/rfc6455#section-4.2.1)
8282
// Rule 1 isn't verified because the socket method is specified elsewhere
8383

FlyingFox/Tests/HTTPEncoderTests.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,28 @@ struct HTTPEncoderTests {
150150
)
151151
}
152152

153+
@Test
154+
func encodesMultipleCookiesHeaders() async throws {
155+
var headers = HTTPHeaders()
156+
headers.addValue("Fish", for: .setCookie)
157+
headers.addValue("Chips", for: .setCookie)
158+
let data = try await HTTPEncoder.encodeResponse(
159+
.make(headers: headers, body: Data())
160+
)
161+
162+
print(String(data: data, encoding: .utf8)!)
163+
#expect(
164+
data == """
165+
HTTP/1.1 200 OK\r
166+
Content-Length: 0\r
167+
Set-Cookie: Fish\r
168+
Set-Cookie: Chips\r
169+
\r
170+
171+
""".data(using: .utf8)
172+
)
173+
}
174+
153175
@Test
154176
func encodesRequest() async throws {
155177
#expect(

FlyingFox/Tests/HTTPHeaderTests.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// HTTPHeaderTests.swift
2+
// HTTPHeadersTests.swift
33
// FlyingFox
44
//
55
// Created by Simon Whitty on 11/07/2024.
@@ -33,12 +33,12 @@
3333
import Foundation
3434
import Testing
3535

36-
struct HTTPHeaderTests {
36+
struct HTTPHeadersTests {
3737

3838
@Test
3939
func stringValue() {
4040
// given
41-
var headers = [HTTPHeader: String]()
41+
var headers = HTTPHeaders()
4242
headers[.transferEncoding] = "Identity"
4343

4444
#expect(

0 commit comments

Comments
 (0)