From 81f7c27129d7f17afe4443e2c6320a54507572b4 Mon Sep 17 00:00:00 2001 From: Chris Leonavicius Date: Wed, 3 Jul 2024 19:52:58 -0700 Subject: [PATCH] feat: Improve codable support --- Sources/Amplitude/Events/BaseEvent.swift | 8 +- .../Utilities/CodableExtension.swift | 239 ++++++++++++------ .../Events/BaseEventTests.swift | 34 ++- 3 files changed, 194 insertions(+), 87 deletions(-) diff --git a/Sources/Amplitude/Events/BaseEvent.swift b/Sources/Amplitude/Events/BaseEvent.swift index caba8fe1..1d5daa64 100644 --- a/Sources/Amplitude/Events/BaseEvent.swift +++ b/Sources/Amplitude/Events/BaseEvent.swift @@ -199,10 +199,10 @@ open class BaseEvent: EventOptions, Codable { public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(eventType, forKey: .eventType) - try container.encodeIfPresent(eventProperties, forKey: .eventProperties) - try container.encodeIfPresent(userProperties, forKey: .userProperties) - try container.encodeIfPresent(groups, forKey: .groups) - try container.encodeIfPresent(groupProperties, forKey: .groupProperties) + try container.encodeAny(eventProperties, forKey: .eventProperties) + try container.encodeAny(userProperties, forKey: .userProperties) + try container.encodeAny(groups, forKey: .groups) + try container.encodeAny(groupProperties, forKey: .groupProperties) try container.encode(userId, forKey: .userId) try container.encode(deviceId, forKey: .deviceId) try container.encode(timestamp, forKey: .timestamp) diff --git a/Sources/Amplitude/Utilities/CodableExtension.swift b/Sources/Amplitude/Utilities/CodableExtension.swift index 22f52c50..5df58f48 100644 --- a/Sources/Amplitude/Utilities/CodableExtension.swift +++ b/Sources/Amplitude/Utilities/CodableExtension.swift @@ -11,6 +11,11 @@ struct JSONCodingKeys: CodingKey { var stringValue: String var intValue: Int? + // Non-failable variant + init(_ stringValue: String) { + self.stringValue = stringValue + } + init?(stringValue: String) { self.stringValue = stringValue } @@ -111,91 +116,181 @@ extension UnkeyedDecodingContainer { } } -extension KeyedEncodingContainer { - mutating func encodeIfPresent(_ value: [String: Any?]?, forKey key: KeyedEncodingContainer.Key) throws { - guard let safeValue = value, !safeValue.isEmpty else { +extension UnkeyedEncodingContainer { + + mutating func encodeAny(_ value: Any?) throws { + guard let value = value else { return } - var container = self.nestedContainer(keyedBy: JSONCodingKeys.self, forKey: key) - for item in safeValue { - if let val = item.value as? Int { - try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) - } else if let val = item.value as? Int32 { - try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) - } else if let val = item.value as? Int64 { - try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) - } else if let val = item.value as? UInt { - try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) - } else if let val = item.value as? String { - try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) - } else if let val = item.value as? Double { - try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) - } else if let val = item.value as? Float { - try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) - } else if let val = item.value as? CGFloat { - try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) - } else if let val = item.value as? Decimal { - try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) - } else if let val = item.value as? Bool { - try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) - } else if let val = item.value as? [Any] { - try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) - } else if let val = item.value as? [String: Any] { - try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) + + // NSNumber bridges into many different types - try to extract the original. + // Note that for swift numeric types, this actually does a double conversion (to NSNumber first). + let encodedValue: Any + if let numberValue = value as? NSNumber, let swiftValue = numberValue.swiftValue { + encodedValue = swiftValue + } else { + encodedValue = value + } + + // Based on https://github.com/apple/swift-corelibs-foundation/blob/ca3669eb9ac282c649e71824d9357dbe140c8251/Sources/Foundation/JSONSerialization.swift#L397 + switch encodedValue { + case let encodable as Encodable: + try encode(encodable) + case let str as String: + try encode(str) + case let boolValue as Bool: + try encode(boolValue) + case let num as Int: + try encode(num) + case let num as Int8: + try encode(num) + case let num as Int16: + try encode(num) + case let num as Int32: + try encode(num) + case let num as Int64: + try encode(num) + case let num as UInt: + try encode(num) + case let num as UInt8: + try encode(num) + case let num as UInt16: + try encode(num) + case let num as UInt32: + try encode(num) + case let num as UInt64: + try encode(num) + case let array as [Any?]: + var container = nestedUnkeyedContainer() + for element in array { + try container.encodeAny(element) + } + case let dict as [AnyHashable: Any?]: + var container = nestedContainer(keyedBy: JSONCodingKeys.self) + for (key, value) in dict { + try container.encodeAny(value, forKey: JSONCodingKeys(String(describing: key))) } + case let num as Float: + try encode(num) + case let num as Double: + try encode(num) + case let num as Decimal: + try encode(num) + case let num as NSDecimalNumber: + try encode(num.decimalValue) + case is NSNull: + try encodeNil() + default: + // Ideally we would throw an error here, but we still want to complete the encode to maintain + // backwards compatibility. + try encode("[Non-Encodable]") } } +} - mutating func encodeIfPresent(_ value: [Any]?, forKey key: KeyedEncodingContainer.Key) throws { - guard let safeValue = value else { +extension KeyedEncodingContainer { + + mutating func encodeAny(_ value: Any?, forKey key: KeyedEncodingContainer.Key) throws { + guard let value = value else { return } - if let val = safeValue as? [Int] { - try self.encodeIfPresent(val, forKey: key) - } else if let val = safeValue as? [UInt] { - try self.encodeIfPresent(val, forKey: key) - } else if let val = safeValue as? [String] { - try self.encodeIfPresent(val, forKey: key) - } else if let val = safeValue as? [Double] { - try self.encodeIfPresent(val, forKey: key) - } else if let val = safeValue as? [Float] { - try self.encodeIfPresent(val, forKey: key) - } else if let val = safeValue as? [Bool] { - try self.encodeIfPresent(val, forKey: key) - } else if let val = value as? [[String: Any]] { - var container = self.nestedUnkeyedContainer(forKey: key) - try container.encode(contentsOf: val) + + // NSNumber bridges into many different types - try to extract the original. + // Note that for swift numeric types, this actually does a double conversion (to NSNumber first). + let encodedValue: Any + if let numberValue = value as? NSNumber, let swiftValue = numberValue.swiftValue { + encodedValue = swiftValue + } else { + encodedValue = value } - } -} -extension UnkeyedEncodingContainer { - mutating func encode(contentsOf sequence: [[String: Any]]) throws { - for dict in sequence { - try self.encodeIfPresent(dict) + // Based on https://github.com/apple/swift-corelibs-foundation/blob/ca3669eb9ac282c649e71824d9357dbe140c8251/Sources/Foundation/JSONSerialization.swift#L397 + switch encodedValue { + case let encodable as Encodable: + try encode(encodable, forKey: key) + case let str as String: + try encode(str, forKey: key) + case let boolValue as Bool: + try encode(boolValue, forKey: key) + case let num as Int: + try encode(num, forKey: key) + case let num as Int8: + try encode(num, forKey: key) + case let num as Int16: + try encode(num, forKey: key) + case let num as Int32: + try encode(num, forKey: key) + case let num as Int64: + try encode(num, forKey: key) + case let num as UInt: + try encode(num, forKey: key) + case let num as UInt8: + try encode(num, forKey: key) + case let num as UInt16: + try encode(num, forKey: key) + case let num as UInt32: + try encode(num, forKey: key) + case let num as UInt64: + try encode(num, forKey: key) + case let array as [Any?]: + var container = nestedUnkeyedContainer(forKey: key) + for element in array { + try container.encodeAny(element) + } + case let dict as [AnyHashable: Any?]: + var container = nestedContainer(keyedBy: JSONCodingKeys.self, forKey: key) + for (key, value) in dict { + try container.encodeAny(value, forKey: JSONCodingKeys(String(describing: key))) + } + case let num as Float: + try encode(num, forKey: key) + case let num as Double: + try encode(num, forKey: key) + case let num as Decimal: + try encode(num, forKey: key) + case let num as NSDecimalNumber: + try encode(num.decimalValue, forKey: key) + case is NSNull: + try encodeNil(forKey: key) + default: + // Ideally we would throw an error here, but we still want to complete the encode to maintain + // backwards compatibility. + try encode("[Non-Encodable]", forKey: key) } } +} - mutating func encodeIfPresent(_ value: [String: Any]) throws { - var container = self.nestedContainer(keyedBy: JSONCodingKeys.self) - for item in value { - if let val = item.value as? Int { - try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) - } else if let val = item.value as? UInt { - try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) - } else if let val = item.value as? String { - try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) - } else if let val = item.value as? Double { - try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) - } else if let val = item.value as? Float { - try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) - } else if let val = item.value as? Bool { - try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) - } else if let val = item.value as? [Any] { - try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) - } else if let val = item.value as? [String: Any] { - try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) - } +fileprivate extension NSNumber { + + var swiftValue: Any? { + // https://developer.apple.com/documentation/foundation/nsnumber#1776615 + switch String(cString: objCType) { + case "c": + return self as? CBool + case "C": + return self as? CBool + case "s": + return self as? CShort + case "S": + return self as? CUnsignedShort + case "i": + return self as? CInt + case "I": + return self as? CUnsignedInt + case "l": + return self as? CLong + case "L": + return self as? CUnsignedLong + case "q": + return self as? CLongLong + case "Q": + return self as? CUnsignedLongLong + case "f": + return self as? CFloat + case "d": + return self as? CDouble + default: + return self } } } diff --git a/Tests/AmplitudeTests/Events/BaseEventTests.swift b/Tests/AmplitudeTests/Events/BaseEventTests.swift index 8b7c0079..2d012b02 100644 --- a/Tests/AmplitudeTests/Events/BaseEventTests.swift +++ b/Tests/AmplitudeTests/Events/BaseEventTests.swift @@ -12,6 +12,23 @@ import XCTest // swiftlint:disable force_cast final class BaseEventTests: XCTestCase { func testToString() { + let eventProperties: [String: Any?] = [ + "integer": 1, + "string": "stringValue", + "array": [1, 2, 3], + "int64": 1 as Int64, + "int32": 1 as Int32, + "cgfloat": 3.14 as CGFloat, + "double": 3.14 as Double, + "decimal": 3.14 as Decimal, + "decimalnumber": 3.14 as NSDecimalNumber, + "bool": true, + "numberbool": true as NSNumber, + "dict": ["a": 1, "b": 2, "c": "d"], + "embeddedArray": ["a": [1, 2, 3]], + "embeddedDict": [1, 2, ["a": 3]], + "array2": ["a", 1, 2, "b"] + ] let baseEvent = BaseEvent( plan: Plan( branch: "test-branch", @@ -24,17 +41,7 @@ final class BaseEventTests: XCTestCase { sourceVersion: "test-source-version" ), eventType: "test", - eventProperties: [ - "integer": 1, - "string": "stringValue", - "array": [1, 2, 3], - "int64": 1 as Int64, - "int32": 1 as Int32, - "cgfloat": 3.14 as CGFloat, - "double": 3.14 as Double, - "decimal": 3.14 as Decimal - ] - ) + eventProperties: eventProperties) let baseEventData = baseEvent.toString().data(using: .utf8)! let baseEventDict = @@ -43,6 +50,11 @@ final class BaseEventTests: XCTestCase { baseEventDict!["event_type"] as! String, "test" ) + XCTAssertEqual(baseEventDict!["event_properties"] as! NSDictionary, eventProperties as NSDictionary) + XCTAssertEqual( + baseEventDict!["event_properties"]!["numberbool"], + true as NSNumber + ) XCTAssertEqual( baseEventDict!["event_properties"]!["integer" as NSString] as! Int, 1