From bac34e82c778acb1ec795c934e9ca6e0a6f66fcc Mon Sep 17 00:00:00 2001 From: Chris Eplett Date: Mon, 25 Sep 2023 10:07:56 -0400 Subject: [PATCH] Encode/Decode SemanticVersion as a single value string Since SemanticVersion is LosslessStringConvertible, it seems to make more sense for its encoding/decoding strategy to use that to allow it to be serialized more succinctly as a single string value, rather than using the synthesized structured encoding provided by simply declaring Codable conformance. This is also more likely to conform to how such values will be served up by APIs. This change implements custom init(from:) and encode(to:) methods to provide this behavior, as well as unit tests to verify the behavior. Note this *is* a breaking change to the encoded format of the structure. --- Sources/SemanticVersion/SemanticVersion.swift | 21 +++++++- .../SemanticVersionTests.swift | 54 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/Sources/SemanticVersion/SemanticVersion.swift b/Sources/SemanticVersion/SemanticVersion.swift index fb4a69d..dae25c7 100644 --- a/Sources/SemanticVersion/SemanticVersion.swift +++ b/Sources/SemanticVersion/SemanticVersion.swift @@ -22,7 +22,7 @@ import Foundation /// 2. MINOR version when you add functionality in a backwards compatible manner, and /// PATCH version when you make backwards compatible bug fixes. /// Additional labels for pre-release and build metadata are available as extensions to the MAJOR.MINOR.PATCH format. -public struct SemanticVersion: Codable, Equatable, Hashable { +public struct SemanticVersion: Equatable, Hashable { public var major: Int public var minor: Int public var patch: Int @@ -45,6 +45,25 @@ public struct SemanticVersion: Codable, 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 { diff --git a/Tests/SemanticVersionTests/SemanticVersionTests.swift b/Tests/SemanticVersionTests/SemanticVersionTests.swift index 1accd26..31cba93 100644 --- a/Tests/SemanticVersionTests/SemanticVersionTests.swift +++ b/Tests/SemanticVersionTests/SemanticVersionTests.swift @@ -176,4 +176,58 @@ final class SemanticVersionTests: XCTestCase { 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)") + } + } + } }