diff --git a/Benchmarks/Benchmarks/URL/BenchmarkURL.swift b/Benchmarks/Benchmarks/URL/BenchmarkURL.swift index 00e7622f1..95fb49e86 100644 --- a/Benchmarks/Benchmarks/URL/BenchmarkURL.swift +++ b/Benchmarks/Benchmarks/URL/BenchmarkURL.swift @@ -231,4 +231,50 @@ let benchmarks = { } } + Benchmark("URL-Template-parsing") { benchmark in + for _ in benchmark.scaledIterations { + blackHole(URL.Template("/api/{version}/accounts/{accountId}/transactions/{transactionId}{?expand*,fields*,embed*,format}")!) + blackHole(URL.Template("/special/{+a}/details")!) + blackHole(URL.Template("/documents/{documentId}{#section,paragraph}")!) + } + } + + let templates = [ + URL.Template("/var/{var}/who/{who}/x/{x}{?keys*,count*,list*,y}")!, + URL.Template("/special/{+keys}/details")!, + URL.Template("x/y/{#path:6}/here")!, + URL.Template("a/b{/var,x}/here")!, + URL.Template("a{?var,y}")!, + ] + + var variables: [URL.Template.VariableName: URL.Template.Value] = [ + "count": ["one", "two", "three"], + "dom": ["example", "com"], + "dub": "me/too", + "hello": "Hello World!", + "half": "50%", + "var": "value", + "who": "fred", + "base": "http://example.com/home/", + "path": "/foo/bar", + "list": ["red", "green", "blue"], + "keys": [ + "semi": ";", + "dot": ".", + "comma": ",", + ], + "v": "6", + "x": "1024", + "y": "768", + "empty": "", + "empty_keys": [:], + ] + + Benchmark("URL-Template-expansion") { benchmark in + for _ in benchmark.scaledIterations { + for t in templates { + blackHole(URL(template: t, variables: variables)) + } + } + } } diff --git a/Sources/FoundationEssentials/URL/CMakeLists.txt b/Sources/FoundationEssentials/URL/CMakeLists.txt index 90407d9bc..b0a0c1b63 100644 --- a/Sources/FoundationEssentials/URL/CMakeLists.txt +++ b/Sources/FoundationEssentials/URL/CMakeLists.txt @@ -16,4 +16,9 @@ target_sources(FoundationEssentials PRIVATE URL.swift URLComponents.swift URLComponents_ObjC.swift - URLParser.swift) + URLParser.swift + URLTemplate_PercentEncoding.swift + URLTemplate_Substitution.swift + URLTemplate_Value.swift + URLTemplate_VariableName.swift + URLTemplate.swift) diff --git a/Sources/FoundationEssentials/URL/URLParser.swift b/Sources/FoundationEssentials/URL/URLParser.swift index 570b476ac..9f0cb22e6 100644 --- a/Sources/FoundationEssentials/URL/URLParser.swift +++ b/Sources/FoundationEssentials/URL/URLParser.swift @@ -2,7 +2,7 @@ // // This source file is part of the Swift.org open source project // -// Copyright (c) 2023 - 2024 Apple Inc. and the Swift project authors +// Copyright (c) 2023 - 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 @@ -1132,8 +1132,8 @@ fileprivate struct URLComponentSet: OptionSet { static let queryItem = URLComponentSet(rawValue: 1 << 7) } -fileprivate extension UTF8.CodeUnit { - func isAllowedIn(_ component: URLComponentSet) -> Bool { +extension UTF8.CodeUnit { + fileprivate func isAllowedIn(_ component: URLComponentSet) -> Bool { return allowedURLComponents & component.rawValue != 0 } @@ -1163,7 +1163,7 @@ fileprivate extension UTF8.CodeUnit { // let queryItemAllowed = queryAllowed.subtracting(CharacterSet(charactersIn: "=&")) // let fragmentAllowed = CharacterSet(charactersIn: pchar + "/?") // ===------------------------------------------------------------------------------------=== // - var allowedURLComponents: URLComponentSet.RawValue { + fileprivate var allowedURLComponents: URLComponentSet.RawValue { switch self { case UInt8(ascii: "!"): return 0b11110110 @@ -1214,8 +1214,8 @@ fileprivate extension UTF8.CodeUnit { } } - // let urlAllowed = CharacterSet(charactersIn: unreserved + reserved) - var isValidURLCharacter: Bool { + /// Is the character in `unreserved + reserved` from RFC 3986. + internal var isValidURLCharacter: Bool { guard self < 128 else { return false } if self < 64 { let allowed = UInt64(12682136387466559488) @@ -1225,4 +1225,11 @@ fileprivate extension UTF8.CodeUnit { return (allowed & (UInt64(1) << (self - 64))) != 0 } } + + /// Is the character in `unreserved` from RFC 3986. + internal var isUnreservedURLCharacter: Bool { + guard self < 128 else { return false } + let allowed: UInt128 = 0x47fffffe87fffffe03ff600000000000 + return allowed & (UInt128(1) << self) != 0 + } } diff --git a/Sources/FoundationEssentials/URL/URLTemplate.swift b/Sources/FoundationEssentials/URL/URLTemplate.swift new file mode 100644 index 000000000..cc72abef8 --- /dev/null +++ b/Sources/FoundationEssentials/URL/URLTemplate.swift @@ -0,0 +1,185 @@ +//===----------------------------------------------------------------------===// +// +// 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 { + /// 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 1 + /// + /// ```swift + /// let template = URL.Template("http://www.example.com/foo{?query,number}")! + /// let url = URL( + /// template: template, + /// variables: [ + /// .query: "bar baz", + /// .number: "234", + /// ] + /// ) + /// + /// extension URL.Template.VariableName { + /// static var query: URL.Template.VariableName { .init("query") } + /// static var number: URL.Template.VariableName { .init("number") } + /// } + /// ``` + /// The resulting URL will be + /// ```text + /// http://www.example.com/foo?query=bar%20baz&number=234 + /// ``` + /// + /// ### Usage + /// + /// Templates provide a description of a URL space and define how URLs can + /// be constructed given specific variable values. Their intended use is, + /// for example, to allow a server to communicate to a client how to + /// construct URLs for particular resources. + /// + /// For each specific resource, an API contract is required to clearly + /// define the variables applicable to that resource and its associated + /// template. For example, such an API contract might specify that the + /// variable `query` is mandatory and must be an alphanumeric string + /// while the variable `number` is optional and must be a positive integer + /// if provided. The server could then provide the client with a template + /// such as `http://www.example.com/foo{?query,number}`, which the client + /// can subsequently use to substitute variables accordingly. + /// + /// An API contract is necessary to define which substitutions are valid + /// within a given URL space. There is no guarantee that every possible + /// expansion of variable expressions corresponds to an existing resource + /// URL; indeed, some expansions may not even produce a valid URL. Only + /// the API specification itself can determine which expansions are + /// expected to yield valid URLs corresponding to existing resources. + /// + /// ### Example 2 + /// + /// Here’s an example, that illustrates how to define a specific set of variables: + /// ```swift + /// struct MyQueryTemplate: Sendable, Hashable { + /// var template: URL.Template + /// + /// init?(_ template: String) { + /// guard let t = URL.Template(template) else { return nil } + /// self.template = t + /// } + /// } + /// + /// struct MyQuery: Sendable, Hashable { + /// var query: String + /// var number: Int? + /// + /// var variables: [URL.Template.VariableName: URL.Template.Value] { + /// var result: [URL.Template.VariableName: URL.Template.Value] = [ + /// .query: .text(query) + /// ] + /// if let number { + /// result[.number] = .text("\(number)") + /// } + /// return result + /// } + /// } + /// + /// extension URL.Template.VariableName { + /// static var query: URL.Template.VariableName { .init("query") } + /// static var number: URL.Template.VariableName { .init("number") } + /// } + /// + /// extension URL { + /// init?( + /// template: MyQueryTemplate, + /// query: MyQuery + /// ) { + /// self.init( + /// template: template.template, + /// variables: query.variables + /// ) + /// } + /// } + /// ``` + @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 return `nil` if the template is invalid. + public init?(_ template: String) { + do { + self.init() + + var remainder = template[...] + + func copyLiteral(upTo end: String.Index) { + guard remainder.startIndex < end else { return } + let literal = remainder[remainder.startIndex..(_ regex: Regex) throws -> Regex.Match? { + guard + let match = try regex.prefixMatch(in: self) + else { return nil } + self = self[match.range.upperBound.. + let separatorRegex: Regex<(Substring)> + let elementRegex: Regex<(Substring, Substring, Substring?, Substring??)> + let uriTemplateRegex: Regex.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 + } +} diff --git a/Sources/FoundationEssentials/URL/URLTemplate_PercentEncoding.swift b/Sources/FoundationEssentials/URL/URLTemplate_PercentEncoding.swift new file mode 100644 index 000000000..93bb6ecf3 --- /dev/null +++ b/Sources/FoundationEssentials/URL/URLTemplate_PercentEncoding.swift @@ -0,0 +1,224 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +internal import RegexBuilder + +extension String { + /// Convert to NFC and percent-escape. + func normalizedAddingPercentEncoding( + withAllowedCharacters allowed: URL.Template.Expression.Operator.AllowedCharacters + ) -> String { + return withContiguousNFCAndOutputBuffer(allowed: allowed) { input -> String in + switch input { + case .noConversionNorEncodedNeeded: return self + case .needsEncoding(input: let inputBuffer, outputCount: let outputCount): + switch allowed { + case .unreserved: + return addingPercentEncodingToNFC( + input: inputBuffer, + outputCount: outputCount, + allowed: allowed + ) + case .unreservedReserved: + return withUnsafeTemporaryAllocation(of: UInt8.self, capacity: outputCount + 1) { outputBuffer -> String in + addPercentEscapesForUnreservedReserved( + inputBuffer: inputBuffer, + outputBuffer: outputBuffer + ) + } + } + } + } + } +} + +/// For the `unreserved / reserved / pct-encoded` case, create a String by percent encoding the NFC input as needed. +private func addPercentEscapesForUnreservedReserved( + inputBuffer: UnsafeBufferPointer, + outputBuffer: UnsafeMutableBufferPointer +) -> String { + let allowed = URL.Template.Expression.Operator.AllowedCharacters.unreservedReserved + + var remainingInput = inputBuffer[...] + var outputIndex = 0 + + func write(_ a: UInt8) { + outputBuffer[outputIndex] = a + outputIndex += 1 + } + + while let next = remainingInput.popFirst() { + // Any (valid) existing escape sequences need to be copied to the output verbatim. + // But any `%` that are not part of a valid escape sequence, need to be encoded. + guard next != UInt8(ascii: "%") || remainingInput.count < 2 else { + // Is this a valid escape sequence? + if remainingInput[remainingInput.startIndex].isValidHexDigit && remainingInput[remainingInput.startIndex + 1].isValidHexDigit { + write(next) + } else { + write(UInt8(ascii: "%")) + write(UInt8(ascii: "2")) + write(UInt8(ascii: "5")) + } + continue + } + if allowed.isAllowedCodeUnit(next) { + write(next) + } else { + write(UInt8(ascii: "%")) + write(hexToAscii(next >> 4)) + write(hexToAscii(next & 0xf)) + } + } + + return String(decoding: outputBuffer[.., + outputCount: Int, + allowed: URL.Template.Expression.Operator.AllowedCharacters +) -> String { + return withUnsafeTemporaryAllocation(of: UInt8.self, capacity: outputCount + 1) { outputBuffer -> String in + var index = 0 + for v in inputBuffer { + if allowed.isAllowedCodeUnit(v) { + outputBuffer[index] = v + index += 1 + } else { + outputBuffer[index + 0] = UInt8(ascii: "%") + outputBuffer[index + 1] = hexToAscii(v >> 4) + outputBuffer[index + 2] = hexToAscii(v & 0xF) + index += 3 + } + } + return String(decoding: outputBuffer[.., outputCount: Int) +} + +extension String { + /// Runs the given closure with a UTF-8 buffer that is the NFC normalized version of the string. + /// + /// If the input is already NFC _and_ it only contains allowed characters, the given closure will + /// be called with ``NeededConversion.noConversionNorEncodedNeeded`. + fileprivate func withContiguousNFCAndOutputBuffer( + allowed: URL.Template.Expression.Operator.AllowedCharacters, + _ body: (AllowedNFCResult) -> R + ) -> R { + // We’ll do a quick check. If the input is valid UTF-8 and bytes are less than + // 0xcc, then it’s NFC. Since most input will be ASCII, this allows us to + // be more efficient in those common cases. + // At the same, we’ll do a check if there are any characters that need + // encoding. If the input (is likely) already NFC, and nothing needs + // percent encoding, we can just use the original input. + + func cheapCheck(utf8Buffer: some Collection) -> NeededConversion { + // The number of code units that need percent encoding: + var needsEncoding = 0 + var count = 0 + for v in utf8Buffer { + count += 1 + switch (v < 0xcc, allowed.isAllowedCodeUnit(v)) { + case (false, _): + // Input might not be NFC. Need to convert. + return .convertAndEncode + case (true, false): + needsEncoding += 1 + case (true, true): + break + } + } + return (needsEncoding == 0) ? .none : .encodeOnly(outputCount: count + 2 * needsEncoding) + } + + let fastResult: R?? = utf8.withContiguousStorageIfAvailable { + switch cheapCheck(utf8Buffer: $0) { + case .none: + return body(.noConversionNorEncodedNeeded) + case .encodeOnly(outputCount: let c): + return body(.needsEncoding(input: $0, outputCount: c)) + case .convertAndEncode: + return nil + } + } + switch fastResult { + case .some(.some(let r)): + return r + case .some(.none): + // We have a continguous UTF-8 buffer, but it’s (probably) not NFC + break + case .none: + // Contiguous UTF-8 storage is not available: + switch cheapCheck(utf8Buffer: utf8) { + case .none: + return body(.noConversionNorEncodedNeeded) + case .encodeOnly(outputCount: let c): + return withUnsafeTemporaryAllocation(of: UInt8.self, capacity: utf8.count) { buffer in + _ = buffer.initialize(from: utf8) + return body(.needsEncoding(input: UnsafeBufferPointer(buffer), outputCount: c)) + } + case .convertAndEncode: + break + } + } + // Convert to NFC: + return _nfcCodeUnits.withUnsafeBufferPointer { input in + let outputCount = input.reduce(into: 0) { + $0 += allowed.isAllowedCodeUnit($1) ? 1 : 3 + } + return body(.needsEncoding(input: input, outputCount: outputCount)) + } + } +} + +extension URL.Template.Expression.Operator.AllowedCharacters { + func isAllowedCodeUnit(_ unit: UTF8.CodeUnit) -> Bool { + switch self { + case .unreserved: + return unit.isUnreservedURLCharacter + case .unreservedReserved: + return unit.isValidURLCharacter + } + } +} + +private func hexToAscii(_ hex: UInt8) -> UInt8 { + switch hex { + case 0x0: UInt8(ascii: "0") + case 0x1: UInt8(ascii: "1") + case 0x2: UInt8(ascii: "2") + case 0x3: UInt8(ascii: "3") + case 0x4: UInt8(ascii: "4") + case 0x5: UInt8(ascii: "5") + case 0x6: UInt8(ascii: "6") + case 0x7: UInt8(ascii: "7") + case 0x8: UInt8(ascii: "8") + case 0x9: UInt8(ascii: "9") + case 0xA: UInt8(ascii: "A") + case 0xB: UInt8(ascii: "B") + case 0xC: UInt8(ascii: "C") + case 0xD: UInt8(ascii: "D") + case 0xE: UInt8(ascii: "E") + case 0xF: UInt8(ascii: "F") + default: fatalError("Invalid hex digit: \(hex)") + } +} diff --git a/Sources/FoundationEssentials/URL/URLTemplate_Substitution.swift b/Sources/FoundationEssentials/URL/URLTemplate_Substitution.swift new file mode 100644 index 000000000..462d4b716 --- /dev/null +++ b/Sources/FoundationEssentials/URL/URLTemplate_Substitution.swift @@ -0,0 +1,156 @@ +//===----------------------------------------------------------------------===// +// +// 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 [] + } + } +} diff --git a/Sources/FoundationEssentials/URL/URLTemplate_Value.swift b/Sources/FoundationEssentials/URL/URLTemplate_Value.swift new file mode 100644 index 000000000..a518b1e63 --- /dev/null +++ b/Sources/FoundationEssentials/URL/URLTemplate_Value.swift @@ -0,0 +1,109 @@ +//===----------------------------------------------------------------------===// +// +// 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.Template { + /// The value of a variable used for expanding a template. + /// + /// A value can either be some text, a list, or an associate list (a dictionary). + /// + /// ### Examples + /// ```swift + /// let hello: URL.Template.Value = .text("Hello World!") + /// let list: URL.Template.Value = .list(["red", "green", "blue"]) + /// let keys: URL.Template.Value = .associativeList([ + /// "semi": ";", + /// "dot": ".", + /// "comma": ",", + /// ]) + /// ``` + /// Alternatively, for constants, the `ExpressibleBy…Literal` implementations + /// can be used, i.e. + /// ```swift + /// let hello: URL.Template.Value = "Hello World!" + /// let list: URL.Template.Value = ["red", "green", "blue"] + /// let keys: URL.Template.Value = [ + /// "semi": ";", + /// "dot": ".", + /// "comma": ",", + /// ] + /// ``` + public struct Value: Sendable, Hashable { + let underlying: Underlying + } +} + +extension URL.Template.Value { + /// A text value to be used with a ``URL.Template``. + public static func text(_ text: String) -> URL.Template.Value { + URL.Template.Value(underlying: .text(text)) + } + + /// A list value (an array of `String`s) to be used with a ``URL.Template``. + public static func list(_ list: some Sequence) -> URL.Template.Value { + URL.Template.Value(underlying: .list(Array(list))) + } + + /// 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 { + URL.Template.Value(underlying: .associativeList(OrderedDictionary(uniqueKeysWithValues: list))) + } +} + +// MARK: - + +extension URL.Template.Value: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .text(value) + } +} + +extension URL.Template.Value: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: String...) { + self.init(underlying: .list(elements)) + } +} + +extension URL.Template.Value: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, String)...) { + self.init(underlying: .associativeList(OrderedDictionary(uniqueKeysWithValues: elements))) + } +} + +// MARK: - + +extension URL.Template.Value: CustomStringConvertible { + public var description: String { + switch underlying { + case .text(let v): return v + case .list(let v): return "\(v)" + case .associativeList(let v): return "\(v)" + } + } +} + +// MARK: - + +extension URL.Template.Value { + enum Underlying: Sendable, Hashable { + case text(String) + case list([String]) + case associativeList(OrderedDictionary) + } +} diff --git a/Sources/FoundationEssentials/URL/URLTemplate_VariableName.swift b/Sources/FoundationEssentials/URL/URLTemplate_VariableName.swift new file mode 100644 index 000000000..b8727fa38 --- /dev/null +++ b/Sources/FoundationEssentials/URL/URLTemplate_VariableName.swift @@ -0,0 +1,41 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +extension URL.Template { + /// The name of a variable used for expanding a template. + public struct VariableName: Sendable, Hashable { + let key: String + + public init(_ key: String) { + self.key = key + } + + init(_ key: Substring) { + self.key = String(key) + } + } +} + +// MARK: - + +extension String { + @available(FoundationPreview 6.2, *) + public init(_ key: URL.Template.VariableName) { + self = key.key + } +} + +extension URL.Template.VariableName: CustomStringConvertible { + public var description: String { + String(self) + } +} diff --git a/Tests/FoundationEssentialsTests/URITemplatingTests/URLTemplate_ExpressionTests.swift b/Tests/FoundationEssentialsTests/URITemplatingTests/URLTemplate_ExpressionTests.swift new file mode 100644 index 000000000..42bcd3c8e --- /dev/null +++ b/Tests/FoundationEssentialsTests/URITemplatingTests/URLTemplate_ExpressionTests.swift @@ -0,0 +1,278 @@ +//===----------------------------------------------------------------------===// +// +// 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(FoundationEssentials) +@testable import FoundationEssentials +import struct FoundationEssentials.URL +#endif +#if FOUNDATION_FRAMEWORK +@testable import Foundation +import struct Foundation.URL +#endif +import Testing + +private typealias Expression = URL.Template.Expression +private typealias Element = URL.Template.Expression.Element + +@Suite("URL.Template Expression") +private enum ExpressionTests { + @Test + static func parsingWithSingleName() throws { + #expect( + try Expression("var") == + Expression( + operator: nil, + elements: [ + Element( + name: .init("var"), + maximumLength: nil, + explode: false + ), + ] + ) + ) + #expect( + try Expression("+var") == + Expression( + operator: .reserved, + elements: [ + Element( + name: .init("var"), + maximumLength: nil, + explode: false + ), + ] + ) + ) + #expect( + try Expression("#hello") == + Expression( + operator: .fragment, + elements: [ + Element( + name: .init("hello"), + maximumLength: nil, + explode: false + ), + ] + ) + ) + #expect( + try Expression(".list") == + Expression( + operator: .nameLabel, + elements: [ + Element( + name: .init("list"), + maximumLength: nil, + explode: false + ), + ] + ) + ) + #expect( + try Expression("/foo") == + Expression( + operator: .pathSegment, + elements: [ + Element( + name: .init("foo"), + maximumLength: nil, + explode: false + ), + ] + ) + ) + #expect( + try Expression(";name") == + Expression( + operator: .pathParameter, + elements: [ + Element( + name: .init("name"), + maximumLength: nil, + explode: false + ), + ] + ) + ) + #expect( + try Expression("?count") == + Expression( + operator: .queryComponent, + elements: [ + Element( + name: .init("count"), + maximumLength: nil, + explode: false + ), + ] + ) + ) + #expect( + try Expression("&max") == + Expression( + operator: .continuation, + elements: [ + Element( + name: .init("max"), + maximumLength: nil, + explode: false + ), + ] + ) + ) + #expect( + try Expression("var:30") == + Expression( + operator: nil, + elements: [ + Element( + name: .init("var"), + maximumLength: 30, + explode: false + ), + ] + ) + ) + #expect( + try Expression("+var:30") == + Expression( + operator: .reserved, + elements: [ + Element( + name: .init("var"), + maximumLength: 30, + explode: false + ), + ] + ) + ) + #expect( + try Expression("list*") == + Expression( + operator: nil, + elements: [ + Element( + name: .init("list"), + maximumLength: nil, + explode: true + ), + ] + ) + ) + #expect( + try Expression("&list*") == + Expression( + operator: .continuation, + elements: [ + Element( + name: .init("list"), + maximumLength: nil, + explode: true + ), + ] + ) + ) + } + + @Test + static func parsingWithMultipleNames() throws { + #expect( + try Expression("x,y") == + Expression( + operator: nil, + elements: [ + Element( + name: .init("x"), + maximumLength: nil, + explode: false + ), + Element( + name: .init("y"), + maximumLength: nil, + explode: false + ), + ] + ) + ) + #expect( + try Expression("&x,y,empty") == + Expression( + operator: .continuation, + elements: [ + Element( + name: .init("x"), + maximumLength: nil, + explode: false + ), + Element( + name: .init("y"), + maximumLength: nil, + explode: false + ), + Element( + name: .init("empty"), + maximumLength: nil, + explode: false + ), + ] + ) + ) + #expect( + try Expression("?q,lang") == + Expression( + operator: .queryComponent, + elements: [ + Element( + name: .init("q"), + maximumLength: nil, + explode: false + ), + Element( + name: .init("lang"), + maximumLength: nil, + explode: false + ), + ] + ) + ) + #expect( + try Expression("/list*,path:4") == + Expression( + operator: .pathSegment, + elements: [ + Element( + name: .init("list"), + maximumLength: nil, + explode: true + ), + Element( + name: .init("path"), + maximumLength: 4, + explode: false + ), + ] + ) + ) + } + + @Test(arguments: [ + "path:a", + "path:-1", + ]) + static func invalid( + input: String + ) { + #expect((try? Expression(input)) == nil, "Should fail to parse, but not crash.") + } +} diff --git a/Tests/FoundationEssentialsTests/URITemplatingTests/URLTemplate_PercentEncodingTests.swift b/Tests/FoundationEssentialsTests/URITemplatingTests/URLTemplate_PercentEncodingTests.swift new file mode 100644 index 000000000..bc64881f4 --- /dev/null +++ b/Tests/FoundationEssentialsTests/URITemplatingTests/URLTemplate_PercentEncodingTests.swift @@ -0,0 +1,140 @@ +//===----------------------------------------------------------------------===// +// +// 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(FoundationEssentials) +@testable import FoundationEssentials +#endif +#if FOUNDATION_FRAMEWORK +@testable import Foundation +#endif +import Testing +#if FOUNDATION_FRAMEWORK +@_spi(Unstable) internal import CollectionsInternal +#elseif canImport(_RopeModule) +internal import _RopeModule +#elseif canImport(_FoundationCollections) +internal import _FoundationCollections +#endif + +extension String { + fileprivate func encoded( + _ allowed: URL.Template.Expression.Operator.AllowedCharacters + ) -> String { + normalizedAddingPercentEncoding(withAllowedCharacters: allowed) + } +} + +@Suite("URL.Template PercentEncoding") +private enum PercentEncodingTests { + @Test + static func allowedUnreserved() { + // unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + let expected: Set = { + var expected = Set() + expected.formUnion(0x61...0x7a) // "a"..."z" + expected.formUnion(0x41...0x5a) // "A"..."Z" + expected.formUnion(0x30...0x39) // "0"..."9" + expected.formUnion([0x2d, 0x2e, 0x5f, 0x7e]) // `-` `.` `_` `~` + return expected + }() + let unreserved = URL.Template.Expression.Operator.AllowedCharacters.unreserved + for c in UInt8.min...UInt8.max { + #expect(unreserved.isAllowedCodeUnit(c) == expected.contains(c), "\(c)") + } + } + + @Test + static func allowedUnreservedReserved() { + // unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + let expected: Set = { + var expected = Set() + expected.formUnion(0x61...0x7a) // "a"..."z" + expected.formUnion(0x41...0x5a) // "A"..."Z" + expected.formUnion(0x30...0x39) // "0"..."9" + expected.formUnion([0x2d, 0x2e, 0x5f, 0x7e]) // `-` `.` `_` `~` + expected.formUnion([0x3a, 0x2f, 0x3f, 0x23, 0x5b, 0x5d, 0x40]) // `:` `/` `?` `#` `[` `]` `@` + expected.formUnion([0x21, 0x24, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x3b, 0x3d]) // `!` `$` `&` `'` `(` `)` `*` `+` `,` `;` `=` + return expected + }() + let unreservedReserved = URL.Template.Expression.Operator.AllowedCharacters.unreservedReserved + for c in UInt8.min...UInt8.max { + #expect(unreservedReserved.isAllowedCodeUnit(c) == expected.contains(c), "\(c)") + } + } + + @Test + static func normalizedAddingPercentEncoding_unreservedReserved() { + #expect("".encoded(.unreservedReserved) == "") + #expect("a".encoded(.unreservedReserved) == "a") + #expect("a1-._~b2".encoded(.unreservedReserved) == "a1-._~b2") + #expect(":/?#[]@".encoded(.unreservedReserved) == ":/?#[]@") + #expect("!$&'()*+,;=".encoded(.unreservedReserved) == "!$&'()*+,;=") + #expect("ä".encoded(.unreservedReserved) == "%C3%A4") + + // Percent encoded characters will be copied literally. + // But the `%` character will be encoded (since it’s not allowed) + // if it’s not part of a `pct-encoded` sequence. + #expect("a%20b".encoded(.unreservedReserved) == "a%20b") + #expect("a%g0b".encoded(.unreservedReserved) == "a%25g0b") + #expect("a%0gb".encoded(.unreservedReserved) == "a%250gb") + #expect("a%@0b".encoded(.unreservedReserved) == "a%25@0b") + #expect("a%0@b".encoded(.unreservedReserved) == "a%250@b") + #expect("a%/0b".encoded(.unreservedReserved) == "a%25/0b") + #expect("a%0/b".encoded(.unreservedReserved) == "a%250/b") + #expect("a%:0b".encoded(.unreservedReserved) == "a%25:0b") + #expect("a%0:b".encoded(.unreservedReserved) == "a%250:b") + #expect("a%aab".encoded(.unreservedReserved) == "a%aab") + #expect("a%AAb".encoded(.unreservedReserved) == "a%AAb") + #expect("a%ffb".encoded(.unreservedReserved) == "a%ffb") + #expect("a%FFb".encoded(.unreservedReserved) == "a%FFb") + #expect("a%b".encoded(.unreservedReserved) == "a%25b") + #expect("a%2".encoded(.unreservedReserved) == "a%252") + #expect("a%2 ".encoded(.unreservedReserved) == "a%252%20") + #expect("a%2 ".encoded(.unreservedReserved) == "a%252%20") + #expect("a%%".encoded(.unreservedReserved) == "a%25%25") + #expect("a%%2".encoded(.unreservedReserved) == "a%25%252") + #expect("a%%20".encoded(.unreservedReserved) == "a%25%20") + } + + @Test + static func normalizedAddingPercentEncoding_unreserved() { + #expect("".encoded(.unreserved) == "") + #expect("a".encoded(.unreserved) == "a") + #expect("a1-._~b2".encoded(.unreserved) == "a1-._~b2") + #expect(":/?#[]@".encoded(.unreserved) == "%3A%2F%3F%23%5B%5D%40") + #expect("!$&'()*+,;=".encoded(.unreserved) == "%21%24%26%27%28%29%2A%2B%2C%3B%3D") + #expect("ä".encoded(.unreservedReserved) == "%C3%A4") + + // In the `unreserved` case, `%` will always get encoded: + #expect("a%20b".encoded(.unreserved) == "a%2520b") + #expect("a%b".encoded(.unreserved) == "a%25b") + #expect("a%22".encoded(.unreserved) == "a%2522") + #expect("a%%22".encoded(.unreserved) == "a%25%2522") + } + + @Test + static func convertToNFCAndPercentEncode() { + // Percent-encode everything to make tests easier to read: + func encodeAll(_ input: String) -> String { + input.normalizedAddingPercentEncoding(withAllowedCharacters: .unreserved) + } + + #expect(encodeAll("") == "") + #expect(encodeAll("a") == "a") + #expect(encodeAll("\u{1}") == "%01") + #expect(encodeAll("\u{C5}") == "%C3%85") + #expect(encodeAll("\u{41}\u{30A}") == "%C3%85", "combining mark") + #expect(encodeAll("\u{41}\u{300}\u{323}") == "%E1%BA%A0%CC%80", "Ordering of combining marks.") + #expect(encodeAll("a b c \u{73}\u{323}\u{307} d e f") == "a%20b%20c%20%E1%B9%A9%20d%20e%20f") + #expect(encodeAll("a b c \u{1e69} d e f") == "a%20b%20c%20%E1%B9%A9%20d%20e%20f") + } +} diff --git a/Tests/FoundationEssentialsTests/URITemplatingTests/URLTemplate_TemplateTests.swift b/Tests/FoundationEssentialsTests/URITemplatingTests/URLTemplate_TemplateTests.swift new file mode 100644 index 000000000..9089a32d9 --- /dev/null +++ b/Tests/FoundationEssentialsTests/URITemplatingTests/URLTemplate_TemplateTests.swift @@ -0,0 +1,265 @@ +//===----------------------------------------------------------------------===// +// +// 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(FoundationEssentials) +@testable import FoundationEssentials +#endif +#if FOUNDATION_FRAMEWORK +@testable import Foundation +#endif +import Testing +#if FOUNDATION_FRAMEWORK +@_spi(Unstable) internal import CollectionsInternal +#elseif canImport(_RopeModule) +internal import _RopeModule +#elseif canImport(_FoundationCollections) +internal import _FoundationCollections +#endif + +// +// These test cases are (mostly) from RFC 6570. +// + +private var variables: [URL.Template.VariableName: URL.Template.Value] { + return [ + .init("count"): ["one", "two", "three"], + .init("dom"): ["example", "com"], + .init("dub"): "me/too", + .init("hello"): "Hello World!", + .init("half"): "50%", + .init("var"): "value", + .init("who"): "fred", + .init("base"): "http://example.com/home/", + .init("path"): "/foo/bar", + .init("list"): ["red", "green", "blue"], + .init("keys"): [ + "semi": ";", + "dot": ".", + "comma": ",", + ], + .init("v"): "6", + .init("x"): "1024", + .init("y"): "768", + .init("empty"): "", + .init("empty_keys"): [:], + ] +} + +private func assertReplacing(template: String, result: String, sourceLocation: SourceLocation = #_sourceLocation) { + do { + let t = try #require(URL.Template(template)) + #expect( + t.expand(variables) == result, + #"template: "\#(template)""#, + sourceLocation: sourceLocation + ) + } catch { + Issue.record( + #"Failed to parse template: "\#(template)": \#(error)"#, + sourceLocation: sourceLocation + ) + } +} + +@Suite("URL.Template Template") +private enum TemplateTests { + @Test(arguments: [ + "a", + "a{count}b", + "O{undef}X", + "here?ref={+path}", + "{/list*,path:4}", + ]) + static func stringRoundTrip( + template: String + ) throws { + let t = try #require(URL.Template(template)) + #expect("\(t)" == template, "original: '\(template)'") + } + + @Test + static func literals() { + // unreserved / reserved / pct-encoded + // -> copy + + assertReplacing(template: "foo", result: "foo") + assertReplacing(template: "foo-._~bar", result: "foo-._~bar") + assertReplacing(template: "foo:/?#[]@bar", result: "foo:/?#[]@bar") + assertReplacing(template: "foo!$&'()*+,;=bar", result: "foo!$&'()*+,;=bar") + assertReplacing(template: "foo%20bar", result: "foo%20bar") + assertReplacing(template: "foo%20-bar", result: "foo%20-bar") + assertReplacing(template: "foo%-bar", result: "foo%25-bar") + assertReplacing(template: "%", result: "%25") + + // others -> escape + + assertReplacing(template: "foo^|bar", result: "foo%5E%7Cbar") + + // Use Normalization Form C (NFC) + + assertReplacing(template: "fooäbar", result: "foo%C3%A4bar") + assertReplacing(template: "\u{00e2}", result: "%C3%A2") + assertReplacing(template: "\u{0061}\u{0302}", result: "%C3%A2") + assertReplacing(template: "\u{fb01}", result: "%EF%AC%81") + } + + @Test + static func separators() { + assertReplacing(template: "{count}", result: "one,two,three") + assertReplacing(template: "{count*}", result: "one,two,three") + assertReplacing(template: "{/count}", result: "/one,two,three") + assertReplacing(template: "{/count*}", result: "/one/two/three") + assertReplacing(template: "{;count}", result: ";count=one,two,three") + assertReplacing(template: "{;count*}", result: ";count=one;count=two;count=three") + assertReplacing(template: "{?count}", result: "?count=one,two,three") + assertReplacing(template: "{?count*}", result: "?count=one&count=two&count=three") + assertReplacing(template: "{&count*}", result: "&count=one&count=two&count=three") + } + + @Test + static func simpleStringExpansion() { + assertReplacing(template: "{var}", result: "value") + assertReplacing(template: "{hello}", result: "Hello%20World%21") + assertReplacing(template: "{half}", result: "50%25") + assertReplacing(template: "O{empty}X", result: "OX") + assertReplacing(template: "O{undef}X", result: "OX") + assertReplacing(template: "{x,y}", result: "1024,768") + assertReplacing(template: "{x,hello,y}", result: "1024,Hello%20World%21,768") + assertReplacing(template: "?{x,empty}", result: "?1024,") + assertReplacing(template: "?{x,undef}", result: "?1024") + assertReplacing(template: "?{undef,y}", result: "?768") + assertReplacing(template: "{var:3}", result: "val") + assertReplacing(template: "{var:30}", result: "value") + assertReplacing(template: "{list}", result: "red,green,blue") + assertReplacing(template: "{list*}", result: "red,green,blue") + assertReplacing(template: "{keys}", result: "semi,%3B,dot,.,comma,%2C") + assertReplacing(template: "{keys*}", result: "semi=%3B,dot=.,comma=%2C") + } + + @Test + static func reservedExpansion() { + assertReplacing(template: "{+var}", result: "value") + assertReplacing(template: "{+hello}", result: "Hello%20World!") + assertReplacing(template: "{+half}", result: "50%25") + assertReplacing(template: "{base}index", result: "http%3A%2F%2Fexample.com%2Fhome%2Findex") + assertReplacing(template: "{+base}index", result: "http://example.com/home/index") + assertReplacing(template: "O{+empty}X", result: "OX") + assertReplacing(template: "O{+undef}X", result: "OX") + assertReplacing(template: "{+path}/here", result: "/foo/bar/here") + assertReplacing(template: "here?ref={+path}", result: "here?ref=/foo/bar") + assertReplacing(template: "up{+path}{var}/here", result: "up/foo/barvalue/here") + assertReplacing(template: "{+x,hello,y}", result: "1024,Hello%20World!,768") + assertReplacing(template: "{+path,x}/here", result: "/foo/bar,1024/here") + assertReplacing(template: "{+path:6}/here", result: "/foo/b/here") + assertReplacing(template: "{+list}", result: "red,green,blue") + assertReplacing(template: "{+list*}", result: "red,green,blue") + assertReplacing(template: "{+keys}", result: "semi,;,dot,.,comma,,") + assertReplacing(template: "{+keys*}", result: "semi=;,dot=.,comma=,") + } + + @Test + static func fragmentExpansion() { + assertReplacing(template: "{#var}", result: "#value") + assertReplacing(template: "{#hello}", result: "#Hello%20World!") + assertReplacing(template: "{#half}", result: "#50%25") + assertReplacing(template: "foo{#empty}", result: "foo#") + assertReplacing(template: "foo{#undef}", result: "foo") + assertReplacing(template: "{#x,hello,y}", result: "#1024,Hello%20World!,768") + assertReplacing(template: "{#path,x}/here", result: "#/foo/bar,1024/here") + assertReplacing(template: "{#path:6}/here", result: "#/foo/b/here") + assertReplacing(template: "{#list}", result: "#red,green,blue") + assertReplacing(template: "{#list*}", result: "#red,green,blue") + assertReplacing(template: "{#keys}", result: "#semi,;,dot,.,comma,,") + assertReplacing(template: "{#keys*}", result: "#semi=;,dot=.,comma=,") + } + + @Test + static func labelExpansionWithDotPrefix() { + assertReplacing(template: "{.who}", result: ".fred") + assertReplacing(template: "{.who,who}", result: ".fred.fred") + assertReplacing(template: "{.half,who}", result: ".50%25.fred") + assertReplacing(template: "www{.dom*}", result: "www.example.com") + assertReplacing(template: "X{.var}", result: "X.value") + assertReplacing(template: "X{.empty}", result: "X.") + assertReplacing(template: "X{.undef}", result: "X") + assertReplacing(template: "X{.var:3}", result: "X.val") + assertReplacing(template: "X{.list}", result: "X.red,green,blue") + assertReplacing(template: "X{.list*}", result: "X.red.green.blue") + assertReplacing(template: "X{.keys}", result: "X.semi,%3B,dot,.,comma,%2C") + assertReplacing(template: "X{.keys*}", result: "X.semi=%3B.dot=..comma=%2C") + assertReplacing(template: "X{.empty_keys}", result: "X") + assertReplacing(template: "X{.empty_keys*}", result: "X") + } + + @Test + static func pathSegmentExpansion() { + assertReplacing(template: "{/who}", result: "/fred") + assertReplacing(template: "{/who,who}", result: "/fred/fred") + assertReplacing(template: "{/half,who}", result: "/50%25/fred") + assertReplacing(template: "{/who,dub}", result: "/fred/me%2Ftoo") + assertReplacing(template: "{/var}", result: "/value") + assertReplacing(template: "{/var,empty}", result: "/value/") + assertReplacing(template: "{/var,undef}", result: "/value") + assertReplacing(template: "{/var,x}/here", result: "/value/1024/here") + assertReplacing(template: "{/var:1,var}", result: "/v/value") + assertReplacing(template: "{/list}", result: "/red,green,blue") + assertReplacing(template: "{/list*}", result: "/red/green/blue") + assertReplacing(template: "{/list*,path:4}", result: "/red/green/blue/%2Ffoo") + assertReplacing(template: "{/keys}", result: "/semi,%3B,dot,.,comma,%2C") + assertReplacing(template: "{/keys*}", result: "/semi=%3B/dot=./comma=%2C") + } + + @Test + static func pathStyleParameterExpansion() { + assertReplacing(template: "{;who}", result: ";who=fred") + assertReplacing(template: "{;half}", result: ";half=50%25") + assertReplacing(template: "{;empty}", result: ";empty") + assertReplacing(template: "{;v,empty,who}", result: ";v=6;empty;who=fred") + assertReplacing(template: "{;v,bar,who}", result: ";v=6;who=fred") + assertReplacing(template: "{;x,y}", result: ";x=1024;y=768") + assertReplacing(template: "{;x,y,empty}", result: ";x=1024;y=768;empty") + assertReplacing(template: "{;x,y,undef}", result: ";x=1024;y=768") + assertReplacing(template: "{;hello:5}", result: ";hello=Hello") + assertReplacing(template: "{;list}", result: ";list=red,green,blue") + assertReplacing(template: "{;list*}", result: ";list=red;list=green;list=blue") + assertReplacing(template: "{;keys}", result: ";keys=semi,%3B,dot,.,comma,%2C") + assertReplacing(template: "{;keys*}", result: ";semi=%3B;dot=.;comma=%2C") + } + + @Test + static func formStyleQueryExpansion() { + assertReplacing(template: "{?who}", result: "?who=fred") + assertReplacing(template: "{?half}", result: "?half=50%25") + assertReplacing(template: "{?x,y}", result: "?x=1024&y=768") + assertReplacing(template: "{?x,y,empty}", result: "?x=1024&y=768&empty=") + assertReplacing(template: "{?x,y,undef}", result: "?x=1024&y=768") + assertReplacing(template: "{?var:3}", result: "?var=val") + assertReplacing(template: "{?list}", result: "?list=red,green,blue") + assertReplacing(template: "{?list*}", result: "?list=red&list=green&list=blue") + assertReplacing(template: "{?keys}", result: "?keys=semi,%3B,dot,.,comma,%2C") + assertReplacing(template: "{?keys*}", result: "?semi=%3B&dot=.&comma=%2C") + } + + @Test + static func formStyleQueryContinuation() { + assertReplacing(template: "{&who}", result: "&who=fred") + assertReplacing(template: "{&half}", result: "&half=50%25") + assertReplacing(template: "?fixed=yes{&x}", result: "?fixed=yes&x=1024") + assertReplacing(template: "{&x,y,empty}", result: "&x=1024&y=768&empty=") + assertReplacing(template: "{&x,y,undef}", result: "&x=1024&y=768") + assertReplacing(template: "{&var:3}", result: "&var=val") + assertReplacing(template: "{&list}", result: "&list=red,green,blue") + assertReplacing(template: "{&list*}", result: "&list=red&list=green&list=blue") + assertReplacing(template: "{&keys}", result: "&keys=semi,%3B,dot,.,comma,%2C") + assertReplacing(template: "{&keys*}", result: "&semi=%3B&dot=.&comma=%2C") + } +} diff --git a/Tests/FoundationEssentialsTests/URITemplatingTests/URLTemplate_ValueTests.swift b/Tests/FoundationEssentialsTests/URITemplatingTests/URLTemplate_ValueTests.swift new file mode 100644 index 000000000..1750c8200 --- /dev/null +++ b/Tests/FoundationEssentialsTests/URITemplatingTests/URLTemplate_ValueTests.swift @@ -0,0 +1,77 @@ +//===----------------------------------------------------------------------===// +// +// 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(FoundationEssentials) +@testable import FoundationEssentials +import struct FoundationEssentials.URL +#endif +#if FOUNDATION_FRAMEWORK +@testable import Foundation +import struct Foundation.URL +#endif +import Testing +#if FOUNDATION_FRAMEWORK +@_spi(Unstable) internal import CollectionsInternal +#elseif canImport(_RopeModule) +internal import _RopeModule +#elseif canImport(_FoundationCollections) +internal import _FoundationCollections +#endif + +@Suite("URL.Template Value") +private enum ValueTests { + @Test + static func creating() { + #expect( + URL.Template.Value.text("foo").underlying == + URL.Template.Value.Underlying.text("foo") + ) + #expect( + URL.Template.Value.list(["bar", "baz"]).underlying == + URL.Template.Value.Underlying.list(["bar", "baz"]) + ) + #expect( + URL.Template.Value.associativeList(["bar": "baz"]).underlying == + URL.Template.Value.Underlying.associativeList(["bar": "baz"]) + ) + } + + @Test + static func expressibleByLiteral() { + let a: URL.Template.Value = "foo" + #expect( + a.underlying == + URL.Template.Value.Underlying.text("foo") + ) + + let b: URL.Template.Value = "1234" + #expect( + b.underlying == + URL.Template.Value.Underlying.text("1234") + ) + + let c: URL.Template.Value = ["bar", "baz"] + #expect( + c.underlying == + URL.Template.Value.Underlying.list(["bar", "baz"]) + ) + + let d: URL.Template.Value = [ + "bar": "baz", + "qux": "2" + ] + #expect( + d.underlying == + URL.Template.Value.Underlying.associativeList(["bar": "baz", "qux": "2"]) + ) + } +}