Skip to content

Commit 4cd346f

Browse files
committed
Conform DecodingError to CustomStringConvertible and return a tidier description for debugging.
1 parent d788713 commit 4cd346f

File tree

2 files changed

+279
-0
lines changed

2 files changed

+279
-0
lines changed

stdlib/public/core/Codable.swift

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3724,6 +3724,60 @@ public enum DecodingError: Error {
37243724
}
37253725
}
37263726

3727+
extension DecodingError: CustomStringConvertible {
3728+
public var description: String {
3729+
switch self {
3730+
case .typeMismatch(let expectedType, let context):
3731+
return "Type mismatch: expected \(expectedType) at \(context.codingPath.readable)"
3732+
case .valueNotFound(let expectedType, let context):
3733+
return "Expected to find value of type \(expectedType), but value was missing at \(context.codingPath.readable)"
3734+
case .keyNotFound(let codingKey, let context):
3735+
return "Key not found: expected '\(codingKey.compactDescription)' at \(context.codingPath.readable)"
3736+
case .dataCorrupted(let context):
3737+
// The codingPath is empty here, so we defer to the underlying error.
3738+
var output = "Data corrupted: \(context.debugDescription)"
3739+
if let underlyingError = context.underlyingError {
3740+
if !output.hasSuffix(".") {
3741+
output.append(".")
3742+
}
3743+
output.append(" Underlying error: \(underlyingError)")
3744+
}
3745+
return output
3746+
}
3747+
}
3748+
}
3749+
3750+
private extension [any CodingKey] {
3751+
var readable: String {
3752+
self
3753+
.map { $0.compactDescription }
3754+
.joined(separator: "/")
3755+
}
3756+
}
3757+
3758+
private extension CodingKey {
3759+
/// Represents a coding key as either its integer representation, if available
3760+
/// (in square brackets), or its string representation. If there is no integer
3761+
/// value, and the string value happens to represent an integer, wrap it in
3762+
/// double quotes to avoid ambiguity.
3763+
var compactDescription: String {
3764+
if let intValue {
3765+
"[" + String(intValue) + "]"
3766+
} else {
3767+
// If the string key looks like an int, put quotes around it so it's clear
3768+
// that it's actually a string. If it's not a numeric string, don't bother
3769+
// because it's not ambiguous.
3770+
if let intyString = Int(stringValue) {
3771+
"""
3772+
"\(intyString)"
3773+
"""
3774+
} else {
3775+
stringValue
3776+
}
3777+
}
3778+
}
3779+
}
3780+
37273781
// The following extensions allow for easier error construction.
37283782

37293783
internal struct _GenericIndexKey: CodingKey, Sendable {

test/stdlib/CodableTests.swift

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,6 +1100,195 @@ class TestCodable : TestCodableSuper {
11001100
expectRoundTripEqualityThroughPlist(for: UUIDCodingWrapper(uuid), lineNumber: testLine)
11011101
}
11021102
}
1103+
1104+
// MARK: - DecodingError
1105+
func expectErrorDescription<T: Decodable>(
1106+
_ expectedErrorDescription: String,
1107+
decoding _: T.Type,
1108+
fromJSON jsonString: String,
1109+
lineNumber: UInt = #line
1110+
) {
1111+
let snakeCaseDecoder: JSONDecoder = {
1112+
let d = JSONDecoder()
1113+
d.keyDecodingStrategy = .convertFromSnakeCase
1114+
return d
1115+
}()
1116+
1117+
let jsonData = Data(jsonString.utf8)
1118+
do {
1119+
_ = try snakeCaseDecoder.decode(T.self, from: jsonData)
1120+
} catch let error as DecodingError {
1121+
expectEqual(String(describing: error), expectedErrorDescription, "Unexpectedly wrong error: \(error)", line: lineNumber)
1122+
} catch {
1123+
expectUnreachableCatch(error, ": Unexpected error type when decoding \(T.self)", line: lineNumber)
1124+
}
1125+
}
1126+
1127+
func test_decodingError_typeMismatch() {
1128+
expectErrorDescription(
1129+
"Type mismatch: expected String at [0]/address/city/birds/[1]/name",
1130+
decoding: [Person].self,
1131+
fromJSON: #"""
1132+
[
1133+
{
1134+
"first_name": "John",
1135+
"last_name": "Appleseed",
1136+
"address": {
1137+
"street": "123 Main St",
1138+
"city": {
1139+
"name": "Cupertino",
1140+
"birds": [
1141+
{
1142+
"name": "The Big One",
1143+
"feathers": "all"
1144+
},
1145+
{
1146+
"name": 123,
1147+
"feathers": "some"
1148+
}
1149+
]
1150+
}
1151+
}
1152+
}
1153+
]
1154+
"""#
1155+
)
1156+
}
1157+
1158+
func test_decodingError_typeMismatch_stringKeysThatLookLikeNumbers() {
1159+
expectErrorDescription(
1160+
#"""
1161+
Type mismatch: expected String at [0]/"3"
1162+
"""#,
1163+
decoding: [TopThreeThings].self,
1164+
fromJSON: #"""
1165+
[
1166+
{
1167+
"1": "Raindrops on roses",
1168+
"2": "Whiskers on kittens",
1169+
"3": 42.0
1170+
}
1171+
]
1172+
"""#
1173+
)
1174+
}
1175+
1176+
func test_decodingError_valueNotFound_firstNameMissing() {
1177+
expectErrorDescription(
1178+
"Expected to find value of type String, but value was missing at [0]/firstName",
1179+
decoding: [Person].self,
1180+
fromJSON: #"""
1181+
[
1182+
{
1183+
"first_name": null,
1184+
"last_name": "Appleseed",
1185+
"address": {
1186+
"street": "123 Main St",
1187+
"city": {
1188+
"name": "Cupertino",
1189+
"birds": []
1190+
}
1191+
}
1192+
}
1193+
]
1194+
"""#
1195+
)
1196+
}
1197+
1198+
func test_decodingError_valueNotFound_birdNameMissing() {
1199+
expectErrorDescription(
1200+
"Expected to find value of type String, but value was missing at [0]/address/city/birds/[1]/name",
1201+
decoding: [Person].self,
1202+
fromJSON: #"""
1203+
[
1204+
{
1205+
"first_name": "John",
1206+
"last_name": "Appleseed",
1207+
"address": {
1208+
"street": "123 Main St",
1209+
"city": {
1210+
"name": "Cupertino",
1211+
"birds": [
1212+
{
1213+
"name": "The Big One",
1214+
"feathers": "all"
1215+
},
1216+
{
1217+
"name": null,
1218+
"feathers": "some"
1219+
}
1220+
]
1221+
}
1222+
}
1223+
}
1224+
]
1225+
"""#
1226+
)
1227+
}
1228+
1229+
func test_decodingError_valueNotFound_addressMissing() {
1230+
expectErrorDescription(
1231+
"Expected to find value of type Dictionary<String, Any>, but value was missing at [0]/address",
1232+
decoding: [Person].self,
1233+
fromJSON: #"""
1234+
[
1235+
{
1236+
"first_name": "John",
1237+
"last_name": "Appleseed",
1238+
"address": null
1239+
}
1240+
]
1241+
"""#
1242+
)
1243+
}
1244+
1245+
func test_decodingError_keyNotFound() {
1246+
expectErrorDescription(
1247+
"Key not found: expected 'name' at [0]/address/city/birds/[1]",
1248+
decoding: [Person].self,
1249+
fromJSON: #"""
1250+
[
1251+
{
1252+
"first_name": "John",
1253+
"last_name": "Appleseed",
1254+
"address": {
1255+
"street": "123 Main St",
1256+
"city": {
1257+
"name": "Cupertino",
1258+
"birds": [
1259+
{
1260+
"name": "The Big One",
1261+
"feathers": "all"
1262+
},
1263+
{
1264+
"feathers": "some"
1265+
}
1266+
]
1267+
}
1268+
}
1269+
}
1270+
]
1271+
"""#
1272+
)
1273+
}
1274+
1275+
func test_decodingError_dataCorrupted() {
1276+
expectErrorDescription(
1277+
#"""
1278+
Data corrupted: The given data was not valid JSON. Underlying error: Error Domain=NSCocoaErrorDomain Code=3840 "Unexpected character 'a' around line 5, column 10." UserInfo={NSJSONSerializationErrorIndex=81, NSDebugDescription=Unexpected character 'a' around line 5, column 10.}
1279+
"""#,
1280+
decoding: [Person].self,
1281+
fromJSON: #"""
1282+
[
1283+
{
1284+
"first_name": "John",
1285+
"last_name": "Appleseed",
1286+
address
1287+
}
1288+
]
1289+
"""#
1290+
)
1291+
}
11031292
}
11041293

11051294
// MARK: - Helper Types
@@ -1130,6 +1319,35 @@ struct TopLevelWrapper<T> : Codable, Equatable where T : Codable, T : Equatable
11301319
}
11311320
}
11321321

1322+
struct Person: Decodable {
1323+
var firstName, lastName: String
1324+
var address: Address
1325+
}
1326+
1327+
struct Address: Decodable {
1328+
var street: String
1329+
var city: City
1330+
}
1331+
1332+
struct City: Decodable {
1333+
var name: String
1334+
var birds: [Bird]
1335+
}
1336+
1337+
struct Bird: Decodable {
1338+
var name, feathers: String
1339+
}
1340+
1341+
/// A struct whose encoded representation has string keys that consist only of numbers.
1342+
struct TopThreeThings: Decodable {
1343+
var one, two, three: String
1344+
enum CodingKeys: String, CodingKey {
1345+
case one = "1"
1346+
case two = "2"
1347+
case three = "3"
1348+
}
1349+
}
1350+
11331351
// MARK: - Tests
11341352

11351353
#if !FOUNDATION_XCTEST
@@ -1183,6 +1401,13 @@ var tests = [
11831401
"test_URL_Plist" : TestCodable.test_URL_Plist,
11841402
"test_UUID_JSON" : TestCodable.test_UUID_JSON,
11851403
"test_UUID_Plist" : TestCodable.test_UUID_Plist,
1404+
"test_decodingError_typeMismatch": TestCodable.test_decodingError_typeMismatch,
1405+
"test_decodingError_typeMismatch_stringKeysThatLookLikeNumbers": TestCodable.test_decodingError_typeMismatch_stringKeysThatLookLikeNumbers,
1406+
"test_decodingError_valueNotFound_firstNameMissing": TestCodable.test_decodingError_valueNotFound_firstNameMissing,
1407+
"test_decodingError_valueNotFound_birdNameMissing": TestCodable.test_decodingError_valueNotFound_birdNameMissing,
1408+
"test_decodingError_valueNotFound_addressMissing": TestCodable.test_decodingError_valueNotFound_addressMissing,
1409+
"test_decodingError_keyNotFound": TestCodable.test_decodingError_keyNotFound,
1410+
"test_decodingError_dataCorrupted": TestCodable.test_decodingError_dataCorrupted,
11861411
]
11871412

11881413
#if os(macOS)

0 commit comments

Comments
 (0)