Skip to content

Commit

Permalink
Improve variable interface
Browse files Browse the repository at this point in the history
  • Loading branch information
alexdeem committed Jun 9, 2024
1 parent 4c2b9fe commit ad49467
Show file tree
Hide file tree
Showing 8 changed files with 208 additions and 50 deletions.
25 changes: 12 additions & 13 deletions Sources/ScreamURITemplate/Internal/Components.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}

Expand All @@ -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")
Expand All @@ -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)
}
}
Expand All @@ -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")
Expand All @@ -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)")
Expand Down
2 changes: 1 addition & 1 deletion Sources/ScreamURITemplate/Internal/ValueFormatting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 5 additions & 6 deletions Sources/ScreamURITemplate/URITemplate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -38,14 +33,18 @@ 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)
}
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
Expand Down
22 changes: 22 additions & 0 deletions Sources/ScreamURITemplate/VariableProvider.swift
Original file line number Diff line number Diff line change
@@ -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 {}
79 changes: 79 additions & 0 deletions Sources/ScreamURITemplate/VariableValue.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// 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<String, StringVariableValue>: VariableValue {
public func asTypedVariableValue() -> TypedVariableValue? {
.associativeArray(map { ($0, $1.asStringVariableValue()) })
}
}

extension [String: StringVariableValue]: VariableValue {
public func asTypedVariableValue() -> TypedVariableValue? {
.associativeArray(map { ($0, $1.asStringVariableValue()) })
}
}

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 {}
2 changes: 1 addition & 1 deletion Tests/ScreamURITemplateTests/TestFileTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
51 changes: 22 additions & 29 deletions Tests/ScreamURITemplateTests/TestModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}

Expand All @@ -38,35 +38,33 @@ 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)
case let .double(double):
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
}
}
Expand Down Expand Up @@ -143,15 +141,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)
}
}
66 changes: 66 additions & 0 deletions Tests/ScreamURITemplateTests/Tests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,73 @@
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)
XCTAssertTrue([
"https://api.example.com/path?a=A&b=42&c=true&b2=42&a2=A&c2=true",
"https://api.example.com/path?a=A&c=true&b=42&b2=42&a2=A&c2=true",
"https://api.example.com/path?b=42&a=A&c=true&b2=42&a2=A&c2=true",
"https://api.example.com/path?b=42&c=true&a=A&b2=42&a2=A&c2=true",
"https://api.example.com/path?c=true&a=A&b=42&b2=42&a2=A&c2=true",
"https://api.example.com/path?c=true&b=42&a=A&b2=42&a2=A&c2=true",
].contains(urlString))
}

func testSendable() {
let template: URITemplate = "https://api.github.com/repos/{owner}/{repo}/collaborators/{username}"
let sendable = template as Sendable
Expand Down

0 comments on commit ad49467

Please sign in to comment.