From 805ef4debe09cc40a33078d784ecfac3564e3b63 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Tue, 13 Nov 2018 21:34:23 -0800 Subject: [PATCH] Support both attributes whose keys can be omitted and attributes whose values can be null. --- README.md | 18 +++- Sources/JSONAPI/Resource/Attribute.swift | 47 ++++++++++ Sources/JSONAPI/Resource/Entity.swift | 11 ++- Tests/JSONAPITests/Entity/EntityTests.swift | 85 ++++++++++++++++++- .../Entity/stubs/EntityStubs.swift | 60 ++++++++++++- Tests/JSONAPITests/XCTestManifests.swift | 5 ++ 6 files changed, 216 insertions(+), 10 deletions(-) create mode 100644 Sources/JSONAPI/Resource/Attribute.swift diff --git a/README.md b/README.md index 0356505..78fac63 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ The primary goals of this framework are: ### Misc - [ ] Support transforms on `Attributes` values (e.g. to support different representations of `Date`) -- [ ] Support ability to distinguish between `Attributes` fields that are optional (i.e. the key might not be there) and `Attributes` values that are optional (i.e. the key is guaranteed to be there but it might be `null`). +- [x] Support ability to distinguish between `Attributes` fields that are optional (i.e. the key might not be there) and `Attributes` values that are optional (i.e. the key is guaranteed to be there but it might be `null`). - [ ] `EntityType` validator (using reflection) - [ ] Property-based testing (using `SwiftCheck`) - [ ] Roll my own `Result` or find an alternative that doesn't use `Foundation`. @@ -82,8 +82,8 @@ enum PersonDescription: IdentifiedEntityDescription { typealias Identifier = Id struct Attributes: JSONAPI.Attributes { - let name: [String] - let favoriteColor: String + let name: Attribute<[String]> + let favoriteColor: Attribute } struct Relationships: JSONAPI.Relationships { @@ -152,7 +152,17 @@ let friendIds: [Person.Identifier] = person ~> \.friends ### `Attributes` -The `Attributes` of an `EntityDescription` can contain any JSON encodable/decodable types. This is the place to store all attributes of an entity. +The `Attributes` of an `EntityDescription` can contain any JSON encodable/decodable types as long as they are wrapped in an `Attribute` `struct`. This is the place to store all attributes of an entity. + +To describe an attribute that may be omitted (i.e. the key might not even be in the JSON object), you make the entire `Attribute` optional: +``` +let optionalAttribute: Attribute? +``` + +To describe an attribute that is expected to exist but might have a `null` value, you make the value within the `Attribute` optional: +``` +let nullableAttribute: Attribute +``` An entity that does not have attributes can be described by adding the following to an `EntityDescription`: ``` diff --git a/Sources/JSONAPI/Resource/Attribute.swift b/Sources/JSONAPI/Resource/Attribute.swift new file mode 100644 index 0000000..b7bd47f --- /dev/null +++ b/Sources/JSONAPI/Resource/Attribute.swift @@ -0,0 +1,47 @@ +// +// Attribute.swift +// JSONAPI +// +// Created by Mathew Polzin on 11/13/18. +// + +public struct Attribute: Codable { + public let value: Value +} + +extension Attribute: Equatable where Value: Equatable {} + +// MARK: - Codable +extension Attribute { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + // A little trickery follows. If the value is nil, the + // container.decode(Value.self) will fail even if Value + // is Optional. However, we can check if decoding nil + // succeeds and then attempt to coerce nil to a Value + // type at which point we can store nil in `value`. + let anyNil: Any? = nil + if container.decodeNil(), + let val = anyNil as? Value { + value = val + return + } + + value = try container.decode(Value.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + // See note in decode above about the weirdness + // going on here. + let anyNil: Any? = nil + if let _ = anyNil as? Value, + (value as Any?) == nil { + try container.encodeNil() + } + + try container.encode(value) + } +} diff --git a/Sources/JSONAPI/Resource/Entity.swift b/Sources/JSONAPI/Resource/Entity.swift index 4de7686..61f054b 100644 --- a/Sources/JSONAPI/Resource/Entity.swift +++ b/Sources/JSONAPI/Resource/Entity.swift @@ -139,8 +139,15 @@ public extension Entity { /// Access the attribute at the given keypath. This just /// allows you to write `entity[\.propertyName]` instead /// of `entity.relationships.propertyName`. - subscript(_ path: KeyPath) -> T { - return attributes[keyPath: path] + subscript(_ path: KeyPath>) -> T { + return attributes[keyPath: path].value + } + + /// Access the attribute at the given keypath. This just + /// allows you to write `entity[\.propertyName]` instead + /// of `entity.relationships.propertyName`. + subscript(_ path: KeyPath?>) -> T? { + return attributes[keyPath: path]?.value } } diff --git a/Tests/JSONAPITests/Entity/EntityTests.swift b/Tests/JSONAPITests/Entity/EntityTests.swift index 8ac2605..0f244aa 100644 --- a/Tests/JSONAPITests/Entity/EntityTests.swift +++ b/Tests/JSONAPITests/Entity/EntityTests.swift @@ -81,6 +81,69 @@ class EntityTests: XCTestCase { XCTAssertEqual(e[\.number], 992299) XCTAssertEqual((e ~> \.other).rawValue, "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF") } + +} + +// MARK: Attribute omission and nullification +extension EntityTests { + + func test_entityOneOmittedAttribute() { + let entity = try? JSONDecoder().decode(TestEntity6.self, from: entity_one_omitted_attribute) + + XCTAssertNotNil(entity) + + guard let e = entity else { return } + + XCTAssertEqual(e[\.here], "Hello") + XCTAssertNil(e[\.maybeHere]) + XCTAssertEqual(e[\.maybeNull], "World") + } + + func test_entityOneNullAttribute() { + let entity = try? JSONDecoder().decode(TestEntity6.self, from: entity_one_null_attribute) + + XCTAssertNotNil(entity) + + guard let e = entity else { return } + + XCTAssertEqual(e[\.here], "Hello") + XCTAssertEqual(e[\.maybeHere], "World") + XCTAssertNil(e[\.maybeNull]) + } + + func test_entityAllAttribute() { + let entity = try? JSONDecoder().decode(TestEntity6.self, from: entity_all_attributes) + + XCTAssertNotNil(entity) + + guard let e = entity else { return } + + XCTAssertEqual(e[\.here], "Hello") + XCTAssertEqual(e[\.maybeHere], "World") + XCTAssertEqual(e[\.maybeNull], "!") + } + + func test_entityOneNullAndOneOmittedAttribute() { + let entity = try? JSONDecoder().decode(TestEntity6.self, from: entity_one_null_and_one_missing_attribute) + + XCTAssertNotNil(entity) + + guard let e = entity else { return } + + XCTAssertEqual(e[\.here], "Hello") + XCTAssertNil(e[\.maybeHere]) + XCTAssertNil(e[\.maybeNull]) + } + + func test_entityBrokenNullableOmittedAttribute() { + let entity = try? JSONDecoder().decode(TestEntity6.self, from: entity_broken_missing_nullable_attribute) + + XCTAssertNil(entity) + } +} + +// MARK: Test Types +extension EntityTests { enum TestEntityType1: EntityDescription { static var type: String { return "test_entities"} @@ -128,8 +191,9 @@ class EntityTests: XCTestCase { } struct Attributes: JSONAPI.Attributes { - let word: String - let number: Int + let word: Attribute + let number: Attribute + let array: Attribute<[Double]> } } @@ -142,11 +206,26 @@ class EntityTests: XCTestCase { typealias Relationships = NoRelatives struct Attributes: JSONAPI.Attributes { - let floater: Double + let floater: Attribute } } typealias TestEntity5 = Entity + + enum TestEntityType6: EntityDescription { + static var type: String { return "sixth_test_entities" } + + typealias Identifier = Id + typealias Relationships = NoRelatives + + struct Attributes: JSONAPI.Attributes { + let here: Attribute + let maybeHere: Attribute? + let maybeNull: Attribute + } + } + + typealias TestEntity6 = Entity } extension Entity where EntityType == EntityTests.TestEntityType2 { diff --git a/Tests/JSONAPITests/Entity/stubs/EntityStubs.swift b/Tests/JSONAPITests/Entity/stubs/EntityStubs.swift index 93cbb5f..1888c2b 100644 --- a/Tests/JSONAPITests/Entity/stubs/EntityStubs.swift +++ b/Tests/JSONAPITests/Entity/stubs/EntityStubs.swift @@ -43,7 +43,8 @@ let entity_some_relationships_some_attributes = """ "type": "fourth_test_entities", "attributes": { "word": "coolio", -"number": 992299 +"number": 992299, +"array": [12.3, 4, 0.1] }, "relationships": { "other": { @@ -55,3 +56,60 @@ let entity_some_relationships_some_attributes = """ } } """.data(using: .utf8)! + +let entity_one_omitted_attribute = """ +{ + "id": "1", + "type": "sixth_test_entities", + "attributes": { + "here": "Hello", + "maybeNull": "World" + } +} +""".data(using: .utf8)! + +let entity_one_null_attribute = """ +{ + "id": "1", + "type": "sixth_test_entities", + "attributes": { + "here": "Hello", + "maybeHere": "World", + "maybeNull": null + } +} +""".data(using: .utf8)! + +let entity_all_attributes = """ +{ + "id": "1", + "type": "sixth_test_entities", + "attributes": { + "here": "Hello", + "maybeHere": "World", + "maybeNull": "!" + } +} +""".data(using: .utf8)! + +let entity_one_null_and_one_missing_attribute = """ +{ + "id": "1", + "type": "sixth_test_entities", + "attributes": { + "here": "Hello", + "maybeNull": null + } +} +""".data(using: .utf8)! + +let entity_broken_missing_nullable_attribute = """ +{ + "id": "1", + "type": "sixth_test_entities", + "attributes": { + "here": "Hello", + "maybeHere": "World" + } +} +""".data(using: .utf8)! diff --git a/Tests/JSONAPITests/XCTestManifests.swift b/Tests/JSONAPITests/XCTestManifests.swift index 8d60d2c..45ad891 100644 --- a/Tests/JSONAPITests/XCTestManifests.swift +++ b/Tests/JSONAPITests/XCTestManifests.swift @@ -11,8 +11,13 @@ extension DocumentTests { extension EntityTests { static let __allTests = [ + ("test_entityAllAttribute", test_entityAllAttribute), + ("test_entityBrokenNullableOmittedAttribute", test_entityBrokenNullableOmittedAttribute), ("test_EntityNoRelationshipsNoAttributes", test_EntityNoRelationshipsNoAttributes), ("test_EntityNoRelationshipsSomeAttributes", test_EntityNoRelationshipsSomeAttributes), + ("test_entityOneNullAndOneOmittedAttribute", test_entityOneNullAndOneOmittedAttribute), + ("test_entityOneNullAttribute", test_entityOneNullAttribute), + ("test_entityOneOmittedAttribute", test_entityOneOmittedAttribute), ("test_EntitySomeRelationshipsNoAttributes", test_EntitySomeRelationshipsNoAttributes), ("test_EntitySomeRelationshipsSomeAttributes", test_EntitySomeRelationshipsSomeAttributes), ("test_relationship_access", test_relationship_access),