diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 36a1f10381..c9213779d8 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -101,6 +101,9 @@ 6271ADF32BA06D9B0098D2E9 /* SentryInternalSerializable.h in Headers */ = {isa = PBXBuildFile; fileRef = 6271ADF22BA06D9B0098D2E9 /* SentryInternalSerializable.h */; settings = {ATTRIBUTES = (Private, ); }; }; 6273513F2CBD14970021D100 /* SentryDebugImageProvider+HybridSDKs.h in Headers */ = {isa = PBXBuildFile; fileRef = 6273513E2CBD14970021D100 /* SentryDebugImageProvider+HybridSDKs.h */; settings = {ATTRIBUTES = (Private, ); }; }; 627E7589299F6FE40085504D /* SentryInternalDefines.h in Headers */ = {isa = PBXBuildFile; fileRef = 627E7588299F6FE40085504D /* SentryInternalDefines.h */; }; + 628094742D39584C00B3F18B /* SentryUserCodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628094732D39584700B3F18B /* SentryUserCodable.swift */; }; + 6281C5722D3E4F12009D0978 /* DecodeArbitraryData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6281C5712D3E4F06009D0978 /* DecodeArbitraryData.swift */; }; + 6281C5742D3E50DF009D0978 /* ArbitraryDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6281C5732D3E50D8009D0978 /* ArbitraryDataTests.swift */; }; 62862B1C2B1DDBC8009B16E3 /* SentryDelayedFrame.h in Headers */ = {isa = PBXBuildFile; fileRef = 62862B1B2B1DDBC8009B16E3 /* SentryDelayedFrame.h */; }; 62862B1E2B1DDC35009B16E3 /* SentryDelayedFrame.m in Sources */ = {isa = PBXBuildFile; fileRef = 62862B1D2B1DDC35009B16E3 /* SentryDelayedFrame.m */; }; 62872B5F2BA1B7F300A4FA7D /* NSLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62872B5E2BA1B7F300A4FA7D /* NSLock.swift */; }; @@ -1116,6 +1119,9 @@ 6271ADF22BA06D9B0098D2E9 /* SentryInternalSerializable.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryInternalSerializable.h; path = include/SentryInternalSerializable.h; sourceTree = ""; }; 6273513E2CBD14970021D100 /* SentryDebugImageProvider+HybridSDKs.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "SentryDebugImageProvider+HybridSDKs.h"; path = "include/HybridPublic/SentryDebugImageProvider+HybridSDKs.h"; sourceTree = ""; }; 627E7588299F6FE40085504D /* SentryInternalDefines.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryInternalDefines.h; path = include/SentryInternalDefines.h; sourceTree = ""; }; + 628094732D39584700B3F18B /* SentryUserCodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryUserCodable.swift; sourceTree = ""; }; + 6281C5712D3E4F06009D0978 /* DecodeArbitraryData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecodeArbitraryData.swift; sourceTree = ""; }; + 6281C5732D3E50D8009D0978 /* ArbitraryDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArbitraryDataTests.swift; sourceTree = ""; }; 62862B1B2B1DDBC8009B16E3 /* SentryDelayedFrame.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryDelayedFrame.h; path = include/SentryDelayedFrame.h; sourceTree = ""; }; 62862B1D2B1DDC35009B16E3 /* SentryDelayedFrame.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryDelayedFrame.m; sourceTree = ""; }; 62872B5E2BA1B7F300A4FA7D /* NSLock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSLock.swift; sourceTree = ""; }; @@ -2140,7 +2146,9 @@ 620078752D38F1110022CB67 /* Codable */ = { isa = PBXGroup; children = ( + 6281C5712D3E4F06009D0978 /* DecodeArbitraryData.swift */, 620078712D38F00D0022CB67 /* SentryGeoCodable.swift */, + 628094732D39584700B3F18B /* SentryUserCodable.swift */, 620078732D38F0DF0022CB67 /* SentryCodable.swift */, ); path = Codable; @@ -2149,6 +2157,7 @@ 620078762D3906AD0022CB67 /* Codable */ = { isa = PBXGroup; children = ( + 6281C5732D3E50D8009D0978 /* ArbitraryDataTests.swift */, 620078772D3906BF0022CB67 /* SentryCodableTests.swift */, ); path = Codable; @@ -4634,6 +4643,7 @@ 0A2D8D9628997845008720F6 /* NSLocale+Sentry.m in Sources */, 620203B22C59025E0008317C /* SentryFileContents.swift in Sources */, 7B0DC730288698F70039995F /* NSMutableDictionary+Sentry.m in Sources */, + 628094742D39584C00B3F18B /* SentryUserCodable.swift in Sources */, 7BD4BD4527EB29F50071F4FF /* SentryClientReport.m in Sources */, D87C89032BC43C9C0086C7DF /* SentryRedactOptions.swift in Sources */, 631E6D341EBC679C00712345 /* SentryQueueableRequestManager.m in Sources */, @@ -4725,6 +4735,7 @@ 844EDC77294144DB00C86F34 /* SentrySystemWrapper.mm in Sources */, D8739CF92BECFFB5007D2F66 /* SentryTransactionNameSource.swift in Sources */, 630435FF1EBCA9D900C4D3FA /* SentryNSURLRequest.m in Sources */, + 6281C5722D3E4F12009D0978 /* DecodeArbitraryData.swift in Sources */, 62C1AFAB2B7E10EA0038C5F7 /* SentrySpotlightTransport.m in Sources */, D87C892B2BC67BC20086C7DF /* SentryExperimentalOptions.swift in Sources */, 7B5CAF7727F5A68C00ED0DB6 /* SentryNSURLRequestBuilder.m in Sources */, @@ -5112,6 +5123,7 @@ D80694C42B7CC9AE00B820E6 /* SentryReplayEventTests.swift in Sources */, 7B34721728086A9D0041F047 /* SentrySwizzleWrapperTests.swift in Sources */, 8EC4CF5025C3A0070093DEE9 /* SentrySpanContextTests.swift in Sources */, + 6281C5742D3E50DF009D0978 /* ArbitraryDataTests.swift in Sources */, 7BE0DC2F272ABAF6004FA8B7 /* SentryAutoBreadcrumbTrackingIntegrationTests.swift in Sources */, 7B869EBE249B964D004F4FDB /* SentryThreadEquality.swift in Sources */, 7BC6EBF8255C05060059822A /* TestData.swift in Sources */, diff --git a/Sources/Sentry/include/SentryDateUtils.h b/Sources/Sentry/include/SentryDateUtils.h index 1a2d16f19c..ee8dd4d76d 100644 --- a/Sources/Sentry/include/SentryDateUtils.h +++ b/Sources/Sentry/include/SentryDateUtils.h @@ -3,6 +3,8 @@ NS_ASSUME_NONNULL_BEGIN +SENTRY_EXTERN NSDateFormatter *sentryGetIso8601FormatterWithMillisecondPrecision(void); + SENTRY_EXTERN NSDate *sentry_fromIso8601String(NSString *string); SENTRY_EXTERN NSString *sentry_toIso8601String(NSDate *date); diff --git a/Sources/Sentry/include/SentryPrivate.h b/Sources/Sentry/include/SentryPrivate.h index 27c7ad8d64..e030c6b45f 100644 --- a/Sources/Sentry/include/SentryPrivate.h +++ b/Sources/Sentry/include/SentryPrivate.h @@ -10,6 +10,7 @@ // Headers that also import SentryDefines should be at the end of this list // otherwise it wont compile #import "SentryDateUtil.h" +#import "SentryDateUtils.h" #import "SentryDisplayLinkWrapper.h" #import "SentryLevelHelper.h" #import "SentryLogC.h" diff --git a/Sources/Swift/Protocol/Codable/DecodeArbitraryData.swift b/Sources/Swift/Protocol/Codable/DecodeArbitraryData.swift new file mode 100644 index 0000000000..98003fc2bd --- /dev/null +++ b/Sources/Swift/Protocol/Codable/DecodeArbitraryData.swift @@ -0,0 +1,105 @@ +@_implementationOnly import _SentryPrivate + +/// Represents arbitrary data that can be decoded from JSON with Decodable. +/// +/// - Note: Some classes on the protocol allow adding extra data in a dictionary of type String:Any. +/// Users can put anything in there that can be serialized to JSON. The SDK uses JSONSerialization to +/// serialize these dictionaries. At first glance, you could assume that we can use JSONSerialization.jsonObject(with:options) +/// to deserialize these dictionaries, but we can't. When using Decodable, you don't have access to the raw +/// data of the JSON. The Decoder and the DecodingContainers don't offer methods to access the underlying +/// data. The Swift Decodable converts the raw data to a JSON object and then casts the JSON object to the +/// class that implements the Decodable protocol, see: +/// https://github.com/swiftlang/swift-foundation/blob/e9d59b6065ad909fee15f174bd5ca2c580490388/Sources/FoundationEssentials/JSON/JSONDecoder.swift#L360-L386 +/// https://github.com/swiftlang/swift-foundation/blob/e9d59b6065ad909fee15f174bd5ca2c580490388/Sources/FoundationEssentials/JSON/JSONScanner.swift#L343-L383 + +/// Therefore, we have to implement decoding the arbitrary dictionary manually. +/// +/// A discarded option is to decode the JSON raw data twice: once with Decodable and once with the JSONSerialization. +/// This has two significant downsides: First, we deserialize the JSON twice, which is a performance overhead. Second, +/// we don't conform to the Decodable protocol, which could lead to unwanted hard-to-detect problems in the future. +enum ArbitraryData: Decodable { + case string(String) + case int(Int) + case number(Double) + case boolean(Bool) + case date(Date) + case dict([String: ArbitraryData]) + case array([ArbitraryData]) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let dateValue = try? container.decode(Date.self) { + self = .date(dateValue) + } else if let stringValue = try? container.decode(String.self) { + self = .string(stringValue) + } else if let intValue = try? container.decode(Int.self) { + self = .int(intValue) + } else if let numberValue = try? container.decode(Double.self) { + self = .number(numberValue) + } else if let boolValue = try? container.decode(Bool.self) { + self = .boolean(boolValue) + } else if let objectValue = try? container.decode([String: ArbitraryData].self) { + self = .dict(objectValue) + } else if let arrayValue = try? container.decode([ArbitraryData].self) { + self = .array(arrayValue) + } else if container.decodeNil() { + self = .null + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid JSON value" + ) + } + } +} + +func decodeArbitraryData(decode: () throws -> [String: ArbitraryData]?) -> [String: Any]? { + do { + let rawData = try decode() + return unwrapArbitraryDict(rawData) + } catch { + SentryLog.error("Failed to decode raw data: \(error)") + return nil + } +} + +private func unwrapArbitraryDict(_ dict: [String: ArbitraryData]?) -> [String: Any]? { + guard let nonNullDict = dict else { + return nil + } + + return nonNullDict.mapValues { unwrapArbitraryValue($0) as Any } +} + +private func unwrapArbitraryArray(_ array: [ArbitraryData]?) -> [Any]? { + guard let nonNullArray = array else { + return nil + } + + return nonNullArray.map { unwrapArbitraryValue($0) as Any } +} + +private func unwrapArbitraryValue(_ value: ArbitraryData?) -> Any? { + switch value { + case .string(let stringValue): + return stringValue + case .number(let numberValue): + return numberValue + case .int(let intValue): + return intValue + case .boolean(let boolValue): + return boolValue + case .date(let dateValue): + return dateValue + case .dict(let dictValue): + return unwrapArbitraryDict(dictValue) + case .array(let arrayValue): + return unwrapArbitraryArray(arrayValue) + case .null: + return NSNull() + case .none: + return nil + } +} diff --git a/Sources/Swift/Protocol/Codable/SentryCodable.swift b/Sources/Swift/Protocol/Codable/SentryCodable.swift index 6f6e5a6d1f..ada271754d 100644 --- a/Sources/Swift/Protocol/Codable/SentryCodable.swift +++ b/Sources/Swift/Protocol/Codable/SentryCodable.swift @@ -1,3 +1,4 @@ +@_implementationOnly import _SentryPrivate import Foundation func decodeFromJSONData(jsonData: Data) -> T? { @@ -7,9 +8,12 @@ func decodeFromJSONData(jsonData: Data) -> T? { do { let decoder = JSONDecoder() + let formatter = sentryGetIso8601FormatterWithMillisecondPrecision() + decoder.dateDecodingStrategy = .formatted(formatter) return try decoder.decode(T.self, from: jsonData) } catch { SentryLog.error("Could not decode object of type \(T.self) from JSON data due to error: \(error)") - return nil } + + return nil } diff --git a/Sources/Swift/Protocol/Codable/SentryUserCodable.swift b/Sources/Swift/Protocol/Codable/SentryUserCodable.swift new file mode 100644 index 0000000000..25ebd887eb --- /dev/null +++ b/Sources/Swift/Protocol/Codable/SentryUserCodable.swift @@ -0,0 +1,41 @@ +@_implementationOnly import _SentryPrivate +import Foundation + +extension User: Decodable { + + enum CodingKeys: String, CodingKey { + case userId = "id" + case email + case username + case ipAddress = "ip_address" + case segment + case name + case geo + case data + } + + @available(*, deprecated, message: """ + This method is only deprecated to silence the deprecation warning of the property \ + segment. Our Xcode project has deprecations as warnings and warnings as errors \ + configured. Therefore, compilation fails without marking this init method as \ + deprecated. It is safe to use this deprecated init method. Instead of turning off \ + deprecation warnings for the whole project, we accept the tradeoff of marking this \ + init method as deprecated because we don't expect many users to use it. Sadly, \ + Swift doesn't offer a better way of silencing a deprecation warning. + """) + required convenience public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init() + self.userId = try container.decodeIfPresent(String.self, forKey: .userId) + self.email = try container.decodeIfPresent(String.self, forKey: .email) + self.username = try container.decodeIfPresent(String.self, forKey: .username) + self.ipAddress = try container.decodeIfPresent(String.self, forKey: .ipAddress) + self.segment = try container.decodeIfPresent(String.self, forKey: .segment) + self.name = try container.decodeIfPresent(String.self, forKey: .name) + self.geo = try container.decodeIfPresent(Geo.self, forKey: .geo) + + self.data = decodeArbitraryData { + try container.decodeIfPresent([String: ArbitraryData].self, forKey: .data) + } + } +} diff --git a/Tests/SentryTests/Protocol/Codable/ArbitraryDataTests.swift b/Tests/SentryTests/Protocol/Codable/ArbitraryDataTests.swift new file mode 100644 index 0000000000..b004c65fcf --- /dev/null +++ b/Tests/SentryTests/Protocol/Codable/ArbitraryDataTests.swift @@ -0,0 +1,259 @@ +@testable import Sentry +import SentryTestUtils +import XCTest + +class ArbitraryDataTests: XCTestCase { + + func testDecode_StringValues() throws { + // Arrange + let jsonData = #""" + { + "data": { + "some": "value", + "empty": "", + } + } + """#.data(using: .utf8)! + + // Act + let actual = try XCTUnwrap(decodeFromJSONData(jsonData: jsonData) as DataWrapper?) + + // Assert + XCTAssertEqual("value", actual.data?["some"] as? String) + XCTAssertEqual("", actual.data?["empty"] as? String) + } + + func testDecode_IntValues() throws { + // Arrange + let jsonData = """ + { + "data": { + "positive": 1, + "zero": 0, + "negative": -1, + "max": \(Int.max), + "min": \(Int.min) + } + } + """.data(using: .utf8)! + + // Act + let actual = try XCTUnwrap(decodeFromJSONData(jsonData: jsonData) as DataWrapper?) + + // Assert + XCTAssertEqual(1, actual.data?["positive"] as? Int) + XCTAssertEqual(0, actual.data?["zero"] as? Int) + XCTAssertEqual(-1, actual.data?["negative"] as? Int) + XCTAssertEqual(Int.max, actual.data?["max"] as? Int) + XCTAssertEqual(Int.min, actual.data?["min"] as? Int) + } + + func testDecode_DoubleValues() throws { + // Arrange + let jsonData = """ + { + "data": { + "positive": 0.1, + "negative": -0.1, + "max": \(Double.greatestFiniteMagnitude), + "min": \(Double.leastNormalMagnitude) + } + } + """.data(using: .utf8)! + + // Act + let actual = try XCTUnwrap(decodeFromJSONData(jsonData: jsonData) as DataWrapper?) + + // Assert + XCTAssertEqual(0.1, actual.data?["positive"] as? Double) + XCTAssertEqual(-0.1, actual.data?["negative"] as? Double) + XCTAssertEqual(Double.greatestFiniteMagnitude, actual.data?["max"] as? Double) + XCTAssertEqual(Double.leastNormalMagnitude, actual.data?["min"] as? Double) + } + + func testDecode_DoubleWithoutFractionalPart_IsDecodedAsInt() throws { + // Arrange + let jsonData = """ + { + "data": { + "zero": 0.0, + "one": 1.0, + "minus_one": -1.0, + + } + } + """.data(using: .utf8)! + + // Act + let actual = try XCTUnwrap(decodeFromJSONData(jsonData: jsonData) as DataWrapper?) + + // Assert + XCTAssertEqual(0, actual.data?["zero"] as? Int) + XCTAssertEqual(1, actual.data?["one"] as? Int) + XCTAssertEqual(-1, actual.data?["minus_one"] as? Int) + } + + func testDecode_BoolValues() throws { + // Arrange + let jsonData = #""" + { + "data": { + "true": true, + "false": false + } + } + """#.data(using: .utf8)! + + // Act + let actual = try XCTUnwrap(decodeFromJSONData(jsonData: jsonData) as DataWrapper?) + + // Assert + XCTAssertEqual(true, actual.data?["true"] as? Bool) + XCTAssertEqual(false, actual.data?["false"] as? Bool) + } + + func testDecode_DateValue() throws { + // Arrange + let date = TestCurrentDateProvider().date().addingTimeInterval(0.001) + let jsonData = #""" + { + "data": { + "date": "\#(sentry_toIso8601String(date))" + } + } + """#.data(using: .utf8)! + + // Act + let actual = try XCTUnwrap(decodeFromJSONData(jsonData: jsonData) as DataWrapper?) + + // Assert + let actualDate = try XCTUnwrap( actual.data?["date"] as? Date) + XCTAssertEqual(date.timeIntervalSinceReferenceDate, actualDate.timeIntervalSinceReferenceDate, accuracy: 0.0001) + } + + func testDecode_Dict() throws { + // Arrange + let jsonData = #""" + { + "data": { + "dict": { + "string": "value", + "true": true, + "number": 10, + }, + } + } + """#.data(using: .utf8)! + + // Act + let actual = try XCTUnwrap(decodeFromJSONData(jsonData: jsonData) as DataWrapper?) + + // Assert + let dict = try XCTUnwrap(actual.data?["dict"] as? [String: Any]) + XCTAssertEqual("value", dict["string"] as? String) + XCTAssertEqual(true, dict["true"] as? Bool) + XCTAssertEqual(10, dict["number"] as? Int) + } + + func testDecode_IntArray() throws { + // Arrange + let jsonData = #""" + { + "data": { + "array": [1, 2, 3] + } + } + """#.data(using: .utf8)! + + // Act + let actual = try XCTUnwrap(decodeFromJSONData(jsonData: jsonData) as DataWrapper?) + + // Assert + XCTAssertEqual([1, 2, 3], actual.data?["array"] as? [Int]) + } + + func testDecode_ArrayOfDicts() throws { + // Arrange + let date = TestCurrentDateProvider().date().addingTimeInterval(0.001) + let jsonData = #""" + { + "data": { + "array": [ + { + "dict1_string": "value", + "dict1_int": 1, + }, + { + "dict2_number": 0.1, + "dict2_date": "\#(sentry_toIso8601String(date))" + }, + ] + } + } + """#.data(using: .utf8)! + + // Act + let actual = try XCTUnwrap(decodeFromJSONData(jsonData: jsonData) as DataWrapper?) + + // Assert + let array = try XCTUnwrap(actual.data?["array"] as? [Any]) + XCTAssertEqual(2, array.count) + + let dict1 = try XCTUnwrap(array[0] as? [String: Any]) + + XCTAssertEqual("value", dict1["dict1_string"] as? String) + XCTAssertEqual(1, dict1["dict1_int"] as? Int) + + let dict2 = try XCTUnwrap(array[1] as? [String: Any]) + XCTAssertEqual(0.1, dict2["dict2_number"] as? Double) + let actualDate = try XCTUnwrap(dict2["dict2_date"] as? Date) + XCTAssertEqual(date.timeIntervalSinceReferenceDate, actualDate.timeIntervalSinceReferenceDate, accuracy: 0.0001) + } + + func testDecode_NullValue() throws { + // Arrange + let jsonData = #""" + { + "data": { "null": null } + } + """#.data(using: .utf8)! + + // Act + let actual = try XCTUnwrap(decodeFromJSONData(jsonData: jsonData) as DataWrapper?) + + // Assert + XCTAssertEqual(NSNull(), actual.data?["null"] as? NSNull) + } + + func testDecode_GarbageJSON() { + // Arrange + let jsonData = #""" + { + "data": { + 1: "garbage" + } + } + """#.data(using: .utf8)! + + // Act & Assert + XCTAssertNil(decodeFromJSONData(jsonData: jsonData) as DataWrapper?) + } +} + +class DataWrapper: Decodable { + + var data: [String: Any]? + + enum CodingKeys: String, CodingKey { + case data + } + + required convenience public init(from decoder: any Decoder) throws { + self.init() + + let container = try decoder.container(keyedBy: CodingKeys.self) + self.data = decodeArbitraryData { + try container.decodeIfPresent([String: ArbitraryData].self, forKey: .data) as [String: ArbitraryData]? + } + } +} diff --git a/Tests/SentryTests/Protocol/SentryUserTests.swift b/Tests/SentryTests/Protocol/SentryUserTests.swift index 2dd3d9fff7..50b6b15dc0 100644 --- a/Tests/SentryTests/Protocol/SentryUserTests.swift +++ b/Tests/SentryTests/Protocol/SentryUserTests.swift @@ -1,3 +1,4 @@ +@testable import Sentry import XCTest @available(*, deprecated) @@ -73,6 +74,32 @@ class SentryUserTests: XCTestCase { XCTAssertNil(actual["id"] as? String) } + func testDecode_WithAllProperties() throws { + // Arrange + let user = TestData.user + let actual = user.serialize() + let data = try XCTUnwrap(SentrySerialization.data(withJSONObject: actual)) + + // Act + let decoded = decodeFromJSONData(jsonData: data) as User? + + // Assert + XCTAssertEqual(user, decoded) + } + + func testDecode_WithAllPropertiesNil() throws { + // Arrange + let user = User() + let actual = user.serialize() + let data = try XCTUnwrap(SentrySerialization.data(withJSONObject: actual)) + + // Act + let decoded = decodeFromJSONData(jsonData: data) as User? + + // Assert + XCTAssertEqual(user, decoded) + } + func testHash() { XCTAssertEqual(TestData.user.hash(), TestData.user.hash())