Add ability to specify an Attribute needs to be transformed once it has been decoded.

This commit is contained in:
Mathew Polzin
2018-11-14 08:38:43 -08:00
parent 805ef4debe
commit fd82d5d7de
8 changed files with 215 additions and 25 deletions
+22 -3
View File
@@ -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<PersonDescription>
```
@@ -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<String, ISODateTransformer>
```
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:
+37 -11
View File
@@ -5,17 +5,28 @@
// Created by Mathew Polzin on 11/13/18.
//
public struct Attribute<Value: Codable>: Codable {
public let value: Value
public struct TransformAttribute<RawValue: Codable, Transformer: JSONAPI.Transformer>: 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<T: Codable> = TransformAttribute<T, IdentityTransformer<T>>
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<T>: Transformer {
public static func transform(_ from: T) throws -> T { return from }
}
+9 -2
View File
@@ -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<T>(_ path: KeyPath<EntityType.Attributes, Attribute<T>>) -> T {
subscript<T, TFRM: Transformer>(_ path: KeyPath<EntityType.Attributes, TransformAttribute<T, TFRM>>) -> 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<T>(_ path: KeyPath<EntityType.Attributes, Attribute<T>?>) -> T? {
subscript<T, TFRM: Transformer>(_ path: KeyPath<EntityType.Attributes, TransformAttribute<T, TFRM>?>) -> 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<T, TFRM: Transformer, U>(_ path: KeyPath<EntityType.Attributes, TransformAttribute<T, TFRM>?>) -> U? where TFRM.To == U? {
return attributes[keyPath: path].flatMap { $0.value }
}
}
// MARK: Relationship Access
@@ -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<TestEntityType6>
enum TestEntityType7: EntityDescription {
static var type: String { return "seventh_test_entities" }
typealias Identifier = Id<String, TestEntityType7>
typealias Relationships = NoRelatives
struct Attributes: JSONAPI.Attributes {
let here: Attribute<String>
let maybeHereMaybeNull: Attribute<String?>?
}
}
typealias TestEntity7 = Entity<TestEntityType7>
enum TestEntityType8: EntityDescription {
static var type: String { return "eighth_test_entities" }
typealias Identifier = Id<String, TestEntityType8>
typealias Relationships = NoRelatives
struct Attributes: JSONAPI.Attributes {
let string: Attribute<String>
let int: Attribute<Int>
let stringFromInt: TransformAttribute<Int, IntToString>
let plus: TransformAttribute<Int, IntPlusOneHundred>
let doubleFromInt: TransformAttribute<Int, IntToDouble>
let omitted: TransformAttribute<Int, IntToString>?
let nullToString: TransformAttribute<Int?, OptionalToString<Int>>
}
}
typealias TestEntity8 = Entity<TestEntityType8>
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<T>: Transformer {
public static func transform(_ from: T?) -> String {
return String(describing: from)
}
}
}
extension Entity where EntityType == EntityTests.TestEntityType2 {
@@ -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)!
@@ -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<String>
let bar: Attribute<Int>
}
}
@@ -132,8 +132,8 @@ extension IncludedTests {
}
public struct Attributes: JSONAPI.Attributes {
let foo: String
let bar: Int
let foo: Attribute<String>
let bar: Attribute<Int>
}
}
@@ -18,7 +18,7 @@ class ResourceBodyTests: XCTestCase {
guard let b = body else { return }
XCTAssertEqual(b.value, Article(id: Id<String, ArticleType>(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<String>
}
}
+3
View File
@@ -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),