From fd82d5d7de7f35bef246925cd8a0f96ca3a6ce96 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Wed, 14 Nov 2018 08:38:43 -0800 Subject: [PATCH] Add ability to specify an Attribute needs to be transformed once it has been decoded. --- README.md | 25 ++++- Sources/JSONAPI/Resource/Attribute.swift | 48 ++++++--- Sources/JSONAPI/Resource/Entity.swift | 11 ++- Tests/JSONAPITests/Entity/EntityTests.swift | 98 +++++++++++++++++++ .../Entity/stubs/EntityStubs.swift | 37 +++++++ .../JSONAPITests/Includes/IncludeTests.swift | 8 +- .../ResourceBody/ResourceBodyTests.swift | 10 +- Tests/JSONAPITests/XCTestManifests.swift | 3 + 8 files changed, 215 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 78fac63..6e64e60 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ The primary goals of this framework are: - [ ] `meta` ### Misc -- [ ] Support transforms on `Attributes` values (e.g. to support different representations of `Date`) +- [x] Support transforms on `Attributes` values (e.g. to support different representations of `Date`) - [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`) @@ -131,7 +131,6 @@ Once you have an `EntityDescription`, you _create_, _encode_, and _decode_ `Enti The `Entity` and `EntityDescription` together embody the rules and properties of a JSON API *Resource Object*. It can be nice to create a `typealias` for each type of entity you want to work with: - ``` typealias Person = Entity ``` @@ -152,7 +151,7 @@ let friendIds: [Person.Identifier] = person ~> \.friends ### `Attributes` -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. +The `Attributes` of an `EntityDescription` can contain any JSON encodable/decodable types as long as they are wrapped in an `Attribute` or `TransformAttribute` `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: ``` @@ -174,6 +173,26 @@ typealias Attributes = NoAttributes let favoriteColor: String = person[\.favoriteColor] ``` +#### `Transformer` + +Sometimes you need to use a type that does not encode or decode itself in the way you need to represent it as a serialized JSON object. For example, the Swift `Foundation` type `Date` can encode/decode itself to `Double` out of the box, but you might want to represent dates as ISO 8601 compliant `String`s instead. To do this, you create a `Transformer`. + +A `Transformer` just provides one static function that transforms one type to another. You might define one for an ISO 8601 compliant `Date` like this: +``` +enum ISODateTransformer: Transformer { + public static func transform(_ from: String) throws -> Date { + // parse Date out of input and return + } +} +``` + +Then you define the attribute as a `TransformAttribute` instead of an `Attribute`: +``` +let date: TransformAttribute +``` + +Note that the first generic parameter of `TransformAttribute` is the type you expect to decode from JSON, not the type you want to end up with after transformation. + ### `JSONAPIDocument` The entirety of a JSON API request or response is encoded or decoded from- or to a `JSONAPIDocument`. As an example, a JSON API response containing one `Person` and no included entities could be decoded as follows: diff --git a/Sources/JSONAPI/Resource/Attribute.swift b/Sources/JSONAPI/Resource/Attribute.swift index b7bd47f..594ab80 100644 --- a/Sources/JSONAPI/Resource/Attribute.swift +++ b/Sources/JSONAPI/Resource/Attribute.swift @@ -5,17 +5,28 @@ // Created by Mathew Polzin on 11/13/18. // -public struct Attribute: Codable { - public let value: Value +public struct TransformAttribute: Codable where Transformer.From == RawValue { + private let rawValue: RawValue + + public let value: Transformer.To + + public init(rawValue: RawValue) throws { + self.rawValue = rawValue + value = try Transformer.transform(rawValue) + } } -extension Attribute: Equatable where Value: Equatable {} +public typealias Attribute = TransformAttribute> + +extension TransformAttribute: Equatable where Transformer.From: Equatable, Transformer.To: Equatable {} // MARK: - Codable -extension Attribute { +extension TransformAttribute { public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() + let rawVal: RawValue + // 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 @@ -23,12 +34,14 @@ extension Attribute { // 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 + let val = anyNil as? Transformer.From { + rawVal = val + } else { + rawVal = try container.decode(Transformer.From.self) } - value = try container.decode(Value.self) + rawValue = rawVal + value = try Transformer.transform(rawVal) } public func encode(to encoder: Encoder) throws { @@ -37,11 +50,24 @@ extension Attribute { // See note in decode above about the weirdness // going on here. let anyNil: Any? = nil - if let _ = anyNil as? Value, - (value as Any?) == nil { + if let _ = anyNil as? Transformer.From, + (rawValue as Any?) == nil { try container.encodeNil() } - try container.encode(value) + try container.encode(rawValue) } } + +// MARK: - Transformers + +public protocol Transformer { + associatedtype From + associatedtype To + + static func transform(_ from: From) throws -> To +} + +public enum IdentityTransformer: Transformer { + public static func transform(_ from: T) throws -> T { return from } +} diff --git a/Sources/JSONAPI/Resource/Entity.swift b/Sources/JSONAPI/Resource/Entity.swift index 61f054b..1d35fd2 100644 --- a/Sources/JSONAPI/Resource/Entity.swift +++ b/Sources/JSONAPI/Resource/Entity.swift @@ -139,16 +139,23 @@ 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 { + subscript(_ path: KeyPath>) -> TFRM.To { 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? { + subscript(_ path: KeyPath?>) -> TFRM.To? { 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?>) -> U? where TFRM.To == U? { + return attributes[keyPath: path].flatMap { $0.value } + } } // MARK: Relationship Access diff --git a/Tests/JSONAPITests/Entity/EntityTests.swift b/Tests/JSONAPITests/Entity/EntityTests.swift index 0f244aa..8423333 100644 --- a/Tests/JSONAPITests/Entity/EntityTests.swift +++ b/Tests/JSONAPITests/Entity/EntityTests.swift @@ -140,6 +140,47 @@ extension EntityTests { XCTAssertNil(entity) } + + func test_NullOptionalNullableAttribute() { + let entity = try? JSONDecoder().decode(TestEntity7.self, from: entity_null_optional_nullable_attribute) + + XCTAssertNotNil(entity) + + guard let e = entity else { return } + + XCTAssertEqual(e[\.here], "Hello") + XCTAssertNil(e[\.maybeHereMaybeNull]) + } + + func test_NonNullOptionalNullableAttribute() { + let entity = try? JSONDecoder().decode(TestEntity7.self, from: entity_non_null_optional_nullable_attribute) + + XCTAssertNotNil(entity) + + guard let e = entity else { return } + + XCTAssertEqual(e[\.here], "Hello") + XCTAssertEqual(e[\.maybeHereMaybeNull], "World") + } +} + +// MARK: Attribute Transformation + +extension EntityTests { + func test_IntToString() { + let entity = try? JSONDecoder().decode(TestEntity8.self, from: entity_int_to_string_attribute) + + XCTAssertNotNil(entity) + + guard let e = entity else { return } + + XCTAssertEqual(e[\.string], "22") + XCTAssertEqual(e[\.int], 22) + XCTAssertEqual(e[\.stringFromInt], "22") + XCTAssertEqual(e[\.plus], 122) + XCTAssertEqual(e[\.doubleFromInt], 22.0) + XCTAssertEqual(e[\.nullToString], "nil") + } } // MARK: Test Types @@ -226,6 +267,63 @@ extension EntityTests { } typealias TestEntity6 = Entity + + enum TestEntityType7: EntityDescription { + static var type: String { return "seventh_test_entities" } + + typealias Identifier = Id + typealias Relationships = NoRelatives + + struct Attributes: JSONAPI.Attributes { + let here: Attribute + let maybeHereMaybeNull: Attribute? + } + } + + typealias TestEntity7 = Entity + + enum TestEntityType8: EntityDescription { + static var type: String { return "eighth_test_entities" } + + typealias Identifier = Id + typealias Relationships = NoRelatives + + struct Attributes: JSONAPI.Attributes { + let string: Attribute + let int: Attribute + let stringFromInt: TransformAttribute + let plus: TransformAttribute + let doubleFromInt: TransformAttribute + let omitted: TransformAttribute? + let nullToString: TransformAttribute> + } + } + + typealias TestEntity8 = Entity + + enum IntToString: Transformer { + public static func transform(_ from: Int) -> String { + return String(from) + } + } + + enum IntPlusOneHundred: Transformer { + public static func transform(_ from: Int) -> Int { + return from + 100 + } + } + + enum IntToDouble: Transformer { + public static func transform(_ from: Int) -> Double { + return Double(from) + } + } + + enum OptionalToString: Transformer { + public static func transform(_ from: T?) -> String { + return String(describing: from) + } + } } extension Entity where EntityType == EntityTests.TestEntityType2 { diff --git a/Tests/JSONAPITests/Entity/stubs/EntityStubs.swift b/Tests/JSONAPITests/Entity/stubs/EntityStubs.swift index 1888c2b..337fd9f 100644 --- a/Tests/JSONAPITests/Entity/stubs/EntityStubs.swift +++ b/Tests/JSONAPITests/Entity/stubs/EntityStubs.swift @@ -113,3 +113,40 @@ let entity_broken_missing_nullable_attribute = """ } } """.data(using: .utf8)! + +let entity_null_optional_nullable_attribute = """ +{ + "id": "1", + "type": "seventh_test_entities", + "attributes": { + "here": "Hello", + "maybeHereMaybeNull": null + } +} +""".data(using: .utf8)! + +let entity_non_null_optional_nullable_attribute = """ +{ + "id": "1", + "type": "seventh_test_entities", + "attributes": { + "here": "Hello", + "maybeHereMaybeNull": "World" + } +} +""".data(using: .utf8)! + +let entity_int_to_string_attribute = """ +{ + "id": "1", + "type": "eighth_test_entities", + "attributes": { + "string": "22", + "int": 22, + "stringFromInt": 22, + "plus": 22, + "doubleFromInt": 22, + "nullToString": null + } +} +""".data(using: .utf8)! diff --git a/Tests/JSONAPITests/Includes/IncludeTests.swift b/Tests/JSONAPITests/Includes/IncludeTests.swift index 30a0d2a..31fa469 100644 --- a/Tests/JSONAPITests/Includes/IncludeTests.swift +++ b/Tests/JSONAPITests/Includes/IncludeTests.swift @@ -115,8 +115,8 @@ extension IncludedTests { public static var type: String { return "test_entity1" } public struct Attributes: JSONAPI.Attributes { - let foo: String - let bar: Int + let foo: Attribute + let bar: Attribute } } @@ -132,8 +132,8 @@ extension IncludedTests { } public struct Attributes: JSONAPI.Attributes { - let foo: String - let bar: Int + let foo: Attribute + let bar: Attribute } } diff --git a/Tests/JSONAPITests/ResourceBody/ResourceBodyTests.swift b/Tests/JSONAPITests/ResourceBody/ResourceBodyTests.swift index 5408a54..1f089b6 100644 --- a/Tests/JSONAPITests/ResourceBody/ResourceBodyTests.swift +++ b/Tests/JSONAPITests/ResourceBody/ResourceBodyTests.swift @@ -18,7 +18,7 @@ class ResourceBodyTests: XCTestCase { guard let b = body else { return } XCTAssertEqual(b.value, Article(id: Id(rawValue: "1"), - attributes: ArticleType.Attributes(title: "JSON:API paints my bikeshed!"))) + attributes: ArticleType.Attributes(title: try! .init(rawValue: "JSON:API paints my bikeshed!")))) } func test_manyResourceBody() { @@ -29,9 +29,9 @@ class ResourceBodyTests: XCTestCase { guard let b = body else { return } XCTAssertEqual(b.values, [ - Article(id: .init(rawValue: "1"), attributes: .init(title: "JSON:API paints my bikeshed!")), - Article(id: .init(rawValue: "2"), attributes: .init(title: "Sick")), - Article(id: .init(rawValue: "3"), attributes: .init(title: "Hello World")) + Article(id: .init(rawValue: "1"), attributes: try! .init(title: .init(rawValue: "JSON:API paints my bikeshed!"))), + Article(id: .init(rawValue: "2"), attributes: try! .init(title: .init(rawValue: "Sick"))), + Article(id: .init(rawValue: "3"), attributes: try! .init(title: .init(rawValue: "Hello World"))) ]) } @@ -42,7 +42,7 @@ class ResourceBodyTests: XCTestCase { typealias Relationships = NoRelatives struct Attributes: JSONAPI.Attributes { - let title: String + let title: Attribute } } diff --git a/Tests/JSONAPITests/XCTestManifests.swift b/Tests/JSONAPITests/XCTestManifests.swift index 45ad891..41d9caf 100644 --- a/Tests/JSONAPITests/XCTestManifests.swift +++ b/Tests/JSONAPITests/XCTestManifests.swift @@ -20,6 +20,9 @@ extension EntityTests { ("test_entityOneOmittedAttribute", test_entityOneOmittedAttribute), ("test_EntitySomeRelationshipsNoAttributes", test_EntitySomeRelationshipsNoAttributes), ("test_EntitySomeRelationshipsSomeAttributes", test_EntitySomeRelationshipsSomeAttributes), + ("test_IntToString", test_IntToString), + ("test_NonNullOptionalNullableAttribute", test_NonNullOptionalNullableAttribute), + ("test_NullOptionalNullableAttribute", test_NullOptionalNullableAttribute), ("test_relationship_access", test_relationship_access), ("test_relationship_operator_access", test_relationship_operator_access), ("test_relationshipIds", test_relationshipIds),