Skip to content
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
15 changes: 13 additions & 2 deletions FlyingFox/Sources/HTTPEncoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,19 @@ struct HTTPEncoder {
httpHeaders.addValue(encoding, for: .transferEncoding)
}

let headers = httpHeaders.map { "\($0.key.rawValue): \($0.value)" }

var headers = [String]()

for header in httpHeaders.storage.keys.sorted(by: { $0.rawValue < $1.rawValue }) {
let values = httpHeaders.storage[header]!
if HTTPHeaders.canCombineValues(for: header) {
let joinedValues = values.joined(separator: ", ")
headers.append("\(header.rawValue): \(joinedValues)")
} else {
headers.append(
contentsOf: values.map { "\(header.rawValue): \($0)" }
)
}
}
return [status] + headers + ["\r\n"]
}

Expand Down
22 changes: 4 additions & 18 deletions FlyingFox/Sources/HTTPHeader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,37 +52,23 @@ public struct HTTPHeader: Sendable, RawRepresentable, Hashable {
public extension HTTPHeader {
static let acceptRanges = HTTPHeader("Accept-Ranges")
static let authorization = HTTPHeader("Authorization")
static let cookie = HTTPHeader("Cookie")
static let connection = HTTPHeader("Connection")
static let contentDisposition = HTTPHeader("Content-Disposition")
static let contentEncoding = HTTPHeader("Content-Encoding")
static let contentLength = HTTPHeader("Content-Length")
static let contentRange = HTTPHeader("Content-Range")
static let contentType = HTTPHeader("Content-Type")
static let date = HTTPHeader("Date")
static let eTag = HTTPHeader("ETag")
static let host = HTTPHeader("Host")
static let location = HTTPHeader("Location")
static let range = HTTPHeader("Range")
static let setCookie = HTTPHeader("Set-Cookie")
static let transferEncoding = HTTPHeader("Transfer-Encoding")
static let upgrade = HTTPHeader("Upgrade")
static let webSocketAccept = HTTPHeader("Sec-WebSocket-Accept")
static let webSocketKey = HTTPHeader("Sec-WebSocket-Key")
static let webSocketVersion = HTTPHeader("Sec-WebSocket-Version")
static let xForwardedFor = HTTPHeader("X-Forwarded-For")
}

public extension [HTTPHeader: String] {

func values(for header: HTTPHeader) -> [String] {
let value = self[header] ?? ""
return value
.split(separator: ",", omittingEmptySubsequences: true)
.map { String($0.trimmingCharacters(in: .whitespaces)) }
}

mutating func setValues(_ values: [String], for header: HTTPHeader) {
self[header] = values.joined(separator: ", ")
}

mutating func addValue(_ value: String, for header: HTTPHeader) {
setValues(values(for: header) + [value], for: header)
}
}
116 changes: 116 additions & 0 deletions FlyingFox/Sources/HTTPHeaders.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
//
// HTTPHeaders.swift
// FlyingFox
//
// Created by Simon Whitty on 13/09/2025.
// Copyright © 2025 Simon Whitty. All rights reserved.
//
// Distributed under the permissive MIT license
// Get the latest version from here:
//
// https://github.com/swhitty/FlyingFox
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//

public struct HTTPHeaders: Hashable, Sendable, Sequence, ExpressibleByDictionaryLiteral {
var storage: [HTTPHeader: [String]] = [:]

public init() { }

public init(_ headers: [HTTPHeader: String]) {
self.storage = headers.mapValues { [$0] }
}

public init(dictionaryLiteral elements: (HTTPHeader, String)...) {
for (header, value) in elements {
if HTTPHeaders.canCombineValues(for: header) {
let values = value
.split(separator: ",", omittingEmptySubsequences: true)
.map { String($0.trimmingCharacters(in: .whitespaces)) }
storage[header, default: []].append(contentsOf: values)
} else {
storage[header, default: []].append(value)
}
}
}

public subscript(header: HTTPHeader) -> String? {
get {
guard let values = storage[header] else { return nil }
if HTTPHeaders.canCombineValues(for: header) {
return values.joined(separator: ", ")
} else {
return values.first
}
}
set {
if let newValue {
if storage[header] != nil {
storage[header]?[0] = newValue
} else {
storage[header] = [newValue]
}
} else {
storage.removeValue(forKey: header)
}
}
}

public var keys: some Collection<HTTPHeader> {
storage.keys
}

public var values: some Collection<[String]> {
storage.values
}

public func values(for header: HTTPHeader) -> [String] {
storage[header] ?? []
}

public mutating func addValue(_ value: String, for header: HTTPHeader) {
storage[header, default: []].append(value)
}

public mutating func setValues(_ values: [String], for header: HTTPHeader) {
storage[header] = values
}

public mutating func removeValue(_ header: HTTPHeader) {
storage.removeValue(forKey: header)
}

public func makeIterator() -> some IteratorProtocol<(key: HTTPHeader, value: String)> {
storage.lazy
.flatMap { (key, values) in values.lazy.map { (key, $0) } }
.makeIterator()
}
}

package extension HTTPHeaders {

private static let singleValueHeaders: Set<HTTPHeader> = [
.cookie, .setCookie, .date, .eTag, .contentLength, .contentType, .authorization, .host
]

static func canCombineValues(for header: HTTPHeader) -> Bool {
!singleValueHeaders.contains(header)
}
}
27 changes: 24 additions & 3 deletions FlyingFox/Sources/HTTPRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public struct HTTPRequest: Sendable {
public var version: HTTPVersion
public var path: String
public var query: [QueryItem]
public var headers: [HTTPHeader: String]
public var headers: HTTPHeaders
public var bodySequence: HTTPBodySequence
public var remoteAddress: Address?

Expand All @@ -62,7 +62,7 @@ public struct HTTPRequest: Sendable {
version: HTTPVersion,
path: String,
query: [QueryItem],
headers: [HTTPHeader: String],
headers: HTTPHeaders,
body: HTTPBodySequence,
remoteAddress: Address? = nil) {
self.method = method
Expand All @@ -84,12 +84,33 @@ public struct HTTPRequest: Sendable {
self.version = version
self.path = path
self.query = query
self.headers = headers
self.headers = HTTPHeaders(headers)
self.bodySequence = HTTPBodySequence(data: body)
self.remoteAddress = nil
}
}

@available(*, deprecated, message: "Use ``HTTPHeaders`` instead of [HTTPHeader: String]")
public extension HTTPRequest {

init(method: HTTPMethod,
version: HTTPVersion,
path: String,
query: [QueryItem],
headers: [HTTPHeader: String],
body: HTTPBodySequence
) {
self.init(
method: method,
version: version,
path: path,
query: query,
headers: HTTPHeaders(headers),
body: body
)
}
}

extension HTTPRequest {
var shouldKeepAlive: Bool {
headers[.connection]?.caseInsensitiveCompare("keep-alive") == .orderedSame
Expand Down
44 changes: 40 additions & 4 deletions FlyingFox/Sources/HTTPResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import Foundation
public struct HTTPResponse: Sendable {
public var version: HTTPVersion
public var statusCode: HTTPStatusCode
public var headers: [HTTPHeader: String]
public var headers: HTTPHeaders
public var payload: Payload

public enum Payload: @unchecked Sendable {
Expand Down Expand Up @@ -65,7 +65,7 @@ public struct HTTPResponse: Sendable {

public init(version: HTTPVersion = .http11,
statusCode: HTTPStatusCode,
headers: [HTTPHeader: String] = [:],
headers: HTTPHeaders = [:],
body: Data = Data()) {
self.version = version
self.statusCode = statusCode
Expand All @@ -75,15 +75,15 @@ public struct HTTPResponse: Sendable {

public init(version: HTTPVersion = .http11,
statusCode: HTTPStatusCode,
headers: [HTTPHeader: String] = [:],
headers: HTTPHeaders = [:],
body: HTTPBodySequence) {
self.version = version
self.statusCode = statusCode
self.headers = headers
self.payload = .httpBody(body)
}

public init(headers: [HTTPHeader: String] = [:],
public init(headers: HTTPHeaders = [:],
webSocket handler: some WSHandler) {
self.version = .http11
self.statusCode = .switchingProtocols
Expand All @@ -92,6 +92,42 @@ public struct HTTPResponse: Sendable {
}
}

@available(*, deprecated, message: "Use ``HTTPHeaders`` instead of [HTTPHeader: String]")
public extension HTTPResponse {

init(
version: HTTPVersion = .http11,
statusCode: HTTPStatusCode,
headers: [HTTPHeader: String],
body: Data = Data()
) {
self.init(
version: version,
statusCode: statusCode,
headers: HTTPHeaders(headers),
body: body
)
}

init(
version: HTTPVersion = .http11,
statusCode: HTTPStatusCode,
headers: [HTTPHeader: String],
body: HTTPBodySequence
) {
self.init(
version: version,
statusCode: statusCode,
headers: HTTPHeaders(headers),
body: body
)
}

init(headers: [HTTPHeader: String], webSocket handler: some WSHandler) {
self.init(headers: HTTPHeaders(headers), webSocket: handler)
}
}

extension HTTPResponse {
var shouldKeepAlive: Bool {
headers[.connection]?.caseInsensitiveCompare("keep-alive") == .orderedSame
Expand Down
2 changes: 1 addition & 1 deletion FlyingFox/Sources/HTTPRoute.swift
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ private extension HTTPRoute {
return true
}

func patternMatch(headers request: [HTTPHeader: String]) -> Bool {
func patternMatch(headers request: HTTPHeaders) -> Bool {
return headers.allSatisfy { header, value in
value ~= request.values(for: header)
}
Expand Down
2 changes: 1 addition & 1 deletion FlyingFox/Sources/Handlers/FileHTTPHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ public struct FileHTTPHandler: HTTPHandler {
}
}

static func makePartialRange(for headers: [HTTPHeader: String], fileSize: Int) -> ClosedRange<Int>? {
static func makePartialRange(for headers: HTTPHeaders, fileSize: Int) -> ClosedRange<Int>? {
guard let headerValue = headers[.range] else { return nil }
let scanner = Scanner(string: headerValue)
guard scanner.scanString("bytes") != nil,
Expand Down
2 changes: 1 addition & 1 deletion FlyingFox/Sources/Handlers/WebSocketHTTPHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ public struct WebSocketHTTPHandler: HTTPHandler, Sendable {
/// - Parameter headers: The headers of the request to verify.
/// - Returns: The request's key.
/// - Throws: An ``WSInvalidHandshakeError`` if the headers are invalid.
static func verifyHandshakeRequestHeaders(_ headers: [HTTPHeader: String]) throws -> String {
static func verifyHandshakeRequestHeaders(_ headers: HTTPHeaders) throws -> String {
// Verify the headers according to RFC 6455 section 4.2.1 (https://datatracker.ietf.org/doc/html/rfc6455#section-4.2.1)
// Rule 1 isn't verified because the socket method is specified elsewhere

Expand Down
22 changes: 22 additions & 0 deletions FlyingFox/Tests/HTTPEncoderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,28 @@ struct HTTPEncoderTests {
)
}

@Test
func encodesMultipleCookiesHeaders() async throws {
var headers = HTTPHeaders()
headers.addValue("Fish", for: .setCookie)
headers.addValue("Chips", for: .setCookie)
let data = try await HTTPEncoder.encodeResponse(
.make(headers: headers, body: Data())
)

print(String(data: data, encoding: .utf8)!)
#expect(
String(data: data, encoding: .utf8) == """
HTTP/1.1 200 OK\r
Content-Length: 0\r
Set-Cookie: Fish\r
Set-Cookie: Chips\r
\r

"""
)
}

@Test
func encodesRequest() async throws {
#expect(
Expand Down
Loading
Loading