swift-foundation/Sources/FoundationEssentials/URL/URLTemplate_Substitution.swift
Daniel Eggert a169cebb45
URI Templating (#1198)
* Initial implementation.

* Cleanup and fix normalizedAddingPercentEncoding()

* Use failing initializer on URL instead of makeURL()

* Add standard copyright header

* Mark helper functions as fileprivate

* URL.Template.init() needs to be failable

* Rename InvalidTemplateExpression → URL.Template.InvalidExpression

* Add missing @available

* Update copyright header

* Convert tests XCTest → Swift Testing

* Use UInt8.isValidHexDigit

* Use URLParser.swift methods for unreserved + reserved characters

* Cleanup normalizedAddingPercentEncoding()

* Use String(decoding:as:)

* guard & white-space

* Cleanup “defer”

* Rename files URI… → URL…

* Add new files to CMakeLists.txt

* Add benchmarks.

* Add missing @available, 2

* Fix doc comment.

* Remove ExpressibleByStringLiteral conformance for URL.Template.VariableName

* Improve documentation comments

* Fix for  7b14d0bc62

* Fix doc comment

* Do not force unwrap “maximum length”
2025-04-21 16:01:10 -07:00

157 lines
5.0 KiB
Swift

//===----------------------------------------------------------------------===//
//
// 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 {
/// 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.
@available(FoundationPreview 6.2, *)
public init?(
template: URL.Template,
variables: [URL.Template.VariableName: URL.Template.Value]
) {
self.init(string: template.expand(variables))
}
}
extension URL.Template {
/// Expands the expressions in the template and returns the resulting URI as a ``Swift/String``.
func expand(_ variables: [VariableName: Value]) -> String {
replaceVariables(variables.mapValues({ $0.underlying }))
}
func replaceVariables(_ variables: [VariableName: Value.Underlying]) -> String {
return elements.reduce(into: "") { result, element in
switch element {
case .literal(let literal):
result.append(literal)
case .expression(let expression):
result += expression.replacement(variables)
}
}
}
}
// MARK: -
extension URL.Template.Expression {
fileprivate func replacement(_ variables: [URL.Template.VariableName: URL.Template.Value.Underlying]) -> String {
let escapedValues: [(String?, String)] = elements.flatMap {
$0.escapedValues(
operator: `operator`,
variables: variables
)
}
return escapedValues.enumerated().reduce(into: "") { result, element in
let isFirst = element.offset == 0
let name = element.element.0
let value = element.element.1
if isFirst {
if let c = `operator`.firstPrefix {
result.append(c)
}
} else {
result.append(`operator`.separator)
}
if let name {
result.append(name)
if value.isEmpty {
if let c = `operator`.replacementForEmpty {
result.append(c)
}
} else {
result.append("=")
result.append(value)
}
} else {
result.append(value)
}
}
}
}
extension URL.Template.Expression.Element {
fileprivate func escapedValues(
`operator`: URL.Template.Expression.Operator?,
variables: [URL.Template.VariableName: URL.Template.Value.Underlying]
) -> [(String?, String)] {
func makeNormalized(_ value: String) -> String {
let v: String = maximumLength.map { String(value.prefix($0)) } ?? value
return v.normalizedAddingPercentEncoding(
withAllowedCharacters: `operator`.allowedCharacters
)
}
func makeElement(_ value: String) -> (String?, String) {
return (
`operator`.isNamed ? String(name) : nil,
makeNormalized(value)
)
}
func makeElement(_ values: [String]) -> (String?, String) {
return (
`operator`.isNamed ? String(name) : nil,
values
.map(makeNormalized)
.joined(separator: ",")
)
}
switch variables[name] {
case .text(let s):
return [makeElement(s)]
case .list(let a):
if explode {
return a.map { makeElement($0) }
} else {
return [makeElement(a)]
}
case .associativeList(let d):
if explode {
return d.lazy.map {
(
makeNormalized($0.key),
makeNormalized($0.value)
)
}
} else if d.isEmpty {
return []
} else {
return [
makeElement(d.lazy.flatMap { [$0.key, $0.value] }),
]
}
default:
return []
}
}
}