Skip to content

URI Templating #1198

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 26 commits into from
Apr 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ecfbdea
Initial implementation.
danieleggert Feb 28, 2025
e99a13d
Cleanup and fix normalizedAddingPercentEncoding()
danieleggert Mar 5, 2025
a05d9fe
Use failing initializer on URL instead of makeURL()
danieleggert Mar 11, 2025
d55dd9d
Add standard copyright header
danieleggert Mar 26, 2025
42a196f
Mark helper functions as fileprivate
danieleggert Mar 26, 2025
90f2a27
URL.Template.init() needs to be failable
danieleggert Mar 26, 2025
3b8b3cd
Rename InvalidTemplateExpression → URL.Template.InvalidExpression
danieleggert Mar 26, 2025
09c613a
Add missing @available
danieleggert Mar 26, 2025
e126dcf
Update copyright header
danieleggert Mar 26, 2025
f0924dd
Convert tests XCTest → Swift Testing
danieleggert Mar 26, 2025
44b6511
Use UInt8.isValidHexDigit
danieleggert Mar 26, 2025
3e80fbc
Use URLParser.swift methods for unreserved + reserved characters
danieleggert Mar 26, 2025
25029fb
Cleanup normalizedAddingPercentEncoding()
danieleggert Mar 26, 2025
cefea66
Use String(decoding:as:)
danieleggert Mar 26, 2025
d7bc3d9
guard & white-space
danieleggert Mar 26, 2025
9fbff6f
Cleanup “defer”
danieleggert Mar 26, 2025
ab051c8
Rename files URI… → URL…
danieleggert Mar 26, 2025
0afd988
Add new files to CMakeLists.txt
danieleggert Mar 26, 2025
1ac4137
Add benchmarks.
danieleggert Mar 26, 2025
ee2a8aa
Add missing @available, 2
danieleggert Mar 27, 2025
888032a
Fix doc comment.
danieleggert Apr 1, 2025
7b14d0b
Remove ExpressibleByStringLiteral conformance for URL.Template.Variab…
danieleggert Apr 12, 2025
6962eb4
Improve documentation comments
danieleggert Apr 12, 2025
6241e1d
Fix for 7b14d0bc62
danieleggert Apr 17, 2025
8f3b452
Fix doc comment
danieleggert Apr 18, 2025
61d15da
Do not force unwrap “maximum length”
danieleggert Apr 18, 2025
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
46 changes: 46 additions & 0 deletions Benchmarks/Benchmarks/URL/BenchmarkURL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -231,4 +231,50 @@ let benchmarks = {
}
}

Benchmark("URL-Template-parsing") { benchmark in
for _ in benchmark.scaledIterations {
blackHole(URL.Template("/api/{version}/accounts/{accountId}/transactions/{transactionId}{?expand*,fields*,embed*,format}")!)
blackHole(URL.Template("/special/{+a}/details")!)
blackHole(URL.Template("/documents/{documentId}{#section,paragraph}")!)
}
}

let templates = [
URL.Template("/var/{var}/who/{who}/x/{x}{?keys*,count*,list*,y}")!,
URL.Template("/special/{+keys}/details")!,
URL.Template("x/y/{#path:6}/here")!,
URL.Template("a/b{/var,x}/here")!,
URL.Template("a{?var,y}")!,
]

var variables: [URL.Template.VariableName: URL.Template.Value] = [
"count": ["one", "two", "three"],
"dom": ["example", "com"],
"dub": "me/too",
"hello": "Hello World!",
"half": "50%",
"var": "value",
"who": "fred",
"base": "http://example.com/home/",
"path": "/foo/bar",
"list": ["red", "green", "blue"],
"keys": [
"semi": ";",
"dot": ".",
"comma": ",",
],
"v": "6",
"x": "1024",
"y": "768",
"empty": "",
"empty_keys": [:],
]

Benchmark("URL-Template-expansion") { benchmark in
for _ in benchmark.scaledIterations {
for t in templates {
blackHole(URL(template: t, variables: variables))
}
}
}
}
7 changes: 6 additions & 1 deletion Sources/FoundationEssentials/URL/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,9 @@ target_sources(FoundationEssentials PRIVATE
URL.swift
URLComponents.swift
URLComponents_ObjC.swift
URLParser.swift)
URLParser.swift
URLTemplate_PercentEncoding.swift
URLTemplate_Substitution.swift
URLTemplate_Value.swift
URLTemplate_VariableName.swift
URLTemplate.swift)
19 changes: 13 additions & 6 deletions Sources/FoundationEssentials/URL/URLParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2023 - 2024 Apple Inc. and the Swift project authors
// Copyright (c) 2023 - 2025 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
Expand Down Expand Up @@ -1132,8 +1132,8 @@ fileprivate struct URLComponentSet: OptionSet {
static let queryItem = URLComponentSet(rawValue: 1 << 7)
}

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

Expand Down Expand Up @@ -1163,7 +1163,7 @@ fileprivate extension UTF8.CodeUnit {
// let queryItemAllowed = queryAllowed.subtracting(CharacterSet(charactersIn: "=&"))
// let fragmentAllowed = CharacterSet(charactersIn: pchar + "/?")
// ===------------------------------------------------------------------------------------=== //
var allowedURLComponents: URLComponentSet.RawValue {
fileprivate var allowedURLComponents: URLComponentSet.RawValue {
switch self {
case UInt8(ascii: "!"):
return 0b11110110
Expand Down Expand Up @@ -1214,8 +1214,8 @@ fileprivate extension UTF8.CodeUnit {
}
}

// let urlAllowed = CharacterSet(charactersIn: unreserved + reserved)
var isValidURLCharacter: Bool {
/// Is the character in `unreserved + reserved` from RFC 3986.
internal var isValidURLCharacter: Bool {
guard self < 128 else { return false }
if self < 64 {
let allowed = UInt64(12682136387466559488)
Expand All @@ -1225,4 +1225,11 @@ fileprivate extension UTF8.CodeUnit {
return (allowed & (UInt64(1) << (self - 64))) != 0
}
}

/// Is the character in `unreserved` from RFC 3986.
internal var isUnreservedURLCharacter: Bool {
guard self < 128 else { return false }
let allowed: UInt128 = 0x47fffffe87fffffe03ff600000000000
return allowed & (UInt128(1) << self) != 0
}
}
185 changes: 185 additions & 0 deletions Sources/FoundationEssentials/URL/URLTemplate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2025 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//

#if canImport(CollectionsInternal)
internal import CollectionsInternal
#elseif canImport(OrderedCollections)
internal import OrderedCollections
#elseif canImport(_FoundationCollections)
internal import _FoundationCollections
#endif

extension URL {
/// A template for constructing a URL from variable expansions.
///
/// This is an template that can be expanded into
/// a ``URL`` by calling ``URL(template:variables:)``.
///
/// Templating has a rich set of options for substituting various parts of URLs. See
/// [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) for
/// details.
///
/// ### Example 1
///
/// ```swift
/// let template = URL.Template("http://www.example.com/foo{?query,number}")!
/// let url = URL(
/// template: template,
/// variables: [
/// .query: "bar baz",
/// .number: "234",
/// ]
/// )
///
/// extension URL.Template.VariableName {
/// static var query: URL.Template.VariableName { .init("query") }
/// static var number: URL.Template.VariableName { .init("number") }
/// }
/// ```
/// The resulting URL will be
/// ```text
/// http://www.example.com/foo?query=bar%20baz&number=234
/// ```
///
/// ### Usage
///
/// Templates provide a description of a URL space and define how URLs can
/// be constructed given specific variable values. Their intended use is,
/// for example, to allow a server to communicate to a client how to
/// construct URLs for particular resources.
///
/// For each specific resource, an API contract is required to clearly
/// define the variables applicable to that resource and its associated
/// template. For example, such an API contract might specify that the
/// variable `query` is mandatory and must be an alphanumeric string
/// while the variable `number` is optional and must be a positive integer
/// if provided. The server could then provide the client with a template
/// such as `http://www.example.com/foo{?query,number}`, which the client
/// can subsequently use to substitute variables accordingly.
///
/// An API contract is necessary to define which substitutions are valid
/// within a given URL space. There is no guarantee that every possible
/// expansion of variable expressions corresponds to an existing resource
/// URL; indeed, some expansions may not even produce a valid URL. Only
/// the API specification itself can determine which expansions are
/// expected to yield valid URLs corresponding to existing resources.
///
/// ### Example 2
///
/// Here’s an example, that illustrates how to define a specific set of variables:
/// ```swift
/// struct MyQueryTemplate: Sendable, Hashable {
/// var template: URL.Template
///
/// init?(_ template: String) {
/// guard let t = URL.Template(template) else { return nil }
/// self.template = t
/// }
/// }
///
/// struct MyQuery: Sendable, Hashable {
/// var query: String
/// var number: Int?
///
/// var variables: [URL.Template.VariableName: URL.Template.Value] {
/// var result: [URL.Template.VariableName: URL.Template.Value] = [
/// .query: .text(query)
/// ]
/// if let number {
/// result[.number] = .text("\(number)")
/// }
/// return result
/// }
/// }
///
/// extension URL.Template.VariableName {
/// static var query: URL.Template.VariableName { .init("query") }
/// static var number: URL.Template.VariableName { .init("number") }
/// }
///
/// extension URL {
/// init?(
/// template: MyQueryTemplate,
/// query: MyQuery
/// ) {
/// self.init(
/// template: template.template,
/// variables: query.variables
/// )
/// }
/// }
/// ```
@available(FoundationPreview 6.2, *)
public struct Template: Sendable, Hashable {
var elements: [Element] = []

enum Element: Sendable, Hashable {
case literal(String)
case expression(Expression)
}
}
}

// MARK: - Parse

extension URL.Template {
/// Creates a new template from its text form.
///
/// The template string needs to be a valid RFC 6570 template.
///
/// This will parse the template and return `nil` if the template is invalid.
public init?(_ template: String) {
do {
self.init()

var remainder = template[...]

func copyLiteral(upTo end: String.Index) {
guard remainder.startIndex < end else { return }
let literal = remainder[remainder.startIndex..<end]
let escaped = String(literal).normalizedAddingPercentEncoding(
withAllowedCharacters: .unreservedReserved
)
elements.append(.literal(escaped))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a thought, not blocking: In copyLiteral and the regex matching function below, you're both working on Substring rather than String. And if I read it correctly, it looks like the associated String will later be used in other places that involves some Substring processing. That makes me wonder if it'd make sense to store the associated value of case literal() as Substring instead, so we can avoid Substring-String copying until the last minute, and if this would actually make a difference performance-wise

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. My thinking was, that it would be better to store String because the case literal() will stay around as part of the URL.Template — and that instance is likely to remain in-memory for a long time. I don’t want that to keep the complete input String alive.

}

while let match = remainder.firstMatch(of: URL.Template.Global.shared.uriTemplateRegex) {
copyLiteral(upTo: match.range.lowerBound)
let expression = try Expression(String(match.output.1))
elements.append(.expression(expression))
remainder = remainder[match.range.upperBound..<remainder.endIndex]
}
copyLiteral(upTo: remainder.endIndex)
} catch {
return nil
}
}
}

// MARK: -

extension URL.Template: CustomStringConvertible {
public var description: String {
elements.reduce(into: "") {
$0.append("\($1)")
}
}
}

extension URL.Template.Element: CustomStringConvertible {
var description: String {
switch self {
case .literal(let l): l
case .expression(let e): "{\(e)}"
}
}
}
Loading