mirror of
https://github.com/encounter/JSONAPI.git
synced 2026-03-30 11:18:38 -07:00
allow EntityDescription to decode nullable relationships
This commit is contained in:
@@ -69,7 +69,7 @@ The primary goals of this framework are:
|
||||
### Misc
|
||||
- [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`).
|
||||
- [ ] Fix `ToOneRelationship` so that it is possible to specify an optional relationship where the value is `null` rather than the key being omitted.
|
||||
- [x] Fix `ToOneRelationship` so that it is possible to specify an optional relationship where the value is `null` rather than the key being omitted.
|
||||
- [ ] Conform to `CustomStringConvertible`
|
||||
- [x] For `NoIncludes`, do not even loop over the "included" JSON API section if it exists.
|
||||
- [ ] `EntityDescription` validator (using reflection)
|
||||
@@ -98,7 +98,7 @@ enum PersonDescription: IdentifiedEntityDescription {
|
||||
}
|
||||
|
||||
struct Relationships: JSONAPI.Relationships {
|
||||
let friends: ToManyRelationship<PersonDescription>
|
||||
let friends: ToManyRelationship<Person>
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -148,7 +148,12 @@ typealias Person = Entity<PersonDescription>
|
||||
|
||||
### `Relationships`
|
||||
|
||||
There are two types of `Relationship`s: `ToOneRelationship` and `ToManyRelationship`. An `EntityDescription`'s `Relationships` type can contain any number of `Relationship`s of either of these types. Do not store anything other than `Relationship`s in the `Relationships` type of an `EntityDescription`.
|
||||
There are two types of `Relationship`s: `ToOneRelationship` and `ToManyRelationship`. An `EntityDescription`'s `Relationships` type can contain any number of `Relationship`s of either of these types. Do not store anything other than `Relationship`s in the `Relationships` struct of an `EntityDescription`.
|
||||
|
||||
To describe a relationship that may be omitted (i.e. the key is not even present in the JSON object), you make the entire `ToOneRelationship` or `ToManyRelationship` optional. However, this is not recommended because you can also represent optional relationships as nullable which means the key is always present. A `ToManyRelationship` can naturally represent no related objects exist with an empty array, so `ToManyRelationship` does not support nullability at all. A `ToOneRelationship` can be marked as nullable (i.e. the value might be `null` or it might be a resource identifier) like this:
|
||||
```
|
||||
let nullableRelative: ToOneRelationship<Person?>
|
||||
```
|
||||
|
||||
An entity that does not have relationships can be described by adding the following to an `EntityDescription`:
|
||||
```
|
||||
|
||||
@@ -163,14 +163,14 @@ public extension Entity {
|
||||
/// Access to an Id of a `ToOneRelationship`.
|
||||
/// This allows you to write `entity ~> \.other` instead
|
||||
/// of `entity.relationships.other.id`.
|
||||
public static func ~><OtherEntity: Relatable>(entity: Entity<EntityType>, path: KeyPath<EntityType.Relationships, ToOneRelationship<OtherEntity>>) -> OtherEntity.Description.Identifier {
|
||||
public static func ~><OtherEntity: OptionalRelatable>(entity: Entity, path: KeyPath<EntityType.Relationships, ToOneRelationship<OtherEntity>>) -> OtherEntity.Identifier {
|
||||
return entity.relationships[keyPath: path].id
|
||||
}
|
||||
|
||||
/// Access to all Ids of a `ToManyRelationship`.
|
||||
/// This allows you to write `entity ~> \.others` instead
|
||||
/// of `entity.relationships.others.ids`.
|
||||
public static func ~><OtherEntity: Relatable>(entity: Entity<EntityType>, path: KeyPath<EntityType.Relationships, ToManyRelationship<OtherEntity>>) -> [OtherEntity.Description.Identifier] {
|
||||
public static func ~><OtherEntity: Relatable>(entity: Entity, path: KeyPath<EntityType.Relationships, ToManyRelationship<OtherEntity>>) -> [OtherEntity.Identifier] {
|
||||
return entity.relationships[keyPath: path].ids
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,48 +10,54 @@
|
||||
/// You should use the `ToOneRelationship` and `ToManyRelationship`
|
||||
/// concrete types.
|
||||
/// See https://jsonapi.org/format/#document-resource-object-linkage
|
||||
public protocol Relationship: Equatable, Encodable, CustomStringConvertible {
|
||||
associatedtype EntityType: JSONAPI.EntityDescription where EntityType.Identifier: IdType
|
||||
var ids: [EntityType.Identifier] { get }
|
||||
}
|
||||
//public protocol Relationship: Equatable, Encodable, CustomStringConvertible {
|
||||
// associatedtype EntityType: JSONAPI.EntityDescription where EntityType.Identifier: IdType
|
||||
// var ids: [EntityType.Identifier] { get }
|
||||
//}
|
||||
|
||||
/// 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<EntityDescription>`
|
||||
public struct ToOneRelationship<Relatable: JSONAPI.OptionalRelatable>: Equatable, Relationship, Decodable {
|
||||
public typealias EntityType = Relatable.Description
|
||||
public struct ToOneRelationship<Relatable: JSONAPI.OptionalRelatable>: Equatable, Codable {
|
||||
|
||||
public let id: EntityType.Identifier
|
||||
|
||||
public init(entity: Entity<EntityType>) {
|
||||
id = entity.id
|
||||
}
|
||||
public let id: Relatable.Identifier
|
||||
|
||||
public var ids: [EntityType.Identifier] {
|
||||
public var ids: [Relatable.Identifier] {
|
||||
return [id]
|
||||
}
|
||||
}
|
||||
|
||||
extension ToOneRelationship where Relatable.Description.Identifier == Relatable.Identifier {
|
||||
public init(entity: Entity<Relatable.Description>) {
|
||||
id = entity.id
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: `Many<EntityDescription>`
|
||||
public struct ToManyRelationship<Relatable: JSONAPI.Relatable>: Equatable, Relationship, Decodable {
|
||||
public typealias EntityType = Relatable.Description
|
||||
public struct ToManyRelationship<Relatable: JSONAPI.Relatable>: Equatable, Codable {
|
||||
|
||||
public let ids: [EntityType.Identifier]
|
||||
|
||||
public init(entities: [Entity<EntityType>]) {
|
||||
ids = entities.map { $0.id }
|
||||
public let ids: [Relatable.Identifier]
|
||||
|
||||
public init<T: JSONAPI.Relatable>(relationships: [ToOneRelationship<T>]) where T.Identifier == Relatable.Identifier {
|
||||
ids = relationships.map { $0.id }
|
||||
}
|
||||
|
||||
public init<T: Relationship>(relationships: [T]) where T.EntityType == EntityType {
|
||||
ids = relationships.flatMap { $0.ids }
|
||||
|
||||
private init() {
|
||||
ids = []
|
||||
}
|
||||
|
||||
public static var none: ToManyRelationship {
|
||||
return .init(entities: [])
|
||||
return .init()
|
||||
}
|
||||
}
|
||||
|
||||
extension ToManyRelationship where Relatable.Description.Identifier == Relatable.Identifier {
|
||||
public init(entities: [Entity<Relatable.Description>]) {
|
||||
ids = entities.map { $0.id }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +65,7 @@ public struct ToManyRelationship<Relatable: JSONAPI.Relatable>: Equatable, Relat
|
||||
/// Optional<T: Relatable> types.
|
||||
public protocol OptionalRelatable {
|
||||
associatedtype Description: EntityDescription where Description.Identifier: IdType
|
||||
associatedtype Identifier: Equatable & Codable
|
||||
}
|
||||
|
||||
/// The Relatable protocol describes anything that
|
||||
@@ -71,6 +78,7 @@ extension Entity: Relatable, OptionalRelatable where EntityType.Identifier: IdTy
|
||||
|
||||
extension Optional: OptionalRelatable where Wrapped: Relatable {
|
||||
public typealias Description = Wrapped.Description
|
||||
public typealias Identifier = Wrapped.Description.Identifier?
|
||||
}
|
||||
|
||||
// MARK: Codable
|
||||
@@ -89,23 +97,41 @@ public enum JSONAPIEncodingError: Swift.Error {
|
||||
extension ToOneRelationship {
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: ResourceLinkageCodingKeys.self)
|
||||
|
||||
// A little trickery follows. If the id is nil, the
|
||||
// container.decode(Identifier.self) will fail even if Identifier
|
||||
// is Optional. However, we can check if decoding nil
|
||||
// succeeds and then attempt to coerce nil to a Identifier
|
||||
// type at which point we can store nil in `id`.
|
||||
let anyNil: Any? = nil
|
||||
if try container.decodeNil(forKey: .data),
|
||||
let val = anyNil as? Relatable.Identifier {
|
||||
id = val
|
||||
return
|
||||
}
|
||||
|
||||
let identifier = try container.nestedContainer(keyedBy: ResourceIdentifierCodingKeys.self, forKey: .data)
|
||||
|
||||
let type = try identifier.decode(String.self, forKey: .entityType)
|
||||
|
||||
guard type == EntityType.type else {
|
||||
throw JSONAPIEncodingError.typeMismatch(expected: EntityType.type, found: type)
|
||||
guard type == Relatable.Description.type else {
|
||||
throw JSONAPIEncodingError.typeMismatch(expected: Relatable.Description.type, found: type)
|
||||
}
|
||||
|
||||
id = try identifier.decode(EntityType.Identifier.self, forKey: .id)
|
||||
id = try identifier.decode(Relatable.Identifier.self, forKey: .id)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: ResourceLinkageCodingKeys.self)
|
||||
|
||||
if (id as Any?) == nil {
|
||||
try container.encodeNil(forKey: .data)
|
||||
}
|
||||
|
||||
var identifier = container.nestedContainer(keyedBy: ResourceIdentifierCodingKeys.self, forKey: .data)
|
||||
|
||||
try identifier.encode(id, forKey: .id)
|
||||
try identifier.encode(EntityType.type, forKey: .entityType)
|
||||
try identifier.encode(Relatable.Description.type, forKey: .entityType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,17 +141,17 @@ extension ToManyRelationship {
|
||||
|
||||
var identifiers = try container.nestedUnkeyedContainer(forKey: .data)
|
||||
|
||||
var newIds = [EntityType.Identifier]()
|
||||
var newIds = [Relatable.Identifier]()
|
||||
while !identifiers.isAtEnd {
|
||||
let identifier = try identifiers.nestedContainer(keyedBy: ResourceIdentifierCodingKeys.self)
|
||||
|
||||
let type = try identifier.decode(String.self, forKey: .entityType)
|
||||
|
||||
guard type == EntityType.type else {
|
||||
throw JSONAPIEncodingError.typeMismatch(expected: EntityType.type, found: type)
|
||||
guard type == Relatable.Description.type else {
|
||||
throw JSONAPIEncodingError.typeMismatch(expected: Relatable.Description.type, found: type)
|
||||
}
|
||||
|
||||
newIds.append(try identifier.decode(EntityType.Identifier.self, forKey: .id))
|
||||
newIds.append(try identifier.decode(Relatable.Identifier.self, forKey: .id))
|
||||
}
|
||||
ids = newIds
|
||||
}
|
||||
@@ -138,7 +164,7 @@ extension ToManyRelationship {
|
||||
var identifier = identifiers.nestedContainer(keyedBy: ResourceIdentifierCodingKeys.self)
|
||||
|
||||
try identifier.encode(id, forKey: .id)
|
||||
try identifier.encode(EntityType.type, forKey: .entityType)
|
||||
try identifier.encode(Relatable.Description.type, forKey: .entityType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ class EntityTests: XCTestCase {
|
||||
let entity2 = TestEntity1()
|
||||
let entity4 = TestEntity1()
|
||||
let entity3 = TestEntity3(others: .init(relationships: [entity1.pointer, entity2.pointer, entity4.pointer]))
|
||||
|
||||
|
||||
XCTAssertEqual(entity3 ~> \.others, [entity1.id, entity2.id, entity4.id])
|
||||
}
|
||||
|
||||
@@ -182,6 +182,31 @@ extension EntityTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Relationship omission and nullification
|
||||
extension EntityTests {
|
||||
func test_nullableRelationshipNotNull() {
|
||||
let entity = try? JSONDecoder().decode(TestEntity9.self, from: entity_omitted_relationship)
|
||||
|
||||
XCTAssertNotNil(entity)
|
||||
|
||||
guard let e = entity else { return }
|
||||
|
||||
XCTAssertEqual((e ~> \.nullableOne)?.rawValue, "3323")
|
||||
XCTAssertEqual((e ~> \.one).rawValue, "4459")
|
||||
}
|
||||
|
||||
func test_nullableRelationshipIsNull() {
|
||||
let entity = try? JSONDecoder().decode(TestEntity9.self, from: entity_nulled_relationship)
|
||||
|
||||
XCTAssertNotNil(entity)
|
||||
|
||||
guard let e = entity else { return }
|
||||
|
||||
XCTAssertNil(e ~> \.nullableOne)
|
||||
XCTAssertEqual((e ~> \.one).rawValue, "4452")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Test Types
|
||||
extension EntityTests {
|
||||
|
||||
@@ -299,7 +324,31 @@ extension EntityTests {
|
||||
}
|
||||
|
||||
typealias TestEntity8 = Entity<TestEntityType8>
|
||||
|
||||
|
||||
enum TestEntityType9: EntityDescription {
|
||||
public static var type: String { return "ninth_test_entities" }
|
||||
|
||||
typealias Identifier = Id<String, TestEntityType9>
|
||||
|
||||
typealias Attributes = NoAttributes
|
||||
|
||||
public struct Relationships: JSONAPI.Relationships {
|
||||
let one: ToOneRelationship<TestEntity1>
|
||||
|
||||
let nullableOne: ToOneRelationship<TestEntity1?>
|
||||
|
||||
// a nullable many is not allowed. it should
|
||||
// just be an empty array.
|
||||
|
||||
// omitted relationships are not allowed either,
|
||||
// so ToOneRelationship<TestEntity1>? (with the
|
||||
// question on the relationship, not the entity)
|
||||
// is not a thing.
|
||||
}
|
||||
}
|
||||
|
||||
typealias TestEntity9 = Entity<TestEntityType9>
|
||||
|
||||
enum IntToString: Transformer {
|
||||
public static func transform(_ from: Int) -> String {
|
||||
return String(from)
|
||||
|
||||
@@ -150,3 +150,42 @@ let entity_int_to_string_attribute = """
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let entity_omitted_relationship = """
|
||||
{
|
||||
"id": "1",
|
||||
"type": "ninth_test_entities",
|
||||
"relationships": {
|
||||
"nullableOne": {
|
||||
"data": {
|
||||
"id": "3323",
|
||||
"type": "test_entities"
|
||||
}
|
||||
},
|
||||
"one": {
|
||||
"data": {
|
||||
"id": "4459",
|
||||
"type": "test_entities"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let entity_nulled_relationship = """
|
||||
{
|
||||
"id": "1",
|
||||
"type": "ninth_test_entities",
|
||||
"relationships": {
|
||||
"nullableOne": {
|
||||
"data": null
|
||||
},
|
||||
"one": {
|
||||
"data": {
|
||||
"id": "4452",
|
||||
"type": "test_entities"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
@@ -61,28 +61,3 @@ class RelationshipTests: XCTestCase {
|
||||
|
||||
typealias TestEntity1 = Entity<TestEntityType1>
|
||||
}
|
||||
|
||||
// MARK: omission and nullification
|
||||
extension RelationshipTests {
|
||||
func test_omittedRelationship() {
|
||||
// TODO: fill out test
|
||||
}
|
||||
|
||||
enum TestEntityType2: EntityDescription {
|
||||
public static var type: String { return "test_entity2" }
|
||||
|
||||
typealias Identifier = Id<String, TestEntityType2>
|
||||
|
||||
typealias Attributes = NoAttributes
|
||||
|
||||
public struct Relationships: JSONAPI.Relationships {
|
||||
let maybeOne: ToOneRelationship<TestEntity1>?
|
||||
let maybeMore: ToManyRelationship<TestEntity1>?
|
||||
|
||||
let nullableOne: ToOneRelationship<TestEntity1?>
|
||||
|
||||
// a nullable many is not allowed. it should
|
||||
// just be an empty array.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user