diff --git a/Sources/MultiArray/Extensions/Decodable.swift b/Sources/MultiArray/Extensions/Decodable.swift index e991000..6558418 100644 --- a/Sources/MultiArray/Extensions/Decodable.swift +++ b/Sources/MultiArray/Extensions/Decodable.swift @@ -14,26 +14,43 @@ extension MultiArray: Decodable where Element: Decodable { private enum CodingKeys: CodingKey { + case version case count case values } public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let count = try container.decode(Int.self, forKey: .count) - var values = try container.nestedUnkeyedContainer(forKey: .values) - self = try .init(count: count) { _ in - try values.decode(Element.self) - } + // If the version tag is absent, decode as v1. Only the 2.0.0 release + // did not include a version tag, but was added since 2.1.0. This format + // is a fairly (human) readable encoding of just {count, values}. + let version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1 + + switch version { + case 1: + let count = try container.decode(Int.self, forKey: .count) + var values = try container.nestedUnkeyedContainer(forKey: .values) + self = try .init(count: count) { _ in + try values.decode(Element.self) + } + + if !values.isAtEnd { + throw DecodingError.dataCorrupted( + .init( + codingPath: values.codingPath, + debugDescription: "More values than expected for count: \(count)" + ) + ) + } - if !values.isAtEnd { - throw DecodingError.dataCorrupted( - .init( - codingPath: values.codingPath, - debugDescription: "More values than expected for count: \(count)" + default: + throw DecodingError.dataCorrupted( + .init( + codingPath: container.codingPath, + debugDescription: "Unsupported MultiArray coding version: \(version)" + ) ) - ) } } } diff --git a/Sources/MultiArray/Extensions/Encodable.swift b/Sources/MultiArray/Extensions/Encodable.swift index 68ba3a6..c8226f6 100644 --- a/Sources/MultiArray/Extensions/Encodable.swift +++ b/Sources/MultiArray/Extensions/Encodable.swift @@ -14,12 +14,18 @@ extension MultiArray: Encodable where Element: Encodable { private enum CodingKeys: CodingKey { + case version case count case values } + // Current version of the encoding schema. + // This needs to be bumped whenever the format changes. + private static var codingVersion: Int { 1 } + public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(Self.codingVersion, forKey: .version) try container.encode(self.count, forKey: .count) var values = container.nestedUnkeyedContainer(forKey: .values) diff --git a/Tests/MultiArrayTests/MultiArrayTests.swift b/Tests/MultiArrayTests/MultiArrayTests.swift index 46a5c9f..a3b019a 100644 --- a/Tests/MultiArrayTests/MultiArrayTests.swift +++ b/Tests/MultiArrayTests/MultiArrayTests.swift @@ -116,14 +116,32 @@ struct MultiArrayTests { let jsonObject = try JSONSerialization.jsonObject(with: data) as? [String: Any] // Keys exist + #expect(jsonObject?["version"] != nil) #expect(jsonObject?["count"] != nil) #expect(jsonObject?["values"] != nil) // Values have expected contents + #expect(jsonObject?["version"] as? Int == 1) #expect(jsonObject?["count"] as? Int == 3) #expect(jsonObject?["values"] as? [Int] == [10, 20, 30]) } + @Test + func decodeWithoutVersion() throws { + let json = """ + { + "count": 3, + "values": [1, 2, 3] + } + """ + + let data = Data(json.utf8) + let decoder = JSONDecoder() + + let decoded = try decoder.decode(MultiArray.self, from: data) + #expect(decoded == MultiArray([1, 2, 3])) + } + @Test func failNotEnoughValues() throws { let json = """ @@ -158,6 +176,24 @@ struct MultiArrayTests { } } + @Test + func failUnsupportedVersion() throws { + let json = """ + { + "version": 999, + "count": 3, + "values": [1, 2, 3] + } + """ + + let data = Data(json.utf8) + let decoder = JSONDecoder() + + #expect(throws: DecodingError.self) { + _ = try decoder.decode(MultiArray.self, from: data) + } + } + @Suite struct RoundTripTests { @Suite