diff --git a/JSONAPI.playground/Pages/Test Library.xcplaygroundpage/Contents.swift b/JSONAPI.playground/Pages/Test Library.xcplaygroundpage/Contents.swift index 3c67cca..3a45b17 100644 --- a/JSONAPI.playground/Pages/Test Library.xcplaygroundpage/Contents.swift +++ b/JSONAPI.playground/Pages/Test Library.xcplaygroundpage/Contents.swift @@ -14,3 +14,11 @@ Please enjoy these examples, but allow me the forced casting and the lack of err // The JSONAPITestLib provides literal expressibility for key types to // make creating tests easier let dog = Dog(id: "1234", attributes: Dog.Attributes(name: "Buddy"), relationships: Dog.Relationships(owner: nil)) + +// MARK: - JSON API structure checking +// The JSONAPITestLib provides a `check` function for each Entity type +// that uses reflection to catch mistakes that are not forbidden by +// Swift's type system but will result in unexpected results when +// encoding/decoding. It is a good idea to add a `check` to each of +// your unit tests that create Entities. +try Dog.check(dog) diff --git a/README.md b/README.md index 5b085db..5e5b207 100644 --- a/README.md +++ b/README.md @@ -75,10 +75,10 @@ To create an Xcode project for JSONAPI, run - [x] `href` - [x] `meta` -### EntityDescription Validator (using reflection) +### Entity Validator (using reflection) - [ ] Disallow optional array in `Attribute` and `Relationship` (should be empty array, not `null`). -- [ ] Only allow `Attribute` and `TransformAttribute` within `Attributes` struct. -- [ ] Only allow `ToManyRelationship` and `ToOneRelationship` within `Relationships` struct. +- [x] Only allow `TransformedAttribute` and its derivatives within `Attributes` struct. +- [x] Only allow `ToManyRelationship` and `ToOneRelationship` within `Relationships` struct. ### Strict Decoding/Encoding Settings - [ ] Error (potentially while still encoding/decoding successfully) if an included entity is not related to a primary entity (Turned off by default). @@ -94,7 +94,7 @@ To create an Xcode project for JSONAPI, run - [ ] Property-based testing (using `SwiftCheck`) - [x] Roll my own `Result` or find an alternative that doesn't use `Foundation`. - [ ] Create more descriptive errors that are easier to use for troubleshooting. -- [ ] Make it easier to construct `Attributes` and `Relationships` values. +- [x] Make it easier to construct `Attributes` and `Relationships` values in test cases (literal expressibility). ## Usage @@ -330,4 +330,4 @@ extension String: CreatableRawIdType { ``` ## Testing -JSONAPI comes with a test library to help you test your JSON API integration. The test library is called `JSONAPITestLib`. It provides literal expressibility for `Attribute`, `ToOneRelationship`, and `Id` in many situations so that you can easily write test `Entity` values into your unit tests. You can see the JSONAPITestLib in action in the Playground included with the JSONAPI repository. +JSONAPI comes with a test library to help you test your JSON API integration. The test library is called `JSONAPITestLib`. It provides literal expressibility for `Attribute`, `ToOneRelationship`, and `Id` in many situations so that you can easily write test `Entity` values into your unit tests. It also provides a `check()` function for each `Entity` type that can be used to catch problems with your JSONAPI structures that are not caught by Swift's type system. You can see the JSONAPITestLib in action in the Playground included with the JSONAPI repository. diff --git a/Sources/JSONAPI/Resource/Attribute.swift b/Sources/JSONAPI/Resource/Attribute.swift index 62f4720..3332892 100644 --- a/Sources/JSONAPI/Resource/Attribute.swift +++ b/Sources/JSONAPI/Resource/Attribute.swift @@ -5,7 +5,10 @@ // Created by Mathew Polzin on 11/13/18. // -public struct TransformedAttribute: Codable where Transformer.From == RawValue { +public protocol AttributeType: Codable { +} + +public struct TransformedAttribute: AttributeType where Transformer.From == RawValue { private let rawValue: RawValue public let value: Transformer.To diff --git a/Sources/JSONAPI/Resource/Entity.swift b/Sources/JSONAPI/Resource/Entity.swift index 2bb58cc..0110979 100644 --- a/Sources/JSONAPI/Resource/Entity.swift +++ b/Sources/JSONAPI/Resource/Entity.swift @@ -190,7 +190,7 @@ public extension Entity { try container.encode(Entity.type, forKey: .type) - if Identifier.self != Unidentified.self { + if Identifier.self != Unidentified.self { try container.encode(id, forKey: .id) } @@ -213,7 +213,7 @@ public extension Entity { throw JSONAPIEncodingError.typeMismatch(expected: Description.type, found: type) } - id = try (Unidentified() as? Identifier) ?? container.decode(Identifier.self, forKey: .id) + id = try (Unidentified() as? Identifier) ?? container.decode(Identifier.self, forKey: .id) attributes = try (NoAttributes() as? Description.Attributes) ?? container.decode(Description.Attributes.self, forKey: .attributes) diff --git a/Sources/JSONAPI/Resource/Id.swift b/Sources/JSONAPI/Resource/Id.swift index fa236a7..921bd46 100644 --- a/Sources/JSONAPI/Resource/Id.swift +++ b/Sources/JSONAPI/Resource/Id.swift @@ -22,16 +22,17 @@ public protocol CreatableRawIdType: RawIdType { extension String: RawIdType {} -public protocol Identifier: Codable, Equatable {} +public protocol Identifier: Codable, Equatable { + associatedtype EntityDescription: JSONAPI.EntityDescription +} -public struct Unidentified: Identifier, CustomStringConvertible { +public struct Unidentified: Identifier, CustomStringConvertible { public init() {} public var description: String { return "Id(Unidentified)" } } public protocol IdType: Identifier, CustomStringConvertible { - associatedtype EntityDescription: JSONAPI.EntityDescription associatedtype RawType: RawIdType var rawValue: RawType { get } diff --git a/Sources/JSONAPI/Resource/Relationship.swift b/Sources/JSONAPI/Resource/Relationship.swift index 23d1958..7079b30 100644 --- a/Sources/JSONAPI/Resource/Relationship.swift +++ b/Sources/JSONAPI/Resource/Relationship.swift @@ -5,11 +5,13 @@ // Created by Mathew Polzin on 8/31/18. // +public protocol RelationshipType: Codable {} + /// An Entity relationship that can be encoded to or decoded from /// a JSON API "Resource Linkage." /// See https://jsonapi.org/format/#document-resource-object-linkage /// A convenient typealias might make your code much more legible: `One` -public struct ToOneRelationship: Equatable, Codable { +public struct ToOneRelationship: RelationshipType, Equatable { public let id: Relatable.WrappedIdentifier @@ -34,7 +36,7 @@ extension ToOneRelationship where Relatable.WrappedIdentifier == Optional` -public struct ToManyRelationship: Equatable, Codable { +public struct ToManyRelationship: RelationshipType, Equatable { public let ids: [Relatable.Identifier] diff --git a/Sources/JSONAPITestLib/EntityCheck.swift b/Sources/JSONAPITestLib/EntityCheck.swift new file mode 100644 index 0000000..8517236 --- /dev/null +++ b/Sources/JSONAPITestLib/EntityCheck.swift @@ -0,0 +1,63 @@ +// +// EntityCheck.swift +// JSONAPITestLib +// +// Created by Mathew Polzin on 11/27/18. +// + +import JSONAPI + +public enum EntityCheckError: Swift.Error { + case attributesNotStruct + case relationshipsNotStruct + case badAttribute(named: String) + case badRelationship(named: String) + case badId +} + +public struct EntityCheckErrors: Swift.Error { + let problems: [EntityCheckError] +} + +public protocol OptionalAttributeType {} + +extension Optional: OptionalAttributeType where Wrapped: AttributeType {} + +public extension Entity { + public static func check(_ entity: Entity) throws { + var problems = [EntityCheckError]() + + if Swift.type(of: entity.id).EntityDescription.self != Description.self { + problems.append(.badId) + } + + let attributesMirror = Mirror(reflecting: entity.attributes) + + if attributesMirror.displayStyle != .`struct` { + problems.append(.attributesNotStruct) + } + + for attribute in attributesMirror.children { + if attribute.value as? AttributeType == nil, + attribute.value as? OptionalAttributeType == nil { + problems.append(.badAttribute(named: attribute.label ?? "unnamed")) + } + } + + let relationshipsMirror = Mirror(reflecting: entity.relationships) + + if relationshipsMirror.displayStyle != .`struct` { + problems.append(.relationshipsNotStruct) + } + + for relationship in relationshipsMirror.children { + if relationship.value as? RelationshipType == nil { + problems.append(.badRelationship(named: relationship.label ?? "unnamed")) + } + } + + guard problems.count == 0 else { + throw EntityCheckErrors(problems: problems) + } + } +} diff --git a/Tests/JSONAPITests/Entity/EntityTests.swift b/Tests/JSONAPITests/Entity/EntityTests.swift index 236d558..006cfe3 100644 --- a/Tests/JSONAPITests/Entity/EntityTests.swift +++ b/Tests/JSONAPITests/Entity/EntityTests.swift @@ -7,6 +7,7 @@ import XCTest import JSONAPI +import JSONAPITestLib class EntityTests: XCTestCase { @@ -50,6 +51,7 @@ extension EntityTests { XCTAssert(type(of: entity.relationships) == NoRelationships.self) XCTAssert(type(of: entity.attributes) == NoAttributes.self) + XCTAssertNoThrow(try TestEntity1.check(entity)) } func test_EntityNoRelationshipsNoAttributes_encode() { @@ -64,6 +66,7 @@ extension EntityTests { XCTAssert(type(of: entity.relationships) == NoRelationships.self) XCTAssertEqual(entity[\.floater], 123.321) + XCTAssertNoThrow(try TestEntity5.check(entity)) } func test_EntityNoRelationshipsSomeAttributes_encode() { @@ -78,6 +81,7 @@ extension EntityTests { XCTAssert(type(of: entity.attributes) == NoAttributes.self) XCTAssertEqual((entity ~> \.others).map { $0.rawValue }, ["364B3B69-4DF1-467F-B52E-B0C9E44F666E"]) + XCTAssertNoThrow(try TestEntity3.check(entity)) } func test_EntitySomeRelationshipsNoAttributes_encode() { @@ -92,6 +96,7 @@ extension EntityTests { XCTAssertEqual(entity[\.word], "coolio") XCTAssertEqual(entity[\.number], 992299) XCTAssertEqual((entity ~> \.other).rawValue, "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF") + XCTAssertNoThrow(try TestEntity4.check(entity)) } func test_EntitySomeRelationshipsSomeAttributes_encode() { @@ -110,6 +115,7 @@ extension EntityTests { XCTAssertEqual(entity[\.here], "Hello") XCTAssertNil(entity[\.maybeHere]) XCTAssertEqual(entity[\.maybeNull], "World") + XCTAssertNoThrow(try TestEntity6.check(entity)) } func test_entityOneOmittedAttribute_encode() { @@ -124,6 +130,7 @@ extension EntityTests { XCTAssertEqual(entity[\.here], "Hello") XCTAssertEqual(entity[\.maybeHere], "World") XCTAssertNil(entity[\.maybeNull]) + XCTAssertNoThrow(try TestEntity6.check(entity)) } func test_entityOneNullAttribute_encode() { @@ -138,6 +145,7 @@ extension EntityTests { XCTAssertEqual(entity[\.here], "Hello") XCTAssertEqual(entity[\.maybeHere], "World") XCTAssertEqual(entity[\.maybeNull], "!") + XCTAssertNoThrow(try TestEntity6.check(entity)) } func test_entityAllAttribute_encode() { @@ -152,6 +160,7 @@ extension EntityTests { XCTAssertEqual(entity[\.here], "Hello") XCTAssertNil(entity[\.maybeHere]) XCTAssertNil(entity[\.maybeNull]) + XCTAssertNoThrow(try TestEntity6.check(entity)) } func test_entityOneNullAndOneOmittedAttribute_encode() { @@ -170,6 +179,7 @@ extension EntityTests { XCTAssertEqual(entity[\.here], "Hello") XCTAssertNil(entity[\.maybeHereMaybeNull]) + XCTAssertNoThrow(try TestEntity7.check(entity)) } func test_NullOptionalNullableAttribute_encode() { @@ -183,6 +193,7 @@ extension EntityTests { XCTAssertEqual(entity[\.here], "Hello") XCTAssertEqual(entity[\.maybeHereMaybeNull], "World") + XCTAssertNoThrow(try TestEntity7.check(entity)) } func test_NonNullOptionalNullableAttribute_encode() { @@ -203,6 +214,7 @@ extension EntityTests { XCTAssertEqual(entity[\.plus], 122) XCTAssertEqual(entity[\.doubleFromInt], 22.0) XCTAssertEqual(entity[\.nullToString], "nil") + XCTAssertNoThrow(try TestEntity8.check(entity)) } func test_IntToString_encode() { @@ -215,8 +227,6 @@ extension EntityTests { extension EntityTests { func test_IntOver10_success() { XCTAssertNoThrow(decoded(type: TestEntity11.self, data: entity_valid_validated_attribute)) - - } func test_IntOver10_encode() { @@ -236,6 +246,7 @@ extension EntityTests { XCTAssertEqual((entity ~> \.nullableOne)?.rawValue, "3323") XCTAssertEqual((entity ~> \.one).rawValue, "4459") + XCTAssertNoThrow(try TestEntity9.check(entity)) } func test_nullableRelationshipNotNull_encode() { @@ -249,6 +260,7 @@ extension EntityTests { XCTAssertNil(entity ~> \.nullableOne) XCTAssertEqual((entity ~> \.one).rawValue, "4452") + XCTAssertNoThrow(try TestEntity9.check(entity)) } func test_nullableRelationshipIsNull_encode() { @@ -265,6 +277,7 @@ extension EntityTests { data: entity_self_ref_relationship) XCTAssertEqual((entity ~> \.selfRef).rawValue, "1") + XCTAssertNoThrow(try TestEntity10.check(entity)) } func test_RleationshipsOfSameType_encode() { @@ -282,6 +295,7 @@ extension EntityTests { XCTAssertNil(entity[\.me]) XCTAssertEqual(entity.id, Unidentified()) + XCTAssertNoThrow(try UnidentifiedTestEntity.check(entity)) } func test_UnidentifiedEntity_encode() { @@ -295,6 +309,7 @@ extension EntityTests { XCTAssertEqual(entity[\.me], "unknown") XCTAssertEqual(entity.id, Unidentified()) + XCTAssertNoThrow(try UnidentifiedTestEntity.check(entity)) } func test_UnidentifiedEntityWithAttributes_encode() { diff --git a/Tests/JSONAPITests/Test Helpers/Entity+Id.swift b/Tests/JSONAPITests/Test Helpers/Entity+Id.swift index 47cdbe9..dfd4279 100644 --- a/Tests/JSONAPITests/Test Helpers/Entity+Id.swift +++ b/Tests/JSONAPITests/Test Helpers/Entity+Id.swift @@ -9,4 +9,4 @@ import JSONAPI public typealias Entity = JSONAPI.Entity> -public typealias NewEntity = JSONAPI.Entity +public typealias NewEntity = JSONAPI.Entity> diff --git a/Tests/JSONAPITests/TestLib/EntityCheckTests.swift b/Tests/JSONAPITests/TestLib/EntityCheckTests.swift new file mode 100644 index 0000000..72fd08b --- /dev/null +++ b/Tests/JSONAPITests/TestLib/EntityCheckTests.swift @@ -0,0 +1,124 @@ +// +// EntityCheckTests.swift +// JSONAPITests +// +// Created by Mathew Polzin on 11/27/18. +// + +import XCTest +import JSONAPI +import JSONAPITestLib + +// Successes are fairly well-checked by the EntityTests. We will confirm failure cases are working +// in this file. +class EntityCheckTests: XCTestCase { + func test_FailsWithBadId() { + let entity = BadIdEntity() + XCTAssertThrowsError(try BadIdEntity.check(entity)) + } + + func test_failsWithEnumAttributes() { + let entity = EnumAttributesEntity(attributes: .hello) + XCTAssertThrowsError(try EnumAttributesEntity.check(entity)) + } + + func test_failsWithEnumRelationships() { + let entity = EnumRelationshipsEntity(relationships: .hello) + XCTAssertThrowsError(try EnumRelationshipsEntity.check(entity)) + } + + func test_failsWithBadAttribute() { + let entity = BadAttributeEntity(attributes: .init(x: "ok", y: "not ok")) + XCTAssertThrowsError(try BadAttributeEntity.check(entity)) + } + + func test_failsWithBadRelationship() { + let entity = BadRelationshipEntity(relationships: .init(x: OkEntity().pointer, y: OkEntity().id)) + XCTAssertThrowsError(try BadRelationshipEntity.check(entity)) + } +} + +// MARK: - Test types +extension EntityCheckTests { + enum OkDescription: EntityDescription { + public static var type: String { return "hello" } + + public typealias Attributes = NoAttributes + public typealias Relationships = NoRelationships + } + + public typealias OkEntity = Entity + + enum OtherOkDescription: EntityDescription { + public static var type: String { return "hmm" } + + public typealias Attributes = NoAttributes + public typealias Relationships = NoRelationships + } + + public typealias BadIdEntity = JSONAPI.Entity> + + enum EnumAttributesDescription: EntityDescription { + public static var type: String { return "hello" } + + public enum Attributes: Codable, Equatable { + case hello + + public init(from decoder: Decoder) throws { + self = .hello + } + + public func encode(to encoder: Encoder) throws { + } + } + + public typealias Relationships = NoRelationships + } + + public typealias EnumAttributesEntity = Entity + + enum EnumRelationshipsDescription: EntityDescription { + public static var type: String { return "hello" } + + public typealias Attributes = NoAttributes + + public enum Relationships: Codable, Equatable { + case hello + + public init(from decoder: Decoder) throws { + self = .hello + } + + public func encode(to encoder: Encoder) throws { + } + } + } + + public typealias EnumRelationshipsEntity = Entity + + enum BadAttributeDescription: EntityDescription { + public static var type: String { return "hello" } + + public struct Attributes: JSONAPI.Attributes { + let x: Attribute + let y: String + } + + public typealias Relationships = NoRelationships + } + + public typealias BadAttributeEntity = Entity + + enum BadRelationshipDescription: EntityDescription { + public static var type: String { return "hello" } + + public typealias Attributes = NoAttributes + + public struct Relationships: JSONAPI.Relationships { + let x: ToOneRelationship + let y: Id + } + } + + public typealias BadRelationshipEntity = Entity +}