Skip to content

Commit

Permalink
Merge pull request #18 from sonos/string_coding
Browse files Browse the repository at this point in the history
Encode/Decode SemanticVersion as a single value string
  • Loading branch information
finestructure authored Nov 14, 2023
2 parents 3a9f1b8 + 727b6d8 commit ea8eea9
Show file tree
Hide file tree
Showing 5 changed files with 312 additions and 6 deletions.
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
102 changes: 102 additions & 0 deletions Sources/SemanticVersion/SemanticVersion+Codable.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
3 changes: 1 addition & 2 deletions Sources/SemanticVersion/SemanticVersion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
193 changes: 193 additions & 0 deletions Tests/SemanticVersionTests/SemanticVersionCodingTests.swift
Original file line number Diff line number Diff line change
@@ -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)")
}
}
}
}
1 change: 0 additions & 1 deletion Tests/SemanticVersionTests/SemanticVersionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,5 +178,4 @@ final class SemanticVersionTests: XCTestCase {
XCTAssertTrue(SemanticVersion(0, 1, 1).isPatchRelease)
XCTAssertFalse(SemanticVersion(0, 0, 0).isPatchRelease)
}

}

0 comments on commit ea8eea9

Please sign in to comment.