Skip to content

Commit ce52882

Browse files
authored
Merge pull request #86 from mattpolzin/clearer-include-failure-errors
Clearer include failure errors
2 parents 158e26e + 119a123 commit ce52882

8 files changed

Lines changed: 649 additions & 112 deletions

File tree

Sources/JSONAPI/Document/Includes.swift

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,14 +76,15 @@ extension Includes: Decodable where I: Decodable {
7676
}
7777
}
7878
guard errors.count == error.individualTypeFailures.count else {
79-
throw IncludesDecodingError(error: error, idx: idx)
79+
throw IncludesDecodingError(error: error, idx: idx, totalIncludesCount: container.count ?? 0)
8080
}
8181
throw IncludesDecodingError(
8282
error: IncludeDecodingError(failures: errors),
83-
idx: idx
83+
idx: idx,
84+
totalIncludesCount: container.count ?? 0
8485
)
8586
} catch let error {
86-
throw IncludesDecodingError(error: error, idx: idx)
87+
throw IncludesDecodingError(error: error, idx: idx, totalIncludesCount: container.count ?? 0)
8788
}
8889
}
8990

@@ -208,7 +209,13 @@ extension Includes where I: _Poly11 {
208209
// MARK: - DecodingError
209210
public struct IncludesDecodingError: Swift.Error, Equatable {
210211
public let error: Swift.Error
212+
/// The zero-based index of the include that failed to decode.
211213
public let idx: Int
214+
/// The total count of includes in the document that failed to decode.
215+
///
216+
/// In other words, "of `totalIncludesCount` includes, the `(idx + 1)`th
217+
/// include failed to decode.
218+
public let totalIncludesCount: Int
212219

213220
public static func ==(lhs: Self, rhs: Self) -> Bool {
214221
return lhs.idx == rhs.idx
@@ -218,18 +225,55 @@ public struct IncludesDecodingError: Swift.Error, Equatable {
218225

219226
extension IncludesDecodingError: CustomStringConvertible {
220227
public var description: String {
221-
return "Include \(idx + 1) failed to parse: \(error)"
228+
let ordinalSuffix: String
229+
if (idx % 100) + 1 > 9 && (idx % 100) + 1 < 20 {
230+
// the teens
231+
ordinalSuffix = "th"
232+
} else {
233+
switch ((idx % 10) + 1) {
234+
case 1:
235+
ordinalSuffix = "st"
236+
case 2:
237+
ordinalSuffix = "nd"
238+
case 3:
239+
ordinalSuffix = "rd"
240+
default:
241+
ordinalSuffix = "th"
242+
}
243+
}
244+
let ordinalDescription = "\(idx + 1)\(ordinalSuffix)"
245+
246+
return "Out of the \(totalIncludesCount) includes in the document, the \(ordinalDescription) one failed to parse: \(error)"
222247
}
223248
}
224249

225250
public struct IncludeDecodingError: Swift.Error, Equatable, CustomStringConvertible {
226251
public let failures: [ResourceObjectDecodingError]
227252

228253
public var description: String {
254+
// concise error when all failures are mismatched JSON:API types:
255+
if case let .jsonTypeMismatch(foundType: foundType)? = failures.first?.cause,
256+
failures.allSatisfy({ $0.cause.isTypeMismatch }) {
257+
let expectedTypes = failures
258+
.compactMap { "'\($0.resourceObjectJsonAPIType)'" }
259+
.joined(separator: ", ")
260+
261+
return "Found JSON:API type '\(foundType)' but expected one of \(expectedTypes)"
262+
}
263+
264+
// concise error when all but failures but one are type mismatches because
265+
// we can assume the correct type was found but there was some other error:
266+
let nonTypeMismatches = failures.filter({ !$0.cause.isTypeMismatch})
267+
if nonTypeMismatches.count == 1, let nonTypeMismatch = nonTypeMismatches.first {
268+
return String(describing: nonTypeMismatch)
269+
}
270+
271+
// fall back to just describing all of the reasons it could not have been any of the available
272+
// types:
229273
return failures
230274
.enumerated()
231275
.map {
232-
"\nCould not have been Include Type \($0.offset + 1) because:\n\($0.element)"
276+
"\nCould not have been Include Type `\($0.element.resourceObjectJsonAPIType)` because:\n\($0.element)"
233277
}.joined(separator: "\n")
234278
}
235279
}

Sources/JSONAPI/Resource/Resource Object/ResourceObject.swift

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,7 @@ public extension ResourceObject {
398398
do {
399399
type = try container.decode(String.self, forKey: .type)
400400
} catch let error as DecodingError {
401-
throw ResourceObjectDecodingError(error)
401+
throw ResourceObjectDecodingError(error, jsonAPIType: Self.jsonType)
402402
?? error
403403
}
404404

@@ -417,13 +417,14 @@ public extension ResourceObject {
417417
?? container.decodeIfPresent(Description.Attributes.self, forKey: .attributes)
418418
?? Description.Attributes(from: EmptyObjectDecoder())
419419
} catch let decodingError as DecodingError {
420-
throw ResourceObjectDecodingError(decodingError)
420+
throw ResourceObjectDecodingError(decodingError, jsonAPIType: Self.jsonType)
421421
?? decodingError
422422
} catch _ as EmptyObjectDecodingError {
423423
throw ResourceObjectDecodingError(
424424
subjectName: ResourceObjectDecodingError.entireObject,
425425
cause: .keyNotFound,
426-
location: .attributes
426+
location: .attributes,
427+
jsonAPIType: Self.jsonType
427428
)
428429
}
429430

@@ -432,16 +433,17 @@ public extension ResourceObject {
432433
?? container.decodeIfPresent(Description.Relationships.self, forKey: .relationships)
433434
?? Description.Relationships(from: EmptyObjectDecoder())
434435
} catch let decodingError as DecodingError {
435-
throw ResourceObjectDecodingError(decodingError)
436+
throw ResourceObjectDecodingError(decodingError, jsonAPIType: Self.jsonType)
436437
?? decodingError
437438
} catch let decodingError as JSONAPICodingError {
438-
throw ResourceObjectDecodingError(decodingError)
439+
throw ResourceObjectDecodingError(decodingError, jsonAPIType: Self.jsonType)
439440
?? decodingError
440441
} catch _ as EmptyObjectDecodingError {
441442
throw ResourceObjectDecodingError(
442443
subjectName: ResourceObjectDecodingError.entireObject,
443444
cause: .keyNotFound,
444-
location: .relationships
445+
location: .relationships,
446+
jsonAPIType: Self.jsonType
445447
)
446448
}
447449

Sources/JSONAPI/Resource/Resource Object/ResourceObjectDecodingError.swift

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
//
77

88
public struct ResourceObjectDecodingError: Swift.Error, Equatable {
9+
public let resourceObjectJsonAPIType: String
910
public let subjectName: String
1011
public let cause: Cause
1112
public let location: Location
@@ -16,8 +17,13 @@ public struct ResourceObjectDecodingError: Swift.Error, Equatable {
1617
case keyNotFound
1718
case valueNotFound
1819
case typeMismatch(expectedTypeName: String)
19-
case jsonTypeMismatch(expectedType: String, foundType: String)
20+
case jsonTypeMismatch(foundType: String)
2021
case quantityMismatch(expected: JSONAPICodingError.Quantity)
22+
23+
internal var isTypeMismatch: Bool {
24+
guard case .jsonTypeMismatch = self else { return false}
25+
return true
26+
}
2127
}
2228

2329
public enum Location: String, Equatable {
@@ -38,7 +44,8 @@ public struct ResourceObjectDecodingError: Swift.Error, Equatable {
3844
}
3945
}
4046

41-
init?(_ decodingError: DecodingError) {
47+
init?(_ decodingError: DecodingError, jsonAPIType: String) {
48+
self.resourceObjectJsonAPIType = jsonAPIType
4249
switch decodingError {
4350
case .typeMismatch(let expectedType, let ctx):
4451
(location, subjectName) = Self.context(ctx)
@@ -67,11 +74,12 @@ public struct ResourceObjectDecodingError: Swift.Error, Equatable {
6774
}
6875
}
6976

70-
init?(_ jsonAPIError: JSONAPICodingError) {
77+
init?(_ jsonAPIError: JSONAPICodingError, jsonAPIType: String) {
78+
self.resourceObjectJsonAPIType = jsonAPIType
7179
switch jsonAPIError {
7280
case .typeMismatch(expected: let expected, found: let found, path: let path):
7381
(location, subjectName) = Self.context(path: path)
74-
cause = .jsonTypeMismatch(expectedType: expected, foundType: found)
82+
cause = .jsonTypeMismatch(foundType: found)
7583
case .quantityMismatch(expected: let expected, path: let path):
7684
(location, subjectName) = Self.context(path: path)
7785
cause = .quantityMismatch(expected: expected)
@@ -81,12 +89,14 @@ public struct ResourceObjectDecodingError: Swift.Error, Equatable {
8189
}
8290

8391
init(expectedJSONAPIType: String, found: String) {
92+
resourceObjectJsonAPIType = expectedJSONAPIType
8493
location = .type
8594
subjectName = "self"
86-
cause = .jsonTypeMismatch(expectedType: expectedJSONAPIType, foundType: found)
95+
cause = .jsonTypeMismatch(foundType: found)
8796
}
8897

89-
init(subjectName: String, cause: Cause, location: Location) {
98+
init(subjectName: String, cause: Cause, location: Location, jsonAPIType: String) {
99+
self.resourceObjectJsonAPIType = jsonAPIType
90100
self.subjectName = subjectName
91101
self.cause = cause
92102
self.location = location
@@ -135,10 +145,10 @@ extension ResourceObjectDecodingError: CustomStringConvertible {
135145
return "'\(location.singular)' (a.k.a. the JSON:API type name) is not a \(expected) as expected."
136146
case .typeMismatch(expectedTypeName: let expected):
137147
return "'\(subjectName)' \(location.singular) is not a \(expected) as expected."
138-
case .jsonTypeMismatch(expectedType: let expected, foundType: let found) where location == .type:
139-
return "found JSON:API type \"\(found)\" but expected \"\(expected)\""
140-
case .jsonTypeMismatch(expectedType: let expected, foundType: let found):
141-
return "'\(subjectName)' \(location.singular) is of JSON:API type \"\(found)\" but it was expected to be \"\(expected)\""
148+
case .jsonTypeMismatch(foundType: let found) where location == .type:
149+
return "found JSON:API type \"\(found)\" but expected \"\(resourceObjectJsonAPIType)\""
150+
case .jsonTypeMismatch(foundType: let found):
151+
return "'\(subjectName)' \(location.singular) is of JSON:API type \"\(found)\" but it was expected to be \"\(resourceObjectJsonAPIType)\""
142152
case .quantityMismatch(expected: let expected):
143153
let expecation: String = {
144154
switch expected {

Tests/JSONAPITests/Document/DocumentDecodingErrorTests.swift

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ final class DocumentDecodingErrorTests: XCTestCase {
7979
}
8080

8181
func test_include_failure() {
82+
// test that if there is only one possible include, we just find out on one line what expecation failed.
8283
XCTAssertThrowsError(
8384
try testDecoder.decode(
8485
Document<SingleResourceBody<Article>, NoMetadata, NoLinks, Include1<Author>, NoAPIDescription, UnknownJSONAPIError>.self,
@@ -91,11 +92,12 @@ final class DocumentDecodingErrorTests: XCTestCase {
9192
return
9293
}
9394

94-
XCTAssertEqual(String(describing: error), #"Include 3 failed to parse: found JSON:API type "not_an_author" but expected "authors""#)
95+
XCTAssertEqual(String(describing: error), #"Out of the 3 includes in the document, the 3rd one failed to parse: found JSON:API type "not_an_author" but expected "authors""#)
9596
}
9697
}
9798

9899
func test_include_failure2() {
100+
// test that if there are two possiblie includes, we find out why each of them was not possible to decode.
99101
XCTAssertThrowsError(
100102
try testDecoder.decode(
101103
Document<SingleResourceBody<Article>, NoMetadata, NoLinks, Include2<Article, Author>, NoAPIDescription, UnknownJSONAPIError>.self,
@@ -108,15 +110,30 @@ final class DocumentDecodingErrorTests: XCTestCase {
108110
return
109111
}
110112

111-
XCTAssertEqual(String(describing: error),
112-
#"""
113-
Include 3 failed to parse:
114-
Could not have been Include Type 1 because:
115-
found JSON:API type "not_an_author" but expected "articles"
113+
XCTAssertEqual(
114+
String(describing: error),
115+
"Out of the 3 includes in the document, the 3rd one failed to parse: Found JSON:API type 'not_an_author' but expected one of 'articles', 'authors'"
116+
)
117+
}
118+
}
119+
120+
func test_include_failure3() {
121+
// test that if the failed include is at a different index, the other index is reported correctly.
122+
XCTAssertThrowsError(
123+
try testDecoder.decode(
124+
Document<SingleResourceBody<Article>, NoMetadata, NoLinks, Include2<Article, Author>, NoAPIDescription, UnknownJSONAPIError>.self,
125+
from: single_document_some_includes_wrong_type2
126+
)
127+
) { error in
128+
guard let docError = error as? DocumentDecodingError,
129+
case .includes = docError else {
130+
XCTFail("Expected primary resource document error. Got \(error)")
131+
return
132+
}
116133

117-
Could not have been Include Type 2 because:
118-
found JSON:API type "not_an_author" but expected "authors"
119-
"""#
134+
XCTAssertEqual(
135+
String(describing: error),
136+
"Out of the 3 includes in the document, the 2nd one failed to parse: Found JSON:API type 'not_an_author' but expected one of 'articles', 'authors'"
120137
)
121138
}
122139
}

Tests/JSONAPITests/Document/stubs/DocumentStubs.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,37 @@ let single_document_some_includes_wrong_type = """
289289
}
290290
""".data(using: .utf8)!
291291

292+
let single_document_some_includes_wrong_type2 = """
293+
{
294+
"data": {
295+
"id": "1",
296+
"type": "articles",
297+
"relationships": {
298+
"author": {
299+
"data": {
300+
"type": "authors",
301+
"id": "33"
302+
}
303+
}
304+
}
305+
},
306+
"included": [
307+
{
308+
"id": "30",
309+
"type": "authors"
310+
},
311+
{
312+
"id": "31",
313+
"type": "not_an_author"
314+
},
315+
{
316+
"id": "33",
317+
"type": "authors"
318+
}
319+
]
320+
}
321+
""".data(using: .utf8)!
322+
292323
let single_document_some_includes_with_api_description = """
293324
{
294325
"data": {

Tests/JSONAPITests/Includes/IncludesDecodingErrorTests.swift

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import JSONAPI
1010

1111
final class IncludesDecodingErrorTests: XCTestCase {
1212
func test_unexpectedIncludeType() {
13-
var error1: Error!
1413
XCTAssertThrowsError(try testDecoder.decode(Includes<Include2<TestEntity, TestEntity2>>.self, from: three_different_type_includes)) { (error: Error) -> Void in
1514
XCTAssertEqual(
1615
(error as? IncludesDecodingError)?.idx,
@@ -19,23 +18,53 @@ final class IncludesDecodingErrorTests: XCTestCase {
1918

2019
XCTAssertEqual(
2120
(error as? IncludesDecodingError).map(String.init(describing:)),
22-
"""
23-
Include 3 failed to parse: \nCould not have been Include Type 1 because:
24-
found JSON:API type "test_entity4" but expected "test_entity1"
21+
"Out of the 3 includes in the document, the 3rd one failed to parse: Found JSON:API type 'test_entity4' but expected one of 'test_entity1', 'test_entity2'"
22+
)
23+
}
2524

26-
Could not have been Include Type 2 because:
27-
found JSON:API type "test_entity4" but expected "test_entity2"
28-
"""
25+
// now test that we get the same error with a different total include count from a different test stub
26+
XCTAssertThrowsError(try testDecoder.decode(Includes<Include2<TestEntity, TestEntity2>>.self, from: four_different_type_includes)) { (error2: Error) -> Void in
27+
XCTAssertEqual(
28+
(error2 as? IncludesDecodingError).map(String.init(describing:)),
29+
"Out of the 4 includes in the document, the 3rd one failed to parse: Found JSON:API type 'test_entity4' but expected one of 'test_entity1', 'test_entity2'"
2930
)
31+
}
3032

31-
error1 = error
33+
// and with six total includes
34+
XCTAssertThrowsError(try testDecoder.decode(Includes<Include2<TestEntity, TestEntity2>>.self, from: six_includes_one_bad_type)) { (error2: Error) -> Void in
35+
XCTAssertEqual(
36+
(error2 as? IncludesDecodingError).map(String.init(describing:)),
37+
"Out of the 6 includes in the document, the 5th one failed to parse: Found JSON:API type 'test_entity4' but expected one of 'test_entity1', 'test_entity2'"
38+
)
3239
}
3340

34-
// now test that we get the same error from a different test stub
35-
XCTAssertThrowsError(try testDecoder.decode(Includes<Include2<TestEntity, TestEntity2>>.self, from: four_different_type_includes)) { (error2: Error) -> Void in
41+
// and with a number of total includes between 10 and 19
42+
XCTAssertThrowsError(try testDecoder.decode(Includes<Include2<TestEntity, TestEntity2>>.self, from: eleven_includes_one_bad_type)) { (error2: Error) -> Void in
3643
XCTAssertEqual(
37-
error1 as? IncludesDecodingError,
38-
error2 as? IncludesDecodingError
44+
(error2 as? IncludesDecodingError).map(String.init(describing:)),
45+
"Out of the 11 includes in the document, the 10th one failed to parse: Found JSON:API type 'test_entity4' but expected one of 'test_entity1', 'test_entity2'"
46+
)
47+
}
48+
49+
// and finally with a larger number of total includes
50+
XCTAssertThrowsError(try testDecoder.decode(Includes<Include2<TestEntity, TestEntity2>>.self, from: twenty_two_includes_one_bad_type)) { (error2: Error) -> Void in
51+
XCTAssertEqual(
52+
(error2 as? IncludesDecodingError).map(String.init(describing:)),
53+
"Out of the 22 includes in the document, the 21st one failed to parse: Found JSON:API type 'test_entity4' but expected one of 'test_entity1', 'test_entity2'"
54+
)
55+
}
56+
}
57+
58+
func test_missingProperty() {
59+
XCTAssertThrowsError(
60+
try testDecoder.decode(
61+
Includes<Include3<TestEntity, TestEntity2, TestEntity4>>.self,
62+
from: three_includes_one_missing_attributes
63+
)
64+
) { (error: Error) -> Void in
65+
XCTAssertEqual(
66+
(error as? IncludesDecodingError).map(String.init(describing:)),
67+
"Out of the 3 includes in the document, the 3rd one failed to parse: 'foo' attribute is required and missing."
3968
)
4069
}
4170
}

0 commit comments

Comments
 (0)