diff --git a/Sources/JSONAPI/Document/Document.swift b/Sources/JSONAPI/Document/Document.swift index ff28502..7c387be 100644 --- a/Sources/JSONAPI/Document/Document.swift +++ b/Sources/JSONAPI/Document/Document.swift @@ -397,10 +397,10 @@ extension Document: Decodable, CodableJSONAPIDocument where PrimaryResourceBody: // TODO come back to this and make robust guard let metaVal = meta else { - throw JSONAPIEncodingError.missingOrMalformedMetadata(path: decoder.codingPath) + throw JSONAPICodingError.missingOrMalformedMetadata(path: decoder.codingPath) } guard let linksVal = links else { - throw JSONAPIEncodingError.missingOrMalformedLinks(path: decoder.codingPath) + throw JSONAPICodingError.missingOrMalformedLinks(path: decoder.codingPath) } body = .data(.init(primary: data, includes: maybeIncludes ?? Includes.none, meta: metaVal, links: linksVal)) diff --git a/Sources/JSONAPI/Document/Includes.swift b/Sources/JSONAPI/Document/Includes.swift index b8ff4b2..e7a701b 100644 --- a/Sources/JSONAPI/Document/Includes.swift +++ b/Sources/JSONAPI/Document/Includes.swift @@ -32,7 +32,7 @@ public struct Includes: Encodable, Equatable { var container = encoder.unkeyedContainer() guard I.self != NoIncludes.self else { - throw JSONAPIEncodingError.illegalEncoding("Attempting to encode Include0, which should be represented by the absense of an 'included' entry altogether.", path: encoder.codingPath) + throw JSONAPICodingError.illegalEncoding("Attempting to encode Include0, which should be represented by the absense of an 'included' entry altogether.", path: encoder.codingPath) } for value in values { diff --git a/Sources/JSONAPI/EncodingError.swift b/Sources/JSONAPI/JSONAPICodingError.swift similarity index 82% rename from Sources/JSONAPI/EncodingError.swift rename to Sources/JSONAPI/JSONAPICodingError.swift index d2423be..281f1de 100644 --- a/Sources/JSONAPI/EncodingError.swift +++ b/Sources/JSONAPI/JSONAPICodingError.swift @@ -1,11 +1,11 @@ // -// EncodingError.swift +// JSONAPICodingError.swift // JSONAPI // // Created by Mathew Polzin on 12/7/18. // -public enum JSONAPIEncodingError: Swift.Error { +public enum JSONAPICodingError: Swift.Error { case typeMismatch(expected: String, found: String, path: [CodingKey]) case illegalEncoding(String, path: [CodingKey]) case illegalDecoding(String, path: [CodingKey]) diff --git a/Sources/JSONAPI/Resource/Poly+PrimaryResource.swift b/Sources/JSONAPI/Resource/Poly+PrimaryResource.swift index 92719b5..73c1246 100644 --- a/Sources/JSONAPI/Resource/Poly+PrimaryResource.swift +++ b/Sources/JSONAPI/Resource/Poly+PrimaryResource.swift @@ -22,11 +22,11 @@ public typealias CodablePolyWrapped = EncodablePolyWrapped & Decodable extension Poly0: CodablePrimaryResource { public init(from decoder: Decoder) throws { - throw JSONAPIEncodingError.illegalDecoding("Attempted to decode Poly0, which should represent a thing that is not expected to be found in a document.", path: decoder.codingPath) + throw JSONAPICodingError.illegalDecoding("Attempted to decode Poly0, which should represent a thing that is not expected to be found in a document.", path: decoder.codingPath) } public func encode(to encoder: Encoder) throws { - throw JSONAPIEncodingError.illegalEncoding("Attempted to encode Poly0, which should represent a thing that is not expected to be found in a document.", path: encoder.codingPath) + throw JSONAPICodingError.illegalEncoding("Attempted to encode Poly0, which should represent a thing that is not expected to be found in a document.", path: encoder.codingPath) } } diff --git a/Sources/JSONAPI/Resource/Relationship.swift b/Sources/JSONAPI/Resource/Relationship.swift index d7202bc..92b43f2 100644 --- a/Sources/JSONAPI/Resource/Relationship.swift +++ b/Sources/JSONAPI/Resource/Relationship.swift @@ -189,7 +189,7 @@ extension ToOneRelationship: Codable where Identifiable.Identifier: OptionalId { let type = try identifier.decode(String.self, forKey: .entityType) guard type == Identifiable.jsonType else { - throw JSONAPIEncodingError.typeMismatch(expected: Identifiable.jsonType, found: type, path: decoder.codingPath) + throw JSONAPICodingError.typeMismatch(expected: Identifiable.jsonType, found: type, path: decoder.codingPath) } id = Identifiable.Identifier(rawValue: try identifier.decode(Identifiable.Identifier.RawType.self, forKey: .id)) @@ -247,7 +247,7 @@ extension ToManyRelationship: Codable { let type = try identifier.decode(String.self, forKey: .entityType) guard type == Relatable.jsonType else { - throw JSONAPIEncodingError.typeMismatch(expected: Relatable.jsonType, found: type, path: decoder.codingPath) + throw JSONAPICodingError.typeMismatch(expected: Relatable.jsonType, found: type, path: decoder.codingPath) } newIds.append(Relatable.Identifier(rawValue: try identifier.decode(Relatable.Identifier.RawType.self, forKey: .id))) diff --git a/Sources/JSONAPI/Resource/Resource Object/ResourceObject.swift b/Sources/JSONAPI/Resource/Resource Object/ResourceObject.swift index b336655..6305ead 100644 --- a/Sources/JSONAPI/Resource/Resource Object/ResourceObject.swift +++ b/Sources/JSONAPI/Resource/Resource Object/ResourceObject.swift @@ -414,21 +414,25 @@ public extension ResourceObject { let type = try container.decode(String.self, forKey: .type) guard ResourceObject.jsonType == type else { - throw JSONAPIEncodingError.typeMismatch(expected: Description.jsonType, found: type, path: decoder.codingPath) + throw JSONAPICodingError.typeMismatch(expected: Description.jsonType, found: type, path: decoder.codingPath) } let maybeUnidentified = Unidentified() as? EntityRawIdType id = try maybeUnidentified.map { ResourceObject.Id(rawValue: $0) } ?? container.decode(ResourceObject.Id.self, forKey: .id) do { - attributes = try (NoAttributes() as? Description.Attributes) ?? - container.decode(Description.Attributes.self, forKey: .attributes) + attributes = try (NoAttributes() as? Description.Attributes) + ?? container.decodeIfPresent(Description.Attributes.self, forKey: .attributes) + ?? Description.Attributes(from: EmptyObjectDecoder()) } catch let decodingError as DecodingError { throw ResourceObjectDecodingError(decodingError) ?? decodingError - } catch let decodingError as JSONAPIEncodingError { - throw ResourceObjectDecodingError(decodingError) - ?? decodingError + } catch _ as EmptyObjectDecodingError { + throw ResourceObjectDecodingError( + subjectName: ResourceObjectDecodingError.entireObject, + cause: .keyNotFound, + location: .attributes + ) } do { @@ -438,7 +442,7 @@ public extension ResourceObject { } catch let decodingError as DecodingError { throw ResourceObjectDecodingError(decodingError) ?? decodingError - } catch let decodingError as JSONAPIEncodingError { + } catch let decodingError as JSONAPICodingError { throw ResourceObjectDecodingError(decodingError) ?? decodingError } catch _ as EmptyObjectDecodingError { @@ -469,21 +473,23 @@ public struct ResourceObjectDecodingError: Swift.Error, Equatable { case jsonTypeMismatch(expectedType: String, foundType: String) } - public enum Location: Equatable { + public enum Location: String, Equatable { case attributes case relationships + + var singular: String { + switch self { + case .attributes: return "attribute" + case .relationships: return "relationship" + } + } } init?(_ decodingError: DecodingError) { switch decodingError { case .typeMismatch(let expectedType, let ctx): (location, subjectName) = Self.context(ctx) - let typeString: String - if let attrType = expectedType as? AbstractAttributeType { - typeString = String(describing: attrType.rawValueType) - } else { - typeString = String(describing: expectedType) - } + let typeString = String(describing: expectedType) cause = .typeMismatch(expectedTypeName: typeString) case .valueNotFound(_, let ctx): (location, subjectName) = Self.context(ctx) @@ -497,7 +503,7 @@ public struct ResourceObjectDecodingError: Swift.Error, Equatable { } } - init?(_ jsonAPIError: JSONAPIEncodingError) { + init?(_ jsonAPIError: JSONAPICodingError) { switch jsonAPIError { case .typeMismatch(expected: let expected, found: let found, path: let path): (location, subjectName) = Self.context(path: path) @@ -525,3 +531,21 @@ public struct ResourceObjectDecodingError: Swift.Error, Equatable { ) } } + +extension ResourceObjectDecodingError: CustomStringConvertible { + public var description: String { + switch cause { + case .keyNotFound: + if subjectName == ResourceObjectDecodingError.entireObject { + return "\(location) object is required and missing." + } + return "'\(subjectName)' \(location.singular) is required and missing." + case .valueNotFound: + return "'\(subjectName)' \(location.singular) is not nullable but null." + case .typeMismatch(expectedTypeName: let expected): + return "'\(subjectName)' \(location.singular) is not a \(expected) as expected." + case .jsonTypeMismatch(expectedType: let expected, foundType: let found): + return "'\(subjectName)' \(location.singular) is of JSON:API type \"\(found)\" but it was expected to be \"\(expected)\"" + } + } +} diff --git a/Tests/JSONAPITests/ResourceObject/ResourceObjectDecodingErrorTests.swift b/Tests/JSONAPITests/ResourceObject/ResourceObjectDecodingErrorTests.swift index a0d28b0..3dbb971 100644 --- a/Tests/JSONAPITests/ResourceObject/ResourceObjectDecodingErrorTests.swift +++ b/Tests/JSONAPITests/ResourceObject/ResourceObjectDecodingErrorTests.swift @@ -23,6 +23,11 @@ final class ResourceObjectDecodingErrorTests: XCTestCase { location: .relationships ) ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + "relationships object is required and missing." + ) } } @@ -39,6 +44,11 @@ final class ResourceObjectDecodingErrorTests: XCTestCase { location: .relationships ) ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + "'required' relationship is required and missing." + ) } } @@ -55,6 +65,11 @@ final class ResourceObjectDecodingErrorTests: XCTestCase { location: .relationships ) ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + "'required' relationship is not nullable but null." + ) } } @@ -71,6 +86,11 @@ final class ResourceObjectDecodingErrorTests: XCTestCase { location: .relationships ) ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + "'required' relationship is not nullable but null." + ) } } @@ -88,13 +108,146 @@ final class ResourceObjectDecodingErrorTests: XCTestCase { location: .relationships ) ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + #"'required' relationship is of JSON:API type "not_the_same" but it was expected to be "thirteenth_test_entities""# + ) } } + + func test_twoOneVsToMany_relationship() { + // TODO: write test + } } // MARK: - Attributes extension ResourceObjectDecodingErrorTests { - // TODO: write tests + func test_missingAttributesObject() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity2.self, + from: entity_attributes_entirely_missing + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: ResourceObjectDecodingError.entireObject, + cause: .keyNotFound, + location: .attributes + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + "attributes object is required and missing." + ) + } + } + + func test_required_attribute() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity2.self, + from: entity_required_attribute_is_omitted + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: "required", + cause: .keyNotFound, + location: .attributes + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + "'required' attribute is required and missing." + ) + } + } + + func test_NonNullable_attribute() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity2.self, + from: entity_nonNullable_attribute_is_null + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: "required", + cause: .valueNotFound, + location: .attributes + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + "'required' attribute is not nullable but null." + ) + } + } + + func test_oneTypeVsAnother_attribute() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity2.self, + from: entity_attribute_is_wrong_type + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: "required", + cause: .typeMismatch(expectedTypeName: String(describing: String.self)), + location: .attributes + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + "'required' attribute is not a String as expected." + ) + } + } + + func test_oneTypeVsAnother_attribute2() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity2.self, + from: entity_attribute_is_wrong_type2 + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: "other", + cause: .typeMismatch(expectedTypeName: String(describing: Int.self)), + location: .attributes + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + "'other' attribute is not a Int as expected." + ) + } + } + + func test_oneTypeVsAnother_attribute3() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity2.self, + from: entity_attribute_is_wrong_type3 + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: "yetAnother", + cause: .typeMismatch(expectedTypeName: String(describing: Bool.self)), + location: .attributes + ) + ) + + XCTAssertEqual( + (error as? ResourceObjectDecodingError)?.description, + "'yetAnother' attribute is not a Bool as expected." + ) + } + } } // MARK: - Test Types @@ -111,4 +264,19 @@ extension ResourceObjectDecodingErrorTests { } typealias TestEntity = BasicEntity + + enum TestEntityType2: ResourceObjectDescription { + public static var jsonType: String { return "thirteenth_test_entities" } + + public struct Attributes: JSONAPI.Attributes { + + let required: Attribute + let other: Attribute? + let yetAnother: Attribute? + } + + typealias Relationships = NoRelationships + } + + typealias TestEntity2 = BasicEntity } diff --git a/Tests/JSONAPITests/ResourceObject/stubs/ResourceObjectStubs.swift b/Tests/JSONAPITests/ResourceObject/stubs/ResourceObjectStubs.swift index 06cf6cd..6817bff 100644 --- a/Tests/JSONAPITests/ResourceObject/stubs/ResourceObjectStubs.swift +++ b/Tests/JSONAPITests/ResourceObject/stubs/ResourceObjectStubs.swift @@ -446,6 +446,64 @@ let entity_relationships_entirely_missing = """ } """.data(using: .utf8)! +let entity_required_attribute_is_omitted = """ +{ + "id": "1", + "type": "thirteenth_test_entities", + "attributes": { + } +} +""".data(using: .utf8)! + +let entity_nonNullable_attribute_is_null = """ +{ + "id": "1", + "type": "thirteenth_test_entities", + "attributes": { + "required": null + } +} +""".data(using: .utf8)! + +let entity_attribute_is_wrong_type = """ +{ + "id": "1", + "type": "thirteenth_test_entities", + "attributes": { + "required": 10 + } +} +""".data(using: .utf8)! + +let entity_attribute_is_wrong_type2 = """ +{ + "id": "1", + "type": "thirteenth_test_entities", + "attributes": { + "required": "hello", + "other": "world" + } +} +""".data(using: .utf8)! + +let entity_attribute_is_wrong_type3 = """ +{ + "id": "1", + "type": "thirteenth_test_entities", + "attributes": { + "required": "hello", + "yetAnother": 101 + } +} +""".data(using: .utf8)! + +let entity_attributes_entirely_missing = """ +{ + "id": "1", + "type": "thirteenth_test_entities" +} +""".data(using: .utf8)! + let entity_unidentified = """ { "type": "unidentified_test_entities",