Skip to content

Commit 33761ab

Browse files
committed
Fix JSONDecoder error message for decimal-to-Int conversion (issue #1604)
This commit fixes the regression where JSONDecoder returns incorrect error information when attempting to decode a decimal value (e.g., 123.45) as Int. **Root Cause (from git history investigation):** - Initial implementation (commit 34c45c1, 2023-03-15) had correct error handling - BufferView refactoring (commit 1ab3832, 2023-04-03) accidentally removed the proper DecodingError.dataCorrupted throw statements - This caused JSONError.numberIsNotRepresentableInSwift to be caught at the top level and converted to a generic error with empty codingPath **Changes:** - Restored proper error handling in `_slowpath_unwrapFixedWidthInteger` - Changed JSONError.numberIsNotRepresentableInSwift throws to DecodingError.dataCorrupted with correct codingPath and debugDescription - Added regression test to prevent future breakage **Before (broken):** - codingPath: [] (empty) - debugDescription: "The given data was not valid JSON." **After (fixed):** - codingPath: ["foo"] - debugDescription: "Parsed JSON number <123.45> does not fit in Int." Fixes #1604 Related: #274
1 parent 5f2bbd9 commit 33761ab

File tree

2 files changed

+134
-2
lines changed

2 files changed

+134
-2
lines changed

Sources/FoundationEssentials/JSON/JSONDecoder.swift

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1000,13 +1000,29 @@ extension JSONDecoderImpl: Decoder {
10001000
}
10011001

10021002
static private func _slowpath_unwrapFixedWidthInteger<T: FixedWidthInteger>(as type: T.Type, json5: Bool, numberBuffer: BufferView<UInt8>, fullSource: BufferView<UInt8>, digitBeginning: BufferViewIndex<UInt8>, for codingPathNode: _CodingPathNode, _ additionalKey: (some CodingKey)?) throws -> T {
1003+
// Helper function to create number conversion error
1004+
func createNumberConversionError() -> DecodingError {
1005+
#if FOUNDATION_FRAMEWORK
1006+
let underlyingError: Error? = JSONError.numberIsNotRepresentableInSwift(
1007+
parsed: String(decoding: numberBuffer, as: UTF8.self)
1008+
).nsError
1009+
#else
1010+
let underlyingError: Error? = nil
1011+
#endif
1012+
return DecodingError.dataCorrupted(DecodingError.Context(
1013+
codingPath: codingPathNode.path(byAppending: additionalKey),
1014+
debugDescription: "Parsed JSON number <\(String(decoding: numberBuffer, as: UTF8.self))> does not fit in \(T.self).",
1015+
underlyingError: underlyingError
1016+
))
1017+
}
1018+
10031019
// This is the slow path... If the fast path has failed. For example for "34.0" as an integer, we try to parse as either a Decimal or a Double and then convert back, losslessly.
10041020
if let double = Double(prevalidatedBuffer: numberBuffer) {
10051021
// T.init(exactly:) guards against non-integer Double(s), but the parser may
10061022
// have already transformed the non-integer "1.0000000000000001" into 1, etc.
10071023
// Proper lossless behavior should be implemented by the parser.
10081024
guard let value = T(exactly: double) else {
1009-
throw JSONError.numberIsNotRepresentableInSwift(parsed: String(decoding: numberBuffer, as: UTF8.self))
1025+
throw createNumberConversionError()
10101026
}
10111027

10121028
// The distance between Double(s) is >=2 from ±2^53.
@@ -1021,7 +1037,7 @@ extension JSONDecoderImpl: Decoder {
10211037
let decimalParseResult = Decimal._decimal(from: numberBuffer, matchEntireString: true).asOptional
10221038
if let decimal = decimalParseResult.result {
10231039
guard let value = T(decimal) else {
1024-
throw JSONError.numberIsNotRepresentableInSwift(parsed: String(decoding: numberBuffer, as: UTF8.self))
1040+
throw createNumberConversionError()
10251041
}
10261042
return value
10271043
}

Tests/FoundationEssentialsTests/JSONEncoderTests.swift

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,122 @@ private struct JSONEncoderTests {
901901
_testDecodeFailure(of: DecodeFailure.self, data: toDecode.data(using: .utf8)!)
902902
}
903903

904+
@Test("Decimal to Int decoding error message regression test for issue #1604")
905+
func decimalToIntDecodingErrorMessage() throws {
906+
struct Object: Decodable {
907+
var foo: Int
908+
}
909+
910+
let json = """
911+
{
912+
"foo": 123.45
913+
}
914+
""".data(using: .utf8)!
915+
916+
do {
917+
_ = try JSONDecoder().decode(Object.self, from: json)
918+
Issue.record("Expected decoding error but decoding succeeded")
919+
} catch let error as DecodingError {
920+
guard case .dataCorrupted(let context) = error else {
921+
Issue.record("Expected dataCorrupted error, got \(error)")
922+
return
923+
}
924+
925+
// Verification 1: Check if codingPath is correctly set
926+
// Expected: ["foo"]
927+
#expect(context.codingPath.count == 1, "codingPath should contain one element, got \(context.codingPath)")
928+
if let key = context.codingPath.first {
929+
#expect(key.stringValue == "foo", "codingPath should be ['foo'], got '\(key.stringValue)'")
930+
}
931+
932+
// Verification 2: Check if debugDescription is appropriate
933+
// Expected: A specific message like "Parsed JSON number <123.45> does not fit in Int."
934+
#expect(
935+
context.debugDescription.contains("123.45"),
936+
"debugDescription should mention the number 123.45, got: \(context.debugDescription)"
937+
)
938+
#expect(
939+
context.debugDescription.contains("Int") || context.debugDescription.contains("fit"),
940+
"debugDescription should mention Int or fit, got: \(context.debugDescription)"
941+
)
942+
943+
// Verification 3 (macOS only): Check if underlyingError is provided
944+
#if FOUNDATION_FRAMEWORK
945+
#expect(context.underlyingError != nil, "macOS should provide underlyingError, got nil")
946+
947+
if let nsError = context.underlyingError as? NSError {
948+
#expect(nsError.domain == NSCocoaErrorDomain, "underlyingError domain should be NSCocoaErrorDomain")
949+
#expect(nsError.code == CocoaError.propertyListReadCorrupt.rawValue, "underlyingError code should match propertyListReadCorrupt")
950+
951+
// Verify the error message mentions the number
952+
if let debugDesc = nsError.userInfo[NSDebugDescriptionErrorKey] as? String {
953+
#expect(debugDesc.contains("123.45"), "underlyingError should mention the number 123.45")
954+
}
955+
}
956+
#endif
957+
} catch {
958+
Issue.record("Expected DecodingError, got \(type(of: error)): \(error)")
959+
}
960+
}
961+
962+
@Test("Negative to UInt decoding error message regression test for issue #1604")
963+
func negativeToUIntDecodingErrorMessage() throws {
964+
struct Object: Decodable {
965+
var foo: UInt
966+
}
967+
968+
let json = """
969+
{
970+
"foo": -123
971+
}
972+
""".data(using: .utf8)!
973+
974+
do {
975+
_ = try JSONDecoder().decode(Object.self, from: json)
976+
Issue.record("Expected decoding error but decoding succeeded")
977+
} catch let error as DecodingError {
978+
guard case .dataCorrupted(let context) = error else {
979+
Issue.record("Expected dataCorrupted error, got \(error)")
980+
return
981+
}
982+
983+
// Verification 1: Check if codingPath is correctly set
984+
// Expected: ["foo"]
985+
#expect(context.codingPath.count == 1, "codingPath should contain one element, got \(context.codingPath)")
986+
if let key = context.codingPath.first {
987+
#expect(key.stringValue == "foo", "codingPath should be ['foo'], got '\(key.stringValue)'")
988+
}
989+
990+
// Verification 2: Check if debugDescription is appropriate
991+
// Expected: A specific message like "Parsed JSON number <-123> does not fit in UInt."
992+
#expect(
993+
context.debugDescription.contains("-123"),
994+
"debugDescription should mention the number -123, got: \(context.debugDescription)"
995+
)
996+
#expect(
997+
context.debugDescription.contains("UInt") || context.debugDescription.contains("fit"),
998+
"debugDescription should mention UInt or fit, got: \(context.debugDescription)"
999+
)
1000+
1001+
// Verification 3 (macOS only): Check if underlyingError is provided
1002+
#if FOUNDATION_FRAMEWORK
1003+
#expect(context.underlyingError != nil, "macOS should provide underlyingError, got nil")
1004+
1005+
if let nsError = context.underlyingError as? NSError {
1006+
#expect(nsError.domain == NSCocoaErrorDomain, "underlyingError domain should be NSCocoaErrorDomain")
1007+
#expect(nsError.code == CocoaError.propertyListReadCorrupt.rawValue, "underlyingError code should match propertyListReadCorrupt")
1008+
1009+
// Verify the error message mentions the number
1010+
if let debugDesc = nsError.userInfo[NSDebugDescriptionErrorKey] as? String {
1011+
#expect(debugDesc.contains("-123"), "underlyingError should mention the number -123")
1012+
}
1013+
}
1014+
#endif
1015+
} catch {
1016+
Issue.record("Expected DecodingError, got \(type(of: error)): \(error)")
1017+
}
1018+
}
1019+
9041020
@Test func repeatedFailedNilChecks() {
9051021
struct RepeatNilCheckDecodable : Decodable {
9061022
enum Failure : Error {

0 commit comments

Comments
 (0)