From fb8a62bac67989f14b4d008719c699dc02687846 Mon Sep 17 00:00:00 2001 From: Simon Whitty Date: Sat, 8 Nov 2025 18:19:22 +1100 Subject: [PATCH] Add HTTPRequest.Target to preserve raw path and query string from request --- FlyingFox/Sources/HTTPDecoder.swift | 26 ++++++-- FlyingFox/Sources/HTTPEncoder.swift | 10 +++ FlyingFox/Sources/HTTPRequest+Target.swift | 71 ++++++++++++++++++++++ FlyingFox/Sources/HTTPRequest.swift | 51 ++++++++++------ FlyingFox/Sources/HTTPRoute.swift | 6 -- FlyingFox/Tests/HTTPConnectionTests.swift | 1 + FlyingFox/Tests/HTTPDecoderTests.swift | 7 +-- FlyingFox/Tests/HTTPRequest+Mock.swift | 50 ++++++++++----- FlyingFox/Tests/HTTPRequestTests.swift | 35 +++++++++++ 9 files changed, 210 insertions(+), 47 deletions(-) create mode 100644 FlyingFox/Sources/HTTPRequest+Target.swift diff --git a/FlyingFox/Sources/HTTPDecoder.swift b/FlyingFox/Sources/HTTPDecoder.swift index 45ab21a9..0a0c5316 100644 --- a/FlyingFox/Sources/HTTPDecoder.swift +++ b/FlyingFox/Sources/HTTPDecoder.swift @@ -48,16 +48,14 @@ struct HTTPDecoder { let method = HTTPMethod(String(comps[0])) let version = HTTPVersion(String(comps[2])) - let (path, query) = readComponents(from: String(comps[1])) - + let target = makeTarget(from: comps[1]) let headers = try await readHeaders(from: bytes) let body = try await readBody(from: bytes, length: headers[.contentLength]) return HTTPRequest( method: method, version: version, - path: path, - query: query, + target: target, headers: HTTPHeaders(headers), body: body ) @@ -87,7 +85,21 @@ struct HTTPDecoder { } func readComponents(from target: String) -> (path: String, query: [HTTPRequest.QueryItem]) { - makeComponents(from: URLComponents(string: target)) + makeComponents(from: makeTarget(from: target)) + } + + func makeTarget(from target: some StringProtocol) -> HTTPRequest.Target { + let comps = target.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false) + let path = comps.first ?? "" + let query = comps.count > 1 ? comps[1] : "" + return HTTPRequest.Target( + path: String(path), + query: String(query) + ) + } + + func makeComponents(from target: HTTPRequest.Target) -> (path: String, query: [HTTPRequest.QueryItem]) { + makeComponents(from: URLComponents(string: target.rawValue)) } func makeComponents(from comps: URLComponents?) -> (path: String, query: [HTTPRequest.QueryItem]) { @@ -142,6 +154,10 @@ struct HTTPDecoder { extension HTTPDecoder { + init() { + self.init(sharedRequestBufferSize: 128, sharedRequestReplaySize: 1024) + } + struct Error: LocalizedError { var errorDescription: String? diff --git a/FlyingFox/Sources/HTTPEncoder.swift b/FlyingFox/Sources/HTTPEncoder.swift index 06258e8b..f796fcf9 100644 --- a/FlyingFox/Sources/HTTPEncoder.swift +++ b/FlyingFox/Sources/HTTPEncoder.swift @@ -117,4 +117,14 @@ struct HTTPEncoder { return data } + + static func makeTarget(path: String, query: [HTTPRequest.QueryItem]) -> HTTPRequest.Target { + var comps = URLComponents() + comps.path = path + comps.queryItems = query.map { .init(name: $0.name, value: $0.value) } + return HTTPRequest.Target( + path: comps.percentEncodedPath, + query: comps.percentEncodedQuery ?? "" + ) + } } diff --git a/FlyingFox/Sources/HTTPRequest+Target.swift b/FlyingFox/Sources/HTTPRequest+Target.swift new file mode 100644 index 00000000..403c208b --- /dev/null +++ b/FlyingFox/Sources/HTTPRequest+Target.swift @@ -0,0 +1,71 @@ +// +// HTTPRequest+Target.swift +// FlyingFox +// +// Created by Simon Whitty on 08/11/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. +// + +import Foundation + +public extension HTTPRequest { + + // RFC9112: e.g. /a%2Fb?q=1 + struct Target: Sendable, Equatable { + + // raw percent encoded path e.g. /fish%20chips + private var _path: String + + // raw percent encoded query string e.g. q=fish%26chips&qty=15 + private var _query: String + + public init(path: String, query: String) { + self._path = path + self._query = query + } + + public func path(percentEncoded: Bool = true) -> String { + guard percentEncoded else { + return _path.removingPercentEncoding ?? _path + } + return _path + } + + public func query(percentEncoded: Bool = true) -> String { + guard percentEncoded else { + return _query.removingPercentEncoding ?? _query + } + return _query + } + + public var rawValue: String { + guard !_query.isEmpty else { + return _path + } + return "\(_path)?\(_query)" + } + } +} diff --git a/FlyingFox/Sources/HTTPRequest.swift b/FlyingFox/Sources/HTTPRequest.swift index 9cf9333a..14a823be 100644 --- a/FlyingFox/Sources/HTTPRequest.swift +++ b/FlyingFox/Sources/HTTPRequest.swift @@ -34,6 +34,8 @@ import Foundation public struct HTTPRequest: Sendable { public var method: HTTPMethod public var version: HTTPVersion + public var target: Target + public var path: String public var query: [QueryItem] public var headers: HTTPHeaders @@ -58,15 +60,35 @@ public struct HTTPRequest: Sendable { set { fatalError("unavailable") } } - public init(method: HTTPMethod, - version: HTTPVersion, - path: String, - query: [QueryItem], - headers: HTTPHeaders, - body: HTTPBodySequence, - remoteAddress: Address? = nil) { + public init( + method: HTTPMethod, + version: HTTPVersion, + target: Target, + headers: HTTPHeaders, + body: HTTPBodySequence, + remoteAddress: Address? = nil + ) { self.method = method self.version = version + self.target = target + (self.path, self.query) = HTTPDecoder().makeComponents(from: target) + self.headers = headers + self.bodySequence = body + self.remoteAddress = remoteAddress + } + + public init( + method: HTTPMethod, + version: HTTPVersion, + path: String, + query: [QueryItem], + headers: HTTPHeaders, + body: HTTPBodySequence, + remoteAddress: Address? = nil + ) { + self.method = method + self.version = version + self.target = HTTPEncoder.makeTarget(path: path, query: query) self.path = path self.query = query self.headers = headers @@ -79,9 +101,11 @@ public struct HTTPRequest: Sendable { path: String, query: [QueryItem], headers: [HTTPHeader: String], - body: Data) { + body: Data + ) { self.method = method self.version = version + self.target = HTTPEncoder.makeTarget(path: path, query: query) self.path = path self.query = query self.headers = HTTPHeaders(headers) @@ -90,9 +114,9 @@ public struct HTTPRequest: Sendable { } } -@available(*, deprecated, message: "Use ``HTTPHeaders`` instead of [HTTPHeader: String]") public extension HTTPRequest { + @available(*, unavailable, message: "Use ``HTTPHeaders`` instead of [HTTPHeader: String]") init(method: HTTPMethod, version: HTTPVersion, path: String, @@ -100,14 +124,7 @@ public extension HTTPRequest { headers: [HTTPHeader: String], body: HTTPBodySequence ) { - self.init( - method: method, - version: version, - path: path, - query: query, - headers: HTTPHeaders(headers), - body: body - ) + fatalError("unavailable") } } diff --git a/FlyingFox/Sources/HTTPRoute.swift b/FlyingFox/Sources/HTTPRoute.swift index efedf3d9..b11877fb 100644 --- a/FlyingFox/Sources/HTTPRoute.swift +++ b/FlyingFox/Sources/HTTPRoute.swift @@ -383,9 +383,3 @@ public extension Array where Element == HTTPRoute.Parameter { } } } - -private extension HTTPDecoder { - init() { - self.init(sharedRequestBufferSize: 128, sharedRequestReplaySize: 1024) - } -} diff --git a/FlyingFox/Tests/HTTPConnectionTests.swift b/FlyingFox/Tests/HTTPConnectionTests.swift index 071f41e9..3d92ac02 100644 --- a/FlyingFox/Tests/HTTPConnectionTests.swift +++ b/FlyingFox/Tests/HTTPConnectionTests.swift @@ -55,6 +55,7 @@ struct HTTPConnectionTests { ) let request = try await connection.requests.first() + print(request) #expect( await request == .make( method: .GET, diff --git a/FlyingFox/Tests/HTTPDecoderTests.swift b/FlyingFox/Tests/HTTPDecoderTests.swift index 41af628f..7033c761 100644 --- a/FlyingFox/Tests/HTTPDecoderTests.swift +++ b/FlyingFox/Tests/HTTPDecoderTests.swift @@ -212,7 +212,7 @@ struct HTTPDecoderTests { @Test func invalidPathDecodes() { - let comps = HTTPDecoder.make().makeComponents(from: nil) + let comps = HTTPDecoder.make().readComponents(from: "") #expect(comps.path == "") #expect(comps.query == []) } @@ -243,11 +243,8 @@ struct HTTPDecoderTests { @Test func emptyQueryItem_Decodes() { - var urlComps = URLComponents() - urlComps.queryItems = [.init(name: "name", value: nil)] - #expect( - HTTPDecoder.make().makeComponents(from: urlComps).query == [ + HTTPDecoder.make().readComponents(from: "/?name=").query == [ .init(name: "name", value: "") ] ) diff --git a/FlyingFox/Tests/HTTPRequest+Mock.swift b/FlyingFox/Tests/HTTPRequest+Mock.swift index 40c68fc0..33793112 100644 --- a/FlyingFox/Tests/HTTPRequest+Mock.swift +++ b/FlyingFox/Tests/HTTPRequest+Mock.swift @@ -33,20 +33,42 @@ import Foundation extension HTTPRequest { - static func make(method: HTTPMethod = .GET, - version: HTTPVersion = .http11, - path: String = "/", - query: [QueryItem] = [], - headers: HTTPHeaders = [:], - body: Data = Data(), - remoteAddress: Address? = nil) -> Self { - HTTPRequest(method: method, - version: version, - path: path, - query: query, - headers: headers, - body: HTTPBodySequence(data: body), - remoteAddress: remoteAddress) + static func make( + method: HTTPMethod = .GET, + version: HTTPVersion = .http11, + target: String = "/", + headers: HTTPHeaders = [:], + body: Data = Data(), + remoteAddress: Address? = nil + ) -> Self { + HTTPRequest( + method: method, + version: version, + target: HTTPDecoder().makeTarget(from: target), + headers: headers, + body: HTTPBodySequence(data: body), + remoteAddress: remoteAddress + ) + } + + static func make( + method: HTTPMethod = .GET, + version: HTTPVersion = .http11, + path: String = "/", + query: [QueryItem] = [], + headers: HTTPHeaders = [:], + body: Data = Data(), + remoteAddress: Address? = nil + ) -> Self { + HTTPRequest( + method: method, + version: version, + path: path, + query: query, + headers: headers, + body: HTTPBodySequence(data: body), + remoteAddress: remoteAddress + ) } static func make(method: HTTPMethod = .GET, _ url: String, headers: HTTPHeaders = [:]) -> Self { diff --git a/FlyingFox/Tests/HTTPRequestTests.swift b/FlyingFox/Tests/HTTPRequestTests.swift index 66e19840..cfb1e444 100644 --- a/FlyingFox/Tests/HTTPRequestTests.swift +++ b/FlyingFox/Tests/HTTPRequestTests.swift @@ -53,4 +53,39 @@ struct HTTPRequestTests { try await request.bodyData == Data([0x05, 0x06]) ) } + + @Test + func targetIsCreated() { + // when + let request = HTTPRequest.make(path: "/meal plan/order", query: [ + .init(name: "food", value: "fish & chips"), + .init(name: "qty", value: "15") + ]) + + // then + #expect(request.target.rawValue == "/meal%20plan/order?food=fish%20%26%20chips&qty=15") + #expect(request.target.path() == "/meal%20plan/order") + #expect(request.target.path(percentEncoded: false) == "/meal plan/order") + #expect(request.target.query() == "food=fish%20%26%20chips&qty=15") + #expect(request.target.query(percentEncoded: false) == "food=fish & chips&qty=15") + } + + @Test + func pathIsCreated() { + // when + let request = HTTPRequest.make( + target: "/meal%20plan/order?food=fish%20%26%20chips&qty=15" + ) + + // then + #expect(request.path == "/meal plan/order") + #expect(request.query == [ + .init(name: "food", value: "fish & chips"), + .init(name: "qty", value: "15") + ]) + #expect(request.target.path() == "/meal%20plan/order") + #expect(request.target.path(percentEncoded: false) == "/meal plan/order") + #expect(request.target.query() == "food=fish%20%26%20chips&qty=15") + #expect(request.target.query(percentEncoded: false) == "food=fish & chips&qty=15") + } }