Skip to content

Commit fb8a62b

Browse files
committed
Add HTTPRequest.Target to preserve raw path and query string from request
1 parent 29fffbd commit fb8a62b

File tree

9 files changed

+210
-47
lines changed

9 files changed

+210
-47
lines changed

FlyingFox/Sources/HTTPDecoder.swift

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,14 @@ struct HTTPDecoder {
4848

4949
let method = HTTPMethod(String(comps[0]))
5050
let version = HTTPVersion(String(comps[2]))
51-
let (path, query) = readComponents(from: String(comps[1]))
52-
51+
let target = makeTarget(from: comps[1])
5352
let headers = try await readHeaders(from: bytes)
5453
let body = try await readBody(from: bytes, length: headers[.contentLength])
5554

5655
return HTTPRequest(
5756
method: method,
5857
version: version,
59-
path: path,
60-
query: query,
58+
target: target,
6159
headers: HTTPHeaders(headers),
6260
body: body
6361
)
@@ -87,7 +85,21 @@ struct HTTPDecoder {
8785
}
8886

8987
func readComponents(from target: String) -> (path: String, query: [HTTPRequest.QueryItem]) {
90-
makeComponents(from: URLComponents(string: target))
88+
makeComponents(from: makeTarget(from: target))
89+
}
90+
91+
func makeTarget(from target: some StringProtocol) -> HTTPRequest.Target {
92+
let comps = target.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false)
93+
let path = comps.first ?? ""
94+
let query = comps.count > 1 ? comps[1] : ""
95+
return HTTPRequest.Target(
96+
path: String(path),
97+
query: String(query)
98+
)
99+
}
100+
101+
func makeComponents(from target: HTTPRequest.Target) -> (path: String, query: [HTTPRequest.QueryItem]) {
102+
makeComponents(from: URLComponents(string: target.rawValue))
91103
}
92104

93105
func makeComponents(from comps: URLComponents?) -> (path: String, query: [HTTPRequest.QueryItem]) {
@@ -142,6 +154,10 @@ struct HTTPDecoder {
142154

143155
extension HTTPDecoder {
144156

157+
init() {
158+
self.init(sharedRequestBufferSize: 128, sharedRequestReplaySize: 1024)
159+
}
160+
145161
struct Error: LocalizedError {
146162
var errorDescription: String?
147163

FlyingFox/Sources/HTTPEncoder.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,4 +117,14 @@ struct HTTPEncoder {
117117

118118
return data
119119
}
120+
121+
static func makeTarget(path: String, query: [HTTPRequest.QueryItem]) -> HTTPRequest.Target {
122+
var comps = URLComponents()
123+
comps.path = path
124+
comps.queryItems = query.map { .init(name: $0.name, value: $0.value) }
125+
return HTTPRequest.Target(
126+
path: comps.percentEncodedPath,
127+
query: comps.percentEncodedQuery ?? ""
128+
)
129+
}
120130
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
//
2+
// HTTPRequest+Target.swift
3+
// FlyingFox
4+
//
5+
// Created by Simon Whitty on 08/11/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+
import Foundation
33+
34+
public extension HTTPRequest {
35+
36+
// RFC9112: e.g. /a%2Fb?q=1
37+
struct Target: Sendable, Equatable {
38+
39+
// raw percent encoded path e.g. /fish%20chips
40+
private var _path: String
41+
42+
// raw percent encoded query string e.g. q=fish%26chips&qty=15
43+
private var _query: String
44+
45+
public init(path: String, query: String) {
46+
self._path = path
47+
self._query = query
48+
}
49+
50+
public func path(percentEncoded: Bool = true) -> String {
51+
guard percentEncoded else {
52+
return _path.removingPercentEncoding ?? _path
53+
}
54+
return _path
55+
}
56+
57+
public func query(percentEncoded: Bool = true) -> String {
58+
guard percentEncoded else {
59+
return _query.removingPercentEncoding ?? _query
60+
}
61+
return _query
62+
}
63+
64+
public var rawValue: String {
65+
guard !_query.isEmpty else {
66+
return _path
67+
}
68+
return "\(_path)?\(_query)"
69+
}
70+
}
71+
}

FlyingFox/Sources/HTTPRequest.swift

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ import Foundation
3434
public struct HTTPRequest: Sendable {
3535
public var method: HTTPMethod
3636
public var version: HTTPVersion
37+
public var target: Target
38+
3739
public var path: String
3840
public var query: [QueryItem]
3941
public var headers: HTTPHeaders
@@ -58,15 +60,35 @@ public struct HTTPRequest: Sendable {
5860
set { fatalError("unavailable") }
5961
}
6062

61-
public init(method: HTTPMethod,
62-
version: HTTPVersion,
63-
path: String,
64-
query: [QueryItem],
65-
headers: HTTPHeaders,
66-
body: HTTPBodySequence,
67-
remoteAddress: Address? = nil) {
63+
public init(
64+
method: HTTPMethod,
65+
version: HTTPVersion,
66+
target: Target,
67+
headers: HTTPHeaders,
68+
body: HTTPBodySequence,
69+
remoteAddress: Address? = nil
70+
) {
6871
self.method = method
6972
self.version = version
73+
self.target = target
74+
(self.path, self.query) = HTTPDecoder().makeComponents(from: target)
75+
self.headers = headers
76+
self.bodySequence = body
77+
self.remoteAddress = remoteAddress
78+
}
79+
80+
public init(
81+
method: HTTPMethod,
82+
version: HTTPVersion,
83+
path: String,
84+
query: [QueryItem],
85+
headers: HTTPHeaders,
86+
body: HTTPBodySequence,
87+
remoteAddress: Address? = nil
88+
) {
89+
self.method = method
90+
self.version = version
91+
self.target = HTTPEncoder.makeTarget(path: path, query: query)
7092
self.path = path
7193
self.query = query
7294
self.headers = headers
@@ -79,9 +101,11 @@ public struct HTTPRequest: Sendable {
79101
path: String,
80102
query: [QueryItem],
81103
headers: [HTTPHeader: String],
82-
body: Data) {
104+
body: Data
105+
) {
83106
self.method = method
84107
self.version = version
108+
self.target = HTTPEncoder.makeTarget(path: path, query: query)
85109
self.path = path
86110
self.query = query
87111
self.headers = HTTPHeaders(headers)
@@ -90,24 +114,17 @@ public struct HTTPRequest: Sendable {
90114
}
91115
}
92116

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

119+
@available(*, unavailable, message: "Use ``HTTPHeaders`` instead of [HTTPHeader: String]")
96120
init(method: HTTPMethod,
97121
version: HTTPVersion,
98122
path: String,
99123
query: [QueryItem],
100124
headers: [HTTPHeader: String],
101125
body: HTTPBodySequence
102126
) {
103-
self.init(
104-
method: method,
105-
version: version,
106-
path: path,
107-
query: query,
108-
headers: HTTPHeaders(headers),
109-
body: body
110-
)
127+
fatalError("unavailable")
111128
}
112129
}
113130

FlyingFox/Sources/HTTPRoute.swift

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -383,9 +383,3 @@ public extension Array where Element == HTTPRoute.Parameter {
383383
}
384384
}
385385
}
386-
387-
private extension HTTPDecoder {
388-
init() {
389-
self.init(sharedRequestBufferSize: 128, sharedRequestReplaySize: 1024)
390-
}
391-
}

FlyingFox/Tests/HTTPConnectionTests.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ struct HTTPConnectionTests {
5555
)
5656

5757
let request = try await connection.requests.first()
58+
print(request)
5859
#expect(
5960
await request == .make(
6061
method: .GET,

FlyingFox/Tests/HTTPDecoderTests.swift

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ struct HTTPDecoderTests {
212212

213213
@Test
214214
func invalidPathDecodes() {
215-
let comps = HTTPDecoder.make().makeComponents(from: nil)
215+
let comps = HTTPDecoder.make().readComponents(from: "")
216216
#expect(comps.path == "")
217217
#expect(comps.query == [])
218218
}
@@ -243,11 +243,8 @@ struct HTTPDecoderTests {
243243

244244
@Test
245245
func emptyQueryItem_Decodes() {
246-
var urlComps = URLComponents()
247-
urlComps.queryItems = [.init(name: "name", value: nil)]
248-
249246
#expect(
250-
HTTPDecoder.make().makeComponents(from: urlComps).query == [
247+
HTTPDecoder.make().readComponents(from: "/?name=").query == [
251248
.init(name: "name", value: "")
252249
]
253250
)

FlyingFox/Tests/HTTPRequest+Mock.swift

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,20 +33,42 @@
3333
import Foundation
3434

3535
extension HTTPRequest {
36-
static func make(method: HTTPMethod = .GET,
37-
version: HTTPVersion = .http11,
38-
path: String = "/",
39-
query: [QueryItem] = [],
40-
headers: HTTPHeaders = [:],
41-
body: Data = Data(),
42-
remoteAddress: Address? = nil) -> Self {
43-
HTTPRequest(method: method,
44-
version: version,
45-
path: path,
46-
query: query,
47-
headers: headers,
48-
body: HTTPBodySequence(data: body),
49-
remoteAddress: remoteAddress)
36+
static func make(
37+
method: HTTPMethod = .GET,
38+
version: HTTPVersion = .http11,
39+
target: String = "/",
40+
headers: HTTPHeaders = [:],
41+
body: Data = Data(),
42+
remoteAddress: Address? = nil
43+
) -> Self {
44+
HTTPRequest(
45+
method: method,
46+
version: version,
47+
target: HTTPDecoder().makeTarget(from: target),
48+
headers: headers,
49+
body: HTTPBodySequence(data: body),
50+
remoteAddress: remoteAddress
51+
)
52+
}
53+
54+
static func make(
55+
method: HTTPMethod = .GET,
56+
version: HTTPVersion = .http11,
57+
path: String = "/",
58+
query: [QueryItem] = [],
59+
headers: HTTPHeaders = [:],
60+
body: Data = Data(),
61+
remoteAddress: Address? = nil
62+
) -> Self {
63+
HTTPRequest(
64+
method: method,
65+
version: version,
66+
path: path,
67+
query: query,
68+
headers: headers,
69+
body: HTTPBodySequence(data: body),
70+
remoteAddress: remoteAddress
71+
)
5072
}
5173

5274
static func make(method: HTTPMethod = .GET, _ url: String, headers: HTTPHeaders = [:]) -> Self {

FlyingFox/Tests/HTTPRequestTests.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,39 @@ struct HTTPRequestTests {
5353
try await request.bodyData == Data([0x05, 0x06])
5454
)
5555
}
56+
57+
@Test
58+
func targetIsCreated() {
59+
// when
60+
let request = HTTPRequest.make(path: "/meal plan/order", query: [
61+
.init(name: "food", value: "fish & chips"),
62+
.init(name: "qty", value: "15")
63+
])
64+
65+
// then
66+
#expect(request.target.rawValue == "/meal%20plan/order?food=fish%20%26%20chips&qty=15")
67+
#expect(request.target.path() == "/meal%20plan/order")
68+
#expect(request.target.path(percentEncoded: false) == "/meal plan/order")
69+
#expect(request.target.query() == "food=fish%20%26%20chips&qty=15")
70+
#expect(request.target.query(percentEncoded: false) == "food=fish & chips&qty=15")
71+
}
72+
73+
@Test
74+
func pathIsCreated() {
75+
// when
76+
let request = HTTPRequest.make(
77+
target: "/meal%20plan/order?food=fish%20%26%20chips&qty=15"
78+
)
79+
80+
// then
81+
#expect(request.path == "/meal plan/order")
82+
#expect(request.query == [
83+
.init(name: "food", value: "fish & chips"),
84+
.init(name: "qty", value: "15")
85+
])
86+
#expect(request.target.path() == "/meal%20plan/order")
87+
#expect(request.target.path(percentEncoded: false) == "/meal plan/order")
88+
#expect(request.target.query() == "food=fish%20%26%20chips&qty=15")
89+
#expect(request.target.query(percentEncoded: false) == "food=fish & chips&qty=15")
90+
}
5691
}

0 commit comments

Comments
 (0)