Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions OptableSDK.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
Unit/OptableIdentifiersTests.swift,
Unit/OptableSDKHelpersIdentifiersEnrichmentTests.swift,
Unit/OptableSDKHelpersTests.swift,
Unit/OptableTargetingTests.swift,
);
target = 6352AB0324EAD403002E66EB /* OptableSDKTests */;
};
Expand Down
27 changes: 25 additions & 2 deletions Source/Core/EdgeAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,35 @@ final class EdgeAPI {
return request
}

func targeting(ids: [OptableIdentifier]) throws -> URLRequest? {
func targeting(ids: [OptableIdentifier], hids: [OptableIdentifier]) throws -> URLRequest? {
guard var url = buildEdgeAPIURL(endpoint: "targeting") else { return nil }

let queryItems = ids
var queryItems = ids
.compactMap({ $0.extendedIdentifier })
.compactMap({ URLQueryItem(name: "id", value: $0) })

let hidQueryItems = hids
.compactMap({ $0.extendedIdentifier })
.compactMap({ URLQueryItem(name: "hid", value: $0) })
Comment thread
OlenaPostindustria marked this conversation as resolved.

queryItems.append(contentsOf: hidQueryItems)

if let bundle = Bundle.main.bundleIdentifier {
queryItems.append(URLQueryItem(name: "bundle", value: bundle))
}

if let ver = Bundle.main.appVersionString {
queryItems.append(URLQueryItem(name: "ver", value: ver))
}

if let userAgent {
queryItems.append(URLQueryItem(name: "ua", value: userAgent))
}

if let id5Signature = storage.getID5Signature() {
queryItems.append(URLQueryItem(name: "id5_signature", value: id5Signature))
}

url.compatAppend(queryItems: queryItems)

let request = try buildRequest(.GET, url: url, headers: resolveHeaders())
Expand Down
11 changes: 11 additions & 0 deletions Source/Core/LocalStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ final class LocalStorage: NSObject {
private let targetingDataKey: String
private let gamTargetingKeywordsKey: String
private let ortb2Key: String
private let id5SignatureKey: String

let keyPfx: String = "OPTABLE"
var passportKey: String
Expand All @@ -33,6 +34,7 @@ final class LocalStorage: NSObject {
self.targetingDataKey = targetingKey + "_targetingData"
self.gamTargetingKeywordsKey = targetingKey + "_gamTargetingKeywords"
self.ortb2Key = targetingKey + "_ortb2"
self.id5SignatureKey = targetingKey + "_id5Signature"
}

func getPassport() -> String? {
Expand Down Expand Up @@ -68,5 +70,14 @@ final class LocalStorage: NSObject {
UserDefaults.standard.removeObject(forKey: targetingDataKey)
UserDefaults.standard.removeObject(forKey: gamTargetingKeywordsKey)
UserDefaults.standard.removeObject(forKey: ortb2Key)
UserDefaults.standard.removeObject(forKey: id5SignatureKey)
}

func getID5Signature() -> String? {
return UserDefaults.standard.string(forKey: id5SignatureKey)
}

func setID5Signature(_ signature: String) {
UserDefaults.standard.set(signature, forKey: id5SignatureKey)
}
}
15 changes: 15 additions & 0 deletions Source/Misc/Bundle++.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//
// Bundle++.swift
// OptableSDK
//
// Copyright © 2026 Optable Technologies, Inc. All rights reserved.
//

import Foundation

extension Bundle {
/// The app's release version (`CFBundleShortVersionString`), if present.
var appVersionString: String? {
infoDictionary?["CFBundleShortVersionString"] as? String
}
}
15 changes: 7 additions & 8 deletions Source/Misc/URL+Compat.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,12 @@ import Foundation

extension URL {
mutating func compatAppend(queryItems: [URLQueryItem]) {
if #available(iOS 16.0, *) {
append(queryItems: queryItems)
} else {
guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else { return }
components.queryItems?.append(contentsOf: queryItems)
guard let url = components.url else { return }
self = url
}
guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else { return }
components.queryItems = (components.queryItems ?? []) + queryItems
// URLComponents leaves `+` literal in the query, but the edge decodes `+` as a space,
// which corrupts base64 values such as the ID5 signature. Encode it explicitly.
components.percentEncodedQuery = components.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B")
guard let url = components.url else { return }
self = url
}
}
46 changes: 33 additions & 13 deletions Source/OptableSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,18 +116,30 @@ public extension OptableSDK {
// MARK: - Targeting
public extension OptableSDK {
/**
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.

On completion, the handler receives:
- .success(OptableTargeting) on success
- .failure(Error) on failure
targeting(ids?, hids?, completion) calls the Optable Sandbox Targeting API and returns key-value targeting data
Comment thread
OlenaPostindustria marked this conversation as resolved.
for the current user/device/app.

- Parameters:
- ids: one or more identifiers to resolve, sent as repeated `id` query parameters. The DCN evaluates them
in the order they are listed and returns the profile of the first successful match (querying the
first-party graph before any third-party graphs). When provided, they take precedence over any
identifier in the passport.
- hids: hint identifiers, sent as `hid` query parameters in addition to `ids`. Hints drive resolver-specific
identity resolution on the DCN, such as ID5 Mobile In-App. All identifier types are forwarded as-is;
which ones a resolver consumes is determined server-side. Custom (`cN`) prefixes must be configured on
the DCN; unconfigured ones are ignored server-side.
- completion: on completion, the handler receives:
- .success(OptableTargeting) on success
- .failure(Error) on failure

Unless `skipAdvertisingIdDetection` is set in the config, the device IDFA is automatically prepended to both
lists when ad tracking is authorized.

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<OptableTargeting, Error>) -> Void) throws {
try _targeting(ids: ids, completion: completion)
func targeting(_ ids: [OptableIdentifier]? = nil, hids: [OptableIdentifier]? = nil, completion: @escaping (Result<OptableTargeting, Error>) -> Void) throws {
try _targeting(ids: ids, hids: hids, completion: completion)
}

/// targetingFromCache() returns the previously cached targeting data, if any.
Expand All @@ -144,15 +156,17 @@ public extension OptableSDK {

// MARK: Async/Await support
/**
This is the Swift Concurrency compatible version of the `targeting(completion)` API.
This is the Swift Concurrency compatible version of the `targeting(ids, hids, completion)` API:
`ids` match the user/device against the DCN, while `hids` are hint identifiers driving resolver-specific
identity resolution such as ID5 Mobile In-App - see `targeting(_:hids:completion:)` for details.

Instead of completion callbacks, results are returned via async/await.
*/
@available(iOS 13.0, *)
func targeting(_ ids: [OptableIdentifier]? = nil) async throws -> OptableTargeting {
func targeting(_ ids: [OptableIdentifier]? = nil, hids: [OptableIdentifier]? = nil) async throws -> OptableTargeting {
return try await withCheckedThrowingContinuation({ [unowned self] continuation in
do {
try self._targeting(ids: ids, completion: { continuation.resume(with: $0) })
try self._targeting(ids: ids, hids: hids, completion: { continuation.resume(with: $0) })
} catch {
continuation.resume(throwing: error)
}
Expand Down Expand Up @@ -311,12 +325,14 @@ extension OptableSDK {
}).resume()
}

func _targeting(ids: [OptableIdentifier]?, completion: @escaping (Result<OptableTargeting, Error>) -> Void) throws {
func _targeting(ids: [OptableIdentifier]?, hids: [OptableIdentifier]?, completion: @escaping (Result<OptableTargeting, Error>) -> Void) throws {
var ids = ids ?? []
var hids = hids ?? []

enrichIfNeeded(ids: &ids)
enrichIfNeeded(ids: &hids)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major - all existing callers are silently opted into hint/ID5 resolution. enrichIfNeeded(ids: &hids) runs even when the caller passed hids: nil, and in EdgeAPI.targeting the bundle/ver/ua/id5_signature params are appended unconditionally. So every current targeting() call will start sending hid=a:<idfa> plus the resolver params, with no opt-out other than skipAdvertisingIdDetection (which also disables the id= enrichment).

If that implicit opt-in is intended edge behavior, fine - otherwise gate hid emission (and arguably the resolver params) on the caller actually supplying hids.

@OlenaPostindustria OlenaPostindustria Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hid=a:<idfa> contains the same IDFA we already send as id=a:<idfa>, and it's still subject to the same ATT and skipAdvertisingIdDetection checks. id5_signature is only sent if the DCN has already returned one. ID5 usage is controlled entirely server-side. if it's enabled, the hid and the related parameters are used, otherwise, it should be ignored. this should let ID5 be enabled through the configuration without requiring an app update


guard let request = try api.targeting(ids: ids) else {
guard let request = try api.targeting(ids: ids, hids: hids) else {
throw OptableError.targeting("Failed to create targeting request")
}

Expand All @@ -340,6 +356,10 @@ extension OptableSDK {

/// We cache the latest targeting result in client storage for targetingFromCache() users:
self.api.storage.setTargeting(optableTargeting)

if let id5Signature = optableTargeting.id5Signature {
self.api.storage.setID5Signature(id5Signature)
}

completion(.success(optableTargeting))
} catch {
Expand Down
20 changes: 18 additions & 2 deletions Source/Public/ObjCSupport/OptableSDK+ObjC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,30 @@ public extension OptableSDK {
}

/**
This is the Objective-C compatible version of the `targeting(completion)` API.
This is the Objective-C compatible version of the `targeting(ids, completion)` API.

Instead of completion callbacks, delegate methods are called.
*/
@objc
func targeting(_ ids: [OptableSDKIdentifier]) throws {
try targeting(ids, hids: [])
}

/**
This is the Objective-C compatible version of the `targeting(ids, hids, completion)` API:
`ids` match the user/device against the DCN, while `hids` are hint identifiers driving resolver-specific
identity resolution such as ID5 Mobile In-App. All identifier types are forwarded as-is; which ones a
resolver consumes is determined server-side. Custom (`cN`) prefixes not configured on the DCN are
ignored server-side.

Instead of completion callbacks, delegate methods are called.
*/
@objc(targetingWithIds:hids:error:)
func targeting(_ ids: [OptableSDKIdentifier], hids: [OptableSDKIdentifier]) throws {
let bridgedIds = ids.compactMap({ OptableIdentifier(objc: $0) })
try self._targeting(ids: bridgedIds, completion: { result in
let bridgedHIds = hids.compactMap({ OptableIdentifier(objc: $0) })

try self._targeting(ids: bridgedIds, hids: bridgedHIds, completion: { result in
switch result {
case let .success(optableTargeting):
self.delegate?.targetingOk(optableTargeting)
Expand Down
40 changes: 40 additions & 0 deletions Source/Public/OptableTargeting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,43 @@ public class OptableTargeting: NSObject {
return desc
}
}

// MARK: - Helpers

extension OptableTargeting {

var id5Signature: String? {
guard let ortb2 = targetingData["ortb2"] as? [String: Any],
let user = ortb2["user"] as? [String: Any],
let eids = user["eids"] as? [[String: Any]] else {
return nil
}

guard let rootRefs = targetingData["refs"] as? [String: Any] else { return nil }

for eid in eids {
guard let source = eid["source"] as? String, source.range(of: "id5", options: [.caseInsensitive]) != nil else {
continue
}

guard let uids = eid["uids"] as? [[String: Any]] else { continue }

for uid in uids {
guard let ext = uid["ext"] as? [String: Any],
let optable = ext["optable"] as? [String: Any],
let uidRef = optable["ref"] as? String else {
continue
}

guard let ref = rootRefs[uidRef] as? [String: Any] else { continue }

if let id5Signature = ref["signature"] as? String,
id5Signature.trimmingCharacters(in: .whitespaces).isEmpty == false {
return id5Signature
}
}
}

return nil
}
}
2 changes: 1 addition & 1 deletion Tests/Integration/OptableSDKTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ class OptableSDKTests: XCTestCase {
}

func test_target_delegate() throws {
try sdk.targeting([OptableSDKIdentifier(type: .emailAddress, value: "test@test.com", customIdx: nil)])
try sdk.targeting([OptableSDKIdentifier(type: .emailAddress, value: "test@test.com", customIdx: nil)], hids: [OptableSDKIdentifier(type: .emailAddress, value: "test@test.com", customIdx: nil)])
wait(for: [targetExpectation], timeout: 10)
}

Expand Down
64 changes: 58 additions & 6 deletions Tests/Unit/EdgeAPITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ class EdgeAPITests: XCTestCase {
)
lazy var sdk = OptableSDK(config: config)

override func tearDown() {
sdk.api.storage.clearTargeting()
super.tearDown()
}

// MARK: URL-s
/**
Expected output:
Expand Down Expand Up @@ -173,18 +178,65 @@ class EdgeAPITests: XCTestCase {
For more info check: [](https://docs.optable.co/optable-documentation/guides/real-time-api-integrations-guide/optable-real-time-api-endpoints/targeting)
*/
func test_targeting_request_generation() throws {
let urlRequest = try sdk.api.targeting(ids: [.emailAddress("12345"), .phoneNumber("54321")])

sdk.api.storage.setID5Signature("id5-sig-abc123")

let email: OptableIdentifier = .emailAddress("12345")
let phone: OptableIdentifier = .phoneNumber("54321")
let utiq: OptableIdentifier = .utiq("496f5db5-681f-4392-acd5-0d4f6e2f6b88")
let urlRequest = try sdk.api.targeting(ids: [email, phone], hids: [email, phone, utiq])

// Method
XCTAssertEqual(urlRequest?.httpMethod, HTTPMethod.GET.rawValue)

// Path
let urlComponents = URLComponents(url: urlRequest!.url!, resolvingAgainstBaseURL: false)!
XCTAssert(urlComponents.path.contains("targeting"))

// Query
XCTAssert(urlComponents.queryItems?.contains(where: { $0.name == "id" && $0.value == "e:12345" }) != nil)
XCTAssert(urlComponents.queryItems?.contains(where: { $0.name == "id" && $0.value == "p:54321" }) != nil)

// Query: every identifier is emitted as an `id` param (email/phone are SHA-256 hashed)
XCTAssertTrue(urlComponents.queryItems?.contains(where: { $0.name == "id" && $0.value == email.extendedIdentifier }) ?? false)
XCTAssertTrue(urlComponents.queryItems?.contains(where: { $0.name == "id" && $0.value == phone.extendedIdentifier }) ?? false)

// HIDs: every identifier passed as a hint is emitted as a repeated `hid` param, regardless of type
XCTAssertTrue(urlComponents.queryItems?.contains(where: { $0.name == "hid" && $0.value == email.extendedIdentifier }) ?? false)
XCTAssertTrue(urlComponents.queryItems?.contains(where: { $0.name == "hid" && $0.value == phone.extendedIdentifier }) ?? false)
XCTAssertTrue(urlComponents.queryItems?.contains(where: { $0.name == "hid" && $0.value == utiq.extendedIdentifier }) ?? false)

// Resolver-specific parameters
XCTAssertEqual(urlComponents.queryItems?.first(where: { $0.name == "ua" })?.value, T.api.userAgent)
XCTAssertEqual(urlComponents.queryItems?.first(where: { $0.name == "id5_signature" })?.value, "id5-sig-abc123")

if let bundle = Bundle.main.bundleIdentifier {
XCTAssertEqual(urlComponents.queryItems?.first(where: { $0.name == "bundle" })?.value, bundle)
}

if let ver = Bundle.main.appVersionString {
XCTAssertEqual(urlComponents.queryItems?.first(where: { $0.name == "ver" })?.value, ver)
}
}

func test_targeting_request_percent_encodes_plus_in_query_values() throws {
// The edge decodes a literal `+` in the query as a space, which would corrupt
// base64 values such as the ID5 signature; it must go out as %2B on the wire.
sdk.api.storage.setID5Signature("sig+abc/123=")

let urlRequest = try sdk.api.targeting(ids: [], hids: [])
let rawQuery = try XCTUnwrap(urlRequest?.url?.query)

XCTAssertFalse(rawQuery.contains("+"))
XCTAssertTrue(rawQuery.contains("id5_signature=sig%2Babc/123"))

// The decoded value round-trips unchanged
let urlComponents = URLComponents(url: urlRequest!.url!, resolvingAgainstBaseURL: false)!
XCTAssertEqual(urlComponents.queryItems?.first(where: { $0.name == "id5_signature" })?.value, "sig+abc/123=")
}

func test_targeting_request_omits_id5_signature_when_no_cached_signature() throws {
sdk.api.storage.setTargeting(OptableTargeting(optableTargeting: ["resolved_ids": ["v:123"]]))

let urlRequest = try sdk.api.targeting(ids: [.emailAddress("12345")], hids: [])
let urlComponents = URLComponents(url: urlRequest!.url!, resolvingAgainstBaseURL: false)!

XCTAssertNil(urlComponents.queryItems?.first(where: { $0.name == "id5_signature" }))
}

/**
Expand Down
8 changes: 8 additions & 0 deletions Tests/Unit/LocalStorageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ class LocalStorageTests: XCTestCase {
XCTAssert(readTargeting!.ortb2 == nil)
}

func testID5SignatureStoring() {
localStorage.setID5Signature("id5-sig-abc123")
XCTAssertEqual(localStorage.getID5Signature(), "id5-sig-abc123")

localStorage.clearTargeting()
XCTAssertNil(localStorage.getID5Signature())
}

func testClearOptableTargeting() {
let optableTargetingFull = OptableTargeting(
optableTargeting: kOptableTargeting as! [String : Any],
Expand Down
1 change: 1 addition & 0 deletions Tests/Unit/OptableIdentifiersTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,5 @@ class OptableIdentifiersTests: XCTestCase {
XCTAssert(c_Idx < c2_Idx)
XCTAssert(c2_Idx < c1_Idx)
}

}
Loading
Loading