Skip to content

Commit f7e7abd

Browse files
Add URL Fragment parser and test (#29)
Co-authored-by: Stephen Celis <stephen@stephencelis.com>
1 parent b47da5b commit f7e7abd

File tree

5 files changed

+127
-0
lines changed

5 files changed

+127
-0
lines changed

Sources/URLRouting/Fragment.swift

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import Parsing
2+
3+
/// Parses a request's fragment subcomponent with a substring parser.
4+
public struct Fragment<ValueParser: Parser>: Parser where ValueParser.Input == Substring {
5+
6+
@usableFromInline
7+
let valueParser: ValueParser
8+
9+
/// Initializes a fragment parser that parses the fragment as a string in its entirety.
10+
@inlinable
11+
public init()
12+
where ValueParser == Parsers.MapConversion<Parsers.ReplaceError<Rest<Substring>>, Conversions.SubstringToString> {
13+
self.valueParser = Rest().replaceError(with: "").map(.string)
14+
}
15+
16+
/// Initializes a fragment parser.
17+
///
18+
/// - Parameter value: A parser that parses the fragment's substring value into something
19+
/// more well-structured.
20+
@inlinable
21+
public init(@ParserBuilder value: () -> ValueParser) {
22+
self.valueParser = value()
23+
}
24+
25+
/// Initializes a fragment parser.
26+
///
27+
/// - Parameter value: A conversion that transforms the fragment's substring value into
28+
/// some other type.
29+
@inlinable
30+
public init<C>(_ value: C)
31+
where ValueParser == Parsers.MapConversion<Parsers.ReplaceError<Rest<Substring>>, C> {
32+
self.valueParser = Rest().replaceError(with: "").map(value)
33+
}
34+
35+
@inlinable
36+
public func parse(_ input: inout URLRequestData) throws -> ValueParser.Output {
37+
guard var fragment = input.fragment?[...] else { throw RoutingError() }
38+
let output = try self.valueParser.parse(&fragment)
39+
input.fragment = String(fragment)
40+
return output
41+
}
42+
}
43+
44+
extension Fragment: ParserPrinter where ValueParser: ParserPrinter {
45+
@inlinable
46+
public func print(_ output: ValueParser.Output, into input: inout URLRequestData) rethrows {
47+
input.fragment = String(try self.valueParser.print(output))
48+
}
49+
}

Sources/URLRouting/Printing.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ where Upstream.Input == URLRequestData {
8484
if let port = self.defaultRequestData.port { input.port = port }
8585
input.path.prepend(contentsOf: self.defaultRequestData.path)
8686
input.query.fields.merge(self.defaultRequestData.query.fields) { $1 + $0 }
87+
if let fragment = self.defaultRequestData.fragment { input.fragment = fragment }
8788
input.headers.fields.merge(self.defaultRequestData.headers.fields) { $1 + $0 }
8889
}
8990
}

Sources/URLRouting/URLRequestData+Foundation.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ extension URLRequestData {
3535
query: components.queryItems?.reduce(into: [:]) { query, item in
3636
query[item.name, default: []].append(item.value)
3737
} ?? [:],
38+
fragment: components.fragment,
3839
headers: request.allHTTPHeaderFields?.mapValues {
3940
$0.split(separator: ",", omittingEmptySubsequences: false).map { String($0) }
4041
} ?? [:],
@@ -82,6 +83,7 @@ extension URLComponents {
8283
values.map { URLQueryItem(name: name, value: $0.map(String.init)) }
8384
}
8485
}
86+
self.fragment = data.fragment
8587
}
8688
}
8789

Sources/URLRouting/URLRequestData.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import Foundation
77
public struct URLRequestData: Equatable, _EmptyInitializable {
88
/// The request body.
99
public var body: Data?
10+
11+
/// The fragment subcomponent of the request URL.
12+
public var fragment: String?
1013

1114
/// The request headers.
1215
///
@@ -61,6 +64,7 @@ public struct URLRequestData: Equatable, _EmptyInitializable {
6164
/// - port: The port subcomponent of the request URL.
6265
/// - path: An array of the request URL's path components.
6366
/// - query: The query subcomponent of the request URL.
67+
/// - fragment: The fragment subcomponent of the request URL.
6468
/// - headers: The request headers.
6569
/// - body: The request body.
6670
@inlinable
@@ -73,10 +77,12 @@ public struct URLRequestData: Equatable, _EmptyInitializable {
7377
port: Int? = nil,
7478
path: String = "",
7579
query: [String: [String?]] = [:],
80+
fragment: String? = nil,
7681
headers: [String: [String?]] = [:],
7782
body: Data? = nil
7883
) {
7984
self.body = body
85+
self.fragment = fragment
8086
self.headers = .init(headers.mapValues { $0.map { $0?[...] }[...] }, isNameCaseSensitive: false)
8187
self.host = host
8288
self.method = method
@@ -147,6 +153,7 @@ extension URLRequestData: Codable {
147153
port: try container.decodeIfPresent(Int.self, forKey: .port),
148154
path: try container.decodeIfPresent(String.self, forKey: .path) ?? "",
149155
query: try container.decodeIfPresent([String: [String?]].self, forKey: .query) ?? [:],
156+
fragment: try container.decodeIfPresent(String.self, forKey: .fragment),
150157
headers: try container.decodeIfPresent([String: [String?]].self, forKey: .headers) ?? [:],
151158
body: try container.decodeIfPresent(Data.self, forKey: .body)
152159
)
@@ -156,6 +163,7 @@ extension URLRequestData: Codable {
156163
public func encode(to encoder: Encoder) throws {
157164
var container = encoder.container(keyedBy: CodingKeys.self)
158165
try container.encodeIfPresent(self.body.map(Array.init), forKey: .body)
166+
try container.encodeIfPresent(self.fragment, forKey: .fragment)
159167
if !self.headers.isEmpty {
160168
try container.encode(
161169
self.headers.fields.mapValues { $0.map { $0.map(String.init) } },
@@ -180,6 +188,7 @@ extension URLRequestData: Codable {
180188
@usableFromInline
181189
enum CodingKeys: CodingKey {
182190
case body
191+
case fragment
183192
case headers
184193
case host
185194
case method
@@ -196,6 +205,7 @@ extension URLRequestData: Hashable {
196205
@inlinable
197206
public func hash(into hasher: inout Hasher) {
198207
hasher.combine(self.body)
208+
hasher.combine(self.fragment)
199209
hasher.combine(self.method)
200210
hasher.combine(self.headers)
201211
hasher.combine(self.host)

Tests/URLRoutingTests/URLRoutingTests.swift

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,71 @@ class URLRoutingTests: XCTestCase {
9797
URLRequestData(query: [:])
9898
)
9999
}
100+
101+
func testFragment() throws {
102+
// test default initializer
103+
let q1 = Fragment()
104+
105+
var request = try XCTUnwrap(URLRequestData(string: "#fragment"))
106+
XCTAssertEqual(
107+
"fragment",
108+
try q1.parse(&request)
109+
)
110+
XCTAssertEqual(
111+
URLRequestData(fragment: "fragment"),
112+
try q1.print("fragment")
113+
)
114+
115+
struct Timestamp: Equatable, RawRepresentable {
116+
let rawValue: String
117+
}
118+
119+
// test conversion initializer
120+
let q2 = Fragment(.string.representing(Timestamp.self))
121+
request = try XCTUnwrap(URLRequestData(string: "https://www.pointfree.co/episodes/ep182-invertible-parsing-map#t802"))
122+
XCTAssertEqual(
123+
Timestamp(rawValue: "t802"),
124+
try q2.parse(&request)
125+
)
126+
XCTAssertEqual(
127+
URLRequestData(fragment: "t802"),
128+
try q2.print(Timestamp(rawValue: "t802"))
129+
)
130+
131+
// test parser builder initializer
132+
let p3 = Fragment {
133+
"section1"
134+
}
135+
136+
request = try XCTUnwrap(URLRequestData(string: "#section1"))
137+
XCTAssertNoThrow(try p3.parse(&request))
138+
request = try XCTUnwrap(URLRequestData(string: "#section2"))
139+
XCTAssertThrowsError(try p3.parse(&request))
140+
XCTAssertEqual(
141+
.init(fragment: "section1"),
142+
try p3.print()
143+
)
144+
145+
enum AppRoute: Equatable {
146+
case privacyPolicy(section: String)
147+
}
148+
149+
// routing example
150+
let r = Route(.case(AppRoute.privacyPolicy(section:))) {
151+
Path { "legal"; "privacy" }
152+
Fragment()
153+
}
154+
155+
request = try XCTUnwrap(URLRequestData(string: "/legal/privacy#faq"))
156+
XCTAssertEqual(
157+
.privacyPolicy(section: "faq"),
158+
try r.parse(&request)
159+
)
160+
XCTAssertEqual(
161+
.init(path: "/legal/privacy", fragment: "faq"),
162+
try r.print(.privacyPolicy(section: "faq"))
163+
)
164+
}
100165

101166
func testCookies() throws {
102167
struct Session: Equatable {

0 commit comments

Comments
 (0)