From 8e8436530232385ac00eefabe95f2505865f3e00 Mon Sep 17 00:00:00 2001 From: Wendell Date: Tue, 24 Mar 2026 21:43:21 +0800 Subject: [PATCH 1/4] feat(core): add JSONValue runtime type --- Sources/CodableKit/JSONValue.swift | 221 +++++++++++++++++++++ Tests/CodableKitTests/JSONValueTests.swift | 176 ++++++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 Sources/CodableKit/JSONValue.swift create mode 100644 Tests/CodableKitTests/JSONValueTests.swift diff --git a/Sources/CodableKit/JSONValue.swift b/Sources/CodableKit/JSONValue.swift new file mode 100644 index 0000000..15abcdb --- /dev/null +++ b/Sources/CodableKit/JSONValue.swift @@ -0,0 +1,221 @@ +// +// JSONValue.swift +// CodableKit +// +// Created by Assistant on 2026/3/24. +// + +/// A dynamic JSON tree value similar to Rust's `serde_json::Value`. +/// +/// Use `JSONValue` when the schema is only partially known or when a payload +/// intentionally carries arbitrary nested JSON content. +public enum JSONValue: Codable, Equatable, Hashable, Sendable { + case null + case bool(Bool) + case string(String) + case int(Int) + case double(Double) + case array([JSONValue]) + case object([String: JSONValue]) + + public var isNull: Bool { + if case .null = self { + return true + } + return false + } + + public var boolValue: Bool? { + if case .bool(let value) = self { + return value + } + return nil + } + + public var stringValue: String? { + if case .string(let value) = self { + return value + } + return nil + } + + public var intValue: Int? { + if case .int(let value) = self { + return value + } + return nil + } + + public var doubleValue: Double? { + if case .double(let value) = self { + return value + } + return nil + } + + public var arrayValue: [JSONValue]? { + if case .array(let value) = self { + return value + } + return nil + } + + public var objectValue: [String: JSONValue]? { + if case .object(let value) = self { + return value + } + return nil + } + + public subscript(key: String) -> JSONValue? { + objectValue?[key] + } + + public subscript(index: Int) -> JSONValue? { + guard case .array(let values) = self, values.indices.contains(index) else { + return nil + } + return values[index] + } + + public init(from decoder: any Decoder) throws { + if let objectContainer = try? decoder.container(keyedBy: JSONValueCodingKey.self) { + var object: [String: JSONValue] = [:] + object.reserveCapacity(objectContainer.allKeys.count) + for key in objectContainer.allKeys { + object[key.stringValue] = try objectContainer.decode(JSONValue.self, forKey: key) + } + self = .object(object) + return + } + + if var arrayContainer = try? decoder.unkeyedContainer() { + var array: [JSONValue] = [] + if let count = arrayContainer.count { + array.reserveCapacity(count) + } + while !arrayContainer.isAtEnd { + array.append(try arrayContainer.decode(JSONValue.self)) + } + self = .array(array) + return + } + + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int.self) { + self = .int(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unsupported JSON value" + ) + } + } + + public func encode(to encoder: any Encoder) throws { + switch self { + case .null: + var container = encoder.singleValueContainer() + try container.encodeNil() + + case .bool(let value): + var container = encoder.singleValueContainer() + try container.encode(value) + + case .string(let value): + var container = encoder.singleValueContainer() + try container.encode(value) + + case .int(let value): + var container = encoder.singleValueContainer() + try container.encode(value) + + case .double(let value): + var container = encoder.singleValueContainer() + try container.encode(value) + + case .array(let values): + var container = encoder.unkeyedContainer() + for value in values { + try container.encode(value) + } + + case .object(let values): + var container = encoder.container(keyedBy: JSONValueCodingKey.self) + for (key, value) in values { + try container.encode(value, forKey: JSONValueCodingKey(key)) + } + } + } +} + +extension JSONValue: ExpressibleByNilLiteral { + public init(nilLiteral: ()) { + self = .null + } +} + +extension JSONValue: ExpressibleByBooleanLiteral { + public init(booleanLiteral value: Bool) { + self = .bool(value) + } +} + +extension JSONValue: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .string(value) + } +} + +extension JSONValue: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .int(value) + } +} + +extension JSONValue: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self = .double(value) + } +} + +extension JSONValue: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: JSONValue...) { + self = .array(elements) + } +} + +extension JSONValue: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, JSONValue)...) { + self = .object(Dictionary(uniqueKeysWithValues: elements)) + } +} + +private struct JSONValueCodingKey: CodingKey { + let stringValue: String + let intValue: Int? + + init(_ stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + init?(stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + init?(intValue: Int) { + self.stringValue = String(intValue) + self.intValue = intValue + } +} diff --git a/Tests/CodableKitTests/JSONValueTests.swift b/Tests/CodableKitTests/JSONValueTests.swift new file mode 100644 index 0000000..aae77b5 --- /dev/null +++ b/Tests/CodableKitTests/JSONValueTests.swift @@ -0,0 +1,176 @@ +// +// JSONValueTests.swift +// CodableKitTests +// +// Created by Assistant on 2026/3/24. +// + +import CodableKit +import Foundation +import Testing + +@Codable +struct JSONValuePayload { + var value: JSONValue +} + +@Suite("JSONValue runtime tests") +struct JSONValueTests { + @Test func decode_scalar_values() throws { + #expect(try decodeJSONValue("null") == .null) + #expect(try decodeJSONValue("true") == .bool(true)) + #expect(try decodeJSONValue(#""hello""#) == .string("hello")) + #expect(try decodeJSONValue("123") == .int(123)) + #expect(try decodeJSONValue("123.5") == .double(123.5)) + } + + @Test func decode_nested_arrays_and_objects() throws { + let value = try decodeJSONValue(#"{"x":[1,"a",true,null],"y":{"z":2.5}}"#) + + #expect( + value == .object([ + "x": .array([.int(1), .string("a"), .bool(true), .null]), + "y": .object(["z": .double(2.5)]), + ]) + ) + } + + @Test func encode_scalar_values() throws { + try assertEncodesToJSON(.null, expected: NSNull()) + try assertEncodesToJSON(.bool(false), expected: false) + try assertEncodesToJSON(.string("hello"), expected: "hello") + try assertEncodesToJSON(.int(123), expected: 123) + try assertEncodesToJSON(.double(123.5), expected: 123.5) + } + + @Test func encode_nested_values() throws { + let value = JSONValue.object([ + "meta": .object([ + "enabled": .bool(true), + "score": .double(9.5), + ]), + "items": .array([.int(1), .string("two"), .null]), + ]) + + let encoded = try JSONEncoder().encode(value) + let object = try JSONSerialization.jsonObject(with: encoded) as! [String: Any] + + let meta = object["meta"] as? [String: Any] + let items = object["items"] as? [Any] + + #expect(meta?["enabled"] as? Bool == true) + #expect((meta?["score"] as? NSNumber)?.doubleValue == 9.5) + #expect((items?[0] as? NSNumber)?.intValue == 1) + #expect(items?[1] as? String == "two") + #expect(items?[2] is NSNull) + } + + @Test func round_trips_mixed_payloads() throws { + let original = JSONValue.object([ + "user": .object([ + "id": .int(7), + "name": .string("Ada"), + "flags": .array([.bool(true), .bool(false)]), + ]), + "score": .double(10.25), + "note": .null, + ]) + + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(JSONValue.self, from: data) + + #expect(decoded == original) + } + + @Test func accessors_and_subscripts_follow_runtime_shape() throws { + let value = JSONValue.object([ + "name": .string("Ada"), + "stats": .array([.int(3), .double(4.5), .null]), + ]) + + #expect(value.isNull == false) + #expect(value["name"]?.stringValue == "Ada") + #expect(value["name"]?.intValue == nil) + #expect(value["stats"]?[0]?.intValue == 3) + #expect(value["stats"]?[1]?.doubleValue == 4.5) + #expect(value["stats"]?[2]?.isNull == true) + #expect(value["stats"]?[3] == nil) + #expect(value["missing"] == nil) + } + + @Test func literal_conveniences_build_expected_tree() { + let nullValue: JSONValue = nil + let boolValue: JSONValue = true + let stringValue: JSONValue = "hello" + let intValue: JSONValue = 123 + let doubleValue: JSONValue = 123.5 + let arrayValue: JSONValue = [1, "two", false, nil] + let objectValue: JSONValue = [ + "name": "Ada", + "count": 3, + "flags": [true, nil], + ] + + #expect(nullValue == .null) + #expect(boolValue == .bool(true)) + #expect(stringValue == .string("hello")) + #expect(intValue == .int(123)) + #expect(doubleValue == .double(123.5)) + #expect(arrayValue == .array([.int(1), .string("two"), .bool(false), .null])) + #expect( + objectValue == .object([ + "name": .string("Ada"), + "count": .int(3), + "flags": .array([.bool(true), .null]), + ]) + ) + } + + @Test func payload_model_decodes_dynamic_values() throws { + let scalarPayload = try JSONDecoder().decode( + JSONValuePayload.self, + from: #"{"value":123}"#.data(using: .utf8)! + ) + #expect(scalarPayload.value == .int(123)) + + let stringPayload = try JSONDecoder().decode( + JSONValuePayload.self, + from: #"{"value":"hello"}"#.data(using: .utf8)! + ) + #expect(stringPayload.value == .string("hello")) + + let nestedPayload = try JSONDecoder().decode( + JSONValuePayload.self, + from: #"{"value":{"x":[1,"a",true,null]}}"#.data(using: .utf8)! + ) + #expect( + nestedPayload.value == .object([ + "x": .array([.int(1), .string("a"), .bool(true), .null]), + ]) + ) + } + + private func decodeJSONValue(_ json: String) throws -> JSONValue { + try JSONDecoder().decode(JSONValue.self, from: Data(json.utf8)) + } + + private func assertEncodesToJSON(_ value: JSONValue, expected: Any) throws { + let data = try JSONEncoder().encode(value) + let decoded = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) + + switch expected { + case is NSNull: + #expect(decoded is NSNull) + case let expected as Bool: + #expect(decoded as? Bool == expected) + case let expected as String: + #expect(decoded as? String == expected) + case let expected as Int: + #expect((decoded as? NSNumber)?.intValue == expected) + case let expected as Double: + #expect((decoded as? NSNumber)?.doubleValue == expected) + default: + fatalError("Unhandled expected value type") + } + } +} From 0e183f387d37845c5fed34d14eb4f82a73747df8 Mon Sep 17 00:00:00 2001 From: Wendell Date: Tue, 24 Mar 2026 21:43:28 +0800 Subject: [PATCH 2/4] docs: add JSONValue usage example --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index 4257cfb..f4e01dd 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,23 @@ struct Feed { } ``` +### Dynamic JSON values + +```swift +@Codable +struct Payload { + var value: JSONValue +} + +let payload = try JSONDecoder().decode( + Payload.self, + from: #"{"value":{"name":"Ada","flags":[true,null]}}"#.data(using: .utf8)! +) + +let name = payload.value["name"]?.stringValue +let firstFlag = payload.value["flags"]?[0]?.boolValue +``` + ### Explicit lifecycle hooks ```swift From 9b69cbee48093f95940b0599b62ded5f8dcba03e Mon Sep 17 00:00:00 2001 From: Wendell Date: Tue, 24 Mar 2026 22:07:02 +0800 Subject: [PATCH 3/4] feat(core): add JSONValue path and serialization helpers --- README.md | 2 + Sources/CodableKit/JSONValue.swift | 140 +++++++++++++++++++++ Tests/CodableKitTests/JSONValueTests.swift | 49 ++++++++ 3 files changed, 191 insertions(+) diff --git a/README.md b/README.md index f4e01dd..bb0a032 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,8 @@ let payload = try JSONDecoder().decode( let name = payload.value["name"]?.stringValue let firstFlag = payload.value["flags"]?[0]?.boolValue +let sameValue = try JSONValue(jsonString: #"{"name":"Ada","flags":[true,null]}"#) +let nestedName = sameValue[path: ["name"]]?.stringValue ``` ### Explicit lifecycle hooks diff --git a/Sources/CodableKit/JSONValue.swift b/Sources/CodableKit/JSONValue.swift index 15abcdb..e5ef24b 100644 --- a/Sources/CodableKit/JSONValue.swift +++ b/Sources/CodableKit/JSONValue.swift @@ -5,19 +5,93 @@ // Created by Assistant on 2026/3/24. // +import Foundation + +/// Errors produced by `JSONValue` convenience APIs that work with JSON text. +public enum JSONValueError: Error, Equatable, Sendable { + /// The provided or generated string could not be represented as UTF-8 data. + case invalidUTF8String +} + +/// A typed path component used to traverse nested `JSONValue` trees. +/// +/// Use string keys for objects and integer indexes for arrays: +/// +/// ```swift +/// let name = value[path: ["user", "profile", "name"]]?.stringValue +/// let firstFlag = value[path: ["user", "flags", 0]]?.boolValue +/// ``` +public enum JSONPathComponent: Hashable, Sendable { + /// Addresses an object member by key. + case key(String) + + /// Addresses an array element by zero-based index. + case index(Int) +} + +extension JSONPathComponent: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .key(value) + } +} + +extension JSONPathComponent: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .index(value) + } +} + /// A dynamic JSON tree value similar to Rust's `serde_json::Value`. /// /// Use `JSONValue` when the schema is only partially known or when a payload /// intentionally carries arbitrary nested JSON content. +/// +/// `JSONValue` can be decoded from standard `JSONDecoder` pipelines, built +/// directly from Swift literals, and traversed with keyed, indexed, or path- +/// based lookups. public enum JSONValue: Codable, Equatable, Hashable, Sendable { + /// A JSON `null`. case null + + /// A JSON boolean. case bool(Bool) + + /// A JSON string. case string(String) + + /// A JSON integer that decoded losslessly as `Int`. case int(Int) + + /// A JSON number that required floating-point representation. case double(Double) + + /// A JSON array containing nested `JSONValue` elements. case array([JSONValue]) + + /// A JSON object containing string-keyed nested `JSONValue` members. case object([String: JSONValue]) + /// Decodes a top-level JSON payload from raw UTF-8 data. + /// + /// - Parameter jsonData: The raw JSON payload. + /// - Throws: Any error thrown by `JSONDecoder`. + public init(jsonData: Data) throws { + self = try JSONDecoder().decode(Self.self, from: jsonData) + } + + /// Decodes a top-level JSON payload from a JSON string. + /// + /// - Parameter jsonString: A string containing JSON text. + /// - Throws: `JSONValueError.invalidUTF8String` when the string cannot be + /// converted to UTF-8, or any error thrown by `JSONDecoder`. + public init(jsonString: String) throws { + guard let data = jsonString.data(using: .utf8) else { + throw JSONValueError.invalidUTF8String + } + try self.init(jsonData: data) + } + + /// Returns `true` when the value is `.null`. public var isNull: Bool { if case .null = self { return true @@ -25,6 +99,7 @@ public enum JSONValue: Codable, Equatable, Hashable, Sendable { return false } + /// Returns the underlying boolean when the value is `.bool`. public var boolValue: Bool? { if case .bool(let value) = self { return value @@ -32,6 +107,7 @@ public enum JSONValue: Codable, Equatable, Hashable, Sendable { return nil } + /// Returns the underlying string when the value is `.string`. public var stringValue: String? { if case .string(let value) = self { return value @@ -39,6 +115,7 @@ public enum JSONValue: Codable, Equatable, Hashable, Sendable { return nil } + /// Returns the underlying integer when the value is `.int`. public var intValue: Int? { if case .int(let value) = self { return value @@ -46,6 +123,7 @@ public enum JSONValue: Codable, Equatable, Hashable, Sendable { return nil } + /// Returns the underlying floating-point number when the value is `.double`. public var doubleValue: Double? { if case .double(let value) = self { return value @@ -53,6 +131,7 @@ public enum JSONValue: Codable, Equatable, Hashable, Sendable { return nil } + /// Returns the underlying array when the value is `.array`. public var arrayValue: [JSONValue]? { if case .array(let value) = self { return value @@ -60,6 +139,7 @@ public enum JSONValue: Codable, Equatable, Hashable, Sendable { return nil } + /// Returns the underlying object when the value is `.object`. public var objectValue: [String: JSONValue]? { if case .object(let value) = self { return value @@ -67,10 +147,12 @@ public enum JSONValue: Codable, Equatable, Hashable, Sendable { return nil } + /// Returns the object member for `key` when the value is `.object`. public subscript(key: String) -> JSONValue? { objectValue?[key] } + /// Returns the array element for `index` when the value is `.array`. public subscript(index: Int) -> JSONValue? { guard case .array(let values) = self, values.indices.contains(index) else { return nil @@ -78,6 +160,56 @@ public enum JSONValue: Codable, Equatable, Hashable, Sendable { return values[index] } + /// Traverses the value using a sequence of object keys and array indexes. + /// + /// - Parameter components: The path to follow from the current node. + /// - Returns: The nested value if every path component resolves successfully. + public subscript(path components: [JSONPathComponent]) -> JSONValue? { + var current: JSONValue? = self + for component in components { + guard let value = current else { return nil } + switch component { + case .key(let key): + current = value[key] + case .index(let index): + current = value[index] + } + } + return current + } + + /// Encodes the value as JSON data. + /// + /// - Parameter prettyPrinted: When `true`, the output uses pretty-printed + /// formatting for easier inspection. + /// - Throws: Any error thrown by `JSONEncoder`. + public func jsonData(prettyPrinted: Bool = false) throws -> Data { + let encoder = JSONEncoder() + if prettyPrinted { + encoder.outputFormatting = [.prettyPrinted] + } + return try encoder.encode(self) + } + + /// Encodes the value as a UTF-8 JSON string. + /// + /// - Parameter prettyPrinted: When `true`, the output uses pretty-printed + /// formatting for easier inspection. + /// - Throws: Any error thrown by `JSONEncoder`, or + /// `JSONValueError.invalidUTF8String` if the encoded data cannot be + /// represented as UTF-8. + public func jsonString(prettyPrinted: Bool = false) throws -> String { + let data = try jsonData(prettyPrinted: prettyPrinted) + guard let string = String(data: data, encoding: .utf8) else { + throw JSONValueError.invalidUTF8String + } + return string + } + + /// Decodes a JSON value from the decoder's current container. + /// + /// The decoding order matches the structure exposed by `Decoder`: + /// keyed container, then unkeyed container, then scalar probing. public init(from decoder: any Decoder) throws { if let objectContainer = try? decoder.container(keyedBy: JSONValueCodingKey.self) { var object: [String: JSONValue] = [:] @@ -121,6 +253,7 @@ public enum JSONValue: Codable, Equatable, Hashable, Sendable { } } + /// Encodes the value back into the matching JSON container or scalar form. public func encode(to encoder: any Encoder) throws { switch self { case .null: @@ -158,42 +291,49 @@ public enum JSONValue: Codable, Equatable, Hashable, Sendable { } } +/// Allows `nil` to construct `.null`. extension JSONValue: ExpressibleByNilLiteral { public init(nilLiteral: ()) { self = .null } } +/// Allows boolean literals like `true` and `false`. extension JSONValue: ExpressibleByBooleanLiteral { public init(booleanLiteral value: Bool) { self = .bool(value) } } +/// Allows string literals like `"hello"`. extension JSONValue: ExpressibleByStringLiteral { public init(stringLiteral value: String) { self = .string(value) } } +/// Allows integer literals like `42`. extension JSONValue: ExpressibleByIntegerLiteral { public init(integerLiteral value: Int) { self = .int(value) } } +/// Allows floating-point literals like `3.14`. extension JSONValue: ExpressibleByFloatLiteral { public init(floatLiteral value: Double) { self = .double(value) } } +/// Allows array literals like `[1, "two", nil]`. extension JSONValue: ExpressibleByArrayLiteral { public init(arrayLiteral elements: JSONValue...) { self = .array(elements) } } +/// Allows dictionary literals like `["name": "Ada", "count": 3]`. extension JSONValue: ExpressibleByDictionaryLiteral { public init(dictionaryLiteral elements: (String, JSONValue)...) { self = .object(Dictionary(uniqueKeysWithValues: elements)) diff --git a/Tests/CodableKitTests/JSONValueTests.swift b/Tests/CodableKitTests/JSONValueTests.swift index aae77b5..88d1648 100644 --- a/Tests/CodableKitTests/JSONValueTests.swift +++ b/Tests/CodableKitTests/JSONValueTests.swift @@ -98,6 +98,23 @@ struct JSONValueTests { #expect(value["missing"] == nil) } + @Test func path_lookup_traverses_keys_and_indexes() { + let value: JSONValue = [ + "user": [ + "profile": [ + "name": "Ada" + ], + "flags": [true, nil, false], + ] + ] + + #expect(value[path: ["user", "profile", "name"]] == .string("Ada")) + #expect(value[path: ["user", "flags", 0]] == .bool(true)) + #expect(value[path: ["user", "flags", 1]] == .null) + #expect(value[path: ["user", "flags", 99]] == nil) + #expect(value[path: ["user", "missing"]] == nil) + } + @Test func literal_conveniences_build_expected_tree() { let nullValue: JSONValue = nil let boolValue: JSONValue = true @@ -126,6 +143,38 @@ struct JSONValueTests { ) } + @Test func json_data_and_string_conveniences_round_trip() throws { + let original: JSONValue = [ + "name": "Ada", + "flags": [true, nil], + "score": 1.5, + "count": 3, + ] + + let data = try original.jsonData() + let decodedFromData = try JSONValue(jsonData: data) + #expect(decodedFromData == original) + + let string = try original.jsonString() + let decodedFromString = try JSONValue(jsonString: string) + #expect(decodedFromString == original) + } + + @Test func pretty_printed_json_conveniences_emit_readable_output() throws { + let value: JSONValue = [ + "user": [ + "name": "Ada", + "flags": [true, nil], + ] + ] + + let string = try value.jsonString(prettyPrinted: true) + #expect(string.contains("\n")) + + let reparsed = try JSONValue(jsonString: string) + #expect(reparsed == value) + } + @Test func payload_model_decodes_dynamic_values() throws { let scalarPayload = try JSONDecoder().decode( JSONValuePayload.self, From 449fe0ef4f832415c6c334d09f9368696662f3dc Mon Sep 17 00:00:00 2001 From: Wendell Date: Tue, 24 Mar 2026 22:22:57 +0800 Subject: [PATCH 4/4] fix(core): avoid duplicate-key trap in JSONValue literals --- Sources/CodableKit/JSONValue.swift | 2 +- Tests/CodableKitTests/JSONValueTests.swift | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/Sources/CodableKit/JSONValue.swift b/Sources/CodableKit/JSONValue.swift index e5ef24b..c695b92 100644 --- a/Sources/CodableKit/JSONValue.swift +++ b/Sources/CodableKit/JSONValue.swift @@ -336,7 +336,7 @@ extension JSONValue: ExpressibleByArrayLiteral { /// Allows dictionary literals like `["name": "Ada", "count": 3]`. extension JSONValue: ExpressibleByDictionaryLiteral { public init(dictionaryLiteral elements: (String, JSONValue)...) { - self = .object(Dictionary(uniqueKeysWithValues: elements)) + self = .object(Dictionary(elements, uniquingKeysWith: { _, new in new })) } } diff --git a/Tests/CodableKitTests/JSONValueTests.swift b/Tests/CodableKitTests/JSONValueTests.swift index 88d1648..282d927 100644 --- a/Tests/CodableKitTests/JSONValueTests.swift +++ b/Tests/CodableKitTests/JSONValueTests.swift @@ -143,6 +143,21 @@ struct JSONValueTests { ) } + @Test func dictionary_literal_initializer_prefers_last_duplicate_key() { + let value = JSONValue( + dictionaryLiteral: ("name", "Ada"), + ("name", "Grace"), + ("count", 3) + ) + + #expect( + value == .object([ + "name": .string("Grace"), + "count": .int(3), + ]) + ) + } + @Test func json_data_and_string_conveniences_round_trip() throws { let original: JSONValue = [ "name": "Ada",