diff --git a/Sources/JSONAPI/Document/Document.swift b/Sources/JSONAPI/Document/Document.swift index e1d1e69..ff28502 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 + throw JSONAPIEncodingError.missingOrMalformedMetadata(path: decoder.codingPath) } guard let linksVal = links else { - throw JSONAPIEncodingError.missingOrMalformedLinks + throw JSONAPIEncodingError.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 5c0ab3f..b8ff4b2 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.") + throw JSONAPIEncodingError.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/EncodingError.swift index 8b461cd..d2423be 100644 --- a/Sources/JSONAPI/EncodingError.swift +++ b/Sources/JSONAPI/EncodingError.swift @@ -6,9 +6,9 @@ // public enum JSONAPIEncodingError: Swift.Error { - case typeMismatch(expected: String, found: String) - case illegalEncoding(String) - case illegalDecoding(String) - case missingOrMalformedMetadata - case missingOrMalformedLinks + case typeMismatch(expected: String, found: String, path: [CodingKey]) + case illegalEncoding(String, path: [CodingKey]) + case illegalDecoding(String, path: [CodingKey]) + case missingOrMalformedMetadata(path: [CodingKey]) + case missingOrMalformedLinks(path: [CodingKey]) } diff --git a/Sources/JSONAPI/Resource/Attribute.swift b/Sources/JSONAPI/Resource/Attribute.swift index f73d672..5b0ed41 100644 --- a/Sources/JSONAPI/Resource/Attribute.swift +++ b/Sources/JSONAPI/Resource/Attribute.swift @@ -5,13 +5,21 @@ // Created by Mathew Polzin on 11/13/18. // -public protocol AttributeType: Codable { +public protocol AbstractAttributeType { + var rawValueType: Any.Type { get } +} + +public protocol AttributeType: Codable, AbstractAttributeType { associatedtype RawValue: Codable associatedtype ValueType var value: ValueType { get } } +extension AttributeType { + public var rawValueType: Any.Type { return RawValue.self } +} + // MARK: TransformedAttribute /// A TransformedAttribute takes a Codable type and attempts to turn it into another type. diff --git a/Sources/JSONAPI/Resource/Poly+PrimaryResource.swift b/Sources/JSONAPI/Resource/Poly+PrimaryResource.swift index 7f1fa1c..92719b5 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.") + 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) } 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.") + 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) } } diff --git a/Sources/JSONAPI/Resource/Relationship.swift b/Sources/JSONAPI/Resource/Relationship.swift index 69fabd9..d7202bc 100644 --- a/Sources/JSONAPI/Resource/Relationship.swift +++ b/Sources/JSONAPI/Resource/Relationship.swift @@ -170,8 +170,16 @@ extension ToOneRelationship: Codable where Identifiable.Identifier: OptionalId { // succeeds and then attempt to coerce nil to a Identifier // type at which point we can store nil in `id`. let anyNil: Any? = nil - if try container.decodeNil(forKey: .data), - let val = anyNil as? Identifiable.Identifier { + if try container.decodeNil(forKey: .data) { + guard let val = anyNil as? Identifiable.Identifier else { + throw DecodingError.valueNotFound( + Self.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Expected non-null relationship data." + ) + ) + } id = val return } @@ -181,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) + throw JSONAPIEncodingError.typeMismatch(expected: Identifiable.jsonType, found: type, path: decoder.codingPath) } id = Identifiable.Identifier(rawValue: try identifier.decode(Identifiable.Identifier.RawType.self, forKey: .id)) @@ -239,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) + throw JSONAPIEncodingError.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 a746e6e..b336655 100644 --- a/Sources/JSONAPI/Resource/Resource Object/ResourceObject.swift +++ b/Sources/JSONAPI/Resource/Resource Object/ResourceObject.swift @@ -414,21 +414,114 @@ 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) + throw JSONAPIEncodingError.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) - attributes = try (NoAttributes() as? Description.Attributes) ?? - container.decode(Description.Attributes.self, forKey: .attributes) + do { + attributes = try (NoAttributes() as? Description.Attributes) ?? + container.decode(Description.Attributes.self, forKey: .attributes) + } catch let decodingError as DecodingError { + throw ResourceObjectDecodingError(decodingError) + ?? decodingError + } catch let decodingError as JSONAPIEncodingError { + throw ResourceObjectDecodingError(decodingError) + ?? decodingError + } - relationships = try (NoRelationships() as? Description.Relationships) - ?? container.decodeIfPresent(Description.Relationships.self, forKey: .relationships) - ?? Description.Relationships(from: EmptyObjectDecoder()) + do { + relationships = try (NoRelationships() as? Description.Relationships) + ?? container.decodeIfPresent(Description.Relationships.self, forKey: .relationships) + ?? Description.Relationships(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: .relationships + ) + } meta = try (NoMetadata() as? MetaType) ?? container.decode(MetaType.self, forKey: .meta) links = try (NoLinks() as? LinksType) ?? container.decode(LinksType.self, forKey: .links) } } + +public struct ResourceObjectDecodingError: Swift.Error, Equatable { + public let subjectName: String + public let cause: Cause + public let location: Location + + static let entireObject = "entire object" + + public enum Cause: Equatable { + case keyNotFound + case valueNotFound + case typeMismatch(expectedTypeName: String) + case jsonTypeMismatch(expectedType: String, foundType: String) + } + + public enum Location: Equatable { + case attributes + case relationships + } + + 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) + } + cause = .typeMismatch(expectedTypeName: typeString) + case .valueNotFound(_, let ctx): + (location, subjectName) = Self.context(ctx) + cause = .valueNotFound + case .keyNotFound(let missingKey, let ctx): + (location, _) = Self.context(ctx) + subjectName = missingKey.stringValue + cause = .keyNotFound + default: + return nil + } + } + + init?(_ jsonAPIError: JSONAPIEncodingError) { + switch jsonAPIError { + case .typeMismatch(expected: let expected, found: let found, path: let path): + (location, subjectName) = Self.context(path: path) + cause = .jsonTypeMismatch(expectedType: expected, foundType: found) + default: + return nil + } + } + + init(subjectName: String, cause: Cause, location: Location) { + self.subjectName = subjectName + self.cause = cause + self.location = location + } + + static func context(_ decodingContext: DecodingError.Context) -> (Location, name: String) { + + return context(path: decodingContext.codingPath) + } + + static func context(path: [CodingKey]) -> (Location, name: String) { + return ( + path.contains { $0.stringValue == "attributes" } ? .attributes : .relationships, + name: path.last?.stringValue ?? "unnamed" + ) + } +} diff --git a/Tests/JSONAPITests/ResourceObject/ResourceObjectDecodingErrorTests.swift b/Tests/JSONAPITests/ResourceObject/ResourceObjectDecodingErrorTests.swift new file mode 100644 index 0000000..a0d28b0 --- /dev/null +++ b/Tests/JSONAPITests/ResourceObject/ResourceObjectDecodingErrorTests.swift @@ -0,0 +1,114 @@ +// +// ResourceObjectDecodingErrorTests.swift +// +// +// Created by Mathew Polzin on 11/8/19. +// + +import XCTest +@testable import JSONAPI + +// MARK: - Relationships +final class ResourceObjectDecodingErrorTests: XCTestCase { + func test_missingRelationshipsObject() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity.self, + from: entity_relationships_entirely_missing + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: ResourceObjectDecodingError.entireObject, + cause: .keyNotFound, + location: .relationships + ) + ) + } + } + + func test_required_relationship() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity.self, + from: entity_required_relationship_is_omitted + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: "required", + cause: .keyNotFound, + location: .relationships + ) + ) + } + } + + func test_NonNullable_relationship() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity.self, + from: entity_nonNullable_relationship_is_null + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: "required", + cause: .valueNotFound, + location: .relationships + ) + ) + } + } + + func test_NonNullable_relationship2() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity.self, + from: entity_nonNullable_relationship_is_null2 + )) { error in + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: "required", + cause: .valueNotFound, + location: .relationships + ) + ) + } + } + + func test_oneTypeVsAnother_relationship() { + XCTAssertThrowsError(try testDecoder.decode( + TestEntity.self, + from: entity_relationship_is_wrong_type + )) { error in + print(error) + XCTAssertEqual( + error as? ResourceObjectDecodingError, + ResourceObjectDecodingError( + subjectName: "required", + cause: .jsonTypeMismatch(expectedType: "thirteenth_test_entities", foundType: "not_the_same"), + location: .relationships + ) + ) + } + } +} + +// MARK: - Attributes +extension ResourceObjectDecodingErrorTests { + // TODO: write tests +} + +// MARK: - Test Types +extension ResourceObjectDecodingErrorTests { + enum TestEntityType: ResourceObjectDescription { + public static var jsonType: String { return "thirteenth_test_entities" } + + typealias Attributes = NoAttributes + + public struct Relationships: JSONAPI.Relationships { + + let required: ToOneRelationship + } + } + + typealias TestEntity = BasicEntity +} diff --git a/Tests/JSONAPITests/ResourceObject/stubs/ResourceObjectStubs.swift b/Tests/JSONAPITests/ResourceObject/stubs/ResourceObjectStubs.swift index 58343ce..06cf6cd 100644 --- a/Tests/JSONAPITests/ResourceObject/stubs/ResourceObjectStubs.swift +++ b/Tests/JSONAPITests/ResourceObject/stubs/ResourceObjectStubs.swift @@ -393,6 +393,59 @@ let entity_all_relationships_optional_and_omitted = """ } """.data(using: .utf8)! +let entity_nonNullable_relationship_is_null = """ +{ + "id": "1", + "type": "thirteenth_test_entities", + "relationships": { + "required": null + } +} +""".data(using: .utf8)! + +let entity_nonNullable_relationship_is_null2 = """ +{ + "id": "1", + "type": "thirteenth_test_entities", + "relationships": { + "required": { + "data": null + } + } +} +""".data(using: .utf8)! + +let entity_required_relationship_is_omitted = """ +{ + "id": "1", + "type": "thirteenth_test_entities", + "relationships": { + } +} +""".data(using: .utf8)! + +let entity_relationship_is_wrong_type = """ +{ + "id": "1", + "type": "thirteenth_test_entities", + "relationships": { + "required": { + "data": { + "id": "123", + "type": "not_the_same" + } + } + } +} +""".data(using: .utf8)! + +let entity_relationships_entirely_missing = """ +{ + "id": "1", + "type": "thirteenth_test_entities", +} +""".data(using: .utf8)! + let entity_unidentified = """ { "type": "unidentified_test_entities", diff --git a/Tests/JSONAPITests/Test Helpers/EncodeDecode.swift b/Tests/JSONAPITests/Test Helpers/EncodeDecode.swift index a2eeb56..d0b1c56 100644 --- a/Tests/JSONAPITests/Test Helpers/EncodeDecode.swift +++ b/Tests/JSONAPITests/Test Helpers/EncodeDecode.swift @@ -8,8 +8,10 @@ import Foundation import XCTest +let testDecoder = JSONDecoder() + func decoded(type: T.Type, data: Data) -> T { - return try! JSONDecoder().decode(T.self, from: data) + return try! testDecoder.decode(T.self, from: data) } func encoded(value: T) -> Data {