Skip to content

Commit a169ceb

Browse files
authored
URI Templating (#1198)
* Initial implementation. * Cleanup and fix normalizedAddingPercentEncoding() * Use failing initializer on URL instead of makeURL() * Add standard copyright header * Mark helper functions as fileprivate * URL.Template.init() needs to be failable * Rename InvalidTemplateExpression → URL.Template.InvalidExpression * Add missing @available * Update copyright header * Convert tests XCTest → Swift Testing * Use UInt8.isValidHexDigit * Use URLParser.swift methods for unreserved + reserved characters * Cleanup normalizedAddingPercentEncoding() * Use String(decoding:as:) * guard & white-space * Cleanup “defer” * Rename files URI… → URL… * Add new files to CMakeLists.txt * Add benchmarks. * Add missing @available, 2 * Fix doc comment. * Remove ExpressibleByStringLiteral conformance for URL.Template.VariableName * Improve documentation comments * Fix for 7b14d0b * Fix doc comment * Do not force unwrap “maximum length”
1 parent 655dd5e commit a169ceb

13 files changed

+1829
-7
lines changed

Benchmarks/Benchmarks/URL/BenchmarkURL.swift

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,4 +231,50 @@ let benchmarks = {
231231
}
232232
}
233233

234+
Benchmark("URL-Template-parsing") { benchmark in
235+
for _ in benchmark.scaledIterations {
236+
blackHole(URL.Template("/api/{version}/accounts/{accountId}/transactions/{transactionId}{?expand*,fields*,embed*,format}")!)
237+
blackHole(URL.Template("/special/{+a}/details")!)
238+
blackHole(URL.Template("/documents/{documentId}{#section,paragraph}")!)
239+
}
240+
}
241+
242+
let templates = [
243+
URL.Template("/var/{var}/who/{who}/x/{x}{?keys*,count*,list*,y}")!,
244+
URL.Template("/special/{+keys}/details")!,
245+
URL.Template("x/y/{#path:6}/here")!,
246+
URL.Template("a/b{/var,x}/here")!,
247+
URL.Template("a{?var,y}")!,
248+
]
249+
250+
var variables: [URL.Template.VariableName: URL.Template.Value] = [
251+
"count": ["one", "two", "three"],
252+
"dom": ["example", "com"],
253+
"dub": "me/too",
254+
"hello": "Hello World!",
255+
"half": "50%",
256+
"var": "value",
257+
"who": "fred",
258+
"base": "http://example.com/home/",
259+
"path": "/foo/bar",
260+
"list": ["red", "green", "blue"],
261+
"keys": [
262+
"semi": ";",
263+
"dot": ".",
264+
"comma": ",",
265+
],
266+
"v": "6",
267+
"x": "1024",
268+
"y": "768",
269+
"empty": "",
270+
"empty_keys": [:],
271+
]
272+
273+
Benchmark("URL-Template-expansion") { benchmark in
274+
for _ in benchmark.scaledIterations {
275+
for t in templates {
276+
blackHole(URL(template: t, variables: variables))
277+
}
278+
}
279+
}
234280
}

Sources/FoundationEssentials/URL/CMakeLists.txt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,9 @@ target_sources(FoundationEssentials PRIVATE
2020
URL_Swift.swift
2121
URLComponents.swift
2222
URLComponents_ObjC.swift
23-
URLParser.swift)
23+
URLParser.swift
24+
URLTemplate_PercentEncoding.swift
25+
URLTemplate_Substitution.swift
26+
URLTemplate_Value.swift
27+
URLTemplate_VariableName.swift
28+
URLTemplate.swift)

Sources/FoundationEssentials/URL/URLParser.swift

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
//
33
// This source file is part of the Swift.org open source project
44
//
5-
// Copyright (c) 2023 - 2024 Apple Inc. and the Swift project authors
5+
// Copyright (c) 2023 - 2025 Apple Inc. and the Swift project authors
66
// Licensed under Apache License v2.0 with Runtime Library Exception
77
//
88
// See https://swift.org/LICENSE.txt for license information
@@ -1229,8 +1229,8 @@ fileprivate struct URLComponentSet: OptionSet {
12291229
static let queryItem = URLComponentSet(rawValue: 1 << 7)
12301230
}
12311231

1232-
fileprivate extension UTF8.CodeUnit {
1233-
func isAllowedIn(_ component: URLComponentSet) -> Bool {
1232+
extension UTF8.CodeUnit {
1233+
fileprivate func isAllowedIn(_ component: URLComponentSet) -> Bool {
12341234
return allowedURLComponents & component.rawValue != 0
12351235
}
12361236

@@ -1260,7 +1260,7 @@ fileprivate extension UTF8.CodeUnit {
12601260
// let queryItemAllowed = queryAllowed.subtracting(CharacterSet(charactersIn: "=&"))
12611261
// let fragmentAllowed = CharacterSet(charactersIn: pchar + "/?")
12621262
// ===------------------------------------------------------------------------------------=== //
1263-
var allowedURLComponents: URLComponentSet.RawValue {
1263+
fileprivate var allowedURLComponents: URLComponentSet.RawValue {
12641264
switch self {
12651265
case UInt8(ascii: "!"):
12661266
return 0b11110110
@@ -1311,8 +1311,8 @@ fileprivate extension UTF8.CodeUnit {
13111311
}
13121312
}
13131313

1314-
// let urlAllowed = CharacterSet(charactersIn: unreserved + reserved)
1315-
var isValidURLCharacter: Bool {
1314+
/// Is the character in `unreserved + reserved` from RFC 3986.
1315+
internal var isValidURLCharacter: Bool {
13161316
guard self < 128 else { return false }
13171317
if self < 64 {
13181318
let allowed = UInt64(12682136387466559488)
@@ -1322,6 +1322,13 @@ fileprivate extension UTF8.CodeUnit {
13221322
return (allowed & (UInt64(1) << (self - 64))) != 0
13231323
}
13241324
}
1325+
1326+
/// Is the character in `unreserved` from RFC 3986.
1327+
internal var isUnreservedURLCharacter: Bool {
1328+
guard self < 128 else { return false }
1329+
let allowed: UInt128 = 0x47fffffe87fffffe03ff600000000000
1330+
return allowed & (UInt128(1) << self) != 0
1331+
}
13251332
}
13261333

13271334
internal extension UInt8 {
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2025 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
#if canImport(CollectionsInternal)
14+
internal import CollectionsInternal
15+
#elseif canImport(OrderedCollections)
16+
internal import OrderedCollections
17+
#elseif canImport(_FoundationCollections)
18+
internal import _FoundationCollections
19+
#endif
20+
21+
extension URL {
22+
/// A template for constructing a URL from variable expansions.
23+
///
24+
/// This is an template that can be expanded into
25+
/// a ``URL`` by calling ``URL(template:variables:)``.
26+
///
27+
/// Templating has a rich set of options for substituting various parts of URLs. See
28+
/// [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) for
29+
/// details.
30+
///
31+
/// ### Example 1
32+
///
33+
/// ```swift
34+
/// let template = URL.Template("http://www.example.com/foo{?query,number}")!
35+
/// let url = URL(
36+
/// template: template,
37+
/// variables: [
38+
/// .query: "bar baz",
39+
/// .number: "234",
40+
/// ]
41+
/// )
42+
///
43+
/// extension URL.Template.VariableName {
44+
/// static var query: URL.Template.VariableName { .init("query") }
45+
/// static var number: URL.Template.VariableName { .init("number") }
46+
/// }
47+
/// ```
48+
/// The resulting URL will be
49+
/// ```text
50+
/// http://www.example.com/foo?query=bar%20baz&number=234
51+
/// ```
52+
///
53+
/// ### Usage
54+
///
55+
/// Templates provide a description of a URL space and define how URLs can
56+
/// be constructed given specific variable values. Their intended use is,
57+
/// for example, to allow a server to communicate to a client how to
58+
/// construct URLs for particular resources.
59+
///
60+
/// For each specific resource, an API contract is required to clearly
61+
/// define the variables applicable to that resource and its associated
62+
/// template. For example, such an API contract might specify that the
63+
/// variable `query` is mandatory and must be an alphanumeric string
64+
/// while the variable `number` is optional and must be a positive integer
65+
/// if provided. The server could then provide the client with a template
66+
/// such as `http://www.example.com/foo{?query,number}`, which the client
67+
/// can subsequently use to substitute variables accordingly.
68+
///
69+
/// An API contract is necessary to define which substitutions are valid
70+
/// within a given URL space. There is no guarantee that every possible
71+
/// expansion of variable expressions corresponds to an existing resource
72+
/// URL; indeed, some expansions may not even produce a valid URL. Only
73+
/// the API specification itself can determine which expansions are
74+
/// expected to yield valid URLs corresponding to existing resources.
75+
///
76+
/// ### Example 2
77+
///
78+
/// Here’s an example, that illustrates how to define a specific set of variables:
79+
/// ```swift
80+
/// struct MyQueryTemplate: Sendable, Hashable {
81+
/// var template: URL.Template
82+
///
83+
/// init?(_ template: String) {
84+
/// guard let t = URL.Template(template) else { return nil }
85+
/// self.template = t
86+
/// }
87+
/// }
88+
///
89+
/// struct MyQuery: Sendable, Hashable {
90+
/// var query: String
91+
/// var number: Int?
92+
///
93+
/// var variables: [URL.Template.VariableName: URL.Template.Value] {
94+
/// var result: [URL.Template.VariableName: URL.Template.Value] = [
95+
/// .query: .text(query)
96+
/// ]
97+
/// if let number {
98+
/// result[.number] = .text("\(number)")
99+
/// }
100+
/// return result
101+
/// }
102+
/// }
103+
///
104+
/// extension URL.Template.VariableName {
105+
/// static var query: URL.Template.VariableName { .init("query") }
106+
/// static var number: URL.Template.VariableName { .init("number") }
107+
/// }
108+
///
109+
/// extension URL {
110+
/// init?(
111+
/// template: MyQueryTemplate,
112+
/// query: MyQuery
113+
/// ) {
114+
/// self.init(
115+
/// template: template.template,
116+
/// variables: query.variables
117+
/// )
118+
/// }
119+
/// }
120+
/// ```
121+
@available(FoundationPreview 6.2, *)
122+
public struct Template: Sendable, Hashable {
123+
var elements: [Element] = []
124+
125+
enum Element: Sendable, Hashable {
126+
case literal(String)
127+
case expression(Expression)
128+
}
129+
}
130+
}
131+
132+
// MARK: - Parse
133+
134+
extension URL.Template {
135+
/// Creates a new template from its text form.
136+
///
137+
/// The template string needs to be a valid RFC 6570 template.
138+
///
139+
/// This will parse the template and return `nil` if the template is invalid.
140+
public init?(_ template: String) {
141+
do {
142+
self.init()
143+
144+
var remainder = template[...]
145+
146+
func copyLiteral(upTo end: String.Index) {
147+
guard remainder.startIndex < end else { return }
148+
let literal = remainder[remainder.startIndex..<end]
149+
let escaped = String(literal).normalizedAddingPercentEncoding(
150+
withAllowedCharacters: .unreservedReserved
151+
)
152+
elements.append(.literal(escaped))
153+
}
154+
155+
while let match = remainder.firstMatch(of: URL.Template.Global.shared.uriTemplateRegex) {
156+
copyLiteral(upTo: match.range.lowerBound)
157+
let expression = try Expression(String(match.output.1))
158+
elements.append(.expression(expression))
159+
remainder = remainder[match.range.upperBound..<remainder.endIndex]
160+
}
161+
copyLiteral(upTo: remainder.endIndex)
162+
} catch {
163+
return nil
164+
}
165+
}
166+
}
167+
168+
// MARK: -
169+
170+
extension URL.Template: CustomStringConvertible {
171+
public var description: String {
172+
elements.reduce(into: "") {
173+
$0.append("\($1)")
174+
}
175+
}
176+
}
177+
178+
extension URL.Template.Element: CustomStringConvertible {
179+
var description: String {
180+
switch self {
181+
case .literal(let l): l
182+
case .expression(let e): "{\(e)}"
183+
}
184+
}
185+
}

0 commit comments

Comments
 (0)