1
0
mirror of https://github.com/apple/swift-foundation.git synced 2025-05-15 17:38:47 +08:00
swift-foundation/Proposals/00020-uri-templating.md
Daniel Eggert b95199858b
[Proposal] URI Templating ()
* 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
2025-03-13 17:42:40 -07:00

11 KiB
Raw Blame History

URI Templating

Introduction

This proposal adds support for RFC 6570 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 lists all 4 levels of increasing complexity.

Motivation

RFC 6570 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 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

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:

extension URL {
    public init?(
        template: URL.Template,
        variables: [URL.Template.VariableName: URL.Template.Value]
    )
}

Detailed design

Templates and Expansion

RFC 6570 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:

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

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:

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:

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:

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:

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.

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 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.