Skip to content

Commit

Permalink
Add JSONSafeEncoder for NaN/Infinity handling in JSON (#280)
Browse files Browse the repository at this point in the history
* Add JSONSafeEncoder for NaN/Infinity handling in JSON

* Make it all user-settable.

* Add tests

* Fix missing comma.

* Specified value for zero test

* Set configuration value to JSON's static prop.

* Updated tests

* Fixed example build.
  • Loading branch information
bsneed authored Nov 28, 2023
1 parent 56b1c19 commit cf78903
Show file tree
Hide file tree
Showing 12 changed files with 185 additions and 44 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ class ViewController: UIViewController {
case .alias:
aliasEvent()
case .none:
analytics?.log(message: "Failed to establish event type", kind: .error)
analytics?.log(message: "Failed to establish event type")
}

clearAll()
Expand Down
33 changes: 20 additions & 13 deletions Package.resolved
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
{
"object": {
"pins": [
{
"package": "Sovran",
"repositoryURL": "https://github.com/segmentio/Sovran-Swift.git",
"state": {
"branch": null,
"revision": "64f3b5150c282a34af4578188dce2fd597e600e3",
"version": "1.1.0"
}
"pins" : [
{
"identity" : "jsonsafeencoder-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/segmentio/jsonsafeencoder-swift.git",
"state" : {
"revision" : "75ad40f07d4e0b938e3afb80811244d6b7acd4ba",
"version" : "1.0.0"
}
]
},
"version": 1
},
{
"identity" : "sovran-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/segmentio/Sovran-Swift.git",
"state" : {
"revision" : "64f3b5150c282a34af4578188dce2fd597e600e3",
"version" : "1.1.0"
}
}
],
"version" : 2
}
6 changes: 4 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,17 @@ let package = Package(
dependencies: [
// Dependencies declare other packages that this package depends on.
// .package(url: /* package url */, from: "1.0.0"),
.package(url: "https://github.com/segmentio/sovran-swift.git", from: "1.1.0")
.package(url: "https://github.com/segmentio/sovran-swift.git", from: "1.1.0"),
.package(url: "https://github.com/segmentio/jsonsafeencoder-swift.git", from: "1.0.0")
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "Segment",
dependencies: [
.product(name: "Sovran", package: "sovran-swift")
.product(name: "Sovran", package: "sovran-swift"),
.product(name: "JSONSafeEncoder", package: "jsonsafeencoder-swift")
],
exclude: ["PrivacyInfo.xcprivacy"]),
.testTarget(
Expand Down
6 changes: 4 additions & 2 deletions [email protected]
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,17 @@ let package = Package(
dependencies: [
// Dependencies declare other packages that this package depends on.
// .package(url: /* package url */, from: "1.0.0"),
.package(url: "https://github.com/segmentio/sovran-swift.git", from: "1.1.0")
.package(url: "https://github.com/segmentio/sovran-swift.git", from: "1.1.0"),
.package(url: "https://github.com/segmentio/jsonsafeencoder-swift.git", from: "1.0.0")
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "Segment",
dependencies: [
.product(name: "Sovran", package: "sovran-swift")
.product(name: "Sovran", package: "sovran-swift"),
.product(name: "JSONSafeEncoder", package: "jsonsafeencoder-swift")
],
exclude: ["PrivacyInfo.xcprivacy"]),
.testTarget(
Expand Down
14 changes: 13 additions & 1 deletion Sources/Segment/Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import Foundation
import JSONSafeEncoder
#if os(Linux)
import FoundationNetworking
#endif
Expand Down Expand Up @@ -37,10 +38,10 @@ public class Configuration {
var requestFactory: ((URLRequest) -> URLRequest)? = nil
var errorHandler: ((Error) -> Void)? = nil
var flushPolicies: [FlushPolicy] = [CountBasedFlushPolicy(), IntervalBasedFlushPolicy()]

var operatingMode: OperatingMode = .asynchronous
var flushQueue: DispatchQueue = OperatingMode.defaultQueue
var userAgent: String? = nil
var jsonNonConformingNumberStrategy: JSONSafeEncoder.NonConformingFloatEncodingStrategy = .zero
}

internal var values: Values
Expand All @@ -50,6 +51,7 @@ public class Configuration {
/// - Parameter writeKey: Your Segment write key value
public init(writeKey: String) {
self.values = Values(writeKey: writeKey)
JSON.jsonNonConformingNumberStrategy = self.values.jsonNonConformingNumberStrategy
// enable segment destination by default
var settings = Settings(writeKey: writeKey)
settings.integrations = try? JSON([
Expand Down Expand Up @@ -216,11 +218,21 @@ public extension Configuration {
return self
}

/// Specify a custom UserAgent string. This bypasses the OS dependent check entirely.
@discardableResult
func userAgent(_ userAgent: String) -> Configuration {
values.userAgent = userAgent
return self
}

/// This option specifies how NaN/Infinity are handled when encoding JSON.
/// The default is .zero. See JSONSafeEncoder.NonConformingFloatEncodingStrategy for more informatino.
@discardableResult
func jsonNonConformingNumberStrategy(_ strategy: JSONSafeEncoder.NonConformingFloatEncodingStrategy) -> Configuration {
values.jsonNonConformingNumberStrategy = strategy
JSON.jsonNonConformingNumberStrategy = values.jsonNonConformingNumberStrategy
return self
}
}

extension Analytics {
Expand Down
3 changes: 2 additions & 1 deletion Sources/Segment/ObjC/ObjCAnalytics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#if !os(Linux)

import Foundation
import JSONSafeEncoder

// MARK: - ObjC Compatibility

Expand Down Expand Up @@ -164,7 +165,7 @@ extension ObjCAnalytics {
var result: [String: Any]? = nil
if let system: System = analytics.store.currentState() {
do {
let encoder = JSONEncoder.default
let encoder = JSONSafeEncoder.default
let json = try encoder.encode(system.settings)
if let r = try JSONSerialization.jsonObject(with: json) as? [String: Any] {
result = r
Expand Down
3 changes: 2 additions & 1 deletion Sources/Segment/ObjC/ObjCConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#if !os(Linux)

import Foundation
import JSONSafeEncoder

@objc(SEGConfiguration)
public class ObjCConfiguration: NSObject {
Expand Down Expand Up @@ -75,7 +76,7 @@ public class ObjCConfiguration: NSObject {
get {
var result = [String: Any]()
do {
let encoder = JSONEncoder.default
let encoder = JSONSafeEncoder.default
let json = try encoder.encode(configuration.values.defaultSettings)
if let r = try JSONSerialization.jsonObject(with: json) as? [String: Any] {
result = r
Expand Down
23 changes: 21 additions & 2 deletions Sources/Segment/Utilities/JSON.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,24 @@
//

import Foundation
import JSONSafeEncoder

extension JSONDecoder {
static var `default`: JSONDecoder {
let d = JSONDecoder()
d.dateDecodingStrategy = .formatted(DateFormatter.iso8601)
return d
}
}

extension JSONSafeEncoder {
static var `default`: JSONSafeEncoder {
let e = JSONSafeEncoder()
e.dateEncodingStrategy = .formatted(DateFormatter.iso8601)
e.nonConformingFloatEncodingStrategy = JSON.jsonNonConformingNumberStrategy
return e
}
}

// MARK: - JSON Definition

Expand All @@ -18,6 +35,8 @@ public enum JSON: Equatable {
case array([JSON])
case object([String: JSON])

static var jsonNonConformingNumberStrategy: JSONSafeEncoder.NonConformingFloatEncodingStrategy = .zero

internal enum JSONError: Error {
case unknown
case nonJSONType(type: String)
Expand All @@ -35,7 +54,7 @@ public enum JSON: Equatable {

// For Value types
public init<T: Codable>(with value: T) throws {
let encoder = JSONEncoder.default
let encoder = JSONSafeEncoder.default
let json = try encoder.encode(value)
let output = try JSONSerialization.jsonObject(with: json)
try self.init(output)
Expand Down Expand Up @@ -136,7 +155,7 @@ extension Encodable {
public func toString(pretty: Bool) -> String {
var returnString = ""
do {
let encoder = JSONEncoder.default
let encoder = JSONSafeEncoder.default
if pretty {
encoder.outputFormatting = .prettyPrinted
}
Expand Down
4 changes: 4 additions & 0 deletions Sources/Segment/Utilities/Utils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ extension Optional: Flattenable {
}
}

/* for dev testing only
#if DEBUG
class TrackingDispatchGroup: CustomStringConvertible {
internal let group = DispatchGroup()
Expand Down Expand Up @@ -102,3 +104,5 @@ class TrackingDispatchGroup: CustomStringConvertible {
group.notify(qos: qos, flags: flags, queue: queue, execute: work)
}
}
#endif
*/
18 changes: 1 addition & 17 deletions Sources/Segment/Utilities/iso8601.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import Foundation
import JSONSafeEncoder

enum SegmentISO8601DateFormatter {
static let shared: ISO8601DateFormatter = {
Expand All @@ -16,7 +17,6 @@ enum SegmentISO8601DateFormatter {
}

internal extension Date {
// TODO: support nanoseconds
func iso8601() -> String {
return SegmentISO8601DateFormatter.shared.string(from: self)
}
Expand All @@ -38,19 +38,3 @@ extension DateFormatter {
return formatter
}()
}

extension JSONDecoder {
static var `default`: JSONDecoder {
let d = JSONDecoder()
d.dateDecodingStrategy = .formatted(DateFormatter.iso8601)
return d
}
}

extension JSONEncoder {
static var `default`: JSONEncoder {
let e = JSONEncoder()
e.dateEncodingStrategy = .formatted(DateFormatter.iso8601)
return e
}
}
35 changes: 35 additions & 0 deletions Tests/Segment-Tests/Analytics_Tests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -823,4 +823,39 @@ final class Analytics_Tests: XCTestCase {
XCTAssertEqual(ziggysFound!.count, 3)
XCTAssertEqual(goobersFound!.count, 2)
}

func testJSONNaNDefaultHandlingZero() throws {
// notice we didn't set the nan handling option. zero is the default.
let analytics = Analytics(configuration: Configuration(writeKey: "test"))
let outputReader = OutputReaderPlugin()
analytics.add(plugin: outputReader)

waitUntilStarted(analytics: analytics)

analytics.track(name: "test track", properties: ["TestNaN": Double.nan])

let trackEvent: TrackEvent? = outputReader.lastEvent as? TrackEvent
XCTAssertTrue(trackEvent?.event == "test track")
XCTAssertTrue(trackEvent?.type == "track")
let d: Double? = trackEvent?.properties?.value(forKeyPath: "TestNaN")
XCTAssertTrue(d! == 0)
}

func testJSONNaNHandlingNull() throws {
let analytics = Analytics(configuration: Configuration(writeKey: "test")
.jsonNonConformingNumberStrategy(.null)
)
let outputReader = OutputReaderPlugin()
analytics.add(plugin: outputReader)

waitUntilStarted(analytics: analytics)

analytics.track(name: "test track", properties: ["TestNaN": Double.nan])

let trackEvent: TrackEvent? = outputReader.lastEvent as? TrackEvent
XCTAssertTrue(trackEvent?.event == "test track")
XCTAssertTrue(trackEvent?.type == "track")
let d: Double? = trackEvent?.properties?.value(forKeyPath: "TestNaN")
XCTAssertNil(d)
}
}
Loading

0 comments on commit cf78903

Please sign in to comment.