Skip to content

Commit 708b84c

Browse files
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.
1 parent 39b7142 commit 708b84c

File tree

4 files changed

+297
-75
lines changed

4 files changed

+297
-75
lines changed
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
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 memberwise
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+
internal static let `default`: Self = .semverString
25+
}
26+
27+
extension JSONEncoder {
28+
/// The strategy to use in decoding semantic versions. Defaults to `.semverString`.
29+
public var semanticVersionEncodingStrategy: SemanticVersionStrategy {
30+
get { userInfo.semanticDecodingStrategy }
31+
set { userInfo.semanticDecodingStrategy = newValue }
32+
}
33+
}
34+
35+
extension JSONDecoder {
36+
/// The strategy to use in decoding semantic versions. Defaults to `.succint`.
37+
public var semanticVersionDecodingStrategy: SemanticVersionStrategy {
38+
get { userInfo.semanticDecodingStrategy }
39+
set { userInfo.semanticDecodingStrategy = newValue }
40+
}
41+
}
42+
43+
private extension [CodingUserInfoKey: Any] {
44+
var semanticDecodingStrategy: SemanticVersionStrategy {
45+
get {
46+
(self[.semanticVersionStrategy] as? SemanticVersionStrategy) ?? .default
47+
}
48+
set {
49+
self[.semanticVersionStrategy] = newValue
50+
}
51+
}
52+
}
53+
54+
private extension CodingUserInfoKey {
55+
static let semanticVersionStrategy = Self(rawValue: "SemanticVersionEncodingStrategy")!
56+
}
57+
58+
extension SemanticVersion: Codable {
59+
enum CodingKeys: CodingKey {
60+
case major
61+
case minor
62+
case patch
63+
case preRelease
64+
case build
65+
}
66+
67+
public init(from decoder: Decoder) throws {
68+
switch decoder.userInfo.semanticDecodingStrategy {
69+
case .memberwise:
70+
let container = try decoder.container(keyedBy: CodingKeys.self)
71+
self.major = try container.decode(Int.self, forKey: .major)
72+
self.minor = try container.decode(Int.self, forKey: .minor)
73+
self.patch = try container.decode(Int.self, forKey: .patch)
74+
self.preRelease = try container.decode(String.self, forKey: .preRelease)
75+
self.build = try container.decode(String.self, forKey: .build)
76+
case .semverString:
77+
let container = try decoder.singleValueContainer()
78+
guard let version = SemanticVersion(try container.decode(String.self)) else {
79+
throw DecodingError.dataCorrupted(
80+
DecodingError.Context(
81+
codingPath: container.codingPath,
82+
debugDescription: "Expected valid semver 2.0 string"
83+
)
84+
)
85+
}
86+
self = version
87+
}
88+
}
89+
90+
public func encode(to encoder: Encoder) throws {
91+
switch encoder.userInfo.semanticDecodingStrategy {
92+
case .memberwise:
93+
var container = encoder.container(keyedBy: CodingKeys.self)
94+
try container.encode(major, forKey: .major)
95+
try container.encode(minor, forKey: .minor)
96+
try container.encode(patch, forKey: .patch)
97+
try container.encode(preRelease, forKey: .preRelease)
98+
try container.encode(build, forKey: .build)
99+
case .semverString:
100+
var container = encoder.singleValueContainer()
101+
try container.encode(description)
102+
}
103+
}
104+
}

Sources/SemanticVersion/SemanticVersion.swift

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -50,26 +50,6 @@ public struct SemanticVersion: Equatable, Hashable {
5050
}
5151
}
5252

53-
extension SemanticVersion: Codable {
54-
public init(from decoder: Decoder) throws {
55-
let container = try decoder.singleValueContainer()
56-
guard let version = SemanticVersion(try container.decode(String.self)) else {
57-
throw DecodingError.dataCorrupted(
58-
DecodingError.Context(
59-
codingPath: container.codingPath,
60-
debugDescription: "Expected valid semver 2.0 string"
61-
)
62-
)
63-
}
64-
self = version
65-
}
66-
67-
public func encode(to encoder: Encoder) throws {
68-
var container = encoder.singleValueContainer()
69-
try container.encode(description)
70-
}
71-
}
72-
7353
extension SemanticVersion: LosslessStringConvertible {
7454

7555
/// 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_semverString_is_default() throws {
14+
XCTAssertEqual(.semverString, JSONEncoder().semanticVersionEncodingStrategy)
15+
XCTAssertEqual(.semverString, 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_memberwise() throws {
38+
let encoder = JSONEncoder()
39+
var actual: String
40+
41+
encoder.semanticVersionEncodingStrategy = .memberwise
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_memberwise() throws {
113+
let decoder = JSONDecoder()
114+
var json: Data
115+
116+
decoder.semanticVersionDecodingStrategy = .memberwise
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 & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -178,59 +178,4 @@ final class SemanticVersionTests: XCTestCase {
178178
XCTAssertTrue(SemanticVersion(0, 1, 1).isPatchRelease)
179179
XCTAssertFalse(SemanticVersion(0, 0, 0).isPatchRelease)
180180
}
181-
182-
func test_encodable() throws {
183-
let encoder = JSONEncoder()
184-
var actual: String
185-
186-
actual = String(data: try encoder.encode(SemanticVersion(1, 2, 3)), encoding: .utf8)!
187-
XCTAssertEqual(actual, #""1.2.3""#)
188-
189-
actual = String(data: try encoder.encode(SemanticVersion(3, 2, 1, "alpha.4")), encoding: .utf8)!
190-
XCTAssertEqual(actual, #""3.2.1-alpha.4""#)
191-
192-
actual = String(data: try encoder.encode(SemanticVersion(3, 2, 1, "", "build.42")), encoding: .utf8)!
193-
XCTAssertEqual(actual, #""3.2.1+build.42""#)
194-
195-
actual = String(data: try encoder.encode(SemanticVersion(7, 7, 7, "beta.423", "build.17")), encoding: .utf8)!
196-
XCTAssertEqual(actual, #""7.7.7-beta.423+build.17""#)
197-
}
198-
199-
func test_decodable() throws {
200-
let decoder = JSONDecoder()
201-
var json: Data
202-
203-
json = #""1.2.3-a.4+42.7""#.data(using: .utf8)!
204-
XCTAssertEqual(
205-
try decoder.decode(SemanticVersion.self, from: json),
206-
SemanticVersion(1, 2, 3, "a.4", "42.7")
207-
)
208-
209-
json = #"["1.2.3-a.4+42.7", "7.7.7"]"#.data(using: .utf8)!
210-
XCTAssertEqual(
211-
try decoder.decode([SemanticVersion].self, from: json),
212-
[SemanticVersion(1, 2, 3, "a.4", "42.7"), SemanticVersion(7, 7, 7)]
213-
)
214-
215-
struct Foo: Decodable, Equatable {
216-
let v: SemanticVersion
217-
}
218-
219-
json = #"{"v": "1.2.3-a.4+42.7"}"#.data(using: .utf8)!
220-
XCTAssertEqual(
221-
try decoder.decode(Foo.self, from: json),
222-
Foo(v: SemanticVersion(1, 2, 3, "a.4", "42.7"))
223-
)
224-
225-
json = #"{"v": "I AM NOT A SEMVER"}"#.data(using: .utf8)!
226-
XCTAssertThrowsError(_ = try decoder.decode(Foo.self, from: json)) { error in
227-
switch error as? DecodingError {
228-
case .dataCorrupted(let context):
229-
XCTAssertEqual(context.codingPath.map(\.stringValue), ["v"])
230-
XCTAssertEqual(context.debugDescription, "Expected valid semver 2.0 string")
231-
default:
232-
XCTFail("Expected DecodingError.dataCorrupted, got \(error)")
233-
}
234-
}
235-
}
236181
}

0 commit comments

Comments
 (0)