diff --git a/Sources/ScreamURITemplate/Internal/Components.swift b/Sources/ScreamURITemplate/Internal/Components.swift index 299cc32..ccd66fb 100644 --- a/Sources/ScreamURITemplate/Internal/Components.swift +++ b/Sources/ScreamURITemplate/Internal/Components.swift @@ -17,7 +17,7 @@ import Foundation typealias ComponentBase = Sendable protocol Component: ComponentBase { - func expand(variables: [String: VariableValue]) throws -> String + func expand(variables: VariableProvider) throws -> String var variableNames: [String] { get } } @@ -33,7 +33,7 @@ struct LiteralComponent: Component { literal = string } - func expand(variables _: [String: VariableValue]) throws -> String { + func expand(variables _: VariableProvider) throws -> String { let expansion = String(literal) guard let encodedExpansion = expansion.addingPercentEncoding(withAllowedCharacters: reservedAndUnreservedCharacterSet) else { throw URITemplate.Error.expansionFailure(position: literal.startIndex, reason: "Percent Encoding Failed") @@ -48,7 +48,7 @@ struct LiteralPercentEncodedTripletComponent: Component { literal = string } - func expand(variables _: [String: VariableValue]) throws -> String { + func expand(variables _: VariableProvider) throws -> String { return String(literal) } } @@ -65,16 +65,17 @@ struct ExpressionComponent: Component { } // swiftlint:disable:next cyclomatic_complexity - func expand(variables: [String: VariableValue]) throws -> String { + func expand(variables: VariableProvider) throws -> String { let configuration = expressionOperator.expansionConfiguration() let expansions = try variableList.compactMap { variableSpec -> String? in - guard let value = variables[String(variableSpec.name)] else { + guard let value = variables[String(variableSpec.name)]?.asTypedVariableValue() else { return nil } do { - if let stringValue = value as? String { - return try stringValue.formatForTemplateExpansion(variableSpec: variableSpec, expansionConfiguration: configuration) - } else if let arrayValue = value as? [String] { + switch value { + case let .string(plainValue): + return try plainValue.formatForTemplateExpansion(variableSpec: variableSpec, expansionConfiguration: configuration) + case let .list(arrayValue): switch variableSpec.modifier { case .prefix: throw FormatError.failure(reason: "Prefix operator can only be applied to string") @@ -83,17 +84,15 @@ struct ExpressionComponent: Component { case .none: return try arrayValue.formatForTemplateExpansion(variableSpec: variableSpec, expansionConfiguration: configuration) } - } else if let dictionaryValue = value as? [String: String] { + case let .associativeArray(associativeArrayValue): switch variableSpec.modifier { case .prefix: throw FormatError.failure(reason: "Prefix operator can only be applied to string") case .explode: - return try dictionaryValue.explodeForTemplateExpansion(variableSpec: variableSpec, expansionConfiguration: configuration) + return try associativeArrayValue.explodeForTemplateExpansion(variableSpec: variableSpec, expansionConfiguration: configuration) case .none: - return try dictionaryValue.formatForTemplateExpansion(variableSpec: variableSpec, expansionConfiguration: configuration) + return try associativeArrayValue.formatForTemplateExpansion(variableSpec: variableSpec, expansionConfiguration: configuration) } - } else { - throw FormatError.failure(reason: "Invalid Value Type") } } catch let FormatError.failure(reason) { throw URITemplate.Error.expansionFailure(position: templatePosition, reason: "Failed expanding variable \"\(variableSpec.name)\": \(reason)") diff --git a/Sources/ScreamURITemplate/Internal/ValueFormatting.swift b/Sources/ScreamURITemplate/Internal/ValueFormatting.swift index 3684f78..1915440 100644 --- a/Sources/ScreamURITemplate/Internal/ValueFormatting.swift +++ b/Sources/ScreamURITemplate/Internal/ValueFormatting.swift @@ -101,7 +101,7 @@ extension Array where Element: StringProtocol { } } -extension Dictionary where Key: StringProtocol, Value: StringProtocol { +extension [TypedVariableValue.AssociativeArrayElement] { func formatForTemplateExpansion(variableSpec: VariableSpec, expansionConfiguration: ExpansionConfiguration) throws -> String? { let encodedExpansions = try map { key, value -> String in let encodedKey = try percentEncode(string: String(key), withAllowedCharacters: expansionConfiguration.percentEncodingAllowedCharacterSet, allowPercentEncodedTriplets: expansionConfiguration.allowPercentEncodedTriplets) diff --git a/Sources/ScreamURITemplate/URITemplate.swift b/Sources/ScreamURITemplate/URITemplate.swift index 2b8cb34..70175c5 100644 --- a/Sources/ScreamURITemplate/URITemplate.swift +++ b/Sources/ScreamURITemplate/URITemplate.swift @@ -14,11 +14,6 @@ import Foundation -public protocol VariableValue {} -extension String: VariableValue {} -extension Array: VariableValue where Element: StringProtocol {} -extension Dictionary: VariableValue where Key: StringProtocol, Value: StringProtocol {} - public struct URITemplate { public enum Error: Swift.Error { case malformedTemplate(position: String.Index, reason: String) @@ -38,7 +33,7 @@ public struct URITemplate { self.components = components } - public func process(variables: [String: VariableValue]) throws -> String { + public func process(variables: VariableProvider) throws -> String { var result = "" for component in components { result += try component.expand(variables: variables) @@ -46,6 +41,10 @@ public struct URITemplate { return result } + public func process(variables: [String: String]) throws -> String { + return try process(variables: variables as VariableDictionary) + } + public var variableNames: [String] { return components.flatMap { component in return component.variableNames diff --git a/Sources/ScreamURITemplate/VariableProvider.swift b/Sources/ScreamURITemplate/VariableProvider.swift new file mode 100644 index 0000000..739d94b --- /dev/null +++ b/Sources/ScreamURITemplate/VariableProvider.swift @@ -0,0 +1,22 @@ +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +public protocol VariableProvider { + subscript(_: String) -> VariableValue? { get } +} + +public typealias VariableDictionary = [String: VariableValue] + +extension VariableDictionary: VariableProvider {} diff --git a/Sources/ScreamURITemplate/VariableValue.swift b/Sources/ScreamURITemplate/VariableValue.swift new file mode 100644 index 0000000..a2f05b6 --- /dev/null +++ b/Sources/ScreamURITemplate/VariableValue.swift @@ -0,0 +1,85 @@ +// Copyright 2018-2023 Alex Deem +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +public enum TypedVariableValue { + public typealias AssociativeArrayElement = (key: String, value: String) + + case string(String) + case list([String]) + case associativeArray([AssociativeArrayElement]) +} + +public protocol VariableValue { + func asTypedVariableValue() -> TypedVariableValue? +} + +public protocol StringVariableValue: VariableValue { + func asStringVariableValue() -> String +} + +public extension StringVariableValue { + func asTypedVariableValue() -> TypedVariableValue? { + .string(asStringVariableValue()) + } +} + +extension [StringVariableValue]: VariableValue { + public func asTypedVariableValue() -> TypedVariableValue? { + .list(map { $0.asStringVariableValue() }) + } +} + +extension KeyValuePairs: VariableValue { + public func asTypedVariableValue() -> TypedVariableValue? { + .associativeArray(map { ($0, $1.asStringVariableValue()) }) + } +} + +extension [String: StringVariableValue]: VariableValue { + public func asTypedVariableValue() -> TypedVariableValue? { + .associativeArray(map { ($0, $1.asStringVariableValue()) }.sorted { $0.0 < $1.0 }) + } +} + +public extension LosslessStringConvertible { + func asStringVariableValue() -> String { + description + } +} + +extension String: StringVariableValue {} +extension Bool: StringVariableValue {} +extension Character: StringVariableValue {} +extension Double: StringVariableValue {} +extension Float: StringVariableValue {} +extension Int: StringVariableValue {} +extension Int16: StringVariableValue {} +extension Int32: StringVariableValue {} +extension Int64: StringVariableValue {} +extension Int8: StringVariableValue {} +extension Substring: StringVariableValue {} +extension UInt: StringVariableValue {} +extension UInt16: StringVariableValue {} +extension UInt32: StringVariableValue {} +extension UInt64: StringVariableValue {} +extension UInt8: StringVariableValue {} +extension Unicode.Scalar: StringVariableValue {} + +extension UUID: StringVariableValue { + public func asStringVariableValue() -> String { + uuidString + } +} diff --git a/Tests/ScreamURITemplateTests/TestFileTests.swift b/Tests/ScreamURITemplateTests/TestFileTests.swift index 781456c..c008a8f 100644 --- a/Tests/ScreamURITemplateTests/TestFileTests.swift +++ b/Tests/ScreamURITemplateTests/TestFileTests.swift @@ -17,7 +17,7 @@ import XCTest class TestFileTests: XCTestCase { private var templateString: String! - private var variables: [String: VariableValue]! + private var variables: VariableDictionary! private var acceptableExpansions: [String]! private var failPosition: Int? private var failReason: String? diff --git a/Tests/ScreamURITemplateTests/TestModels.swift b/Tests/ScreamURITemplateTests/TestModels.swift index 7352989..b3ac1d2 100644 --- a/Tests/ScreamURITemplateTests/TestModels.swift +++ b/Tests/ScreamURITemplateTests/TestModels.swift @@ -26,7 +26,7 @@ private struct TestGroupDecodable: Decodable { public struct TestGroup { public let name: String public let level: Int? - public let variables: [String: VariableValue] + public let variables: VariableDictionary public let testcases: [TestCase] } @@ -38,8 +38,25 @@ public struct TestCase { public let failReason: String? } -extension JSONValue { - func toVariableValue() -> VariableValue? { +extension JSONValue: VariableValue { + public func asTypedVariableValue() -> ScreamURITemplate.TypedVariableValue? { + switch self { + case let .int(int): + return int.asTypedVariableValue() + case let .double(double): + return double.asTypedVariableValue() + case let .string(string): + return string.asTypedVariableValue() + case let .object(object): + return object.compactMapValues { $0.asString() }.asTypedVariableValue() + case let .array(array): + return array.compactMap { $0.asString() }.asTypedVariableValue() + case .null, .bool: + return nil + } + } + + private func asString() -> String? { switch self { case let .int(int): return String(int) @@ -47,26 +64,7 @@ extension JSONValue { return String(double) case let .string(string): return string - case let .object(object): - return object.mapValues { element -> String? in - switch element { - case let .string(string): - return string - default: - return nil - } - }.filter { $0.value != nil } - .mapValues { $0! } - case let .array(array): - return array.compactMap { element -> String? in - switch element { - case let .string(string): - return string - default: - return nil - } - } - default: + case .null, .bool, .object, .array: return nil } } @@ -86,18 +84,7 @@ extension TestCase { let expansionsData = data[1] switch expansionsData { case let .string(string): - // HACK: ensure the tests support alternate ordering for dictionary explode tests - // A PR has been raised to add support for the alternate ordering https://github.com/uri-templates/uritemplate-test/pull/58 - switch string { - case "key1,val1%2F,key2,val2%2F": - acceptableExpansions = [string, "key2,val2%2F,key1,val1%2F"] - case "#key1,val1%2F,key2,val2%2F": - acceptableExpansions = [string, "#key2,val2%2F,key1,val1%2F"] - case "key1,val1%252F,key2,val2%252F": - acceptableExpansions = [string, "key2,val2%252F,key1,val1%252F"] - default: - acceptableExpansions = [string] - } + acceptableExpansions = [string] shouldFail = false case let .array(array): acceptableExpansions = array.compactMap { value in @@ -143,15 +130,10 @@ public func parseTestFile(URL: URL) -> [TestGroup]? { } return testCollection.map { testGroupName, testGroupData in - let variables = testGroupData.variables.mapValues { element in - return element.toVariableValue() - }.filter { return $0.value != nil } - .mapValues { return $0! } - let testcases = testGroupData.testcases.compactMap { element in return TestCase(element) } - return TestGroup(name: testGroupName, level: testGroupData.level, variables: variables, testcases: testcases) + return TestGroup(name: testGroupName, level: testGroupData.level, variables: testGroupData.variables, testcases: testcases) } } diff --git a/Tests/ScreamURITemplateTests/Tests.swift b/Tests/ScreamURITemplateTests/Tests.swift index e6de45d..681c762 100644 --- a/Tests/ScreamURITemplateTests/Tests.swift +++ b/Tests/ScreamURITemplateTests/Tests.swift @@ -15,7 +15,75 @@ import ScreamURITemplate import XCTest +struct TestVariableProvider: VariableProvider { + subscript(_ key: String) -> VariableValue? { + return "_\(key)_" + } +} + class Tests: XCTestCase { + func testVariableProvider() throws { + let template: URITemplate = "https://api.github.com/repos/{owner}/{repo}/collaborators/{username}" + let urlString = try template.process(variables: TestVariableProvider()) + XCTAssertEqual(urlString, "https://api.github.com/repos/_owner_/_repo_/collaborators/_username_") + } + + func testStringStringDictionary() throws { + let template: URITemplate = "https://api.github.com/repos/{owner}/{repo}/collaborators/{username}" + let variables = ["owner": "SwiftScream", + "repo": "URITemplate", + "username": "alexdeem"] + let urlString = try template.process(variables: variables) + XCTAssertEqual(urlString, "https://api.github.com/repos/SwiftScream/URITemplate/collaborators/alexdeem") + } + + func testVariableDictionaryPlain() throws { + let template: URITemplate = "https://api.example.com/{string}/{int}/{bool}" + let variables: VariableDictionary = [ + "string": "SwiftScream", + "int": 42, + "bool": true, + ] + let urlString = try template.process(variables: variables) + XCTAssertEqual(urlString, "https://api.example.com/SwiftScream/42/true") + } + + func testVariableDictionaryList() throws { + let template: URITemplate = "https://api.example.com/{list}" + let variables: VariableDictionary = [ + "list": ["SwiftScream", 42, true], + ] + let urlString = try template.process(variables: variables) + XCTAssertEqual(urlString, "https://api.example.com/SwiftScream,42,true") + } + + func testVariableDictionaryAssocList() throws { + let template: URITemplate = "https://api.example.com/path{?unordered*,ordered*}" + let variables: VariableDictionary = [ + "unordered": [ + "b": 42, + "a": "A", + "c": true, + ], + "ordered": [ + "b2": 42, + "a2": "A", + "c2": true, + ] as KeyValuePairs, + ] + let urlString = try template.process(variables: variables) + XCTAssertEqual("https://api.example.com/path?a=A&b=42&c=true&b2=42&a2=A&c2=true", urlString) + } + + func testUUIDVariable() throws { + let template: URITemplate = "https://api.example.com/{id}" + let variables: VariableDictionary = [ + "id": UUID(uuidString: "1740A1A9-B3AD-4AE9-954B-918CEDE95285")!, + ] + let urlString = try template.process(variables: variables) + XCTAssertEqual(urlString, "https://api.example.com/1740A1A9-B3AD-4AE9-954B-918CEDE95285") + } + func testSendable() { let template: URITemplate = "https://api.github.com/repos/{owner}/{repo}/collaborators/{username}" let sendable = template as Sendable