From 9c4abc063c94b93818478be19f30d1637e37cada Mon Sep 17 00:00:00 2001 From: Chris Eplett Date: Fri, 3 Nov 2023 08:58:56 -0400 Subject: [PATCH] Add coding strategy support This allows SemanticVersion to optionally be encoded/decoded as a semver string, in addition to supporting the pre-existing memberwise coding. --- .../SemanticVersion+Codable.swift | 104 ++++++++++ Sources/SemanticVersion/SemanticVersion.swift | 20 -- .../SemanticVersionCodingTests.swift | 193 ++++++++++++++++++ .../SemanticVersionTests.swift | 55 ----- 4 files changed, 297 insertions(+), 75 deletions(-) create mode 100644 Sources/SemanticVersion/SemanticVersion+Codable.swift create mode 100644 Tests/SemanticVersionTests/SemanticVersionCodingTests.swift diff --git a/Sources/SemanticVersion/SemanticVersion+Codable.swift b/Sources/SemanticVersion/SemanticVersion+Codable.swift new file mode 100644 index 0000000..f3c3168 --- /dev/null +++ b/Sources/SemanticVersion/SemanticVersion+Codable.swift @@ -0,0 +1,104 @@ +// Copyright Dave Verwer, Sven A. Schmidt, and other contributors. +// +// 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 SemanticVersionStrategy { + /// Encode/decode the `SemanticVersion` as a structure to/from a JSON object + case memberwise + /// Encode/decode the `SemanticVersion` to/fromfrom a string that conforms to the + /// semantic version 2.0 specification at https://semver.org. + case semverString + + internal static let `default`: Self = .semverString +} + +extension JSONEncoder { + /// The strategy to use in decoding semantic versions. Defaults to `.semverString`. + public var semanticVersionEncodingStrategy: SemanticVersionStrategy { + get { userInfo.semanticDecodingStrategy } + set { userInfo.semanticDecodingStrategy = newValue } + } +} + +extension JSONDecoder { + /// The strategy to use in decoding semantic versions. Defaults to `.succint`. + public var semanticVersionDecodingStrategy: SemanticVersionStrategy { + get { userInfo.semanticDecodingStrategy } + set { userInfo.semanticDecodingStrategy = newValue } + } +} + +private extension [CodingUserInfoKey: Any] { + var semanticDecodingStrategy: SemanticVersionStrategy { + get { + (self[.semanticVersionStrategy] as? SemanticVersionStrategy) ?? .default + } + set { + self[.semanticVersionStrategy] = newValue + } + } +} + +private extension CodingUserInfoKey { + static let semanticVersionStrategy = Self(rawValue: "SemanticVersionEncodingStrategy")! +} + +extension SemanticVersion: Codable { + enum CodingKeys: CodingKey { + case major + case minor + case patch + case preRelease + case build + } + + public init(from decoder: Decoder) throws { + switch decoder.userInfo.semanticDecodingStrategy { + case .memberwise: + let container = try decoder.container(keyedBy: CodingKeys.self) + self.major = try container.decode(Int.self, forKey: .major) + self.minor = try container.decode(Int.self, forKey: .minor) + self.patch = try container.decode(Int.self, forKey: .patch) + self.preRelease = try container.decode(String.self, forKey: .preRelease) + self.build = try container.decode(String.self, forKey: .build) + case .semverString: + let container = try decoder.singleValueContainer() + guard let version = SemanticVersion(try container.decode(String.self)) else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "Expected valid semver 2.0 string" + ) + ) + } + self = version + } + } + + public func encode(to encoder: Encoder) throws { + switch encoder.userInfo.semanticDecodingStrategy { + case .memberwise: + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(major, forKey: .major) + try container.encode(minor, forKey: .minor) + try container.encode(patch, forKey: .patch) + try container.encode(preRelease, forKey: .preRelease) + try container.encode(build, forKey: .build) + case .semverString: + var container = encoder.singleValueContainer() + try container.encode(description) + } + } +} diff --git a/Sources/SemanticVersion/SemanticVersion.swift b/Sources/SemanticVersion/SemanticVersion.swift index 67dc252..d5bbf0f 100644 --- a/Sources/SemanticVersion/SemanticVersion.swift +++ b/Sources/SemanticVersion/SemanticVersion.swift @@ -50,26 +50,6 @@ public struct SemanticVersion: Equatable, Hashable { } } -extension SemanticVersion: Codable { - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - guard let version = SemanticVersion(try container.decode(String.self)) else { - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: container.codingPath, - debugDescription: "Expected valid semver 2.0 string" - ) - ) - } - self = version - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(description) - } -} - extension SemanticVersion: LosslessStringConvertible { /// Initialize a version from a string. Returns `nil` if the string is not a semantic version. diff --git a/Tests/SemanticVersionTests/SemanticVersionCodingTests.swift b/Tests/SemanticVersionTests/SemanticVersionCodingTests.swift new file mode 100644 index 0000000..b0b0f36 --- /dev/null +++ b/Tests/SemanticVersionTests/SemanticVersionCodingTests.swift @@ -0,0 +1,193 @@ +// +// SemanticVersionCodingTests.swift +// +// +// Created by Chris Eplett on 11/3/23. +// + +import XCTest + +import SemanticVersion + +final class SemanticVersionCodingTests: XCTestCase { + func test_semverString_is_default() throws { + XCTAssertEqual(.semverString, JSONEncoder().semanticVersionEncodingStrategy) + XCTAssertEqual(.semverString, JSONDecoder().semanticVersionDecodingStrategy) + } + + func test_encodable_semverString() throws { + let encoder = JSONEncoder() + var actual: String + + encoder.semanticVersionEncodingStrategy = .semverString + + actual = String(data: try encoder.encode(SemanticVersion(1, 2, 3)), encoding: .utf8)! + XCTAssertEqual(actual, #""1.2.3""#) + + actual = String(data: try encoder.encode(SemanticVersion(3, 2, 1, "alpha.4")), encoding: .utf8)! + XCTAssertEqual(actual, #""3.2.1-alpha.4""#) + + actual = String(data: try encoder.encode(SemanticVersion(3, 2, 1, "", "build.42")), encoding: .utf8)! + XCTAssertEqual(actual, #""3.2.1+build.42""#) + + actual = String(data: try encoder.encode(SemanticVersion(7, 7, 7, "beta.423", "build.17")), encoding: .utf8)! + XCTAssertEqual(actual, #""7.7.7-beta.423+build.17""#) + } + + func test_encodable_memberwise() throws { + let encoder = JSONEncoder() + var actual: String + + encoder.semanticVersionEncodingStrategy = .memberwise + + actual = String(data: try encoder.encode(SemanticVersion(1, 2, 3)), encoding: .utf8)! + XCTAssertTrue(actual.contains(#""major":1"#)) + XCTAssertTrue(actual.contains(#""minor":2"#)) + XCTAssertTrue(actual.contains(#""patch":3"#)) + XCTAssertTrue(actual.contains(#""preRelease":"""#)) + XCTAssertTrue(actual.contains(#""build":"""#)) + + actual = String(data: try encoder.encode(SemanticVersion(3, 2, 1, "alpha.4")), encoding: .utf8)! + XCTAssertTrue(actual.contains(#""major":3"#)) + XCTAssertTrue(actual.contains(#""minor":2"#)) + XCTAssertTrue(actual.contains(#""patch":1"#)) + XCTAssertTrue(actual.contains(#""preRelease":"alpha.4""#)) + XCTAssertTrue(actual.contains(#""build":"""#)) + + actual = String(data: try encoder.encode(SemanticVersion(3, 2, 1, "", "build.42")), encoding: .utf8)! + XCTAssertTrue(actual.contains(#""major":3"#)) + XCTAssertTrue(actual.contains(#""minor":2"#)) + XCTAssertTrue(actual.contains(#""patch":1"#)) + XCTAssertTrue(actual.contains(#""preRelease":"""#)) + XCTAssertTrue(actual.contains(#""build":"build.42""#)) + + actual = String(data: try encoder.encode(SemanticVersion(7, 7, 7, "beta.423", "build.17")), encoding: .utf8)! + XCTAssertTrue(actual.contains(#""major":7"#)) + XCTAssertTrue(actual.contains(#""minor":7"#)) + XCTAssertTrue(actual.contains(#""patch":7"#)) + XCTAssertTrue(actual.contains(#""preRelease":"beta.423""#)) + XCTAssertTrue(actual.contains(#""build":"build.17""#)) + } + + func test_decodable_semverString() throws { + let decoder = JSONDecoder() + var json: Data + + decoder.semanticVersionDecodingStrategy = .semverString + + json = #""1.2.3-a.4+42.7""#.data(using: .utf8)! + XCTAssertEqual( + try decoder.decode(SemanticVersion.self, from: json), + SemanticVersion(1, 2, 3, "a.4", "42.7") + ) + + json = #"["1.2.3-a.4+42.7", "7.7.7"]"#.data(using: .utf8)! + XCTAssertEqual( + try decoder.decode([SemanticVersion].self, from: json), + [SemanticVersion(1, 2, 3, "a.4", "42.7"), SemanticVersion(7, 7, 7)] + ) + + struct Foo: Decodable, Equatable { + let v: SemanticVersion + } + + json = #"{"v": "1.2.3-a.4+42.7"}"#.data(using: .utf8)! + XCTAssertEqual( + try decoder.decode(Foo.self, from: json), + Foo(v: SemanticVersion(1, 2, 3, "a.4", "42.7")) + ) + + json = #"{"v": "I AM NOT A SEMVER"}"#.data(using: .utf8)! + XCTAssertThrowsError(_ = try decoder.decode(Foo.self, from: json)) { error in + switch error as? DecodingError { + case .dataCorrupted(let context): + XCTAssertEqual(context.codingPath.map(\.stringValue), ["v"]) + XCTAssertEqual(context.debugDescription, "Expected valid semver 2.0 string") + default: + XCTFail("Expected DecodingError.dataCorrupted, got \(error)") + } + } + } + + func test_decodable_memberwise() throws { + let decoder = JSONDecoder() + var json: Data + + decoder.semanticVersionDecodingStrategy = .memberwise + + json = """ + { + "major": 1, + "minor": 2, + "patch": 3, + "preRelease": "a.4", + "build": "42.7" + } + """.data(using: .utf8)! + XCTAssertEqual( + try decoder.decode(SemanticVersion.self, from: json), + SemanticVersion(1, 2, 3, "a.4", "42.7") + ) + + json = """ + [ + { + "major": 1, + "minor": 2, + "patch": 3, + "preRelease": "a.4", + "build": "42.7" + },{ + "major": 7, + "minor": 7, + "patch": 7, + "preRelease": "", + "build": "" + } + ] + """.data(using: .utf8)! + XCTAssertEqual( + try decoder.decode([SemanticVersion].self, from: json), + [SemanticVersion(1, 2, 3, "a.4", "42.7"), SemanticVersion(7, 7, 7)] + ) + + struct Foo: Decodable, Equatable { + let v: SemanticVersion + } + + json = """ + { + "v": { + "major": 1, + "minor": 2, + "patch": 3, + "preRelease": "a.4", + "build": "42.7" + } + } + """.data(using: .utf8)! + XCTAssertEqual( + try decoder.decode(Foo.self, from: json), + Foo(v: SemanticVersion(1, 2, 3, "a.4", "42.7")) + ) + + json = """ + { + "v": { + "major": 1, + "preRelease": "a.4", + "build": "42.7" + } + } + """.data(using: .utf8)! + XCTAssertThrowsError(_ = try decoder.decode(Foo.self, from: json)) { error in + switch error as? DecodingError { + case .keyNotFound(let key, let context): + XCTAssertEqual("minor", key.stringValue) + XCTAssertEqual(["v"], context.codingPath.map(\.stringValue)) + default: + XCTFail("Expected DecodingError.keyNotFound, got \(error)") + } + } + } +} diff --git a/Tests/SemanticVersionTests/SemanticVersionTests.swift b/Tests/SemanticVersionTests/SemanticVersionTests.swift index 17656bb..a9d32c0 100644 --- a/Tests/SemanticVersionTests/SemanticVersionTests.swift +++ b/Tests/SemanticVersionTests/SemanticVersionTests.swift @@ -178,59 +178,4 @@ final class SemanticVersionTests: XCTestCase { XCTAssertTrue(SemanticVersion(0, 1, 1).isPatchRelease) XCTAssertFalse(SemanticVersion(0, 0, 0).isPatchRelease) } - - func test_encodable() throws { - let encoder = JSONEncoder() - var actual: String - - actual = String(data: try encoder.encode(SemanticVersion(1, 2, 3)), encoding: .utf8)! - XCTAssertEqual(actual, #""1.2.3""#) - - actual = String(data: try encoder.encode(SemanticVersion(3, 2, 1, "alpha.4")), encoding: .utf8)! - XCTAssertEqual(actual, #""3.2.1-alpha.4""#) - - actual = String(data: try encoder.encode(SemanticVersion(3, 2, 1, "", "build.42")), encoding: .utf8)! - XCTAssertEqual(actual, #""3.2.1+build.42""#) - - actual = String(data: try encoder.encode(SemanticVersion(7, 7, 7, "beta.423", "build.17")), encoding: .utf8)! - XCTAssertEqual(actual, #""7.7.7-beta.423+build.17""#) - } - - func test_decodable() throws { - let decoder = JSONDecoder() - var json: Data - - json = #""1.2.3-a.4+42.7""#.data(using: .utf8)! - XCTAssertEqual( - try decoder.decode(SemanticVersion.self, from: json), - SemanticVersion(1, 2, 3, "a.4", "42.7") - ) - - json = #"["1.2.3-a.4+42.7", "7.7.7"]"#.data(using: .utf8)! - XCTAssertEqual( - try decoder.decode([SemanticVersion].self, from: json), - [SemanticVersion(1, 2, 3, "a.4", "42.7"), SemanticVersion(7, 7, 7)] - ) - - struct Foo: Decodable, Equatable { - let v: SemanticVersion - } - - json = #"{"v": "1.2.3-a.4+42.7"}"#.data(using: .utf8)! - XCTAssertEqual( - try decoder.decode(Foo.self, from: json), - Foo(v: SemanticVersion(1, 2, 3, "a.4", "42.7")) - ) - - json = #"{"v": "I AM NOT A SEMVER"}"#.data(using: .utf8)! - XCTAssertThrowsError(_ = try decoder.decode(Foo.self, from: json)) { error in - switch error as? DecodingError { - case .dataCorrupted(let context): - XCTAssertEqual(context.codingPath.map(\.stringValue), ["v"]) - XCTAssertEqual(context.debugDescription, "Expected valid semver 2.0 string") - default: - XCTFail("Expected DecodingError.dataCorrupted, got \(error)") - } - } - } }