Skip to content

Commit ea8eea9

Browse files
Merge pull request #18 from sonos/string_coding
Encode/Decode SemanticVersion as a single value string
2 parents 3a9f1b8 + 727b6d8 commit ea8eea9

File tree

5 files changed

+312
-6
lines changed

5 files changed

+312
-6
lines changed

README.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,20 @@ let dict = [ // [{major 3, minor 0, patch 0,...
5656
]
5757

5858
// SemanticVersion is Codable
59-
let data = try JSONEncoder().encode(v123) // 58 bytes
60-
let decoded = try JSONDecoder().decode(SemanticVersion.self, from: data) // 1.2.3
61-
decoded == v123 // true
59+
// Note: the strategy defaults to `.defaultCodable`
60+
let defaultEncoder = JSONEncoder()
61+
defaultEncoder.semanticVersionEncodingStrategy = .defaultCodable
62+
let defaultDecoder = JSONDecoder()
63+
defaultDecoder.semanticVersionDecodingStrategy = .defaultCodable
64+
let defaultData = try defaultEncoder.encode(v123) // 58 bytes
65+
let defaultDecoded = try defaultDecoder.decode(SemanticVersion.self, from: defaultData) // 1.2.3
66+
defaultDecoded == v123 // true
67+
68+
let stringEncoder = JSONEncoder()
69+
stringEncoder.semanticVersionEncodingStrategy = .semverString
70+
let stringDecoder = JSONDecoder()
71+
stringDecoder.semanticVersionDecodingStrategy = .semverString
72+
let stringData = try stringEncoder.encode(v123) // 7 bytes -> "1.2.3", including quotes
73+
let stringDecoded = try stringDecoder.decode(SemanticVersion.self, from: stringData) // 1.2.3
74+
stringDecoded == v123 // true
6275
```
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright Dave Verwer, Sven A. Schmidt, and other contributors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import Foundation
16+
17+
public enum SemanticVersionStrategy {
18+
/// Encode/decode the `SemanticVersion` as a structure to/from a JSON object
19+
case defaultCodable
20+
/// Encode/decode the `SemanticVersion` to/fromfrom a string that conforms to the
21+
/// semantic version 2.0 specification at https://semver.org.
22+
case semverString
23+
}
24+
25+
extension JSONEncoder {
26+
/// The strategy to use in decoding semantic versions. Defaults to `.defaultCodable`.
27+
public var semanticVersionEncodingStrategy: SemanticVersionStrategy {
28+
get { userInfo.semanticDecodingStrategy }
29+
set { userInfo.semanticDecodingStrategy = newValue }
30+
}
31+
}
32+
33+
extension JSONDecoder {
34+
/// The strategy to use in decoding semantic versions. Defaults to `.semverString`.
35+
public var semanticVersionDecodingStrategy: SemanticVersionStrategy {
36+
get { userInfo.semanticDecodingStrategy }
37+
set { userInfo.semanticDecodingStrategy = newValue }
38+
}
39+
}
40+
41+
private extension Dictionary where Key == CodingUserInfoKey, Value == Any {
42+
var semanticDecodingStrategy: SemanticVersionStrategy {
43+
get {
44+
(self[.semanticVersionStrategy] as? SemanticVersionStrategy) ?? .defaultCodable
45+
}
46+
set {
47+
self[.semanticVersionStrategy] = newValue
48+
}
49+
}
50+
}
51+
52+
private extension CodingUserInfoKey {
53+
static let semanticVersionStrategy = Self(rawValue: "SemanticVersionEncodingStrategy")!
54+
}
55+
56+
extension SemanticVersion: Codable {
57+
enum CodingKeys: CodingKey {
58+
case major
59+
case minor
60+
case patch
61+
case preRelease
62+
case build
63+
}
64+
65+
public init(from decoder: Decoder) throws {
66+
switch decoder.userInfo.semanticDecodingStrategy {
67+
case .defaultCodable:
68+
let container = try decoder.container(keyedBy: CodingKeys.self)
69+
self.major = try container.decode(Int.self, forKey: .major)
70+
self.minor = try container.decode(Int.self, forKey: .minor)
71+
self.patch = try container.decode(Int.self, forKey: .patch)
72+
self.preRelease = try container.decode(String.self, forKey: .preRelease)
73+
self.build = try container.decode(String.self, forKey: .build)
74+
case .semverString:
75+
let container = try decoder.singleValueContainer()
76+
guard let version = SemanticVersion(try container.decode(String.self)) else {
77+
throw DecodingError.dataCorrupted(
78+
DecodingError.Context(
79+
codingPath: container.codingPath,
80+
debugDescription: "Expected valid semver 2.0 string"
81+
)
82+
)
83+
}
84+
self = version
85+
}
86+
}
87+
88+
public func encode(to encoder: Encoder) throws {
89+
switch encoder.userInfo.semanticDecodingStrategy {
90+
case .defaultCodable:
91+
var container = encoder.container(keyedBy: CodingKeys.self)
92+
try container.encode(major, forKey: .major)
93+
try container.encode(minor, forKey: .minor)
94+
try container.encode(patch, forKey: .patch)
95+
try container.encode(preRelease, forKey: .preRelease)
96+
try container.encode(build, forKey: .build)
97+
case .semverString:
98+
var container = encoder.singleValueContainer()
99+
try container.encode(description)
100+
}
101+
}
102+
}

Sources/SemanticVersion/SemanticVersion.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import Foundation
2222
/// 2. MINOR version when you add functionality in a backwards compatible manner, and
2323
/// PATCH version when you make backwards compatible bug fixes.
2424
/// Additional labels for pre-release and build metadata are available as extensions to the MAJOR.MINOR.PATCH format.
25-
public struct SemanticVersion: Codable, Equatable, Hashable {
25+
public struct SemanticVersion: Equatable, Hashable {
2626
public var major: Int
2727
public var minor: Int
2828
public var patch: Int
@@ -50,7 +50,6 @@ public struct SemanticVersion: Codable, Equatable, Hashable {
5050
}
5151
}
5252

53-
5453
extension SemanticVersion: LosslessStringConvertible {
5554

5655
/// Initialize a version from a string. Returns `nil` if the string is not a semantic version.
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
//
2+
// SemanticVersionCodingTests.swift
3+
//
4+
//
5+
// Created by Chris Eplett on 11/3/23.
6+
//
7+
8+
import XCTest
9+
10+
import SemanticVersion
11+
12+
final class SemanticVersionCodingTests: XCTestCase {
13+
func test_defaultCodable_is_default() throws {
14+
XCTAssertEqual(.defaultCodable, JSONEncoder().semanticVersionEncodingStrategy)
15+
XCTAssertEqual(.defaultCodable, JSONDecoder().semanticVersionDecodingStrategy)
16+
}
17+
18+
func test_encodable_semverString() throws {
19+
let encoder = JSONEncoder()
20+
var actual: String
21+
22+
encoder.semanticVersionEncodingStrategy = .semverString
23+
24+
actual = String(data: try encoder.encode(SemanticVersion(1, 2, 3)), encoding: .utf8)!
25+
XCTAssertEqual(actual, #""1.2.3""#)
26+
27+
actual = String(data: try encoder.encode(SemanticVersion(3, 2, 1, "alpha.4")), encoding: .utf8)!
28+
XCTAssertEqual(actual, #""3.2.1-alpha.4""#)
29+
30+
actual = String(data: try encoder.encode(SemanticVersion(3, 2, 1, "", "build.42")), encoding: .utf8)!
31+
XCTAssertEqual(actual, #""3.2.1+build.42""#)
32+
33+
actual = String(data: try encoder.encode(SemanticVersion(7, 7, 7, "beta.423", "build.17")), encoding: .utf8)!
34+
XCTAssertEqual(actual, #""7.7.7-beta.423+build.17""#)
35+
}
36+
37+
func test_encodable_defaultCodable() throws {
38+
let encoder = JSONEncoder()
39+
var actual: String
40+
41+
encoder.semanticVersionEncodingStrategy = .defaultCodable
42+
43+
actual = String(data: try encoder.encode(SemanticVersion(1, 2, 3)), encoding: .utf8)!
44+
XCTAssertTrue(actual.contains(#""major":1"#))
45+
XCTAssertTrue(actual.contains(#""minor":2"#))
46+
XCTAssertTrue(actual.contains(#""patch":3"#))
47+
XCTAssertTrue(actual.contains(#""preRelease":"""#))
48+
XCTAssertTrue(actual.contains(#""build":"""#))
49+
50+
actual = String(data: try encoder.encode(SemanticVersion(3, 2, 1, "alpha.4")), encoding: .utf8)!
51+
XCTAssertTrue(actual.contains(#""major":3"#))
52+
XCTAssertTrue(actual.contains(#""minor":2"#))
53+
XCTAssertTrue(actual.contains(#""patch":1"#))
54+
XCTAssertTrue(actual.contains(#""preRelease":"alpha.4""#))
55+
XCTAssertTrue(actual.contains(#""build":"""#))
56+
57+
actual = String(data: try encoder.encode(SemanticVersion(3, 2, 1, "", "build.42")), encoding: .utf8)!
58+
XCTAssertTrue(actual.contains(#""major":3"#))
59+
XCTAssertTrue(actual.contains(#""minor":2"#))
60+
XCTAssertTrue(actual.contains(#""patch":1"#))
61+
XCTAssertTrue(actual.contains(#""preRelease":"""#))
62+
XCTAssertTrue(actual.contains(#""build":"build.42""#))
63+
64+
actual = String(data: try encoder.encode(SemanticVersion(7, 7, 7, "beta.423", "build.17")), encoding: .utf8)!
65+
XCTAssertTrue(actual.contains(#""major":7"#))
66+
XCTAssertTrue(actual.contains(#""minor":7"#))
67+
XCTAssertTrue(actual.contains(#""patch":7"#))
68+
XCTAssertTrue(actual.contains(#""preRelease":"beta.423""#))
69+
XCTAssertTrue(actual.contains(#""build":"build.17""#))
70+
}
71+
72+
func test_decodable_semverString() throws {
73+
let decoder = JSONDecoder()
74+
var json: Data
75+
76+
decoder.semanticVersionDecodingStrategy = .semverString
77+
78+
json = #""1.2.3-a.4+42.7""#.data(using: .utf8)!
79+
XCTAssertEqual(
80+
try decoder.decode(SemanticVersion.self, from: json),
81+
SemanticVersion(1, 2, 3, "a.4", "42.7")
82+
)
83+
84+
json = #"["1.2.3-a.4+42.7", "7.7.7"]"#.data(using: .utf8)!
85+
XCTAssertEqual(
86+
try decoder.decode([SemanticVersion].self, from: json),
87+
[SemanticVersion(1, 2, 3, "a.4", "42.7"), SemanticVersion(7, 7, 7)]
88+
)
89+
90+
struct Foo: Decodable, Equatable {
91+
let v: SemanticVersion
92+
}
93+
94+
json = #"{"v": "1.2.3-a.4+42.7"}"#.data(using: .utf8)!
95+
XCTAssertEqual(
96+
try decoder.decode(Foo.self, from: json),
97+
Foo(v: SemanticVersion(1, 2, 3, "a.4", "42.7"))
98+
)
99+
100+
json = #"{"v": "I AM NOT A SEMVER"}"#.data(using: .utf8)!
101+
XCTAssertThrowsError(_ = try decoder.decode(Foo.self, from: json)) { error in
102+
switch error as? DecodingError {
103+
case .dataCorrupted(let context):
104+
XCTAssertEqual(context.codingPath.map(\.stringValue), ["v"])
105+
XCTAssertEqual(context.debugDescription, "Expected valid semver 2.0 string")
106+
default:
107+
XCTFail("Expected DecodingError.dataCorrupted, got \(error)")
108+
}
109+
}
110+
}
111+
112+
func test_decodable_defaultCodable() throws {
113+
let decoder = JSONDecoder()
114+
var json: Data
115+
116+
decoder.semanticVersionDecodingStrategy = .defaultCodable
117+
118+
json = """
119+
{
120+
"major": 1,
121+
"minor": 2,
122+
"patch": 3,
123+
"preRelease": "a.4",
124+
"build": "42.7"
125+
}
126+
""".data(using: .utf8)!
127+
XCTAssertEqual(
128+
try decoder.decode(SemanticVersion.self, from: json),
129+
SemanticVersion(1, 2, 3, "a.4", "42.7")
130+
)
131+
132+
json = """
133+
[
134+
{
135+
"major": 1,
136+
"minor": 2,
137+
"patch": 3,
138+
"preRelease": "a.4",
139+
"build": "42.7"
140+
},{
141+
"major": 7,
142+
"minor": 7,
143+
"patch": 7,
144+
"preRelease": "",
145+
"build": ""
146+
}
147+
]
148+
""".data(using: .utf8)!
149+
XCTAssertEqual(
150+
try decoder.decode([SemanticVersion].self, from: json),
151+
[SemanticVersion(1, 2, 3, "a.4", "42.7"), SemanticVersion(7, 7, 7)]
152+
)
153+
154+
struct Foo: Decodable, Equatable {
155+
let v: SemanticVersion
156+
}
157+
158+
json = """
159+
{
160+
"v": {
161+
"major": 1,
162+
"minor": 2,
163+
"patch": 3,
164+
"preRelease": "a.4",
165+
"build": "42.7"
166+
}
167+
}
168+
""".data(using: .utf8)!
169+
XCTAssertEqual(
170+
try decoder.decode(Foo.self, from: json),
171+
Foo(v: SemanticVersion(1, 2, 3, "a.4", "42.7"))
172+
)
173+
174+
json = """
175+
{
176+
"v": {
177+
"major": 1,
178+
"preRelease": "a.4",
179+
"build": "42.7"
180+
}
181+
}
182+
""".data(using: .utf8)!
183+
XCTAssertThrowsError(_ = try decoder.decode(Foo.self, from: json)) { error in
184+
switch error as? DecodingError {
185+
case .keyNotFound(let key, let context):
186+
XCTAssertEqual("minor", key.stringValue)
187+
XCTAssertEqual(["v"], context.codingPath.map(\.stringValue))
188+
default:
189+
XCTFail("Expected DecodingError.keyNotFound, got \(error)")
190+
}
191+
}
192+
}
193+
}

Tests/SemanticVersionTests/SemanticVersionTests.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,5 +178,4 @@ final class SemanticVersionTests: XCTestCase {
178178
XCTAssertTrue(SemanticVersion(0, 1, 1).isPatchRelease)
179179
XCTAssertFalse(SemanticVersion(0, 0, 0).isPatchRelease)
180180
}
181-
182181
}

0 commit comments

Comments
 (0)