-
Notifications
You must be signed in to change notification settings - Fork 197
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
URI Templating #1198
Changes from 3 commits
ecfbdea
e99a13d
a05d9fe
d55dd9d
42a196f
90f2a27
3b8b3cd
09c613a
e126dcf
f0924dd
44b6511
3e80fbc
25029fb
cefea66
d7bc3d9
9fbff6f
ab051c8
0afd988
1ac4137
ee2a8aa
888032a
7b14d0b
6962eb4
6241e1d
8f3b452
61d15da
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IIRC, having |
||
self.init() | ||
|
||
var remainder = template[...] | ||
|
||
func copyLiteral(upTo end: String.Index) { | ||
guard | ||
remainder.startIndex < end | ||
danieleggert marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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] | ||
danieleggert marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
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)}" | ||
} | ||
} | ||
} |
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? { | ||
danieleggert marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.