From d5a24c4adb5407259d48a959be58277572e4680b Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sat, 29 Dec 2018 23:07:14 -0800 Subject: [PATCH] Split Attribute out into its own Type (no longer just a type alias to TransformedAttribute) --- JSONAPI.playground/Sources/Entities.swift | 6 +- Sources/JSONAPI/Document/APIDescription.swift | 2 + .../JSONAPI/Resource/Attribute+Functor.swift | 15 ++++ Sources/JSONAPI/Resource/Attribute.swift | 79 ++++++++++++++++--- Sources/JSONAPI/Resource/Entity.swift | 6 +- Sources/JSONAPI/Resource/Transformer.swift | 4 + .../JSONAPITestLib/Attribute+Literal.swift | 18 ++--- Sources/JSONAPITestLib/EntityCheck.swift | 7 +- .../Attribute/AttributeTests.swift | 8 +- .../JSONAPITests/Document/DocumentTests.swift | 2 + .../ResourceBody/ResourceBodyTests.swift | 23 +++++- .../Test Helpers/EncodedAttributeTest.swift | 27 ++++++- Tests/JSONAPITests/XCTestManifests.swift | 3 +- 13 files changed, 157 insertions(+), 43 deletions(-) diff --git a/JSONAPI.playground/Sources/Entities.swift b/JSONAPI.playground/Sources/Entities.swift index d221a01..c871304 100644 --- a/JSONAPI.playground/Sources/Entities.swift +++ b/JSONAPI.playground/Sources/Entities.swift @@ -64,7 +64,7 @@ public typealias Person = ExampleEntity public extension Entity where Description == PersonDescription, MetaType == NoMetadata, LinksType == NoLinks, EntityRawIdType == String { public init(id: Person.Id? = nil,name: [String], favoriteColor: String, friends: [Person], dogs: [Dog], home: House) throws { - self = try Person(id: id ?? Person.Id(), attributes: .init(name: .init(rawValue: name), favoriteColor: .init(rawValue: favoriteColor)), relationships: .init(friends: .init(entities: friends), dogs: .init(entities: dogs), home: .init(entity: home)), meta: .none, links: .none) + self = Person(id: id ?? Person.Id(), attributes: .init(name: .init(value: name), favoriteColor: .init(value: favoriteColor)), relationships: .init(friends: .init(entities: friends), dogs: .init(entities: dogs), home: .init(entity: home)), meta: .none, links: .none) } } @@ -121,11 +121,11 @@ public typealias AlternativeDog = ExampleEntity public extension Entity where Description == DogDescription, MetaType == NoMetadata, LinksType == NoLinks, EntityRawIdType == String { public init(name: String, owner: Person?) throws { - self = try Dog(attributes: .init(name: .init(rawValue: name)), relationships: DogDescription.Relationships(owner: .init(entity: owner)), meta: .none, links: .none) + self = Dog(attributes: .init(name: .init(value: name)), relationships: DogDescription.Relationships(owner: .init(entity: owner)), meta: .none, links: .none) } public init(name: String, owner: Person.Id) throws { - self = try Dog(attributes: .init(name: .init(rawValue: name)), relationships: .init(owner: .init(id: owner)), meta: .none, links: .none) + self = Dog(attributes: .init(name: .init(value: name)), relationships: .init(owner: .init(id: owner)), meta: .none, links: .none) } } diff --git a/Sources/JSONAPI/Document/APIDescription.swift b/Sources/JSONAPI/Document/APIDescription.swift index 228f46e..676aca6 100644 --- a/Sources/JSONAPI/Document/APIDescription.swift +++ b/Sources/JSONAPI/Document/APIDescription.swift @@ -16,6 +16,8 @@ public struct APIDescription: APIDescriptionType { public let meta: Meta } +/// Can be used as `APIDescriptionType` for Documents that do not +/// have any API Description (a.k.a. "JSON:API Object"). public struct NoAPIDescription: APIDescriptionType, CustomStringConvertible { public typealias Meta = NoMetadata diff --git a/Sources/JSONAPI/Resource/Attribute+Functor.swift b/Sources/JSONAPI/Resource/Attribute+Functor.swift index 39f2a81..e914e2e 100644 --- a/Sources/JSONAPI/Resource/Attribute+Functor.swift +++ b/Sources/JSONAPI/Resource/Attribute+Functor.swift @@ -19,3 +19,18 @@ public extension TransformedAttribute { return Attribute(value: try transform(value)) } } + +public extension Attribute { + /// Map an Attribute to a new wrapped type. + /// Note that the resulting Attribute will have no transformer, even if the + /// source Attribute has a transformer. + /// You are mapping the output of the source transform into + /// the RawValue of a new transformerless Attribute. + /// + /// Generally, this is the most useful operation. The transformer gives you + /// control over the decoding of the Attribute, but once the Attribute exists, + /// mapping on it is most useful for creating computed Attribute properties. + public func map(_ transform: (ValueType) throws -> T) rethrows -> Attribute { + return Attribute(value: try transform(value)) + } +} diff --git a/Sources/JSONAPI/Resource/Attribute.swift b/Sources/JSONAPI/Resource/Attribute.swift index 2b351fd..670dca5 100644 --- a/Sources/JSONAPI/Resource/Attribute.swift +++ b/Sources/JSONAPI/Resource/Attribute.swift @@ -6,9 +6,14 @@ // public protocol AttributeType: Codable { + associatedtype RawValue: Codable + associatedtype ValueType + + var value: ValueType { get } } // MARK: TransformedAttribute + /// A TransformedAttribute takes a Codable type and attempts to turn it into another type. public struct TransformedAttribute: AttributeType where Transformer.From == RawValue { let rawValue: RawValue @@ -21,16 +26,19 @@ public struct TransformedAttribute = TransformedAttribute where RawValue == Validator.From - -// MARK: Attribute -/// An Attribute simply represents a type that can be encoded and decoded. -public typealias Attribute = TransformedAttribute> +extension TransformedAttribute where Transformer == IdentityTransformer { + // If we are using the identity transform, we can skip the transform and guarantee no + // error is thrown. + public init(value: RawValue) { + rawValue = value + self.value = value + } +} extension TransformedAttribute where Transformer: ReversibleTransformer { + /// Initialize a TransformedAttribute from its transformed value. The + /// RawValue, which is what gets encoded/decoded, is determined using + /// The Transformer's reverse function. public init(transformedValue: Transformer.To) throws { self.value = transformedValue rawValue = try Transformer.reverse(value) @@ -45,15 +53,35 @@ extension TransformedAttribute: CustomStringConvertible { extension TransformedAttribute: Equatable where Transformer.From: Equatable, Transformer.To: Equatable {} -extension TransformedAttribute where Transformer == IdentityTransformer { - // If we are using the identity transform, we can skip the transform and guarantee no - // error is thrown. +// MARK: ValidatedAttribute + +/// A ValidatedAttribute does not transform its raw value, but it throws +/// an error if the raw value does not match expectations. +public typealias ValidatedAttribute = TransformedAttribute where RawValue == Validator.From + +// MARK: Attribute + +/// An Attribute simply represents a type that can be encoded and decoded. +public struct Attribute: AttributeType { + let attribute: TransformedAttribute> + + public var value: RawValue { + return attribute.value + } + public init(value: RawValue) { - rawValue = value - self.value = value + attribute = .init(value: value) } } +extension Attribute: CustomStringConvertible { + public var description: String { + return "Attribute<\(String(describing: RawValue.self))>(\(String(describing: value)))" + } +} + +extension Attribute: Equatable where RawValue: Equatable {} + // MARK: - Codable extension TransformedAttribute { public init(from decoder: Decoder) throws { @@ -85,6 +113,31 @@ extension TransformedAttribute { } } +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? RawValue { + attribute = .init(value: val) + } else { + attribute = try container.decode(TransformedAttribute>.self) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + try container.encode(attribute) + } +} + // MARK: Attribute decoding and encoding defaults extension AttributeType { diff --git a/Sources/JSONAPI/Resource/Entity.swift b/Sources/JSONAPI/Resource/Entity.swift index b28de51..e78caff 100644 --- a/Sources/JSONAPI/Resource/Entity.swift +++ b/Sources/JSONAPI/Resource/Entity.swift @@ -433,21 +433,21 @@ public extension EntityProxy { /// Access the attribute at the given keypath. This just /// allows you to write `entity[\.propertyName]` instead /// of `entity.relationships.propertyName`. - subscript(_ path: KeyPath>) -> TFRM.To { + subscript(_ path: KeyPath) -> T.ValueType { 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?>) -> TFRM.To? { + subscript(_ path: KeyPath) -> T.ValueType? { 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? { + subscript(_ path: KeyPath) -> U? where T.ValueType == U? { // Implementation Note: Handles Transform that returns optional // type. return attributes[keyPath: path].flatMap { $0.value } diff --git a/Sources/JSONAPI/Resource/Transformer.swift b/Sources/JSONAPI/Resource/Transformer.swift index b4382a2..920b9d4 100644 --- a/Sources/JSONAPI/Resource/Transformer.swift +++ b/Sources/JSONAPI/Resource/Transformer.swift @@ -5,6 +5,7 @@ // Created by Mathew Polzin on 11/17/18. // +/// A Transformer simply defines a static function that transforms a value. public protocol Transformer { associatedtype From associatedtype To @@ -12,10 +13,13 @@ public protocol Transformer { static func transform(_ value: From) throws -> To } +/// ReversibleTransformers define a function that reverses the transform +/// operation. public protocol ReversibleTransformer: Transformer { static func reverse(_ value: To) throws -> From } +/// The IdentityTransformer does not perform any transformation on a value. public enum IdentityTransformer: ReversibleTransformer { public static func transform(_ value: T) throws -> T { return value } public static func reverse(_ value: T) throws -> T { return value } diff --git a/Sources/JSONAPITestLib/Attribute+Literal.swift b/Sources/JSONAPITestLib/Attribute+Literal.swift index 07339c0..706b6e4 100644 --- a/Sources/JSONAPITestLib/Attribute+Literal.swift +++ b/Sources/JSONAPITestLib/Attribute+Literal.swift @@ -1,7 +1,7 @@ import JSONAPI -extension TransformedAttribute: ExpressibleByUnicodeScalarLiteral where RawValue: ExpressibleByUnicodeScalarLiteral, Transformer == IdentityTransformer { +extension Attribute: ExpressibleByUnicodeScalarLiteral where RawValue: ExpressibleByUnicodeScalarLiteral { public typealias UnicodeScalarLiteralType = RawValue.UnicodeScalarLiteralType public init(unicodeScalarLiteral value: RawValue.UnicodeScalarLiteralType) { @@ -9,7 +9,7 @@ extension TransformedAttribute: ExpressibleByUnicodeScalarLiteral where RawValue } } -extension TransformedAttribute: ExpressibleByExtendedGraphemeClusterLiteral where RawValue: ExpressibleByExtendedGraphemeClusterLiteral, Transformer == IdentityTransformer { +extension Attribute: ExpressibleByExtendedGraphemeClusterLiteral where RawValue: ExpressibleByExtendedGraphemeClusterLiteral { public typealias ExtendedGraphemeClusterLiteralType = RawValue.ExtendedGraphemeClusterLiteralType public init(extendedGraphemeClusterLiteral value: RawValue.ExtendedGraphemeClusterLiteralType) { @@ -17,7 +17,7 @@ extension TransformedAttribute: ExpressibleByExtendedGraphemeClusterLiteral wher } } -extension TransformedAttribute: ExpressibleByStringLiteral where RawValue: ExpressibleByStringLiteral, Transformer == IdentityTransformer { +extension Attribute: ExpressibleByStringLiteral where RawValue: ExpressibleByStringLiteral { public typealias StringLiteralType = RawValue.StringLiteralType public init(stringLiteral value: RawValue.StringLiteralType) { @@ -25,13 +25,13 @@ extension TransformedAttribute: ExpressibleByStringLiteral where RawValue: Expre } } -extension TransformedAttribute: ExpressibleByNilLiteral where RawValue: ExpressibleByNilLiteral, Transformer == IdentityTransformer { +extension Attribute: ExpressibleByNilLiteral where RawValue: ExpressibleByNilLiteral { public init(nilLiteral: ()) { self.init(value: RawValue(nilLiteral: ())) } } -extension TransformedAttribute: ExpressibleByFloatLiteral where RawValue: ExpressibleByFloatLiteral, Transformer == IdentityTransformer { +extension Attribute: ExpressibleByFloatLiteral where RawValue: ExpressibleByFloatLiteral { public typealias FloatLiteralType = RawValue.FloatLiteralType public init(floatLiteral value: RawValue.FloatLiteralType) { @@ -47,7 +47,7 @@ extension Optional: ExpressibleByFloatLiteral where Wrapped: ExpressibleByFloatL } } -extension TransformedAttribute: ExpressibleByBooleanLiteral where RawValue: ExpressibleByBooleanLiteral, Transformer == IdentityTransformer { +extension Attribute: ExpressibleByBooleanLiteral where RawValue: ExpressibleByBooleanLiteral { public typealias BooleanLiteralType = RawValue.BooleanLiteralType public init(booleanLiteral value: BooleanLiteralType) { @@ -63,7 +63,7 @@ extension Optional: ExpressibleByBooleanLiteral where Wrapped: ExpressibleByBool } } -extension TransformedAttribute: ExpressibleByIntegerLiteral where RawValue: ExpressibleByIntegerLiteral, Transformer == IdentityTransformer { +extension Attribute: ExpressibleByIntegerLiteral where RawValue: ExpressibleByIntegerLiteral { public typealias IntegerLiteralType = RawValue.IntegerLiteralType public init(integerLiteral value: IntegerLiteralType) { @@ -91,7 +91,7 @@ public protocol DictionaryType { } extension Dictionary: DictionaryType {} -extension TransformedAttribute: ExpressibleByDictionaryLiteral where RawValue: DictionaryType, Transformer == IdentityTransformer { +extension Attribute: ExpressibleByDictionaryLiteral where RawValue: DictionaryType { public typealias Key = RawValue.Key public typealias Value = RawValue.Value @@ -121,7 +121,7 @@ public protocol ArrayType { extension Array: ArrayType {} extension ArraySlice: ArrayType {} -extension TransformedAttribute: ExpressibleByArrayLiteral where RawValue: ArrayType, Transformer == IdentityTransformer { +extension Attribute: ExpressibleByArrayLiteral where RawValue: ArrayType { public typealias ArrayLiteralElement = RawValue.Element public init(arrayLiteral elements: ArrayLiteralElement...) { diff --git a/Sources/JSONAPITestLib/EntityCheck.swift b/Sources/JSONAPITestLib/EntityCheck.swift index e27890f..2a91e31 100644 --- a/Sources/JSONAPITestLib/EntityCheck.swift +++ b/Sources/JSONAPITestLib/EntityCheck.swift @@ -41,6 +41,7 @@ extension Optional: OptionalArray where Wrapped: ArrayType {} private protocol AttributeTypeWithOptionalArray {} extension TransformedAttribute: AttributeTypeWithOptionalArray where RawValue: OptionalArray {} +extension Attribute: AttributeTypeWithOptionalArray where RawValue: OptionalArray {} private protocol OptionalRelationshipType {} extension Optional: OptionalRelationshipType where Wrapped: RelationshipType {} @@ -49,6 +50,10 @@ private protocol _RelationshipType {} extension ToOneRelationship: _RelationshipType {} extension ToManyRelationship: _RelationshipType {} +private protocol _AttributeType {} +extension TransformedAttribute: _AttributeType {} +extension Attribute: _AttributeType {} + public extension Entity { public static func check(_ entity: Entity) throws { var problems = [EntityCheckError]() @@ -60,7 +65,7 @@ public extension Entity { } for attribute in attributesMirror.children { - if attribute.value as? AttributeType == nil, + if attribute.value as? _AttributeType == nil, attribute.value as? OptionalAttributeType == nil { problems.append(.nonAttribute(named: attribute.label ?? "unnamed")) } diff --git a/Tests/JSONAPITests/Attribute/AttributeTests.swift b/Tests/JSONAPITests/Attribute/AttributeTests.swift index 4c5710a..22acd71 100644 --- a/Tests/JSONAPITests/Attribute/AttributeTests.swift +++ b/Tests/JSONAPITests/Attribute/AttributeTests.swift @@ -10,12 +10,8 @@ import JSONAPI class AttributeTests: XCTestCase { - func test_AttributeIsTransformedAttribute() { - XCTAssertEqual(try TransformedAttribute>(rawValue: "hello"), try Attribute(rawValue: "hello")) - } - - func test_AttributeNonThrowingConstructor() { - XCTAssertEqual(try Attribute(rawValue: "hello"), Attribute(value: "hello")) + func test_AttributeConstructor() { + XCTAssertEqual(Attribute(value: "hello").value, "hello") } func test_TransformedAttributeNoThrow() { diff --git a/Tests/JSONAPITests/Document/DocumentTests.swift b/Tests/JSONAPITests/Document/DocumentTests.swift index 9f4972f..cc0bcec 100644 --- a/Tests/JSONAPITests/Document/DocumentTests.swift +++ b/Tests/JSONAPITests/Document/DocumentTests.swift @@ -73,6 +73,7 @@ extension DocumentTests { XCTAssertTrue(document.body.isError) XCTAssertEqual(document.body.meta, NoMetadata()) + XCTAssertNil(document.body.data) XCTAssertNil(document.body.primaryResource) XCTAssertNil(document.body.includes) @@ -525,6 +526,7 @@ extension DocumentTests { XCTAssertNil(document.body.errors) XCTAssertNotNil(document.body.primaryResource) XCTAssertEqual(document.body.primaryResource?.value.id.rawValue, "1") + XCTAssertEqual(document.body.data?.primary, document.body.primaryResource) XCTAssertEqual(document.body.includes?.count, 0) XCTAssertEqual(document.body.meta, NoMetadata()) } diff --git a/Tests/JSONAPITests/ResourceBody/ResourceBodyTests.swift b/Tests/JSONAPITests/ResourceBody/ResourceBodyTests.swift index 64dc447..46a6716 100644 --- a/Tests/JSONAPITests/ResourceBody/ResourceBodyTests.swift +++ b/Tests/JSONAPITests/ResourceBody/ResourceBodyTests.swift @@ -25,7 +25,10 @@ class ResourceBodyTests: XCTestCase { data: single_resource_body) XCTAssertEqual(body.value, Article(id: Id(rawValue: "1"), - attributes: ArticleType.Attributes(title: try! .init(rawValue: "JSON:API paints my bikeshed!")), relationships: .none, meta: .none, links: .none)) + attributes: ArticleType.Attributes(title: .init(value: "JSON:API paints my bikeshed!")), + relationships: .none, + meta: .none, + links: .none)) } func test_singleResourceBody_encode() { @@ -38,9 +41,21 @@ class ResourceBodyTests: XCTestCase { data: many_resource_body) XCTAssertEqual(body.values, [ - Article(id: .init(rawValue: "1"), attributes: try! .init(title: .init(rawValue: "JSON:API paints my bikeshed!")), relationships: .none, meta: .none, links: .none), - Article(id: .init(rawValue: "2"), attributes: try! .init(title: .init(rawValue: "Sick")), relationships: .none, meta: .none, links: .none), - Article(id: .init(rawValue: "3"), attributes: try! .init(title: .init(rawValue: "Hello World")), relationships: .none, meta: .none, links: .none) + Article(id: .init(rawValue: "1"), + attributes: .init(title: .init(value: "JSON:API paints my bikeshed!")), + relationships: .none, + meta: .none, + links: .none), + Article(id: .init(rawValue: "2"), + attributes: .init(title: .init(value: "Sick")), + relationships: .none, + meta: .none, + links: .none), + Article(id: .init(rawValue: "3"), + attributes: .init(title: .init(value: "Hello World")), + relationships: .none, + meta: .none, + links: .none) ]) } diff --git a/Tests/JSONAPITests/Test Helpers/EncodedAttributeTest.swift b/Tests/JSONAPITests/Test Helpers/EncodedAttributeTest.swift index 6bb3777..55f0c2f 100644 --- a/Tests/JSONAPITests/Test Helpers/EncodedAttributeTest.swift +++ b/Tests/JSONAPITests/Test Helpers/EncodedAttributeTest.swift @@ -10,7 +10,7 @@ import XCTest @testable import JSONAPI import JSONAPITestLib -private struct Wrapper: Codable where Value == Transform.From { +private struct TransformedWrapper: Codable where Value == Transform.From { let x: TransformedAttribute init(x: TransformedAttribute) { @@ -18,10 +18,18 @@ private struct Wrapper: Coda } } +private struct Wrapper: Codable { + let x: Attribute + + init(x: Attribute) { + self.x = x + } +} + /// This function attempts to just cast to the type, so it only works /// for Attributes of primitive types (primitive to JSON). func testEncodedPrimitive(attribute: TransformedAttribute) { - let encodedAttributeData = encoded(value: Wrapper(x: attribute)) + let encodedAttributeData = encoded(value: TransformedWrapper(x: attribute)) let wrapperObject = try! JSONSerialization.jsonObject(with: encodedAttributeData, options: []) as! [String: Any] let jsonObject = wrapperObject["x"] @@ -32,3 +40,18 @@ func testEncodedPrimitive(at XCTAssertEqual(attribute.rawValue, jsonAttribute) } + +/// This function attempts to just cast to the type, so it only works +/// for Attributes of primitive types (primitive to JSON). +func testEncodedPrimitive(attribute: Attribute) { + let encodedAttributeData = encoded(value: Wrapper(x: attribute)) + let wrapperObject = try! JSONSerialization.jsonObject(with: encodedAttributeData, options: []) as! [String: Any] + let jsonObject = wrapperObject["x"] + + guard let jsonAttribute = jsonObject as? Value else { + XCTFail("Attribute did not encode to the correct type") + return + } + + XCTAssertEqual(attribute.value, jsonAttribute) +} diff --git a/Tests/JSONAPITests/XCTestManifests.swift b/Tests/JSONAPITests/XCTestManifests.swift index a358a1b..54f8a92 100644 --- a/Tests/JSONAPITests/XCTestManifests.swift +++ b/Tests/JSONAPITests/XCTestManifests.swift @@ -13,8 +13,7 @@ extension APIDescriptionTests { extension AttributeTests { static let __allTests = [ - ("test_AttributeIsTransformedAttribute", test_AttributeIsTransformedAttribute), - ("test_AttributeNonThrowingConstructor", test_AttributeNonThrowingConstructor), + ("test_AttributeConstructor", test_AttributeConstructor), ("test_EncodedPrimitives", test_EncodedPrimitives), ("test_NullableIsEqualToNonNullableIfNotNil", test_NullableIsEqualToNonNullableIfNotNil), ("test_NullableIsNullIfNil", test_NullableIsNullIfNil),