diff --git a/README.md b/README.md index 0ee5fdf..d6f2896 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,20 @@ let dict = [ // [{major 3, minor 0, patch 0,... ] // SemanticVersion is Codable -let data = try JSONEncoder().encode(v123) // 58 bytes -let decoded = try JSONDecoder().decode(SemanticVersion.self, from: data) // 1.2.3 -decoded == v123 // true +// Note: the strategy defaults to `.defaultCodable` +let defaultEncoder = JSONEncoder() +defaultEncoder.semanticVersionEncodingStrategy = .defaultCodable +let defaultDecoder = JSONDecoder() +defaultDecoder.semanticVersionDecodingStrategy = .defaultCodable +let defaultData = try defaultEncoder.encode(v123) // 58 bytes +let defaultDecoded = try defaultDecoder.decode(SemanticVersion.self, from: defaultData) // 1.2.3 +defaultDecoded == v123 // true + +let stringEncoder = JSONEncoder() +stringEncoder.semanticVersionEncodingStrategy = .semverString +let stringDecoder = JSONDecoder() +stringDecoder.semanticVersionDecodingStrategy = .semverString +let stringData = try stringEncoder.encode(v123) // 7 bytes -> "1.2.3", including quotes +let stringDecoded = try stringDecoder.decode(SemanticVersion.self, from: stringData) // 1.2.3 +stringDecoded == v123 // true ``` diff --git a/Sources/SemanticVersion/SemanticVersion+Codable.swift b/Sources/SemanticVersion/SemanticVersion+Codable.swift new file mode 100644 index 0000000..8b106ae --- /dev/null +++ b/Sources/SemanticVersion/SemanticVersion+Codable.swift @@ -0,0 +1,102 @@ +// 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 defaultCodable + /// Encode/decode the `SemanticVersion` to/fromfrom a string that conforms to the + /// semantic version 2.0 specification at https://semver.org. + case semverString +} + +extension JSONEncoder { + /// The strategy to use in decoding semantic versions. Defaults to `.defaultCodable`. + public var semanticVersionEncodingStrategy: SemanticVersionStrategy { + get { userInfo.semanticDecodingStrategy } + set { userInfo.semanticDecodingStrategy = newValue } + } +} + +extension JSONDecoder { + /// The strategy to use in decoding semantic versions. Defaults to `.semverString`. + public var semanticVersionDecodingStrategy: SemanticVersionStrategy { + get { userInfo.semanticDecodingStrategy } + set { userInfo.semanticDecodingStrategy = newValue } + } +} + +private extension Dictionary where Key == CodingUserInfoKey, Value == Any { + var semanticDecodingStrategy: SemanticVersionStrategy { + get { + (self[.semanticVersionStrategy] as? SemanticVersionStrategy) ?? .defaultCodable + } + 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 .defaultCodable: + 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 .defaultCodable: + 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 f772b81..d5bbf0f 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 @@ -50,7 +50,6 @@ public struct SemanticVersion: Codable, Equatable, Hashable { } } - 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..b60d423 --- /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_defaultCodable_is_default() throws { + XCTAssertEqual(.defaultCodable, JSONEncoder().semanticVersionEncodingStrategy) + XCTAssertEqual(.defaultCodable, 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_defaultCodable() throws { + let encoder = JSONEncoder() + var actual: String + + encoder.semanticVersionEncodingStrategy = .defaultCodable + + 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_defaultCodable() throws { + let decoder = JSONDecoder() + var json: Data + + decoder.semanticVersionDecodingStrategy = .defaultCodable + + 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 a3ad6c6..a9d32c0 100644 --- a/Tests/SemanticVersionTests/SemanticVersionTests.swift +++ b/Tests/SemanticVersionTests/SemanticVersionTests.swift @@ -178,5 +178,4 @@ final class SemanticVersionTests: XCTestCase { XCTAssertTrue(SemanticVersion(0, 1, 1).isPatchRelease) XCTAssertFalse(SemanticVersion(0, 0, 0).isPatchRelease) } - }