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 3 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
98 changes: 98 additions & 0 deletions Sources/FoundationEssentials/URL/URITemplate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
#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
/// ```swift
/// let template = URL.Template("http://www.example.com/foo{?query,number}")
/// let url = URL(
/// template: template,
/// variables: [
/// "query": "bar baz",
/// "number": "234",
/// ]
/// )
/// ```
/// The resulting URL will be
/// ```text
/// http://www.example.com/foo?query=bar%20baz&number=234
/// ```
@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 throw an error if the template is invalid.
public init(_ template: String) throws {
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need to add availability annotations to this, too, even though the type it is an extension of is new?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

IIRC, having @available on the type is sufficient.

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))
}

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

// 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)}"
}
}
}
272 changes: 272 additions & 0 deletions Sources/FoundationEssentials/URL/URITemplate_Expression.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
internal import RegexBuilder
#if canImport(CollectionsInternal)
internal import CollectionsInternal
#elseif canImport(OrderedCollections)
internal import OrderedCollections
#elseif canImport(_FoundationCollections)
internal import _FoundationCollections
#endif

extension URL.Template {
struct Expression: Sendable, Hashable {
var `operator`: Operator?
var elements: [Element]

struct Element: Sendable, Hashable {
var name: URL.Template.VariableName
var maximumLength: Int?
var explode: Bool
}

enum Operator: String, Sendable, Hashable {
/// `+` Reserved character strings;
case reserved = "+"
/// `#` Fragment identifiers prefixed by "#";
case fragment = "#"
/// `.` Name labels or extensions prefixed by ".";
case nameLabel = "."
/// `/` Path segments prefixed by "/";
case pathSegment = "/"
/// `;` Path parameter name or name=value pairs prefixed by ";";
case pathParameter = ";"
/// `?` Query component beginning with "?" and consisting of
/// name=value pairs separated by "&"; and,
case queryComponent = "?"
/// `&` Continuation of query-style &name=value pairs within
/// a literal query component.
case continuation = "&"
}
}
}

private struct InvalidTemplateExpression: Swift.Error {
Copy link
Contributor

Choose a reason for hiding this comment

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

Hm, I wonder how we can make it easier to ensure this error isn't leaked into any public functions.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

None of the public functions are throwing. That should be enough, no?

var text: String
}

extension Substring {
mutating func popPrefixMatch<Output>(_ regex: Regex<Output>) throws -> Regex<Output>.Match? {
guard
let match = try regex.prefixMatch(in: self)
else { return nil }
self = self[match.range.upperBound..<self.endIndex]
return match
}
}

extension URL.Template.Expression: CustomStringConvertible {
var description: String {
"\(`operator`?.rawValue ?? "")" + elements.map { "\($0)" }.joined(separator: ",")
}
}

extension URL.Template.Expression.Element: CustomStringConvertible {
var description: String {
"\(name)\(maximumLength.map { ":\($0)" } ?? "")\(explode ? "*" : "")"
}
}

extension URL.Template.Expression {
init(_ input: String) throws {
var remainder = input[...]
guard
let opString = try remainder.popPrefixMatch(URL.Template.Global.shared.operatorRegex)
else { throw InvalidTemplateExpression(text: input) }

let op = try opString.1.map {
guard
let o = Operator(rawValue: String($0))
else { throw InvalidTemplateExpression(text: input) }
return o
}
var elements: [Element] = []

func popElement() throws {
guard
let match = try remainder.popPrefixMatch(URL.Template.Global.shared.elementRegex)
else { throw InvalidTemplateExpression(text: input) }

let name: Substring = match.output.1
let maximumLength: Int?
let explode: Bool
if let max = match.output.3 {
maximumLength = Int(max!)!
explode = false
} else if match.output.2 != nil {
maximumLength = nil
explode = true
} else {
maximumLength = nil
explode = false
}
elements.append(Element(
name: URL.Template.VariableName(name),
maximumLength: maximumLength,
explode: explode
))
}

try popElement()

while !remainder.isEmpty {
guard
try remainder.popPrefixMatch(URL.Template.Global.shared.separatorRegex) != nil
else { throw InvalidTemplateExpression(text: input) }

try popElement()
}

self.init(
operator: op,
elements: elements
)
}
}

extension URL.Template {
// Making the type unchecked Sendable is fine, Regex is safe in this context, as it only contains
// other Sendable types. For details, see https://forums.swift.org/t/should-regex-be-sendable/69529/7
internal final class Global: @unchecked Sendable {

static let shared: Global = .init()

let operatorRegex: Regex<(Substring, Substring?)>
let separatorRegex: Regex<(Substring)>
let elementRegex: Regex<(Substring, Substring, Substring?, Substring??)>
let uriTemplateRegex: Regex<Regex<(Substring, Regex<OneOrMore<Substring>.RegexOutput>.RegexOutput)>.RegexOutput>

private init() {
self.operatorRegex = Regex {
Optionally {
Capture {
One(.anyOf("+#./;?&"))
}
}
}
.asciiOnlyWordCharacters()
.asciiOnlyDigits()
.asciiOnlyCharacterClasses()
self.separatorRegex = Regex {
","
}
.asciiOnlyWordCharacters()
.asciiOnlyDigits()
.asciiOnlyCharacterClasses()
self.elementRegex = Regex {
Capture {
One(("a"..."z").union("A"..."Z"))
ZeroOrMore(("a"..."z").union("A"..."Z").union("0"..."9").union(.anyOf("_")))
}
Optionally {
Capture {
ChoiceOf {
Regex {
":"
Capture {
ZeroOrMore(.digit)
}
}
"*"
}
}
}
}
.asciiOnlyWordCharacters()
.asciiOnlyDigits()
.asciiOnlyCharacterClasses()
self.uriTemplateRegex = Regex {
"{"
Capture {
OneOrMore {
CharacterClass.any.subtracting(.anyOf("}"))
}
}
"}"
}
}
}
}

// .------------------------------------------------------------------.
// | NUL + . / ; ? & # |
// |------------------------------------------------------------------|
// | first | "" "" "." "/" ";" "?" "&" "#" |
// | sep | "," "," "." "/" ";" "&" "&" "," |
// | named | false false false false true true true false |
// | ifemp | "" "" "" "" "" "=" "=" "" |
// | allow | U U+R U U U U U U+R |
// `------------------------------------------------------------------'

extension URL.Template.Expression.Operator? {
var firstPrefix: Character? {
switch self {
case nil: return nil
case .reserved?: return nil
case .nameLabel?: return "."
case .pathSegment?: return "/"
case .pathParameter?: return ";"
case .queryComponent?: return "?"
case .continuation?: return "&"
case .fragment?: return "#"
}
}

var separator: Character {
switch self {
case nil: return ","
case .reserved?: return ","
case .nameLabel?: return "."
case .pathSegment?: return "/"
case .pathParameter?: return ";"
case .queryComponent?: return "&"
case .continuation?: return "&"
case .fragment?: return ","
}
}

var isNamed: Bool {
switch self {
case nil: return false
case .reserved?: return false
case .nameLabel?: return false
case .pathSegment?: return false
case .pathParameter?: return true
case .queryComponent?: return true
case .continuation?: return true
case .fragment?: return false
}
}

var replacementForEmpty: Character? {
switch self {
case nil: return nil
case .reserved?: return nil
case .nameLabel?: return nil
case .pathSegment?: return nil
case .pathParameter?: return nil
case .queryComponent?: return "="
case .continuation?: return "="
case .fragment?: return nil
}
}

var allowedCharacters: URL.Template.Expression.Operator.AllowedCharacters {
switch self {
case nil: return .unreserved
case .reserved?: return .unreservedReserved
case .nameLabel?: return .unreserved
case .pathSegment?: return .unreserved
case .pathParameter?: return .unreserved
case .queryComponent?: return .unreserved
case .continuation?: return .unreserved
case .fragment?: return .unreservedReserved
}
}
}

extension URL.Template.Expression.Operator {
enum AllowedCharacters {
case unreserved
// The union of (unreserved / reserved / pct-encoded)
case unreservedReserved
}
}
Loading