[Proposal] URI Templating (#1186)

* Proposal: URI Templating

* throwing → fallible

* Clarifications, typos, grammar.

* Add links to implementation and pitch.

* Change status to “awaiting review”.

* Use failing initializer on URL instead of makeURL()

* Update metadata

* Rename
This commit is contained in:
Daniel Eggert 2025-03-14 01:42:40 +01:00 committed by GitHub
parent a95c2c8d43
commit b95199858b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -0,0 +1,256 @@
# URI Templating
* Proposal: [SF-00020](00020-uri-templating.md)
* Authors: [Daniel Eggert](https://github.com/danieleggert)
* Review Manager: [Tina L](https://github.com/itingliu)
* Status: **Review: March 14, 2025...March 21, 2025**
* Implementation: [swiftlang/swift-foundation#1198](https://github.com/swiftlang/swift-foundation/pull/1198)
* Review: ([pitch](https://forums.swift.org/t/pitch-uri-templating/78030))
## Introduction
This proposal adds support for [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) _URI templates_ to the Swift URL type.
Although there are multiple levels of expansion, the core concept is that you can define a _template_ such as
```
http://example.com/~{username}/
http://example.com/dictionary/{term:1}/{term}
http://example.com/search{?q,lang}
```
and then _expand_ these using named values (i.e. a dictionary) into a `URL`.
The templating has a rich set of options for substituting various parts of URLs. [RFC 6570 section 1.2](https://datatracker.ietf.org/doc/html/rfc6570#section-1.2) lists all 4 levels of increasing complexity.
## Motivation
[RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) provides a simple, yet powerful way to allow for variable expansion in URLs.
This provides a mechanism for a server to convey to clients how to construct URLs for specific resources. In the [RFC 8620 JMAP protocol](https://datatracker.ietf.org/doc/html/rfc8620) for example, the server sends it client a template such as
```
https://jmap.example.com/download/{accountId}/{blobId}/{name}?accept={type}
```
and the client can then use variable expansion to construct a URL for resources. The API contract between the server and the client defines which variables this specific template has, and which ones are optional.
Since URI templates provide a powerful way to define URL patterns with placeholders, they are adopted in various standards.
## Proposed solution
```swift
guard
let template = URL.Template("http://www.example.com/foo{?query,number}"),
let url = URL(
template: template,
variables: [
"query": "bar baz",
"number": "234",
]
)
else { return }
```
The RFC 6570 template gets parsed as part of the `URL.Template(_:)` initializer. It will return `nil` if the passed in string is not a valid template.
The `Template` can then be expanded with _variables_ to create a URL:
```swift
extension URL {
public init?(
template: URL.Template,
variables: [URL.Template.VariableName: URL.Template.Value]
)
}
```
## Detailed design
### Templates and Expansion
[RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) defines 8 different kinds of expansions:
* Simple String Expansion: `{var}`
* Reserved Expansion: `{+var}`
* Fragment Expansion: `{#var}`
* Label Expansion with Dot-Prefix: `{.var}`
* Path Segment Expansion: `{/var}`
* Path-Style Parameter Expansion: `{;var}`
* Form-Style Query Expansion: `{?var}`
* Form-Style Query Continuation: `{&var}`
Additionally, RFC 6570 allows for _prefix values_ and _composite values_.
Prefix values allow for e.g. `{var:3}` which would result in (up to) the first 3 characters of `var`.
Composite values allow `/mapper{?address*}` to e.g. expand into `/mapper?city=Newport%20Beach&state=CA`.
This implementation covers all levels and expression types defined in the RFC.
### API Details
There are 3 new types:
* `URL.Template`
* `URL.Template.VariableName`
* `URL.Template.Value`
All new API is guarded by `@available(FoundationPreview 6.2, *)`.
#### `Template`
`URL.Template` represents a parsed template that can be used to create a `URL` from it by _expanding_ variables according to RFC 6570.
Its sole API is its initializer:
```swift
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.
public struct Template: Sendable, Hashable {}
}
extension URL.Template {
/// Creates a new template from its text form.
///
/// The template string needs to be a valid RFC 6570 template.
///
/// If parsing the template fails, this will return `nil`.
public init?(_ template: String)
}
```
It will return `nil` if the provided string can not be parsed as a valid template.
#### Variables
Variables are represented as a `[URL.Template.VariableName: URL.Template.Value]`.
#### `VariableName`
The `URL.Template.VariableName` type is a type-safe wrapper around `String`
```swift
extension URL.Template {
/// The name of a variable used for expanding a template.
public struct VariableName: Sendable, Hashable {
public init(_ key: String)
}
}
```
The following extensions and conformances make it easy to convert between `VariableName` and `String`:
```swift
extension String {
public init(_ key: URL.Template.VariableName)
}
extension URL.Template.VariableName: CustomStringConvertible {
public var description: String
}
extension URL.Template.VariableName: ExpressibleByStringLiteral {
public init(stringLiteral value: String)
}
```
#### `Value`
The `URL.Template.Value` type can represent the 3 different kinds of values that RFC 6570 supports:
```swift
extension URL.Template {
/// The value of a variable used for expanding a template.
///
/// A ``Value`` can be one of 3 kinds:
/// 1. "text": a single `String`
/// 2. "list": an array of `String`
/// 3. "associative list": an ordered array of key-value pairs of `String` (similar to `Dictionary`, but ordered).
public struct Value: Sendable, Hashable {}
}
extension URL.Template.Value {
/// A text value to be used with a ``URL.Template``.
public static func text(_ text: String) -> URL.Template.Value
/// A list value (an array of `String`s) to be used with a ``URL.Template``.
public static func list(_ list: some Sequence<String>) -> URL.Template.Value
/// An associative list value (ordered key-value pairs) to be used with a ``URL.Template``.
public static func associativeList(_ list: some Sequence<(key: String, value: String)>) -> URL.Template.Value
}
```
To make it easier to use hard-coded values, the following `ExpressibleBy…` conformances are provided:
```swift
extension URL.Template.Value: ExpressibleByStringLiteral {
public init(stringLiteral value: String)
}
extension URL.Template.Value: ExpressibleByArrayLiteral {
public init(arrayLiteral elements: String...)
}
extension URL.Template.Value: ExpressibleByDictionaryLiteral {
public init(dictionaryLiteral elements: (String, String)...)
}
```
#### Expansion to `URL`
Finally, `URL.Template` has this factory method:
```swift
extension URL {
/// Creates a new `URL` by expanding the RFC 6570 template and variables.
///
/// This will fail if variable expansion does not produce a valid,
/// well-formed URL.
///
/// All text will be converted to NFC (Unicode Normalization Form C) and UTF-8
/// before being percent-encoded if needed.
///
/// - Parameters:
/// - template: The RFC 6570 template to be expanded.
/// - variables: Variables to expand in the template.
public init?(
template: URL.Template,
variables: [URL.Template.VariableName: URL.Template.Value]
)
}
```
This will only fail (return `nil`) if `URL.init?(string:)` fails.
It may seem counterintuitive when and how this could fail, but a string such as `http://example.com:bad%port/` would cause `URL.init?(string:)` to fail, and URI Templates do not provide a way to prevent this. It is also worth noting that it is valid to not provide values for all variables in the template. Expansion will still succeed, generating a string. If this string is a valid URL, depends on the exact details of the template. Determining which variables exist in a template, which are required for expansion, and whether the resulting URL is valid is part of the API contract between the server providing the template and the client generating the URL.
Additionally, the new types `URL.Template`, `URL.Template.VariableName`, and `URL.Template.Value` all conform to `CustomStringConvertible`.
### Unicode
The _expansion_ that happens as part of calling `URL(template:variables:)` will
* convert text to NFC (Unicode Normalization Form C)
* convert text to UTF-8 before being percent-encoded (if needed).
as per [RFC 6570 section 1.6](https://datatracker.ietf.org/doc/html/rfc6570#section-1.6).
## Source compatibility
These changes are additive only and are not expected to have an impact on source compatibility.
## Implications on adoption
This feature can be freely adopted and un-adopted in source code with no deployment constraints and without affecting source compatibility.
## Future directions
Since this proposal covers all of RFC 6570, the current expectation is for it to not be extended further.
## Alternatives considered
Instead of `URL.init?(template:variables)`, the API could have used a method on `URL.Template`, e.g. `URL.Template.makeURL(variables:)`. The `URL.init?` approach would be less discoverable. There was some feedback to the initial pitch, though, that preferred the `URL.init?` method which aligns with the existing `URL.init?(string:)` initializer. This initializer approach aligns more with existing `URL.init?(string:)` and `String.init(format:)`.
Additionally, the API _could_ expose a (non-failing!) `URL.Template.expand(variables:)` (or other naming) that returns a `String`. But since the purpose is very clearly to create URLs, it feels like that would just add noise.
Using a DSL (domain-specific language) for `URL.Template` could improve type safety. However, because servers typically send templates as strings for client-side processing and request generation, the added complexity of a DSL outweighs its benefits. The proposed implementation is string-based (_stringly typed_) because that is what the RFC 6570 mandates.
There was a lot of interest during the pitch to have an API that lends itself to _routing_, providing a way to go back-and-forth between a route and its variables. But thats a very different use case than the RFC 6570 templates provide, and it would be better suited to have a web server routing specific API, either in Foundation or in a web server specific package. [pointfreeco/swift-url-routing](https://github.com/pointfreeco/swift-url-routing) is one such example.
Instead of using the `text`, `list` and `associativeList` names (which are _terms of art_ in RFC 6570), the names `string`, `array`, and `orderedDictioary` would align better with normal Swift naming conventions. The proposal favors the _terms of art_, but there was some interest in using changing this.