From 6e19692434effddbeff7c8034d55f60994022535 Mon Sep 17 00:00:00 2001 From: vladislav-yermakov Date: Tue, 17 Mar 2026 12:13:51 +0100 Subject: [PATCH 1/2] - Replaced NSDictionary with Swift Dictionary in public API --- Source/Core/EdgeAPI.swift | 4 +- Source/Core/LocalStorage.swift | 4 +- Source/OptableSDK.swift | 20 +++++----- Source/Public/OptableTargeting.swift | 6 +-- Tests/Integration/OptableSDKTests.swift | 12 +++--- Tests/Unit/LocalStorageTests.swift | 51 ++++++++++++++----------- 6 files changed, 52 insertions(+), 45 deletions(-) diff --git a/Source/Core/EdgeAPI.swift b/Source/Core/EdgeAPI.swift index d64cfc0..893c4ba 100644 --- a/Source/Core/EdgeAPI.swift +++ b/Source/Core/EdgeAPI.swift @@ -46,7 +46,7 @@ final class EdgeAPI { return request } - func profile(traits: NSDictionary, id: String? = nil, neighbors: [String]? = nil) throws -> URLRequest? { + func profile(traits: [String: Any], id: String? = nil, neighbors: [String]? = nil) throws -> URLRequest? { guard let url = buildEdgeAPIURL(endpoint: "profile") else { return nil } var payload: [String: Any] = ["traits": traits] @@ -75,7 +75,7 @@ final class EdgeAPI { return request } - func witness(event: String, properties: NSDictionary) throws -> URLRequest? { + func witness(event: String, properties: [String: Any]) throws -> URLRequest? { guard let url = buildEdgeAPIURL(endpoint: "witness") else { return nil } let request = try buildRequest(.POST, url: url, headers: resolveHeaders(), obj: ["event": event, "properties": properties]) return request diff --git a/Source/Core/LocalStorage.swift b/Source/Core/LocalStorage.swift index e4dfd30..84ff17f 100644 --- a/Source/Core/LocalStorage.swift +++ b/Source/Core/LocalStorage.swift @@ -44,12 +44,12 @@ final class LocalStorage: NSObject { } func getTargeting() -> OptableTargeting? { - guard let targetingData = UserDefaults.standard.object(forKey: targetingDataKey) as? NSDictionary else { + guard let targetingData = UserDefaults.standard.object(forKey: targetingDataKey) as? [String: Any] else { return nil } let optableTargeting = OptableTargeting( optableTargeting: targetingData, - gamTargetingKeywords: UserDefaults.standard.object(forKey: gamTargetingKeywordsKey) as? NSDictionary, + gamTargetingKeywords: UserDefaults.standard.object(forKey: gamTargetingKeywordsKey) as? [String: Any], ortb2: UserDefaults.standard.string(forKey: ortb2Key) ) return optableTargeting diff --git a/Source/OptableSDK.swift b/Source/OptableSDK.swift index 8588677..05be8d8 100644 --- a/Source/OptableSDK.swift +++ b/Source/OptableSDK.swift @@ -203,7 +203,7 @@ public extension OptableSDK { The witness method is asynchronous, and on completion it will call the specified completion handler, passing it either the HTTPURLResponse on success, or an NSError on failure. */ - func witness(event: String, properties: NSDictionary, _ completion: @escaping (Result) -> Void) throws { + func witness(event: String, properties: [String: Any], _ completion: @escaping (Result) -> Void) throws { try _witness(event: event, properties: properties, completion: completion) } @@ -214,7 +214,7 @@ public extension OptableSDK { Instead of completion callbacks, function have to be awaited. */ @available(iOS 13.0, *) - func witness(event: String, properties: NSDictionary) async throws -> HTTPURLResponse { + func witness(event: String, properties: [String: Any]) async throws -> HTTPURLResponse { return try await withCheckedThrowingContinuation({ [unowned self] continuation in do { try self._witness(event: event, properties: properties, completion: { continuation.resume(with: $0) }) @@ -231,7 +231,7 @@ public extension OptableSDK { Instead of completion callbacks, delegate methods are called. */ @objc - func witness(event: String, properties: NSDictionary) throws { + func witness(event: String, properties: [String: Any]) throws { try self.witness(event: event, properties: properties) { result in switch result { case let .success(response): @@ -251,7 +251,7 @@ public extension OptableSDK { The specified NSDictionary 'traits' can be subsequently used for audience assembly. The profile method is asynchronous, and on completion it will call the specified completion handler, passing it either the HTTPURLResponse on success, or an NSError on failure. */ - func profile(traits: NSDictionary, id: String? = nil, neighbors: [String]? = nil, _ completion: @escaping (Result) -> Void) throws { + func profile(traits: [String: Any], id: String? = nil, neighbors: [String]? = nil, _ completion: @escaping (Result) -> Void) throws { try _profile(traits: traits, id: id, neighbors: neighbors, completion: completion) } @@ -262,7 +262,7 @@ public extension OptableSDK { Instead of completion callbacks, function have to be awaited. */ @available(iOS 13.0, *) - func profile(traits: NSDictionary, id: String? = nil, neighbors: [String]? = nil) async throws -> OptableTargeting { + func profile(traits: [String: Any], id: String? = nil, neighbors: [String]? = nil) async throws -> OptableTargeting { return try await withCheckedThrowingContinuation({ [unowned self] continuation in do { try self._profile(traits: traits, id: id, neighbors: neighbors, completion: { continuation.resume(with: $0) }) @@ -279,7 +279,7 @@ public extension OptableSDK { Instead of completion callbacks, delegate methods are called. */ @objc - func profile(traits: NSDictionary, id: String? = nil, neighbors: [String]? = nil) throws { + func profile(traits: [String: Any], id: String? = nil, neighbors: [String]? = nil) throws { try _profile(traits: traits, id: id, neighbors: neighbors, completion: { result in switch result { case let .success(response): @@ -378,7 +378,7 @@ private extension OptableSDK { }).resume() } - func _witness(event: String, properties: NSDictionary, completion: @escaping (Result) -> Void) throws { + func _witness(event: String, properties: [String: Any], completion: @escaping (Result) -> Void) throws { guard let request = try api.witness(event: event, properties: properties) else { throw OptableError.witness("Failed to create witness request") } @@ -401,7 +401,7 @@ private extension OptableSDK { }).resume() } - func _profile(traits: NSDictionary, id: String?, neighbors: [String]?, completion: @escaping (Result) -> Void) throws { + func _profile(traits: [String: Any], id: String?, neighbors: [String]?, completion: @escaping (Result) -> Void) throws { guard let request = try api.profile(traits: traits, id: id, neighbors: neighbors) else { throw OptableError.profile("Failed to create profile request") } @@ -495,8 +495,8 @@ private extension OptableSDK { let optableTargetingData = try JSONSerialization.jsonObject(with: responseData, options: []) let optableTargetingDict: NSMutableDictionary = ((optableTargetingData as? NSDictionary)?.mutableCopy() as? NSMutableDictionary) ?? NSMutableDictionary() let optableTargeting = OptableTargeting( - optableTargeting: optableTargetingDict, - gamTargetingKeywords: OptableSDK.generateGAMTargetingKeywords(from: optableTargetingDict), + optableTargeting: optableTargetingDict as? [String: Any] ?? [:], + gamTargetingKeywords: OptableSDK.generateGAMTargetingKeywords(from: optableTargetingDict) as? [String : Any], ortb2: OptableSDK.generateORTB2Config(from: optableTargetingDict) ) diff --git a/Source/Public/OptableTargeting.swift b/Source/Public/OptableTargeting.swift index 3afa0b4..5710fff 100644 --- a/Source/Public/OptableTargeting.swift +++ b/Source/Public/OptableTargeting.swift @@ -9,11 +9,11 @@ import Foundation @objcMembers public class OptableTargeting: NSObject { - public let targetingData: NSDictionary - public let gamTargetingKeywords: NSDictionary? + public let targetingData: [String: Any] + public let gamTargetingKeywords: [String: Any]? public let ortb2: String? - public init(optableTargeting: NSDictionary, gamTargetingKeywords: NSDictionary? = nil, ortb2: String? = nil) { + public init(optableTargeting: [String: Any], gamTargetingKeywords: [String: Any]? = nil, ortb2: String? = nil) { self.targetingData = optableTargeting self.gamTargetingKeywords = gamTargetingKeywords self.ortb2 = ortb2 diff --git a/Tests/Integration/OptableSDKTests.swift b/Tests/Integration/OptableSDKTests.swift index d4bd350..c08b13e 100644 --- a/Tests/Integration/OptableSDKTests.swift +++ b/Tests/Integration/OptableSDKTests.swift @@ -55,7 +55,7 @@ class OptableSDKTests: XCTestCase { @available(iOS 13.0, *) func test_target_async() async throws { let response = try await sdk.targeting([.emailAddress("test@test.com")]) - XCTAssert(response.targetingData.allKeys.isEmpty == false) + XCTAssert(response.targetingData.keys.isEmpty == false) } func test_target_callback() throws { @@ -63,7 +63,7 @@ class OptableSDKTests: XCTestCase { try sdk.targeting([.emailAddress("test@test.com")], completion: { result in switch result { case let .success(response): - XCTAssert(response.targetingData.allKeys.isEmpty == false) + XCTAssert(response.targetingData.keys.isEmpty == false) case let .failure(failure): XCTFail("Expected success, got error: \(failure)") } @@ -109,7 +109,7 @@ class OptableSDKTests: XCTestCase { @available(iOS 13.0, *) func test_profile_async() async throws { let response = try await sdk.profile(traits: ["integration-test-profile": "integration-test-profile-value"]) - XCTAssert(response.targetingData.allKeys.isEmpty == false) + XCTAssert(response.targetingData.keys.isEmpty == false) } func test_profile_callbacks() throws { @@ -117,7 +117,7 @@ class OptableSDKTests: XCTestCase { try sdk.profile(traits: ["integration-test-profile": "integration-test-profile-value"], { result in switch result { case let .success(response): - XCTAssert(response.targetingData.allKeys.isEmpty == false) + XCTAssert(response.targetingData.keys.isEmpty == false) case let .failure(failure): XCTFail("Expected success, got error: \(failure)") } @@ -146,7 +146,7 @@ extension OptableSDKTests: OptableDelegate { } func profileOk(_ result: OptableTargeting) { - XCTAssert(result.targetingData.allKeys.isEmpty == false) + XCTAssert(result.targetingData.keys.isEmpty == false) profileExpectation.fulfill() } @@ -156,7 +156,7 @@ extension OptableSDKTests: OptableDelegate { } func targetingOk(_ result: OptableTargeting) { - XCTAssert(result.targetingData.allKeys.isEmpty == false) + XCTAssert(result.targetingData.keys.isEmpty == false) targetExpectation.fulfill() } diff --git a/Tests/Unit/LocalStorageTests.swift b/Tests/Unit/LocalStorageTests.swift index 125a9e3..28b8aa3 100644 --- a/Tests/Unit/LocalStorageTests.swift +++ b/Tests/Unit/LocalStorageTests.swift @@ -10,66 +10,73 @@ import XCTest // MARK: - LocalStorageTests class LocalStorageTests: XCTestCase { + /* + NOTE: + Swift Dictionary does not conform `Equatable` because of `Any`. + But Swift effortlessly bridges Dictionary to NSDictionary and vice versa. + Thus casting to NSDictionary is used to perform comparsion and equality operations. + */ + private let optableConfig = OptableConfig(tenant: "tenant", originSlug: "slug") private lazy var localStorage = LocalStorage(optableConfig) - + func testOptableTargetingStoringFull() { let optableTargetingFull = OptableTargeting( - optableTargeting: kOptableTargeting, - gamTargetingKeywords: kGamTargetingKeywords, + optableTargeting: kOptableTargeting as! [String : Any], + gamTargetingKeywords: kGamTargetingKeywords as? [String : Any], ortb2: kORTB2 ) - + localStorage.setTargeting(optableTargetingFull) - + let readTargeting = localStorage.getTargeting() XCTAssert(readTargeting != nil) - XCTAssert(readTargeting!.targetingData == kOptableTargeting) - XCTAssert(readTargeting!.gamTargetingKeywords == kGamTargetingKeywords) + XCTAssert(readTargeting!.targetingData as NSDictionary == kOptableTargeting) + XCTAssert(readTargeting!.gamTargetingKeywords as? NSDictionary == kGamTargetingKeywords) XCTAssert(readTargeting!.ortb2 == kORTB2) } - + func testOptableTargetingStoringPartial1() { let optableTargetingFull = OptableTargeting( - optableTargeting: kOptableTargeting, + optableTargeting: kOptableTargeting as! [String : Any], gamTargetingKeywords: nil, ortb2: kORTB2 ) localStorage.setTargeting(optableTargetingFull) - + let readTargeting = localStorage.getTargeting() XCTAssert(readTargeting != nil) - XCTAssert(readTargeting!.targetingData == kOptableTargeting) - XCTAssert(readTargeting!.gamTargetingKeywords == nil) + XCTAssert(readTargeting!.targetingData as NSDictionary == kOptableTargeting) + XCTAssert(readTargeting!.gamTargetingKeywords as? NSDictionary == nil) XCTAssert(readTargeting!.ortb2 == kORTB2) } - + func testOptableTargetingStoringPartial2() { let optableTargetingFull = OptableTargeting( - optableTargeting: kOptableTargeting, - gamTargetingKeywords: kGamTargetingKeywords, + optableTargeting: kOptableTargeting as! [String : Any], + gamTargetingKeywords: kGamTargetingKeywords as? [String : Any], ortb2: nil ) localStorage.setTargeting(optableTargetingFull) - + let readTargeting = localStorage.getTargeting() XCTAssert(readTargeting != nil) - XCTAssert(readTargeting!.targetingData == kOptableTargeting) - XCTAssert(readTargeting!.gamTargetingKeywords == kGamTargetingKeywords) + XCTAssert(readTargeting!.targetingData as NSDictionary == kOptableTargeting) + XCTAssert(readTargeting!.gamTargetingKeywords as? NSDictionary == kGamTargetingKeywords) XCTAssert(readTargeting!.ortb2 == nil) } func testClearOptableTargeting() { let optableTargetingFull = OptableTargeting( - optableTargeting: kOptableTargeting, - gamTargetingKeywords: kGamTargetingKeywords, + optableTargeting: kOptableTargeting as! [String : Any], + gamTargetingKeywords: kGamTargetingKeywords as? [String : Any], ortb2: kORTB2 ) - + localStorage.setTargeting(optableTargetingFull) - + localStorage.clearTargeting() XCTAssert(localStorage.getTargeting() == nil) From dc56373a9f46b098105679a45fa7fc2af2ed3d1c Mon Sep 17 00:00:00 2001 From: vladislav-yermakov Date: Tue, 17 Mar 2026 12:20:56 +0100 Subject: [PATCH 2/2] - Updated code docs to match updated API --- Source/OptableSDK.swift | 95 ++++++++++++++++++++++++----------------- 1 file changed, 55 insertions(+), 40 deletions(-) diff --git a/Source/OptableSDK.swift b/Source/OptableSDK.swift index 05be8d8..c8e5ba7 100644 --- a/Source/OptableSDK.swift +++ b/Source/OptableSDK.swift @@ -10,16 +10,17 @@ import Foundation // MARK: - OptableDelegate /** - OptableDelegate is a delegate protocol that the caller may optionally use. - Swift applications can choose to integrate using callbacks or the delegator pattern, whereas Objective-C apps must use the delegator pattern. + OptableDelegate enables Objective-C and Swift apps to receive results via delegate callbacks. - The OptableDelegate protocol consists of implementing *Ok() and *Err() event handlers. - The *Ok() handler will receive an NSDictionary when the delegate variant of the targeting() API is called, - and an HTTPURLResponse in all other SDK APIs that do not return actual data on success (e.g., identify(), witness(), etc.) + - Ok callbacks: + - identifyOk and witnessOk receive an HTTPURLResponse on success. + - targetingOk and profileOk receive an OptableTargeting result on success. + - Err callbacks: + - All Err methods receive an NSError describing the failure. - The *Err() handlers will be called with an NSError instance on SDK API errors. - - Finally note that for the delegate variant of SDK API methods, internal exceptions will result in setting the NSError object passed which is passed by reference to the method, and not calling the delegate. + Note for Objective-C callers: delegate-style APIs are exposed as throwing @objc methods. + If a synchronous error occurs while preparing a request, the method sets the passed NSError** + and does not invoke the delegate callbacks. */ @objc public protocol OptableDelegate { @@ -37,11 +38,11 @@ public protocol OptableDelegate { /** OptableSDK exposes an API that is used by an iOS app developer integrating with an Optable Sandbox. - An instance of OptableSDK refers to an Optable Sandbox specified by the caller via `host` and `app` arguments provided to the constructor. + An instance of OptableSDK refers to an Optable Sandbox specified by values in OptableConfig provided to the initializer. It is possible to create multiple instances of OptableSDK, should the developer want to integrate with multiple Sandboxes. - The OptableSDK keeps some state in UserDefaults (https:///developer.apple.com/documentation/foundation/userdefaults), a key/value store persisted across launches of the app. The state is therefore unique to the app+device, and not globally unique to the app across devices. + The OptableSDK keeps some state in [UserDefaults](https://developer.apple.com/documentation/foundation/userdefaults), a key/value store persisted across launches of the app. The state is therefore unique to the app+device, and not globally unique to the app across devices. */ @objc public class OptableSDK: NSObject { @@ -51,7 +52,7 @@ public class OptableSDK: NSObject { let config: OptableConfig let api: EdgeAPI - /// `OptableSDK` returns an instance of the SDK configured to use the sandbox specified by `OptableConfig`: + /// Initializes the SDK with the provided OptableConfig. On iOS 14+, requests tracking authorization unless skipAdvertisingIdDetection is true. @objc public init(config: OptableConfig) { self.config = config @@ -81,13 +82,20 @@ public class OptableSDK: NSObject { // MARK: - Identify public extension OptableSDK { /** - identify(ids, completion) issues a call to the Optable Sandbox "identify" API, passing the specified list of type-prefixed IDs. + identify(ids, completion) calls the Optable Sandbox Identify API with the provided identifiers. + + On completion, the handler receives: + - .success(HTTPURLResponse) on success + - .failure(Error) on failure - It is asynchronous, and on completion it will call the specified completion handler, passing - it either the HTTPURLResponse on success, or an NSError on failure. + Example: ```swift - // Example - optableSDK.identify(.init(emailAddress: "example@example.com", phoneNumber: "1234567890"), completion) + // Swift + try optableSDK.identify( + [.emailAddress("example@example.com"), .phoneNumber("1234567890")] + ) { result in + // handle result + } ``` */ func identify(_ ids: [OptableIdentifier], _ completion: @escaping (Result) -> Void) throws { @@ -98,7 +106,7 @@ public extension OptableSDK { /** This is the Swift Concurrency compatible version of the `identify(ids, completion)` API. - Instead of completion callbacks, function have to be awaited. + Instead of completion callbacks, results are returned via async/await. */ @available(iOS 13.0, *) func identify(_ ids: [OptableIdentifier]) async throws -> HTTPURLResponse { @@ -134,13 +142,15 @@ public extension OptableSDK { // MARK: - Targeting public extension OptableSDK { /** - targeting(completion) calls the Optable Sandbox "targeting" API, which returns the key-value targeting data matching the user/device/app. + targeting(ids?, completion) calls the Optable Sandbox Targeting API and returns key-value targeting data + for the current user/device/app. You may optionally supply identifiers to enrich the request. - The targeting method is asynchronous, and on completion it will call the specified completion handler, - passing it either the NSDictionary targeting data on success, or an NSError on failure. + On completion, the handler receives: + - .success(OptableTargeting) on success + - .failure(Error) on failure - On success, this method will also cache the resulting targeting data in client storage, which can - be access using targetingFromCache(), and cleared using targetingClearCache(). + On success, the result is cached in client storage. You can read it using targetingFromCache() + and clear it using targetingClearCache(). */ func targeting(_ ids: [OptableIdentifier]? = nil, completion: @escaping (Result) -> Void) throws { try _targeting(ids: ids, completion: completion) @@ -162,7 +172,7 @@ public extension OptableSDK { /** This is the Swift Concurrency compatible version of the `targeting(completion)` API. - Instead of completion callbacks, function have to be awaited. + Instead of completion callbacks, results are returned via async/await. */ @available(iOS 13.0, *) func targeting(_ ids: [OptableIdentifier]? = nil) async throws -> OptableTargeting { @@ -198,10 +208,13 @@ public extension OptableSDK { // MARK: - Witness public extension OptableSDK { /** - witness(event, properties, completion) calls the Optable Sandbox "witness" API in order to log a specified 'event' (e.g., "app.screenView", "ui.buttonPressed"), with the specified keyvalue NSDictionary 'properties', which can be subsequently used for audience assembly. + witness(event, properties, completion) calls the Optable Sandbox Witness API to log an event + (for example, "app.screenView" or "ui.buttonPressed") with the provided properties. + These events can later be used for audience assembly. - The witness method is asynchronous, and on completion it will call the specified completion handler, - passing it either the HTTPURLResponse on success, or an NSError on failure. + On completion, the handler receives: + - .success(HTTPURLResponse) on success + - .failure(Error) on failure */ func witness(event: String, properties: [String: Any], _ completion: @escaping (Result) -> Void) throws { try _witness(event: event, properties: properties, completion: completion) @@ -211,7 +224,7 @@ public extension OptableSDK { /** This is the Swift Concurrency compatible version of the `witness(event, properties, completion)` API. - Instead of completion callbacks, function have to be awaited. + Instead of completion callbacks, results are returned via async/await. */ @available(iOS 13.0, *) func witness(event: String, properties: [String: Any]) async throws -> HTTPURLResponse { @@ -246,10 +259,14 @@ public extension OptableSDK { // MARK: - Profile public extension OptableSDK { /** - profile(traits, completion) calls the Optable Sandbox "profile" API in order to associate specified 'traits' (i.e., key-value pairs) with the user's device. + profile(traits, id, neighbors, completion) calls the Optable Sandbox Profile API to associate the provided + traits (key-value pairs) with the user/device. You can optionally include a specific id and neighbor ids. + + On completion, the handler receives: + - .success(OptableTargeting) on success + - .failure(Error) on failure - The specified NSDictionary 'traits' can be subsequently used for audience assembly. - The profile method is asynchronous, and on completion it will call the specified completion handler, passing it either the HTTPURLResponse on success, or an NSError on failure. + The resulting OptableTargeting is also cached for targetingFromCache(). */ func profile(traits: [String: Any], id: String? = nil, neighbors: [String]? = nil, _ completion: @escaping (Result) -> Void) throws { try _profile(traits: traits, id: id, neighbors: neighbors, completion: completion) @@ -259,7 +276,7 @@ public extension OptableSDK { /** This is the Swift Concurrency compatible version of the `profile(traits, completion)` API. - Instead of completion callbacks, function have to be awaited. + Instead of completion callbacks, results are returned via async/await. */ @available(iOS 13.0, *) func profile(traits: [String: Any], id: String? = nil, neighbors: [String]? = nil) async throws -> OptableTargeting { @@ -293,15 +310,13 @@ public extension OptableSDK { // MARK: - Identify from URL public extension OptableSDK { - /// - /// tryIdentifyFromURL(urlString) is a helper that attempts to find a valid-looking - /// "oeid" parameter in the specified urlString's query string parameters and, if found, - /// calls self.identify([oeid]). - /// - /// The use for this is when handling incoming universal links which might contain an - /// "oeid" value with the SHA256(downcase(email)) of an incoming user, such as encoded - /// links in newsletter Emails sent by the application developer. - /// + /** + Attempts to extract an "oeid" query parameter from the given URL string and, if present, + calls identify with that identifier. + + Use this when handling incoming universal links that may contain an "oeid" + (for example, SHA256(lowercased(email)) embedded in campaign links). + */ @objc func tryIdentifyFromURL(_ urlString: String) throws { let eidStr = OptableIdentifierEncoder.eidFromURL(urlString)