diff --git a/Sources/OpenAPIKitCore/AnyCodable/AnyCodable.swift b/Sources/OpenAPIKitCore/AnyCodable/AnyCodable.swift index 25c9de012..da4e022e9 100644 --- a/Sources/OpenAPIKitCore/AnyCodable/AnyCodable.swift +++ b/Sources/OpenAPIKitCore/AnyCodable/AnyCodable.swift @@ -31,6 +31,13 @@ import Foundation You can encode or decode mixed-type values in dictionaries and other collections that require `Encodable` or `Decodable` conformance by declaring their contained type to be `AnyCodable`. + + Note that there are some caveats related to the fact that this type centers around + encoding/decoding values. For example, some technically distinct nil-like types + are all encoded as `nil` and compare equally under the `AnyCodable` type: + - `nil` + - `NSNull()` + - `Void()` */ public struct AnyCodable: @unchecked Sendable { // IMPORTANT: @@ -54,7 +61,11 @@ protocol _Optional { } extension Optional: _Optional { - var isNil: Bool { return self == nil } + var isNil: Bool { self == nil } +} + +extension NSNull: _Optional { + var isNil: Bool { true } } extension AnyCodable: Encodable { @@ -178,8 +189,28 @@ extension AnyCodable: Decodable { } } +func isNilEquivalent(value: AnyCodable) -> Bool { + let valueIsNil: Bool + + if let optionalValue = value.value as? _Optional, + optionalValue.isNil { + valueIsNil = true + } else if let _ = value.value as? Void { + valueIsNil = true + } else { + valueIsNil = false + } + + return valueIsNil +} + extension AnyCodable: Equatable { public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool { + // special case for nil + if isNilEquivalent(value: lhs) && isNilEquivalent(value: rhs) { + return true + } + switch (lhs.value, rhs.value) { case is (Void, Void): return true diff --git a/Tests/AnyCodableTests/AnyCodableTests.swift b/Tests/AnyCodableTests/AnyCodableTests.swift index 6dfde77c5..6bb8f22ba 100644 --- a/Tests/AnyCodableTests/AnyCodableTests.swift +++ b/Tests/AnyCodableTests/AnyCodableTests.swift @@ -14,7 +14,12 @@ class AnyCodableTests: XCTestCase { } func testEquality() throws { - XCTAssertEqual(AnyCodable(()), AnyCodable(())) + // nil, NSNull(), and Void() all encode as "null" and + // compare equally. + XCTAssertEqual(AnyCodable(nil), AnyCodable(nil)) + XCTAssertEqual(AnyCodable(nil), AnyCodable(NSNull())) + XCTAssertEqual(AnyCodable(nil), AnyCodable(())) + XCTAssertEqual(AnyCodable(true), AnyCodable(true)) XCTAssertEqual(AnyCodable(2), AnyCodable(2)) XCTAssertEqual(AnyCodable(Int8(2)), AnyCodable(Int8(2))) @@ -53,7 +58,8 @@ class AnyCodableTests: XCTestCase { "a": "alpha", "b": "bravo", "c": "charlie" - } + }, + "null": null } """.data(using: .utf8)! let decoder = JSONDecoder() @@ -133,7 +139,8 @@ class AnyCodableTests: XCTestCase { "a": "alpha", "b": "bravo", "c": "charlie" - } + }, + "null": null } """.data(using: .utf8)! @@ -146,6 +153,7 @@ class AnyCodableTests: XCTestCase { XCTAssertEqual(dictionary["string"]?.value as! String, "string") XCTAssertEqual(dictionary["array"]?.value as! [Int], [1, 2, 3]) XCTAssertEqual(dictionary["nested"]?.value as! [String: String], ["a": "alpha", "b": "bravo", "c": "charlie"]) + XCTAssertEqual(dictionary["null"], AnyCodable(nil)) } func testJSONEncoding() throws { @@ -159,6 +167,9 @@ class AnyCodableTests: XCTestCase { "b": "bravo", "c": "charlie", ]), + "null": nil, + "void": .init(Void()), + "nsnull": .init(NSNull()) ] let result = try testStringFromEncoding(of: dictionary) @@ -179,7 +190,10 @@ class AnyCodableTests: XCTestCase { "b" : "bravo", "c" : "charlie" }, - "string" : "string" + "nsnull" : null, + "null" : null, + "string" : "string", + "void" : null } """ )