From 47c4f54878cfbfb1be8621111b38f071eb6c4a93 Mon Sep 17 00:00:00 2001 From: Shannon Young Date: Tue, 14 Mar 2023 11:10:17 -0700 Subject: [PATCH 1/8] Add property wrappers for polymorphic value and array --- Sources/JsonModel/PolymorphicWrapper.swift | 173 ++++++++++++++++++ .../PolymorphicWrapperTests.swift | 67 +++++++ 2 files changed, 240 insertions(+) create mode 100644 Sources/JsonModel/PolymorphicWrapper.swift create mode 100644 Tests/JsonModelTests/PolymorphicWrapperTests.swift diff --git a/Sources/JsonModel/PolymorphicWrapper.swift b/Sources/JsonModel/PolymorphicWrapper.swift new file mode 100644 index 0000000..22d60d8 --- /dev/null +++ b/Sources/JsonModel/PolymorphicWrapper.swift @@ -0,0 +1,173 @@ +// Created 3/13/23 +// swift-tools-version:5.0 + +import Foundation + +/// An explicitly defined protocol for a polymorphically-typed serializable value. +public protocol PolymorphicCodable : PolymorphicRepresentable, Encodable { +} + +/// Wrap a ``PolymorphicCodable`` interface to allow automatic synthesis of a polymorphically-typed value. +/// +/// This implementation was modified from an investigation of property wrappers originally created +/// by Aaron Rabara in January, 2023. - syoung 03/14/2023 +/// +/// There are several limitations to this implementation which are described below. Because of these +/// limitations, it is **highly recommended** that you thoroughly unit test your implementations +/// for the expected encoding and decoding of polymorphic objects. +/// +/// The simpliest example is a non-null, read/write, required value without a default such as: +/// +/// ``` +/// struct SampleTest : Codable { +/// @PolymorphicValue var single: Sample +/// } +/// ``` +/// +/// - Limitation 1: +/// If the property is read-only, it must still be defined with a setter, though the setter can be +/// private. +/// +/// ``` +/// struct SampleTest : Codable { +/// @PolymorphicValue private(set) var single: Sample +/// } +/// ``` +/// +/// - Limitation 2: +/// This property wrapper will only auto-synthesize the `Codable` methods for a non-null value +/// without a default. Therefore, if you wish to define a default or nullable property, then +/// you must unwrap in your implementation. +/// +/// ``` +/// public struct SampleTest : Codable { +/// private enum CodingKeys : String, CodingKey { +/// case _nullable = "nullable", _defaultValue = "defaultValue" +/// } +/// +/// public var nullable: Sample? { +/// get { +/// _nullable?.wrappedValue +/// } +/// set { +/// _nullable = newValue.map { .init(wrappedValue: $0) } +/// } +/// } +/// private let _nullable: PolymorphicValue? +/// +/// public var defaultValue: Sample! { +/// get { +/// _defaultValue?.wrappedValue ?? SampleA(value: 0) +/// } +/// set { +/// _defaultValue = newValue.map { .init(wrappedValue: $0) } +/// } +/// } +/// private let _defaultValue: PolymorphicValue? +/// +/// public init(nullable: Sample? = nil) { +/// self._nullable = nullable.map { .init(wrappedValue: $0) } +/// } +/// } +/// ``` +/// +/// - Limitation 3: +/// This property wrapper does not explicitly require conformance to the `Codable` or +/// `PolymorphicTyped` protocols (limitation of Swift Generics as of 03/14/2022), but will fail to +/// encode at runtime if the objects do *not* conform to these protocols. Finally, if you attempt +/// to decode with a ``SerializationFactory`` that does not have a registered serializer for the +/// given ``ProtocolValue``, then decoding will fail at runtime. +/// +/// ``` +/// +/// // This struct will encode and decode properly. +/// struct SampleA : Sample, PolymorphicCodable { +/// public private(set) var type: SampleType = .a +/// public let value: Int +/// } +/// +/// // This struct will fail to encode and decode because it does not match the required +/// // `Codable` protocols. +/// struct SampleThatFails : Sample { +/// public private(set) var type: SampleType = .fails +/// public let value: String +/// } +/// +/// // The decoded object must use a factory to create the JSONDecoder that registers +/// // the serializer for the matching `ProtocolValue`. +/// class TestFactory : SerializationFactory { +/// let sampleSerializer = SampleSerializer() +/// required init() { +/// super.init() +/// self.registerSerializer(sampleSerializer) +/// } +/// } +/// ``` +/// +@propertyWrapper +public struct PolymorphicValue : Codable { + public var wrappedValue: ProtocolValue + + public init(wrappedValue: ProtocolValue, description: String? = nil) { + self.wrappedValue = wrappedValue + } + + public init(from decoder: Decoder) throws { + self.wrappedValue = try decoder.serializationFactory.decodePolymorphicObject(ProtocolValue.self, from: decoder) + } + + public func encode(to encoder: Encoder) throws { + try encoder.encodePolymorphic(wrappedValue) + } +} + +/// Wrap a ``PolymorphicCodable`` interface to allow automatic synthesis of a polymorphically-typed array. +/// +/// - Example: +/// ``` +/// struct SampleTest : Codable { +/// @PolymorphicValue var array: [Sample] +/// } +/// ``` +/// +/// - See Also: +/// ``PolymorphicValue``. The same limitations on that implementation apply to this one. +/// +@propertyWrapper +public struct PolymorphicArray : Codable { + public var wrappedValue: [ProtocolValue] + + public init(wrappedValue: [ProtocolValue], description: String? = nil) { + self.wrappedValue = wrappedValue + } + + public init(from decoder: Decoder) throws { + let container = try decoder.unkeyedContainer() + self.wrappedValue = try decoder.serializationFactory.decodePolymorphicArray(ProtocolValue.self, from: container) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + try wrappedValue.forEach { obj in + let nestedEncoder = container.superEncoder() + try nestedEncoder.encodePolymorphic(obj) + } + } +} + +fileprivate enum PolymorphicCodableTypeKeys: String, CodingKey { + case type +} + +extension Encoder { + fileprivate func encodePolymorphic(_ obj: Any) throws { + guard let encodable = obj as? Encodable else { + throw EncodingError.invalidValue(obj, + .init(codingPath: self.codingPath, debugDescription: "Object `\(type(of: obj))` does not conform to the `Encodable` protocol")) + } + let typeName = (obj as? PolymorphicTyped)?.typeName ?? "\(type(of: obj))" + var container = self.container(keyedBy: PolymorphicCodableTypeKeys.self) + try container.encode(typeName, forKey: .type) + try encodable.encode(to: self) + } +} diff --git a/Tests/JsonModelTests/PolymorphicWrapperTests.swift b/Tests/JsonModelTests/PolymorphicWrapperTests.swift new file mode 100644 index 0000000..d3a62e4 --- /dev/null +++ b/Tests/JsonModelTests/PolymorphicWrapperTests.swift @@ -0,0 +1,67 @@ +// Created 3/13/23 +// swift-tools-version:5.0 + +import XCTest +@testable import JsonModel + +final class PolymorphicWrapperTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testPolymorphicPropertyWrappers() throws { + let json = """ + { + "single" : { "type" : "a", "value" : 1 }, + "array" : [ + { "type" : "a", "value" : 10 }, + { "type" : "a", "value" : 11 }, + { "type" : "b", "value" : "foo" } + ] + } + """.data(using: .utf8)! + + let factory = TestFactory.defaultFactory + let decoder = factory.createJSONDecoder() + let encoder = factory.createJSONEncoder() + + let decodedObject = try decoder.decode(SampleTest.self, from: json) + + XCTAssertEqual(SampleA(value: 1), decodedObject.single as? SampleA) + XCTAssertEqual(3, decodedObject.array.count) + XCTAssertEqual(SampleA(value: 10), decodedObject.array.first as? SampleA) + XCTAssertEqual(SampleB(value: "foo"), decodedObject.array.last as? SampleB) + XCTAssertNil(decodedObject.nullable) + + let encodedData = try encoder.encode(decodedObject) + let encodedJson = try JSONDecoder().decode(JsonElement.self, from: encodedData) + let expectedJson = try JSONDecoder().decode(JsonElement.self, from: json) + + XCTAssertEqual(expectedJson, encodedJson) + } +} + +fileprivate struct SampleTest : Codable { + private enum CodingKeys : String, CodingKey { + case single, array, _nullable = "nullable" + } + + @PolymorphicValue private(set) var single: Sample + @PolymorphicArray var array: [Sample] + + public var nullable: Sample? { + _nullable?.wrappedValue + } + private let _nullable: PolymorphicValue? + + init(single: Sample, array: [Sample], nullable: Sample? = nil) { + self.single = single + self.array = array + self._nullable = nullable.map { PolymorphicValue(wrappedValue: $0) } + } +} From aabd8d324c8d0f9031eab725ddeb19bd07ac7a61 Mon Sep 17 00:00:00 2001 From: Shannon Young Date: Tue, 14 Mar 2023 12:12:31 -0700 Subject: [PATCH 2/8] Fix test of ordered keys. Not sure why but overriding the open `keyEncodingStrategy` property fails as of 03/14/2023 so changing to use a setter. --- Sources/JsonModel/OrderedJSONEncoder.swift | 32 +++++++++------------- Tests/JsonModelTests/AnyCodableTests.swift | 12 ++++---- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/Sources/JsonModel/OrderedJSONEncoder.swift b/Sources/JsonModel/OrderedJSONEncoder.swift index c3f898f..1fea6f9 100644 --- a/Sources/JsonModel/OrderedJSONEncoder.swift +++ b/Sources/JsonModel/OrderedJSONEncoder.swift @@ -31,27 +31,21 @@ public protocol OpenOrderedCodingKey : OrderedCodingKey { /// `OrderedCodingKey` protocol. open class OrderedJSONEncoder : JSONEncoder { - public override init() { - self._keyEncodingStrategy = .custom({ codingPath in - return IndexedCodingKey(key: codingPath.last!) ?? codingPath.last! - }) - super.init() - } - - /// Should the encoded data be sorted to order the keys for coding keys that implement the `OrderedCodingKey` protocol? - /// By default, keys are *not* ordered so that encoding will run faster, but they can be if the protocol supports doing so. - public var shouldOrderKeys: Bool = false - - override open var keyEncodingStrategy: JSONEncoder.KeyEncodingStrategy { - get { - shouldOrderKeys ? _keyEncodingStrategy : super.keyEncodingStrategy - } - set { - super.keyEncodingStrategy = newValue - shouldOrderKeys = false + /// Should the encoded data be sorted to order the keys for coding keys that implement the + /// `OrderedCodingKey` protocol? By default, keys are *not* ordered so that encoding will + /// run faster, but they can be if the protocol supports doing so. + public var shouldOrderKeys: Bool = false { + didSet { + if shouldOrderKeys { + self.keyEncodingStrategy = .custom({ codingPath in + return IndexedCodingKey(key: codingPath.last!) ?? codingPath.last! + }) + } + else { + self.keyEncodingStrategy = .useDefaultKeys + } } } - private var _keyEncodingStrategy: JSONEncoder.KeyEncodingStrategy override open var outputFormatting: JSONEncoder.OutputFormatting { get { diff --git a/Tests/JsonModelTests/AnyCodableTests.swift b/Tests/JsonModelTests/AnyCodableTests.swift index 5e1f544..9d388ea 100644 --- a/Tests/JsonModelTests/AnyCodableTests.swift +++ b/Tests/JsonModelTests/AnyCodableTests.swift @@ -39,15 +39,15 @@ final class AnyCodableTests: XCTestCase { // The order of the keys should be the same as the `orderedKeys` and *not* // in the order defined either using alphabetical sort or the `input` declaration. - let actualOrder: [String.Index] = orderedKeys.map { + let mappedOrder: [(index: String.Index, value: String) ] = orderedKeys.map { guard let range = jsonString.range(of: $0) else { XCTFail("Could not find \($0) in the json string") - return jsonString.endIndex + return (jsonString.endIndex, "") } - return range.lowerBound - } - let sortedOrder = actualOrder.sorted() - XCTAssertEqual(actualOrder, sortedOrder) + return (range.lowerBound, $0) + }.sorted(by: { $0.index < $1.index }) + let actualOrder = mappedOrder.map { $0.value } + XCTAssertEqual(orderedKeys, actualOrder) // Decode from the data and the dictionaries should match. let object = try decoder.decode(AnyCodableDictionary.self, from: jsonData) From 6f5f784f8910809abc7d5d5074188be8d872db3e Mon Sep 17 00:00:00 2001 From: Shannon Young Date: Tue, 14 Mar 2023 20:04:49 -0700 Subject: [PATCH 3/8] Add serializer that allows for using a statically typed decoder --- README.md | 5 + .../IdentifiableInterfaceSerializer.swift | 2 + Sources/JsonModel/PolymorphicSerializer.swift | 241 +++++++++++++++++- Sources/JsonModel/PolymorphicWrapper.swift | 19 +- Sources/ResultModel/AnswerType.swift | 17 +- .../ResultModel/ResultDataSerializer.swift | 24 +- Tests/JsonModelTests/DocumentableTests.swift | 12 +- .../PolymorphicSerializerTests.swift | 121 ++++++--- .../PolymorphicWrapperTests.swift | 38 +++ 9 files changed, 387 insertions(+), 92 deletions(-) diff --git a/README.md b/README.md index af7026a..befe36e 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,11 @@ Moved the results protocols and objects into a separate target within the JsonMo library. To migrate to this version, you will need to `import ResultModel` anywhere that you reference `ResultData` model objects. +### Version 2.1 + +- Added property wrappers that can be used in polymorphic serialization. +- Deprecated `PolymorphicSerializer` and replaced with `GenericPolymorphicSerializer` + ## License JsonModel is available under the BSD license: diff --git a/Sources/JsonModel/IdentifiableInterfaceSerializer.swift b/Sources/JsonModel/IdentifiableInterfaceSerializer.swift index 589c04d..d52f9ee 100644 --- a/Sources/JsonModel/IdentifiableInterfaceSerializer.swift +++ b/Sources/JsonModel/IdentifiableInterfaceSerializer.swift @@ -6,6 +6,7 @@ import Foundation /// Convenience implementation for a serializer that includes a required `identifier` key. +@available(*, deprecated, message: "Use `GenericPolymorphicSerializer` instead.") open class IdentifiableInterfaceSerializer : AbstractPolymorphicSerializer { private enum InterfaceKeys : String, OpenOrderedCodingKey { case identifier @@ -30,3 +31,4 @@ open class IdentifiableInterfaceSerializer : AbstractPolymorphicSerializer { } } } + diff --git a/Sources/JsonModel/PolymorphicSerializer.swift b/Sources/JsonModel/PolymorphicSerializer.swift index e3423ac..a26567c 100644 --- a/Sources/JsonModel/PolymorphicSerializer.swift +++ b/Sources/JsonModel/PolymorphicSerializer.swift @@ -17,19 +17,55 @@ import Foundation /// that word makes for messy, hard-to-read code. Therefore, this protocol returns the `String` /// value as `typeName` rather than mapping directly to `type`. /// -/// - seealso: `PolymorphicSerializerTests` +/// - See Also: `PolymorphicSerializerTests` /// public protocol PolymorphicRepresentable : PolymorphicTyped, Decodable { } +/// An explicitly defined protocol for a polymorphically-typed serializable value. +/// +/// - Discussion: +/// Older implementations of the `JsonModel-Swift` package followed the Obj-C compatible +/// serialization protocols defined in Swift 2.0 where an object could conform to either the +/// `Encodable` protocol or the `Decodable` protocol without conforming to the `Codable` +/// protocol. Additionally, these older objects often had serialization strategies that +/// conflicted with either encoding or decoding. To work-around this, we introduced the +/// `PolymorphicRepresentable` protocol which **only** required adherence to the `Decodable` +/// protocol and not to the `Encodable` protocol. This allowed developers who only needed +/// to decode their objects, to use the ``SerializationFactory`` to handle polymorphic +/// object decoding without requiring them to also implement an unused `Encodable` protocol +/// adherence. +public protocol PolymorphicCodable : PolymorphicRepresentable, Encodable { +} + +/// This is the original protocol used by ``PolymorphicSerializer`` to decode an object from +/// a list of example instances. It is retained so that older implementations of polymorphic +/// objects do not need to be migrated. public protocol PolymorphicTyped { /// A "name" for the class of object that can be used in Dictionary representable objects. var typeName: String { get } } +/// A **static** implementation that allows decoding from an object Type rather than requiring +/// an example instance. +public protocol PolymorphicStaticTyped : PolymorphicTyped { + /// A "name" for the class of object that can be used in Dictionary representable objects. + static var typeName: String { get } +} + +extension PolymorphicStaticTyped { + public var typeName: String { Self.typeName } +} + /// The generic method for a decodable. This is a work-around for the limitations of Swift generics /// where an instance of a class that has an associated type cannot be stored in a dictionary or /// array. +/// +/// - Note: +/// When this library was originally written, Swift Generics were very limited in functionality. +/// This protocol was originally designed as a work-around to those limitations. While generics +/// are much more useful with Swift 5.7, the original implementation is retained. +/// public protocol GenericSerializer : AnyObject { var interfaceName : String { get } func decode(from decoder: Decoder) throws -> Any @@ -38,7 +74,13 @@ public protocol GenericSerializer : AnyObject { func validate() throws } -/// A serializer protocol for decoding serializable objects. +extension Decodable { + static func decodingType() -> Decodable.Type { + self + } +} + +/// A serializer for decoding serializable objects. /// /// This serializer is designed to allow for decoding objects that use /// [kotlinx.serialization](https://github.com/Kotlin/kotlinx.serialization) so it requires that @@ -46,6 +88,194 @@ public protocol GenericSerializer : AnyObject { /// key in the JSON dictionary, this is not recommended because it would require all of your /// Swift Codable implementations to also use the new coding key. /// +open class GenericPolymorphicSerializer : GenericSerializer { + public private(set) var typeMap: [String : Decodable.Type] = [:] + + public var examples: [ProtocolValue] { + _examples.map { $0.value } + } + private var _examples: [String : ProtocolValue] = [:] + + public init(_ examples: [ProtocolValue]) { + examples.forEach { example in + try? self.add(example) + } + } + + public init(_ typeMap: [String : Decodable.Type]) { + self.typeMap = typeMap + } + + /// Insert the given example into the example array, replacing any existing example with the + /// same `typeName` as one of the new example. + public final func add(_ example: ProtocolValue) throws { + guard let decodable = example as? Decodable else { + throw PolymorphicSerializerError.exampleNotDecodable(example) + } + let typeValue = type(of: decodable).decodingType() + let typeName = (example as? PolymorphicTyped)?.typeName ?? "\(typeValue)" + typeMap[typeName] = typeValue + _examples[typeName] = example + } + + /// Insert the given examples into the example array, replacing any existing examples with the + /// same `typeName` as one of the new examples. + public final func add(contentsOf newExamples: [ProtocolValue]) throws { + try newExamples.forEach { example in + try self.add(example) + } + } + + /// Insert the given `ProtocolValue.Type` into the type map, replacing any existing class with + /// the same "type" decoding. + public final func add(typeOf typeValue: Decodable.Type) throws { + let typeName = (typeValue as? PolymorphicStaticTyped.Type)?.typeName ?? "\(typeValue)" + typeMap[typeName] = typeValue + _examples[typeName] = nil + } + + open func typeName(from decoder: Decoder) throws -> String { + let container = try decoder.container(keyedBy: PolymorphicCodableTypeKeys.self) + guard let type = try container.decodeIfPresent(String.self, forKey: .type) else { + throw PolymorphicSerializerError.typeKeyNotFound + } + return type + } + + // MARK: GenericSerializer + + public var interfaceName: String { + "\(ProtocolValue.self)" + } + + public func decode(from decoder: Decoder) throws -> Any { + let name = try typeName(from: decoder) + guard let typeValue = typeMap[name] else { + throw PolymorphicSerializerError.exampleNotFound(name) + } + return try typeValue.init(from: decoder) + } + + public func documentableExamples() -> [DocumentableObject] { + examples.compactMap { $0 as? DocumentableObject } + } + + public func canDecode(_ typeName: String) -> Bool { + typeMap[typeName] != nil + } + + public func validate() throws { + // do nothing + } + + // MARK: DocumentableInterface + + /// Protocols are not sealed. + open func isSealed() -> Bool { + false + } + + /// Default is to return the "type" key. + open class func codingKeys() -> [CodingKey] { + [PolymorphicCodableTypeKeys.type] + } + + /// Default is to return `true`. + open class func isRequired(_ codingKey: CodingKey) -> Bool { + true + } + + /// Default is to return the "type" property. + open class func documentProperty(for codingKey: CodingKey) throws -> DocumentProperty { + guard let _ = codingKey as? PolymorphicCodableTypeKeys else { + throw DocumentableError.invalidCodingKey(codingKey, "\(codingKey) is not handled by \(self).") + } + return typeDocumentProperty() + } + + /// Default is a string but this can be overriden to return a `TypeRepresentable` reference. + open class func typeDocumentProperty() -> DocumentProperty { + DocumentProperty(propertyType: .primitive(.string)) + } +} + +public enum PolymorphicSerializerError : Error { + case typeKeyNotFound + case exampleNotFound(String) + case exampleNotDecodable(Any) + case typeNotDecodable(Any.Type) +} + +enum PolymorphicCodableTypeKeys: String, Codable, OpenOrderedCodingKey { + case type + public var sortOrderIndex: Int? { 0 } + public var relativeIndex: Int { 0 } +} + +extension Encoder { + + /// Add the "type" key to an encoded object. + public func encodePolymorphic(_ obj: T) throws { + let polymorphicEncoder = PolymorphicEncoder(self, encodable: obj) + try obj.encode(to: polymorphicEncoder) + if let error = polymorphicEncoder.error { + throw error + } + } +} + +/// Work-around for polymorphic encoding that includes a "type" in a dictionary where the "type" +/// field is not encoded by the object. +class PolymorphicEncoder : Encoder { + let wrappedEncoder : Encoder + let encodable: Encodable + var error: Error? + var typeAdded: Bool = false + + init(_ wrappedEncoder : Encoder, encodable: Encodable) { + self.wrappedEncoder = wrappedEncoder + self.encodable = encodable + } + + var codingPath: [CodingKey] { + wrappedEncoder.codingPath + } + + var userInfo: [CodingUserInfoKey : Any] { + wrappedEncoder.userInfo + } + + func insertType() { + guard !typeAdded else { return } + typeAdded = true + do { + let typeName = (encodable as? PolymorphicTyped)?.typeName ?? "\(type(of: encodable))" + var container = wrappedEncoder.container(keyedBy: PolymorphicCodableTypeKeys.self) + try container.encode(typeName, forKey: .type) + } catch { + self.error = error + } + } + + func container(keyedBy type: Key.Type) -> KeyedEncodingContainer where Key : CodingKey { + insertType() + return wrappedEncoder.container(keyedBy: type) + } + + func unkeyedContainer() -> UnkeyedEncodingContainer { + self.error = EncodingError.invalidValue(encodable, + .init(codingPath: codingPath, debugDescription: "Cannot encode a polymorphic object to an array.")) + return wrappedEncoder.unkeyedContainer() + } + + func singleValueContainer() -> SingleValueEncodingContainer { + self.error = EncodingError.invalidValue(encodable, + .init(codingPath: codingPath, debugDescription: "Cannot encode a polymorphic object to a single value container.")) + return wrappedEncoder.singleValueContainer() + } +} + +@available(*, deprecated, message: "Use `GenericPolymorphicSerializer` instead.") public protocol PolymorphicSerializer : GenericSerializer, DocumentableInterface { /// The `ProtocolValue` is the protocol or base class to which all the codable objects for this /// serializer should conform. @@ -65,6 +295,7 @@ public protocol PolymorphicSerializer : GenericSerializer, DocumentableInterface func typeName(from decoder: Decoder) throws -> String } +@available(*, deprecated, message: "Use `GenericPolymorphicSerializer` instead.") extension PolymorphicSerializer { /// The name of the base class or protocol to set as the base implementation that is deserialized @@ -104,6 +335,7 @@ extension PolymorphicSerializer { } } +@available(*, deprecated, message: "Use `GenericPolymorphicSerializer` instead.") open class AbstractPolymorphicSerializer { public enum TypeKeys: String, Codable, OpenOrderedCodingKey { case type @@ -149,8 +381,3 @@ open class AbstractPolymorphicSerializer { } } -public enum PolymorphicSerializerError : Error { - case typeKeyNotFound - case exampleNotFound(String) -} - diff --git a/Sources/JsonModel/PolymorphicWrapper.swift b/Sources/JsonModel/PolymorphicWrapper.swift index 22d60d8..cae043d 100644 --- a/Sources/JsonModel/PolymorphicWrapper.swift +++ b/Sources/JsonModel/PolymorphicWrapper.swift @@ -3,10 +3,6 @@ import Foundation -/// An explicitly defined protocol for a polymorphically-typed serializable value. -public protocol PolymorphicCodable : PolymorphicRepresentable, Encodable { -} - /// Wrap a ``PolymorphicCodable`` interface to allow automatic synthesis of a polymorphically-typed value. /// /// This implementation was modified from an investigation of property wrappers originally created @@ -117,7 +113,7 @@ public struct PolymorphicValue : Codable { } public func encode(to encoder: Encoder) throws { - try encoder.encodePolymorphic(wrappedValue) + try encoder.encodePolymorphicAny(wrappedValue) } } @@ -150,24 +146,17 @@ public struct PolymorphicArray : Codable { var container = encoder.unkeyedContainer() try wrappedValue.forEach { obj in let nestedEncoder = container.superEncoder() - try nestedEncoder.encodePolymorphic(obj) + try nestedEncoder.encodePolymorphicAny(obj) } } } -fileprivate enum PolymorphicCodableTypeKeys: String, CodingKey { - case type -} - extension Encoder { - fileprivate func encodePolymorphic(_ obj: Any) throws { + fileprivate func encodePolymorphicAny(_ obj: Any) throws { guard let encodable = obj as? Encodable else { throw EncodingError.invalidValue(obj, .init(codingPath: self.codingPath, debugDescription: "Object `\(type(of: obj))` does not conform to the `Encodable` protocol")) } - let typeName = (obj as? PolymorphicTyped)?.typeName ?? "\(type(of: obj))" - var container = self.container(keyedBy: PolymorphicCodableTypeKeys.self) - try container.encode(typeName, forKey: .type) - try encodable.encode(to: self) + try self.encodePolymorphic(encodable) } } diff --git a/Sources/ResultModel/AnswerType.swift b/Sources/ResultModel/AnswerType.swift index ee7c5a5..11c0271 100644 --- a/Sources/ResultModel/AnswerType.swift +++ b/Sources/ResultModel/AnswerType.swift @@ -35,7 +35,7 @@ public protocol AnswerType : PolymorphicTyped, DictionaryRepresentable { func encodeAnswer(from value: Any?) throws -> JsonElement } -public final class AnswerTypeSerializer : AbstractPolymorphicSerializer, PolymorphicSerializer { +public final class AnswerTypeSerializer : GenericPolymorphicSerializer, DocumentableInterface { public var documentDescription: String? { """ `AnswerType` is used to allow carrying additional information about the properties of a @@ -47,8 +47,8 @@ public final class AnswerTypeSerializer : AbstractPolymorphicSerializer, Polymor URL(string: "\(self.interfaceName).json", relativeTo: kSageJsonSchemaBaseURL)! } - override init() { - examples = [ + init() { + super.init([ AnswerTypeArray.examples().first!, AnswerTypeBoolean.examples().first!, AnswerTypeDateTime.examples().first!, @@ -59,21 +59,12 @@ public final class AnswerTypeSerializer : AbstractPolymorphicSerializer, Polymor AnswerTypeObject.examples().first!, AnswerTypeString.examples().first!, AnswerTypeTime.examples().first!, - ] + ]) } - public private(set) var examples: [AnswerType] - public override class func typeDocumentProperty() -> DocumentProperty { .init(propertyType: .reference(AnswerTypeType.documentableType())) } - - public func add(_ example: AnswerType) { - if let idx = examples.firstIndex(where: { $0.typeName == example.typeName }) { - examples.remove(at: idx) - } - examples.append(example) - } } public protocol SerializableAnswerType : AnswerType, PolymorphicRepresentable, Encodable { diff --git a/Sources/ResultModel/ResultDataSerializer.swift b/Sources/ResultModel/ResultDataSerializer.swift index 79fef92..d69e1a2 100644 --- a/Sources/ResultModel/ResultDataSerializer.swift +++ b/Sources/ResultModel/ResultDataSerializer.swift @@ -54,7 +54,7 @@ extension SerializableResultType : DocumentableStringLiteral { } } -public final class ResultDataSerializer : IdentifiableInterfaceSerializer, PolymorphicSerializer { +public final class ResultDataSerializer : GenericPolymorphicSerializer, DocumentableInterface { public var documentDescription: String? { """ The interface for any `ResultData` that is serialized using the `Codable` protocol and the @@ -66,8 +66,8 @@ public final class ResultDataSerializer : IdentifiableInterfaceSerializer, Polym URL(string: "\(self.interfaceName).json", relativeTo: kSageJsonSchemaBaseURL)! } - override init() { - self.examples = [ + init() { + super.init([ AnswerResultObject.examples().first!, CollectionResultObject.examples().first!, ErrorResultObject.examples().first!, @@ -75,11 +75,9 @@ public final class ResultDataSerializer : IdentifiableInterfaceSerializer, Polym ResultObject.examples().first!, BranchNodeResultObject.examples().first!, AssessmentResultObject(), - ] + ]) } - public private(set) var examples: [ResultData] - public override class func typeDocumentProperty() -> DocumentProperty { .init(propertyType: .reference(SerializableResultType.documentableType())) } @@ -87,20 +85,17 @@ public final class ResultDataSerializer : IdentifiableInterfaceSerializer, Polym /// Insert the given example into the example array, replacing any existing example with the /// same `typeName` as one of the new example. public func add(_ example: SerializableResultData) { - examples.removeAll(where: { $0.typeName == example.typeName }) - examples.append(example) + try? add(example as ResultData) } /// Insert the given examples into the example array, replacing any existing examples with the /// same `typeName` as one of the new examples. public func add(contentsOf newExamples: [SerializableResultData]) { - let newNames = newExamples.map { $0.typeName } - self.examples.removeAll(where: { newNames.contains($0.typeName) }) - self.examples.append(contentsOf: newExamples) + try? add(contentsOf: newExamples as [ResultData]) } private enum InterfaceKeys : String, OrderedEnumCodingKey, OpenOrderedCodingKey { - case startDate, endDate + case identifier, startDate, endDate var relativeIndex: Int { 2 } } @@ -114,7 +109,7 @@ public final class ResultDataSerializer : IdentifiableInterfaceSerializer, Polym guard let key = codingKey as? InterfaceKeys else { return super.isRequired(codingKey) } - return key == .startDate + return key == .startDate || key == .identifier } public override class func documentProperty(for codingKey: CodingKey) throws -> DocumentProperty { @@ -122,6 +117,9 @@ public final class ResultDataSerializer : IdentifiableInterfaceSerializer, Polym return try super.documentProperty(for: codingKey) } switch key { + case .identifier: + return .init(propertyType: .primitive(.string), propertyDescription: + "The identifier for the result.") case .startDate: return .init(propertyType: .format(.dateTime), propertyDescription: "The start date timestamp for the result.") diff --git a/Tests/JsonModelTests/DocumentableTests.swift b/Tests/JsonModelTests/DocumentableTests.swift index 6d997e8..e117179 100644 --- a/Tests/JsonModelTests/DocumentableTests.swift +++ b/Tests/JsonModelTests/DocumentableTests.swift @@ -196,7 +196,7 @@ class AnotherTestFactory : SerializationFactory { } } -class AnotherSerializer : AbstractPolymorphicSerializer, PolymorphicSerializer { +class AnotherSerializer : GenericPolymorphicSerializer, DocumentableInterface { var jsonSchema: URL { URL(string: "Another.json", relativeTo: kSageJsonSchemaBaseURL)! } @@ -205,10 +205,12 @@ class AnotherSerializer : AbstractPolymorphicSerializer, PolymorphicSerializer { "Another example interface used for unit testing." } - let examples: [Another] = [ - AnotherA(), - AnotherB(), - ] + init() { + super.init([ + AnotherA(), + AnotherB(), + ]) + } override class func typeDocumentProperty() -> DocumentProperty { DocumentProperty(propertyType: .reference(AnotherType.self)) diff --git a/Tests/JsonModelTests/PolymorphicSerializerTests.swift b/Tests/JsonModelTests/PolymorphicSerializerTests.swift index fe7c644..af328e9 100644 --- a/Tests/JsonModelTests/PolymorphicSerializerTests.swift +++ b/Tests/JsonModelTests/PolymorphicSerializerTests.swift @@ -7,11 +7,17 @@ import XCTest final class PolymorphicSerializerTests: XCTestCase { - func testSampleSerializer() { + func testSampleSerializer() throws { let serializer = SampleSerializer() XCTAssertEqual("Sample", serializer.interfaceName) + try serializer.validate() + + } + + func testSampleSerializer_Decoding() throws { + let json = """ { "value": 5, @@ -21,41 +27,41 @@ final class PolymorphicSerializerTests: XCTestCase { let factory = TestFactory.defaultFactory let decoder = factory.createJSONDecoder() + + let sampleWrapper = try decoder.decode(SampleWrapper.self, from: json) + + guard let sample = sampleWrapper.value as? SampleA else { + XCTFail("\(sampleWrapper.value) not of expected type.") + return + } + + XCTAssertEqual(5, sample.value) + } + + func testSampleSerializer_Encoding() throws { + let sampleWrapper = SampleWrapper(value: SampleA(value: 5)) + let factory = TestFactory.defaultFactory let encoder = factory.createJSONEncoder() - do { - let sampleWrapper = try decoder.decode(SampleWrapper.self, from: json) - - guard let sample = sampleWrapper.value as? SampleA else { - XCTFail("\(sampleWrapper.value) not of expected type.") - return - } - - XCTAssertEqual(5, sample.value) - - let encoding = try encoder.encode(sampleWrapper) - let encodedJson = try JSONSerialization.jsonObject(with: encoding, options: []) - guard let dictionary = encodedJson as? [String : Any] else { - XCTFail("\(encodedJson) not a dictionary.") - return - } - - if let value = dictionary["value"] as? Int { - XCTAssertEqual(5, value) - } - else { - XCTFail("Encoding does not include 'value' keyword. \(dictionary)") - } - - if let typeName = dictionary["type"] as? String { - XCTAssertEqual("a", typeName) - } - else { - XCTFail("Encoding does not include 'valtypeue' keyword. \(dictionary)") - } - - } catch let err { - XCTFail("Failed to decode/encode object: \(err)") + let encoding = try encoder.encode(sampleWrapper) + let encodedJson = try JSONSerialization.jsonObject(with: encoding, options: []) + guard let dictionary = encodedJson as? [String : Any] else { + XCTFail("\(encodedJson) not a dictionary.") + return + } + + if let value = dictionary["value"] as? Int { + XCTAssertEqual(5, value) + } + else { + XCTFail("Encoding does not include 'value' keyword. \(dictionary)") + } + + if let typeName = dictionary["type"] as? String { + XCTAssertEqual("a", typeName) + } + else { + XCTFail("Encoding does not include 'valtypeue' keyword. \(dictionary)") } } @@ -84,10 +90,37 @@ final class PolymorphicSerializerTests: XCTestCase { XCTFail("Failed to decode/encode object: \(err)") } } + + func testSampleSerializer_StaticTyped() throws { + let json = """ + { + "name": "moo", + "type": "c", + "value": 2 + } + """.data(using: .utf8)! // our data in native (JSON) format + + let factory = TestFactory.defaultFactory + try factory.sampleSerializer.add(typeOf: SampleC.self) + let decoder = factory.createJSONDecoder() + + let sampleWrapper = try decoder.decode(SampleWrapper.self, from: json) + + guard let sample = sampleWrapper.value as? SampleC else { + XCTFail("\(sampleWrapper.value) not of expected type.") + return + } + + XCTAssertEqual("moo", sample.name) + XCTAssertEqual(2, sample.value) + } } struct SampleWrapper : Codable { let value: Sample + init(value: Sample) { + self.value = value + } init(from decoder: Decoder) throws { self.value = try decoder.serializationFactory.decodePolymorphicObject(Sample.self, from: decoder) } @@ -100,7 +133,8 @@ struct SampleWrapper : Codable { } } -class SampleSerializer : AbstractPolymorphicSerializer, PolymorphicSerializer { +class SampleSerializer : GenericPolymorphicSerializer, DocumentableInterface { + //AbstractPolymorphicSerializer, PolymorphicSerializer { var jsonSchema: URL { URL(string: "Sample.json", relativeTo: kSageJsonSchemaBaseURL)! } @@ -109,10 +143,12 @@ class SampleSerializer : AbstractPolymorphicSerializer, PolymorphicSerializer { "Sample is an example interface used for unit testing." } - let examples: [Sample] = [ - SampleA(value: 3), - SampleB(value: "foo"), - ] + init() { + super.init([ + SampleA(value: 3), + SampleB(value: "foo"), + ]) + } override class func typeDocumentProperty() -> DocumentProperty { DocumentProperty(propertyType: .reference(SampleType.self)) @@ -342,6 +378,13 @@ extension SampleB : DocumentableStruct { } } +struct SampleC : Sample, Codable, PolymorphicStaticTyped { + static var typeName: String { "c" } + + let name: String + let value: UInt +} + struct SampleNotRegistered : Sample, Codable { let name: String } diff --git a/Tests/JsonModelTests/PolymorphicWrapperTests.swift b/Tests/JsonModelTests/PolymorphicWrapperTests.swift index d3a62e4..b8ac81b 100644 --- a/Tests/JsonModelTests/PolymorphicWrapperTests.swift +++ b/Tests/JsonModelTests/PolymorphicWrapperTests.swift @@ -44,6 +44,44 @@ final class PolymorphicWrapperTests: XCTestCase { XCTAssertEqual(expectedJson, encodedJson) } + + func testPolymorphicPropertyWrapper_DefaultTyped() throws { + let sampleTest = SampleTest(single: SampleX(name: "foo", value: 5), array: []) + let encoder = JSONEncoder() + let jsonData = try encoder.encode(sampleTest) + let dictionary = try JSONSerialization.jsonObject(with: jsonData) as! NSDictionary + let expectedDictionary : NSDictionary = [ + "single" : [ + "type" : "SampleX", + "name" : "foo", + "value" : 5 + ], + "array" : [] + ] + XCTAssertEqual(expectedDictionary, dictionary) + } + + func testPolymorphicPropertyWrapper_NotDictionary() throws { + let sampleTest = SampleTest(single: "foo", array: []) + let encoder = JSONEncoder() + do { + let _ = try encoder.encode(sampleTest) + } + catch EncodingError.invalidValue(_, let context) { + XCTAssertEqual("Cannot encode a polymorphic object to a single value container.", context.debugDescription) + return + } + + XCTFail("This test should throw an invalid value error and exit before here.") + } +} + +extension String : Sample { +} + +fileprivate struct SampleX : Sample, Encodable { + let name: String + let value: UInt } fileprivate struct SampleTest : Codable { From 887b995caec0631cdd49e894ab6e1e7061426e21 Mon Sep 17 00:00:00 2001 From: Shannon Young Date: Wed, 15 Mar 2023 10:36:23 -0700 Subject: [PATCH 4/8] Refactor polymorphic encoding and apply to all objects in this library --- Sources/JsonModel/PolymorphicSerializer.swift | 44 ++++++++++++++++--- Sources/JsonModel/PolymorphicWrapper.swift | 16 +------ Sources/ResultModel/AnswerResult.swift | 3 +- Sources/ResultModel/BranchNodeResult.swift | 10 +---- Sources/ResultModel/CollectionResult.swift | 5 +-- Tests/JsonModelTests/DocumentableTests.swift | 6 +-- 6 files changed, 45 insertions(+), 39 deletions(-) diff --git a/Sources/JsonModel/PolymorphicSerializer.swift b/Sources/JsonModel/PolymorphicSerializer.swift index a26567c..0b3c066 100644 --- a/Sources/JsonModel/PolymorphicSerializer.swift +++ b/Sources/JsonModel/PolymorphicSerializer.swift @@ -215,15 +215,47 @@ enum PolymorphicCodableTypeKeys: String, Codable, OpenOrderedCodingKey { extension Encoder { /// Add the "type" key to an encoded object. - public func encodePolymorphic(_ obj: T) throws { - let polymorphicEncoder = PolymorphicEncoder(self, encodable: obj) - try obj.encode(to: polymorphicEncoder) - if let error = polymorphicEncoder.error { - throw error + public func encodePolymorphic(_ obj: Any) throws { + if let encodable = obj as? Encodable { + // Use the `Encodable` protocol if supported. This can pass on the `userInfo` from the encoder. + let polymorphicEncoder = PolymorphicEncoder(self, encodable: encodable) + try encodable.encode(to: polymorphicEncoder) + if let error = polymorphicEncoder.error { + throw error + } + } + else if let dictionaryRep = obj as? DictionaryRepresentable { + // Otherwise, look to see if this is an older object that pre-dates Swift 2.0 `Codable` + var dictionary = try dictionaryRep.jsonDictionary() + if dictionary[PolymorphicCodableTypeKeys.type.rawValue] == nil { + dictionary[PolymorphicCodableTypeKeys.type.rawValue] = typeName(for: obj) + } + let jsonElement = JsonElement.object(dictionary) + try jsonElement.encode(to: self) + } + else { + // If the object isn't serializable as a dictionary, then can't encode it. + throw EncodingError.invalidValue(obj, + .init(codingPath: self.codingPath, debugDescription: "Object `\(type(of: obj))` does not conform to the `Encodable` protocol")) } } } +extension UnkeyedEncodingContainer { + + /// Add the "type" key to an array of encoded object. + mutating public func encodePolymorphic(_ array: [Any]) throws { + try array.forEach { obj in + let nestedEncoder = self.superEncoder() + try nestedEncoder.encodePolymorphic(obj) + } + } +} + +fileprivate func typeName(for obj: Any) -> String { + (obj as? PolymorphicTyped)?.typeName ?? "\(type(of: obj))" +} + /// Work-around for polymorphic encoding that includes a "type" in a dictionary where the "type" /// field is not encoded by the object. class PolymorphicEncoder : Encoder { @@ -249,7 +281,7 @@ class PolymorphicEncoder : Encoder { guard !typeAdded else { return } typeAdded = true do { - let typeName = (encodable as? PolymorphicTyped)?.typeName ?? "\(type(of: encodable))" + let typeName = typeName(for: encodable) var container = wrappedEncoder.container(keyedBy: PolymorphicCodableTypeKeys.self) try container.encode(typeName, forKey: .type) } catch { diff --git a/Sources/JsonModel/PolymorphicWrapper.swift b/Sources/JsonModel/PolymorphicWrapper.swift index cae043d..8472da4 100644 --- a/Sources/JsonModel/PolymorphicWrapper.swift +++ b/Sources/JsonModel/PolymorphicWrapper.swift @@ -113,7 +113,7 @@ public struct PolymorphicValue : Codable { } public func encode(to encoder: Encoder) throws { - try encoder.encodePolymorphicAny(wrappedValue) + try encoder.encodePolymorphic(wrappedValue) } } @@ -144,19 +144,7 @@ public struct PolymorphicArray : Codable { public func encode(to encoder: Encoder) throws { var container = encoder.unkeyedContainer() - try wrappedValue.forEach { obj in - let nestedEncoder = container.superEncoder() - try nestedEncoder.encodePolymorphicAny(obj) - } + try container.encodePolymorphic(wrappedValue) } } -extension Encoder { - fileprivate func encodePolymorphicAny(_ obj: Any) throws { - guard let encodable = obj as? Encodable else { - throw EncodingError.invalidValue(obj, - .init(codingPath: self.codingPath, debugDescription: "Object `\(type(of: obj))` does not conform to the `Encodable` protocol")) - } - try self.encodePolymorphic(encodable) - } -} diff --git a/Sources/ResultModel/AnswerResult.swift b/Sources/ResultModel/AnswerResult.swift index 5f65594..a77c94f 100644 --- a/Sources/ResultModel/AnswerResult.swift +++ b/Sources/ResultModel/AnswerResult.swift @@ -131,9 +131,8 @@ public final class AnswerResultObject : SerializableResultData, AnswerResult, Mu try container.encode(self.startDateTime, forKey: .startDate) try container.encodeIfPresent(self.endDateTime, forKey: .endDate) if let info = self.jsonAnswerType { - let encodable = try info as? Encodable ?? JsonElement.object(try info.jsonDictionary()) let nestedEncoder = container.superEncoder(forKey: .jsonAnswerType) - try encodable.encode(to: nestedEncoder) + try nestedEncoder.encodePolymorphic(info) } let jsonVal = try self.encodingValue() try container.encodeIfPresent(jsonVal, forKey: .jsonValue) diff --git a/Sources/ResultModel/BranchNodeResult.swift b/Sources/ResultModel/BranchNodeResult.swift index 0af7af5..f1597b5 100644 --- a/Sources/ResultModel/BranchNodeResult.swift +++ b/Sources/ResultModel/BranchNodeResult.swift @@ -159,17 +159,11 @@ open class AbstractBranchNodeResultObject : AbstractResultObject { try container.encode(path, forKey: .path) var nestedContainer = container.nestedUnkeyedContainer(forKey: .stepHistory) - for result in stepHistory { - let nestedEncoder = nestedContainer.superEncoder() - try result.encode(to: nestedEncoder) - } + try nestedContainer.encodePolymorphic(stepHistory) if let results = asyncResults { var asyncContainer = container.nestedUnkeyedContainer(forKey: .asyncResults) - for result in results { - let nestedEncoder = asyncContainer.superEncoder() - try result.encode(to: nestedEncoder) - } + try asyncContainer.encodePolymorphic(results) } } diff --git a/Sources/ResultModel/CollectionResult.swift b/Sources/ResultModel/CollectionResult.swift index c4a373b..4ddb610 100644 --- a/Sources/ResultModel/CollectionResult.swift +++ b/Sources/ResultModel/CollectionResult.swift @@ -76,10 +76,7 @@ open class AbstractCollectionResultObject : AbstractResultObject { try super.encode(to: encoder) var container = encoder.container(keyedBy: CodingKeys.self) var nestedContainer = container.nestedUnkeyedContainer(forKey: .children) - try children.forEach { result in - let nestedEncoder = nestedContainer.superEncoder() - try result.encode(to: nestedEncoder) - } + try nestedContainer.encodePolymorphic(children) } override open class func codingKeys() -> [CodingKey] { diff --git a/Tests/JsonModelTests/DocumentableTests.swift b/Tests/JsonModelTests/DocumentableTests.swift index e117179..2f9e0bb 100644 --- a/Tests/JsonModelTests/DocumentableTests.swift +++ b/Tests/JsonModelTests/DocumentableTests.swift @@ -269,11 +269,7 @@ struct AnotherA : Another, Codable { try container.encodeIfPresent(self.sampleItem, forKey: .sampleItem) if let samples = self.samples { var nestedContainer = container.nestedUnkeyedContainer(forKey: .samples) - try samples.forEach { - let encodable = $0 as! Encodable - let nestedEncoder = nestedContainer.superEncoder() - try encodable.encode(to: nestedEncoder) - } + try nestedContainer.encodePolymorphic(samples) } } } From ff37568463b1fd2f4bfc872c9aca1d90f34c204b Mon Sep 17 00:00:00 2001 From: Shannon Young Date: Wed, 15 Mar 2023 13:57:32 -0700 Subject: [PATCH 5/8] Add additional documentation --- README.md | 119 ++++++++++++++- Sources/JsonModel/PolymorphicSerializer.swift | 11 +- Sources/ResultModel/AnswerType.swift | 2 +- .../ResultModel/ResultDataSerializer.swift | 2 +- Tests/JsonModelTests/DocumentableTests.swift | 2 +- .../GooProcotolExampleTests.swift | 139 ++++++++++++++++++ .../PolymorphicSerializerTests.swift | 4 +- 7 files changed, 270 insertions(+), 9 deletions(-) create mode 100644 Tests/JsonModelTests/GooProcotolExampleTests.swift diff --git a/README.md b/README.md index befe36e..978b918 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,128 @@ that you reference `ResultData` model objects. - Added property wrappers that can be used in polymorphic serialization. - Deprecated `PolymorphicSerializer` and replaced with `GenericPolymorphicSerializer` +Note: Polymorphic encoding using the static `typeName` defined by the `PolymorphicStaticTyped` +protocol is not currently supported for encoding root objects, and is therefore *not* +used by any of the `SerializableResultData` model objects defined within this library. + +A root object can be encoded and decoded using the `PolymorphicValue` as a wrapper or +by defining the `typeName` as a read/write instance property. + +For example, + +``` +public protocol GooProtocol { + var value: Int { get } +} + +public struct FooObject : Codable, PolymorphicStaticTyped, GooProtocol { + public static var typeName: String { "foo" } + + public let value: Int + + public init(value: Int = 0) { + self.value = value + } +} + +public struct MooObject : Codable, PolymorphicTyped, GooProtocol { + private enum CodingKeys : String, CodingKey { + case typeName = "type", goos + } + public private(set) var typeName: String = "moo" + + public var value: Int { + goos.count + } + + @PolymorphicArray public var goos: [GooProtocol] + + public init(goos: [GooProtocol] = []) { + self.goos = goos + } +} + +public struct RaguObject : Codable, PolymorphicStaticTyped, GooProtocol { + public static let typeName: String = "ragu" + + public let value: Int + @PolymorphicValue public private(set) var goo: GooProtocol + + public init(value: Int, goo: GooProtocol) { + self.value = value + self.goo = goo + } +} + +open class GooFactory : SerializationFactory { + + public let gooSerializer = GenericPolymorphicSerializer([ + MooObject(), + FooObject(), + ]) + + public required init() { + super.init() + self.registerSerializer(gooSerializer) + gooSerializer.add(typeOf: RaguObject.self) + } +} + +``` + +In this example, `MooObject` can be directly serialized because the `typeName` is a read/write +instance property. Decoding can be handled like this: + +``` + let factory = GooFactory() + let decoder = factory.createJSONDecoder() + + let json = """ + { + "type" : "moo", + "goos" : [ + { "type" : "foo", "value" : 2 }, + { "type" : "moo", "goos" : [{ "type" : "foo", "value" : 5 }] } + ] + } + """.data(using: .utf8)! + + let decodedObject = try decoder.decode(MooObject.self, from: json) + +``` + +And because the root object does *not* use a static `typeName`, can be encoded as follows: + +``` + let encoder = JSONEncoder() + let encodedData = try encoder.encode(decodedObject) +``` + +Whereas `RaguObject` must be wrapped: + +``` + let factory = GooFactory() + let decoder = factory.createJSONDecoder() + let encoder = factory.createJSONEncoder() + + let json = """ + { + "type" : "ragu", + "value" : 7, + "goo" : { "type" : "foo", "value" : 2 } + } + """.data(using: .utf8)! + + let decodedObject = try decoder.decode(PolymorphicValue.self, from: json) + let encodedData = try encoder.encode(decodedObject) + +``` + ## License JsonModel is available under the BSD license: -Copyright (c) 2017-2022, Sage Bionetworks +Copyright (c) 2017-2023, Sage Bionetworks All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/Sources/JsonModel/PolymorphicSerializer.swift b/Sources/JsonModel/PolymorphicSerializer.swift index 0b3c066..6013923 100644 --- a/Sources/JsonModel/PolymorphicSerializer.swift +++ b/Sources/JsonModel/PolymorphicSerializer.swift @@ -96,14 +96,19 @@ open class GenericPolymorphicSerializer : GenericSerializer { } private var _examples: [String : ProtocolValue] = [:] + public init() { + } + public init(_ examples: [ProtocolValue]) { examples.forEach { example in try? self.add(example) } } - public init(_ typeMap: [String : Decodable.Type]) { - self.typeMap = typeMap + public init(_ types: [Decodable.Type]) { + types.forEach { decodable in + self.add(typeOf: decodable) + } } /// Insert the given example into the example array, replacing any existing example with the @@ -128,7 +133,7 @@ open class GenericPolymorphicSerializer : GenericSerializer { /// Insert the given `ProtocolValue.Type` into the type map, replacing any existing class with /// the same "type" decoding. - public final func add(typeOf typeValue: Decodable.Type) throws { + public final func add(typeOf typeValue: Decodable.Type) { let typeName = (typeValue as? PolymorphicStaticTyped.Type)?.typeName ?? "\(typeValue)" typeMap[typeName] = typeValue _examples[typeName] = nil diff --git a/Sources/ResultModel/AnswerType.swift b/Sources/ResultModel/AnswerType.swift index 11c0271..d3a76f8 100644 --- a/Sources/ResultModel/AnswerType.swift +++ b/Sources/ResultModel/AnswerType.swift @@ -47,7 +47,7 @@ public final class AnswerTypeSerializer : GenericPolymorphicSerializer, DocumentableInt "Another example interface used for unit testing." } - init() { + override init() { super.init([ AnotherA(), AnotherB(), diff --git a/Tests/JsonModelTests/GooProcotolExampleTests.swift b/Tests/JsonModelTests/GooProcotolExampleTests.swift new file mode 100644 index 0000000..6d182aa --- /dev/null +++ b/Tests/JsonModelTests/GooProcotolExampleTests.swift @@ -0,0 +1,139 @@ +// Created 3/15/23 +// swift-tools-version:5.0 + +import XCTest +@testable import JsonModel + +final class GooProcotolExampleTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExampleMoo() throws { + let factory = GooFactory() + let decoder = factory.createJSONDecoder() + let encoder = factory.createJSONEncoder() + + let json = """ + { + "type" : "moo", + "goos" : [ + { "type" : "foo", "value" : 2 }, + { "type" : "moo", "goos" : [{ "type" : "foo", "value" : 5 }] } + ] + } + """.data(using: .utf8)! + + let decodedObject = try decoder.decode(MooObject.self, from: json) + let encodedData = try encoder.encode(decodedObject) + + let expectedDictionary = try JSONSerialization.jsonObject(with: json) as! NSDictionary + let actualDictionary = try JSONSerialization.jsonObject(with: encodedData) as! NSDictionary + XCTAssertEqual(expectedDictionary, actualDictionary) + } + + func testExampleRagu() throws { + let factory = GooFactory() + let decoder = factory.createJSONDecoder() + let encoder = factory.createJSONEncoder() + + let json = """ + { + "type" : "ragu", + "value" : 7, + "goo" : { "type" : "foo", "value" : 2 } + } + """.data(using: .utf8)! + + let decodedObject = try decoder.decode(PolymorphicValue.self, from: json) + let encodedData = try encoder.encode(decodedObject) + + let expectedDictionary = try JSONSerialization.jsonObject(with: json) as! NSDictionary + let actualDictionary = try JSONSerialization.jsonObject(with: encodedData) as! NSDictionary + XCTAssertEqual(expectedDictionary, actualDictionary) + } +} + +public protocol GooProtocol { + var value: Int { get } +} + +public struct FooObject : Codable, PolymorphicStaticTyped, GooProtocol { + public static let typeName: String = "foo" + + public let value: Int + + public init(value: Int = 0) { + self.value = value + } +} + +/// This object can be serialized directly. +public struct MooObject : Codable, PolymorphicTyped, GooProtocol { + private enum CodingKeys : String, CodingKey { + case typeName = "type", goos + } + public private(set) var typeName: String = "moo" + + public var value: Int { + goos.count + } + + @PolymorphicArray public var goos: [GooProtocol] + + public init(goos: [GooProtocol] = []) { + self.goos = goos + } +} + +/// This object must be wrapped to allow serialization at the root. +/// +/// - Example: +/// ``` +/// let factory = GooFactory() +/// let decoder = factory.createJSONDecoder() +/// let encoder = factory.createJSONEncoder() +/// +/// let json = """ +/// { +/// "type" : "ragu", +/// "value" : 7, +/// "goo" : { "type" : "foo", "value" : 2 } +/// } +/// """.data(using: .utf8)! +/// +/// let decodedObject = try decoder.decode(PolymorphicValue.self, from: json) +/// let encodedData = try encoder.encode(decodedObject) +/// ``` +public struct RaguObject : Codable, PolymorphicStaticTyped, GooProtocol { + public static let typeName: String = "ragu" + + public let value: Int + @PolymorphicValue public private(set) var goo: GooProtocol + + public init(value: Int, goo: GooProtocol) { + self.value = value + self.goo = goo + } +} + +open class GooFactory : SerializationFactory { + + public let gooSerializer = GenericPolymorphicSerializer([ + MooObject(), + FooObject(), + ]) + + public required init() { + super.init() + + self.registerSerializer(gooSerializer) + gooSerializer.add(typeOf: RaguObject.self) + } +} + diff --git a/Tests/JsonModelTests/PolymorphicSerializerTests.swift b/Tests/JsonModelTests/PolymorphicSerializerTests.swift index af328e9..d437891 100644 --- a/Tests/JsonModelTests/PolymorphicSerializerTests.swift +++ b/Tests/JsonModelTests/PolymorphicSerializerTests.swift @@ -101,7 +101,7 @@ final class PolymorphicSerializerTests: XCTestCase { """.data(using: .utf8)! // our data in native (JSON) format let factory = TestFactory.defaultFactory - try factory.sampleSerializer.add(typeOf: SampleC.self) + factory.sampleSerializer.add(typeOf: SampleC.self) let decoder = factory.createJSONDecoder() let sampleWrapper = try decoder.decode(SampleWrapper.self, from: json) @@ -143,7 +143,7 @@ class SampleSerializer : GenericPolymorphicSerializer, DocumentableInter "Sample is an example interface used for unit testing." } - init() { + override init() { super.init([ SampleA(value: 3), SampleB(value: "foo"), From 59ad2fa60bffea4ef8a0c92e8b403dfefe32d302 Mon Sep 17 00:00:00 2001 From: Shannon Young Date: Wed, 15 Mar 2023 14:43:41 -0700 Subject: [PATCH 6/8] Fix date in comment --- Sources/JsonModel/PolymorphicWrapper.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/JsonModel/PolymorphicWrapper.swift b/Sources/JsonModel/PolymorphicWrapper.swift index 8472da4..85710f3 100644 --- a/Sources/JsonModel/PolymorphicWrapper.swift +++ b/Sources/JsonModel/PolymorphicWrapper.swift @@ -69,7 +69,7 @@ import Foundation /// /// - Limitation 3: /// This property wrapper does not explicitly require conformance to the `Codable` or -/// `PolymorphicTyped` protocols (limitation of Swift Generics as of 03/14/2022), but will fail to +/// `PolymorphicTyped` protocols (limitation of Swift Generics as of 03/14/2023), but will fail to /// encode at runtime if the objects do *not* conform to these protocols. Finally, if you attempt /// to decode with a ``SerializationFactory`` that does not have a registered serializer for the /// given ``ProtocolValue``, then decoding will fail at runtime. From 7373c52394c7290c7d4916f40b0eff7b5c791384 Mon Sep 17 00:00:00 2001 From: Shannon Young Date: Wed, 15 Mar 2023 14:53:46 -0700 Subject: [PATCH 7/8] Cleanup comments --- .../ResultModel/ResultDataSerializer.swift | 4 ++-- .../GooProcotolExampleTests.swift | 19 ------------------- .../PolymorphicSerializerTests.swift | 1 - 3 files changed, 2 insertions(+), 22 deletions(-) diff --git a/Sources/ResultModel/ResultDataSerializer.swift b/Sources/ResultModel/ResultDataSerializer.swift index 2bf923f..4be67ee 100644 --- a/Sources/ResultModel/ResultDataSerializer.swift +++ b/Sources/ResultModel/ResultDataSerializer.swift @@ -85,13 +85,13 @@ public final class ResultDataSerializer : GenericPolymorphicSerializer.self, from: json) -/// let encodedData = try encoder.encode(decodedObject) -/// ``` public struct RaguObject : Codable, PolymorphicStaticTyped, GooProtocol { public static let typeName: String = "ragu" diff --git a/Tests/JsonModelTests/PolymorphicSerializerTests.swift b/Tests/JsonModelTests/PolymorphicSerializerTests.swift index d437891..496ffe5 100644 --- a/Tests/JsonModelTests/PolymorphicSerializerTests.swift +++ b/Tests/JsonModelTests/PolymorphicSerializerTests.swift @@ -134,7 +134,6 @@ struct SampleWrapper : Codable { } class SampleSerializer : GenericPolymorphicSerializer, DocumentableInterface { - //AbstractPolymorphicSerializer, PolymorphicSerializer { var jsonSchema: URL { URL(string: "Sample.json", relativeTo: kSageJsonSchemaBaseURL)! } From 1d415bdc485c8f257b0615eb7ab8ea014c72dbbe Mon Sep 17 00:00:00 2001 From: Shannon Young Date: Wed, 15 Mar 2023 16:48:05 -0700 Subject: [PATCH 8/8] Apply suggestions from code review Co-authored-by: Erin-Mounts --- Sources/JsonModel/PolymorphicSerializer.swift | 2 +- Sources/JsonModel/PolymorphicWrapper.swift | 2 +- Tests/JsonModelTests/PolymorphicSerializerTests.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/JsonModel/PolymorphicSerializer.swift b/Sources/JsonModel/PolymorphicSerializer.swift index 6013923..f1bd052 100644 --- a/Sources/JsonModel/PolymorphicSerializer.swift +++ b/Sources/JsonModel/PolymorphicSerializer.swift @@ -29,7 +29,7 @@ public protocol PolymorphicRepresentable : PolymorphicTyped, Decodable { /// serialization protocols defined in Swift 2.0 where an object could conform to either the /// `Encodable` protocol or the `Decodable` protocol without conforming to the `Codable` /// protocol. Additionally, these older objects often had serialization strategies that -/// conflicted with either encoding or decoding. To work-around this, we introduced the +/// conflicted with either encoding or decoding. To work around this, we introduced the /// `PolymorphicRepresentable` protocol which **only** required adherence to the `Decodable` /// protocol and not to the `Encodable` protocol. This allowed developers who only needed /// to decode their objects, to use the ``SerializationFactory`` to handle polymorphic diff --git a/Sources/JsonModel/PolymorphicWrapper.swift b/Sources/JsonModel/PolymorphicWrapper.swift index 85710f3..e52d604 100644 --- a/Sources/JsonModel/PolymorphicWrapper.swift +++ b/Sources/JsonModel/PolymorphicWrapper.swift @@ -12,7 +12,7 @@ import Foundation /// limitations, it is **highly recommended** that you thoroughly unit test your implementations /// for the expected encoding and decoding of polymorphic objects. /// -/// The simpliest example is a non-null, read/write, required value without a default such as: +/// The simplest example is a non-null, read/write, required value without a default such as: /// /// ``` /// struct SampleTest : Codable { diff --git a/Tests/JsonModelTests/PolymorphicSerializerTests.swift b/Tests/JsonModelTests/PolymorphicSerializerTests.swift index 496ffe5..5947655 100644 --- a/Tests/JsonModelTests/PolymorphicSerializerTests.swift +++ b/Tests/JsonModelTests/PolymorphicSerializerTests.swift @@ -61,7 +61,7 @@ final class PolymorphicSerializerTests: XCTestCase { XCTAssertEqual("a", typeName) } else { - XCTFail("Encoding does not include 'valtypeue' keyword. \(dictionary)") + XCTFail("Encoding does not include 'type' keyword. \(dictionary)") } }