mirror of
https://github.com/encounter/JSONAPI.git
synced 2026-03-30 11:18:38 -07:00
Add ability to specify an Attribute needs to be transformed once it has been decoded.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user