Skip to content

Commit

Permalink
Add coding strategy support
Browse files Browse the repository at this point in the history
This allows SemanticVersion to optionally be encoded/decoded as a semver string, in addition to supporting the pre-existing memberwise coding.
  • Loading branch information
chriseplettsonos committed Nov 13, 2023
1 parent 39b7142 commit 708b84c
Show file tree
Hide file tree
Showing 4 changed files with 297 additions and 75 deletions.
104 changes: 104 additions & 0 deletions Sources/SemanticVersion/SemanticVersion+Codable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// 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 memberwise
/// Encode/decode the `SemanticVersion` to/fromfrom a string that conforms to the
/// semantic version 2.0 specification at https://semver.org.
case semverString

internal static let `default`: Self = .semverString
}

extension JSONEncoder {
/// The strategy to use in decoding semantic versions. Defaults to `.semverString`.
public var semanticVersionEncodingStrategy: SemanticVersionStrategy {
get { userInfo.semanticDecodingStrategy }
set { userInfo.semanticDecodingStrategy = newValue }
}
}

extension JSONDecoder {
/// The strategy to use in decoding semantic versions. Defaults to `.succint`.
public var semanticVersionDecodingStrategy: SemanticVersionStrategy {
get { userInfo.semanticDecodingStrategy }
set { userInfo.semanticDecodingStrategy = newValue }
}
}

private extension [CodingUserInfoKey: Any] {
var semanticDecodingStrategy: SemanticVersionStrategy {
get {
(self[.semanticVersionStrategy] as? SemanticVersionStrategy) ?? .default
}
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 .memberwise:
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 .memberwise:
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)
}
}
}
20 changes: 0 additions & 20 deletions Sources/SemanticVersion/SemanticVersion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,26 +50,6 @@ public struct SemanticVersion: 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 {

/// 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_semverString_is_default() throws {
XCTAssertEqual(.semverString, JSONEncoder().semanticVersionEncodingStrategy)
XCTAssertEqual(.semverString, 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_memberwise() throws {
let encoder = JSONEncoder()
var actual: String

encoder.semanticVersionEncodingStrategy = .memberwise

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_memberwise() throws {
let decoder = JSONDecoder()
var json: Data

decoder.semanticVersionDecodingStrategy = .memberwise

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)")
}
}
}
}
55 changes: 0 additions & 55 deletions Tests/SemanticVersionTests/SemanticVersionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,59 +178,4 @@ final class SemanticVersionTests: XCTestCase {
XCTAssertTrue(SemanticVersion(0, 1, 1).isPatchRelease)
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)")
}
}
}
}

0 comments on commit 708b84c

Please sign in to comment.