diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/ci.yml similarity index 68% rename from .github/workflows/build-and-test.yml rename to .github/workflows/ci.yml index 3d8bcec..87acf80 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: Build and Test +name: CI on: push: @@ -8,9 +8,7 @@ on: jobs: build: - runs-on: macos-latest - steps: - uses: actions/checkout@v3 with: @@ -26,3 +24,17 @@ jobs: with: fail_ci_if_error: true verbose: true + + build-5_4_2: + runs-on: macos-11 + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + - name: Check Swift version + run: | + sudo xcode-select -s /Applications/Xcode_12.5.1.app/ + export TOOLCHAINS=swift + swift --version + - name: Run tests + run: swift test diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d608c0..276ed26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to this project will be documented in this file. + +# [3.1.0](https://github.com/SwiftScream/URITemplate/compare/3.0.1...3.1.0) (2023-01-20) + +- Allow single-quote as literal, refer [RFC errata 6937](https://www.rfc-editor.org/errata/eid6937) +- Add `Sendable` conformance +- Do not encode percent-encoded-triplets in reserved or fragment expansion +- Update test suite + + # [3.0.1](https://github.com/SwiftScream/URITemplate/compare/3.0.0...3.0.1) (2023-01-08) diff --git a/Package.swift b/Package.swift index 5793e9f..fd7ed4d 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.7 +// swift-tools-version: 5.4 import PackageDescription @@ -25,8 +25,13 @@ let package = Package( .process("data/uritemplate-test/extended-tests.json"), .process("data/uritemplate-test/negative-tests.json"), ]), - .executableTarget( - name: "ScreamURITemplateExample", - dependencies: ["ScreamURITemplate"]), ], swiftLanguageVersions: [.v5]) + +#if swift(>=5.6) || os(macOS) || os(Linux) + package.targets.append( + .executableTarget( + name: "ScreamURITemplateExample", + dependencies: ["ScreamURITemplate"]) + ) +#endif diff --git a/README.md b/README.md index 43c6ea8..0669356 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ -# URITemplate [![license](https://img.shields.io/github/license/SwiftScream/URITemplate.svg)](https://raw.githubusercontent.com/SwiftScream/URITemplate/master/LICENSE) [![GitHub release](https://img.shields.io/github/release/SwiftScream/URITemplate.svg)](https://github.com/SwiftScream/URITemplate/releases/latest) +# ScreamURITemplate +A robust and performant Swift 5 implementation of [RFC6570](https://tools.ietf.org/html/rfc6570) URI Template. Full Level 4 support is provided. -[![Travis](https://api.travis-ci.com/SwiftScream/URITemplate.svg?branch=master)](https://travis-ci.com/SwiftScream/URITemplate) +[![CI](https://github.com/SwiftScream/URITemplate/actions/workflows/ci.yml/badge.svg)](https://github.com/SwiftScream/URITemplate/actions/workflows/ci.yml) [![Codecov branch](https://img.shields.io/codecov/c/github/SwiftScream/URITemplate/master.svg)](https://codecov.io/gh/SwiftScream/URITemplate/branch/master) -![Swift 5](https://img.shields.io/badge/swift-5-4BC51D.svg?style=flat) -[![Swift Package Manager compatible](https://img.shields.io/badge/Swift%20Package%20Manager-compatible-4BC51D.svg?style=flat)](https://swift.org/package-manager/) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FSwiftScream%2FURITemplate%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/SwiftScream/URITemplate) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FSwiftScream%2FURITemplate%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/SwiftScream/URITemplate) -A robust and performant Swift 5 implementation of [RFC6570](https://tools.ietf.org/html/rfc6570) URI Template. Full Level 4 support is provided. +[![license](https://img.shields.io/github/license/SwiftScream/URITemplate.svg)](https://raw.githubusercontent.com/SwiftScream/URITemplate/master/LICENSE) [![GitHub release](https://img.shields.io/github/release/SwiftScream/URITemplate.svg)](https://github.com/SwiftScream/URITemplate/releases/latest) ## Getting Started @@ -19,6 +20,8 @@ Add `.package(url: "https://github.com/SwiftScream/URITemplate.git", from: "3.0. ### Template Processing ```swift +import ScreamURITemplate + let template = try URITemplate(string:"https://api.github.com/repos/{owner}/{repository}/traffic/views") let variables = ["owner":"SwiftScream", "repository":"URITemplate"] let urlString = try template.process(variables) @@ -56,4 +59,4 @@ struct HALObject : Codable { ``` ## Tests -The library is tested against the [standard test suite](https://github.com/uri-templates/uritemplate-test), as well as some additional tests for behavior specific to this implementation. It is intended to keep test coverage as high as possible. \ No newline at end of file +The library is tested against the [standard test suite](https://github.com/uri-templates/uritemplate-test), as well as some additional tests for behavior specific to this implementation. It is intended to keep test coverage as high as possible. diff --git a/Sources/ScreamURITemplate/Internal/CharacterSets.swift b/Sources/ScreamURITemplate/Internal/CharacterSets.swift index 235f47e..ec56760 100644 --- a/Sources/ScreamURITemplate/Internal/CharacterSets.swift +++ b/Sources/ScreamURITemplate/Internal/CharacterSets.swift @@ -19,7 +19,7 @@ private let genDelimsCharacterSet = CharacterSet(charactersIn: ":/?#[]@") private let subDelimsCharacterSet = CharacterSet(charactersIn: "!$&'()*+,;=") internal let reservedCharacterSet = genDelimsCharacterSet.union(subDelimsCharacterSet) internal let reservedAndUnreservedCharacterSet = reservedCharacterSet.union(unreservedCharacterSet) -internal let invertedLiteralCharacterSet = CharacterSet.illegalCharacters.union(CharacterSet.controlCharacters).union(CharacterSet(charactersIn: " \"'%<>\\^`{|}")) +internal let invertedLiteralCharacterSet = CharacterSet.illegalCharacters.union(CharacterSet.controlCharacters).union(CharacterSet(charactersIn: " \"%<>\\^`{|}")) internal let literalCharacterSet = invertedLiteralCharacterSet.inverted internal let hexCharacterSet = CharacterSet(charactersIn: "0123456789abcdefABCDEF") internal let varnameCharacterSet = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_%.")) diff --git a/Sources/ScreamURITemplate/Internal/Components.swift b/Sources/ScreamURITemplate/Internal/Components.swift index 38017dc..c6d7db0 100644 --- a/Sources/ScreamURITemplate/Internal/Components.swift +++ b/Sources/ScreamURITemplate/Internal/Components.swift @@ -14,7 +14,13 @@ import Foundation -internal protocol Component { +#if swift(>=5.5) + internal typealias ComponentBase = Sendable +#else + internal protocol ComponentBase {} +#endif + +internal protocol Component: ComponentBase { func expand(variables: [String: VariableValue]) throws -> String var variableNames: [String] { get } } diff --git a/Sources/ScreamURITemplate/Internal/ExpansionConfiguration.swift b/Sources/ScreamURITemplate/Internal/ExpansionConfiguration.swift index 68fb182..f4c2a49 100644 --- a/Sources/ScreamURITemplate/Internal/ExpansionConfiguration.swift +++ b/Sources/ScreamURITemplate/Internal/ExpansionConfiguration.swift @@ -16,6 +16,7 @@ import Foundation internal struct ExpansionConfiguration { let percentEncodingAllowedCharacterSet: CharacterSet + let allowPercentEncodedTriplets: Bool let prefix: String? let separator: String let named: Bool diff --git a/Sources/ScreamURITemplate/Internal/ExpressionOperator.swift b/Sources/ScreamURITemplate/Internal/ExpressionOperator.swift index 19d083c..cfd13d0 100644 --- a/Sources/ScreamURITemplate/Internal/ExpressionOperator.swift +++ b/Sources/ScreamURITemplate/Internal/ExpressionOperator.swift @@ -24,52 +24,61 @@ internal enum ExpressionOperator: Unicode.Scalar { case query = "?" case queryContinuation = "&" + // swiftlint:disable:next function_body_length func expansionConfiguration() -> ExpansionConfiguration { switch self { case .simple: return ExpansionConfiguration(percentEncodingAllowedCharacterSet: unreservedCharacterSet, + allowPercentEncodedTriplets: false, prefix: nil, separator: ",", named: false, omittOrphanedEquals: false) case .reserved: return ExpansionConfiguration(percentEncodingAllowedCharacterSet: reservedAndUnreservedCharacterSet, + allowPercentEncodedTriplets: true, prefix: nil, separator: ",", named: false, omittOrphanedEquals: false) case .fragment: return ExpansionConfiguration(percentEncodingAllowedCharacterSet: reservedAndUnreservedCharacterSet, + allowPercentEncodedTriplets: true, prefix: "#", separator: ",", named: false, omittOrphanedEquals: false) case .label: return ExpansionConfiguration(percentEncodingAllowedCharacterSet: unreservedCharacterSet, + allowPercentEncodedTriplets: false, prefix: ".", separator: ".", named: false, omittOrphanedEquals: false) case .pathSegment: return ExpansionConfiguration(percentEncodingAllowedCharacterSet: unreservedCharacterSet, + allowPercentEncodedTriplets: false, prefix: "/", separator: "/", named: false, omittOrphanedEquals: false) case .pathStyle: return ExpansionConfiguration(percentEncodingAllowedCharacterSet: unreservedCharacterSet, + allowPercentEncodedTriplets: false, prefix: ";", separator: ";", named: true, omittOrphanedEquals: true) case .query: return ExpansionConfiguration(percentEncodingAllowedCharacterSet: unreservedCharacterSet, + allowPercentEncodedTriplets: false, prefix: "?", separator: "&", named: true, omittOrphanedEquals: false) case .queryContinuation: return ExpansionConfiguration(percentEncodingAllowedCharacterSet: unreservedCharacterSet, + allowPercentEncodedTriplets: false, prefix: "&", separator: "&", named: true, diff --git a/Sources/ScreamURITemplate/Internal/ValueFormatting.swift b/Sources/ScreamURITemplate/Internal/ValueFormatting.swift index 9b5ddbc..0432976 100644 --- a/Sources/ScreamURITemplate/Internal/ValueFormatting.swift +++ b/Sources/ScreamURITemplate/Internal/ValueFormatting.swift @@ -18,10 +18,30 @@ internal enum FormatError: Error { case failure(reason: String) } -internal func percentEncode(string: String, withAllowedCharacters allowedCharacterSet: CharacterSet) throws -> String { - guard let encoded = string.addingPercentEncoding(withAllowedCharacters: allowedCharacterSet) else { +internal func percentEncode(string: String, withAllowedCharacters allowedCharacterSet: CharacterSet, allowPercentEncodedTriplets: Bool) throws -> String { + guard var encoded = string.addingPercentEncoding(withAllowedCharacters: allowedCharacterSet) else { throw FormatError.failure(reason: "Percent Encoding Failed") } + if allowPercentEncodedTriplets { + // Revert where any percent-encode-triplets had their % encoded (to %25) + var searchRange = encoded.startIndex.. String? { let separator = "," let encodedExpansions = try map { element -> String in - return try percentEncode(string: String(element), withAllowedCharacters: expansionConfiguration.percentEncodingAllowedCharacterSet) + return try percentEncode(string: String(element), withAllowedCharacters: expansionConfiguration.percentEncodingAllowedCharacterSet, allowPercentEncodedTriplets: expansionConfiguration.allowPercentEncodedTriplets) } if encodedExpansions.count == 0 { return nil @@ -66,7 +86,7 @@ internal extension Array where Element: StringProtocol { func explodeForTemplateExpansion(variableSpec: VariableSpec, expansionConfiguration: ExpansionConfiguration) throws -> String? { let separator = expansionConfiguration.separator let encodedExpansions = try map { element -> String in - let encodedElement = try percentEncode(string: String(element), withAllowedCharacters: expansionConfiguration.percentEncodingAllowedCharacterSet) + let encodedElement = try percentEncode(string: String(element), withAllowedCharacters: expansionConfiguration.percentEncodingAllowedCharacterSet, allowPercentEncodedTriplets: expansionConfiguration.allowPercentEncodedTriplets) if expansionConfiguration.named { if encodedElement.isEmpty && expansionConfiguration.omittOrphanedEquals { return String(variableSpec.name) @@ -85,8 +105,8 @@ internal extension Array where Element: StringProtocol { internal extension Dictionary where Key: StringProtocol, Value: StringProtocol { 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) - let encodedValue = try percentEncode(string: String(value), withAllowedCharacters: expansionConfiguration.percentEncodingAllowedCharacterSet) + let encodedKey = try percentEncode(string: String(key), withAllowedCharacters: expansionConfiguration.percentEncodingAllowedCharacterSet, allowPercentEncodedTriplets: expansionConfiguration.allowPercentEncodedTriplets) + let encodedValue = try percentEncode(string: String(value), withAllowedCharacters: expansionConfiguration.percentEncodingAllowedCharacterSet, allowPercentEncodedTriplets: expansionConfiguration.allowPercentEncodedTriplets) return "\(encodedKey),\(encodedValue)" } if encodedExpansions.count == 0 { @@ -102,8 +122,8 @@ internal extension Dictionary where Key: StringProtocol, Value: StringProtocol { func explodeForTemplateExpansion(variableSpec: VariableSpec, expansionConfiguration: ExpansionConfiguration) throws -> String? { let separator = expansionConfiguration.separator let encodedExpansions = try map { key, value -> String in - let encodedKey = try percentEncode(string: String(key), withAllowedCharacters: expansionConfiguration.percentEncodingAllowedCharacterSet) - let encodedValue = try percentEncode(string: String(value), withAllowedCharacters: expansionConfiguration.percentEncodingAllowedCharacterSet) + let encodedKey = try percentEncode(string: String(key), withAllowedCharacters: expansionConfiguration.percentEncodingAllowedCharacterSet, allowPercentEncodedTriplets: expansionConfiguration.allowPercentEncodedTriplets) + let encodedValue = try percentEncode(string: String(value), withAllowedCharacters: expansionConfiguration.percentEncodingAllowedCharacterSet, allowPercentEncodedTriplets: expansionConfiguration.allowPercentEncodedTriplets) if expansionConfiguration.named && encodedValue.isEmpty && expansionConfiguration.omittOrphanedEquals { return String(variableSpec.name) } diff --git a/Sources/ScreamURITemplate/URITemplate.swift b/Sources/ScreamURITemplate/URITemplate.swift index bd36075..8c31bc7 100644 --- a/Sources/ScreamURITemplate/URITemplate.swift +++ b/Sources/ScreamURITemplate/URITemplate.swift @@ -53,6 +53,10 @@ public struct URITemplate { } } +#if swift(>=5.5) + extension URITemplate: Sendable {} +#endif + extension URITemplate: CustomStringConvertible { public var description: String { return string diff --git a/Tests/ScreamURITemplateTests/TestModels.swift b/Tests/ScreamURITemplateTests/TestModels.swift index f424952..37efab9 100644 --- a/Tests/ScreamURITemplateTests/TestModels.swift +++ b/Tests/ScreamURITemplateTests/TestModels.swift @@ -86,7 +86,18 @@ extension TestCase { let expansionsData = data[1] switch expansionsData { case let .string(string): - acceptableExpansions = [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] + } shouldFail = false case let .array(array): acceptableExpansions = array.compactMap { value in diff --git a/Tests/ScreamURITemplateTests/Tests.swift b/Tests/ScreamURITemplateTests/Tests.swift index 185284f..dc447ba 100644 --- a/Tests/ScreamURITemplateTests/Tests.swift +++ b/Tests/ScreamURITemplateTests/Tests.swift @@ -16,6 +16,14 @@ import ScreamURITemplate import XCTest class Tests: XCTestCase { + #if swift(>=5.5) + func testSendable() { + let template: URITemplate = "https://api.github.com/repos/{owner}/{repo}/collaborators/{username}" + let sendable = template as Sendable + XCTAssertNotNil(sendable) + } + #endif + func testCustomStringConvertible() { let template: URITemplate = "https://api.github.com/repos/{owner}/{repo}/collaborators/{username}" XCTAssertEqual(template.description, "https://api.github.com/repos/{owner}/{repo}/collaborators/{username}") diff --git a/Tests/ScreamURITemplateTests/data/uritemplate-test b/Tests/ScreamURITemplateTests/data/uritemplate-test index 520fdd8..1eb27ab 160000 --- a/Tests/ScreamURITemplateTests/data/uritemplate-test +++ b/Tests/ScreamURITemplateTests/data/uritemplate-test @@ -1 +1 @@ -Subproject commit 520fdd8b0f78779d12178c357a986e0e727f4bd0 +Subproject commit 1eb27ab4462b9e5819dc47db99044f5fd1fa9bc7