Skip to content

Commit

Permalink
feat: Improve codable support
Browse files Browse the repository at this point in the history
  • Loading branch information
crleona committed Jul 8, 2024
1 parent c838bd9 commit 81f7c27
Show file tree
Hide file tree
Showing 3 changed files with 194 additions and 87 deletions.
8 changes: 4 additions & 4 deletions Sources/Amplitude/Events/BaseEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
239 changes: 167 additions & 72 deletions Sources/Amplitude/Utilities/CodableExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -111,91 +116,181 @@ extension UnkeyedDecodingContainer {
}
}

extension KeyedEncodingContainer {
mutating func encodeIfPresent(_ value: [String: Any?]?, forKey key: KeyedEncodingContainer<K>.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<K>.Key) throws {
guard let safeValue = value else {
extension KeyedEncodingContainer {

mutating func encodeAny(_ value: Any?, forKey key: KeyedEncodingContainer<K>.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
}
}
}
34 changes: 23 additions & 11 deletions Tests/AmplitudeTests/Events/BaseEventTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 =
Expand All @@ -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
Expand Down

0 comments on commit 81f7c27

Please sign in to comment.