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.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
}
}
48 changes: 35 additions & 13 deletions Source/OptableSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,18 +116,31 @@ 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. Only the identifier types valid as hints are
forwarded: email address, phone number, IPv6 address, Apple IDFA, Google GAID and custom IDs — any other
type in `hids` is dropped client-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 +157,18 @@ 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 on which
identifier types are valid hints.

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 +327,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 +358,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. Only email address, phone number, IPv6 address, Apple IDFA,
Google GAID and custom identifiers are valid hints — any other type in `hids` is dropped client-side, and
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
19 changes: 19 additions & 0 deletions Source/Public/OptableIdentifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,22 @@ public extension OptableIdentifier {
}
}
}

// MARK: - HIDs
extension Array where Element == OptableIdentifier {
/// https://docs.optable.co/optable-documentation/guides/real-time-api-integrations-guide/resolver-specific-parameters#id5-mobile-in-app
///
/// Note: all `.custom` identifiers are passed through, but only the custom (`cN`) prefixes
/// configured on the DCN are valid resolver hints — unconfigured ones are ignored server-side.
var hids: [OptableIdentifier] {
filter {
switch $0 {
case .ipv6Address(_), .emailAddress(_), .phoneNumber(_),
Comment thread
OlenaPostindustria marked this conversation as resolved.
.appleIDFA(_), .googleGAID(_), .custom(_, _):
return true
default:
return false
}
}
}
}
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
46 changes: 40 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,47 @@ 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 urlRequest = try sdk.api.targeting(ids: [email, phone], hids: [email, phone])

// 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: email and phone are part of the HID set, so they are also emitted as `hid` params
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)

// 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_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
Loading
Loading