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
3 changes: 2 additions & 1 deletion Source/Core/EdgeAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ final class EdgeAPI: Sendable {
.compactMap({ URLQueryItem(name: "id", value: $0) })

let hidQueryItems = hids
.compactMap({ $0.extendedIdentifier })
.map({ $0.extendedIdentifier })
.filter({ $0.isEmpty == false })
.compactMap({ URLQueryItem(name: "hid", value: $0) })

queryItems.append(contentsOf: hidQueryItems)
Expand Down
15 changes: 15 additions & 0 deletions Source/Core/OptableIdentifierEncoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,25 @@ enum OptableIdentifierEncoder {
case let .optableVID(value): vid(prefix, value)
case let .hem(value): hem(prefix, value)
case let .hashedPhoneNumber(value): hashedPhoneNumber(prefix, value)
case let .raw(value): raw(value)
}
return eid
}

/// Builds Extended Identifier from a pre-encoded "type:value" string.
/// A known type is encoded like its case, so an "e" or "p" value that is not a SHA256 digest is dropped.
/// An unknown type is sent as given. Returns an empty string when the type or value is missing or only whitespace.
static func raw(_ value: String) -> String {
if let known = OptableIdentifier(extendedIdentifier: value) {
return eid(known)
}
let parts = value.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
guard parts.count == 2,
parts[0].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false,
parts[1].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { return "" }
return value
}

/// Builds Extended Identifier from an already-hashed Email address (HEM).
/// Returns an empty string when the value is not a SHA256 digest, so that a
/// plaintext Email is never sent over the wire.
Expand Down
5 changes: 5 additions & 0 deletions Source/Public/OptableIdentifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ public enum OptableIdentifier: Sendable {
case hem(String) // e, Hashed Email: SHA256 of the normalized Email address
case hashedPhoneNumber(String) // p

// Pre-encoded "type:value" Extended Identifier, for types without a case above.
// A known type is checked and normalized like its case ("e" and "p" must already be SHA256 hashes); an unknown one is sent as given.
case raw(String) // type

public var prefix: String {
switch self {
case .emailAddress: return "e"
Expand All @@ -67,6 +71,7 @@ public enum OptableIdentifier: Sendable {
case .optableVID: return "v"
case .hem: return "e"
case .hashedPhoneNumber: return "p"
case let .raw(value): return value.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? ""
}
}

Expand Down
10 changes: 10 additions & 0 deletions Tests/Unit/EdgeAPITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,16 @@ class EdgeAPITests: XCTestCase {
}
}

func test_targeting_request_raw_identifiers() throws {
let raw: OptableIdentifier = .raw("xyz:abc")
let plaintextEmail: OptableIdentifier = .raw("e:test@foobarbaz.com")
let urlRequest = try sdk.api.targeting(ids: [raw, plaintextEmail], hids: [raw, plaintextEmail])
let queryItems = URLComponents(url: urlRequest!.url!, resolvingAgainstBaseURL: false)!.queryItems ?? []

XCTAssertEqual(queryItems.filter({ $0.name == "id" }).map(\.value), ["xyz:abc"])
XCTAssertEqual(queryItems.filter({ $0.name == "hid" }).map(\.value), ["xyz:abc"])
}

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.
Expand Down
36 changes: 36 additions & 0 deletions Tests/Unit/OptableIdentifierEncoderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,42 @@ class OptableIdentifierEncoderTests: XCTestCase {
)
}

func test_raw_unknownTypeIsSentAsGiven() {
XCTAssertEqual("xyz:Some Value", SUT.raw("xyz:Some Value"))
XCTAssertEqual("cookie:abc", SUT.raw("cookie:abc"))
XCTAssertEqual("xyz:a:b", SUT.raw("xyz:a:b"))
}

func test_raw_knownTypeIsEncodedLikeItsCase() {
let hem = "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"

XCTAssertEqual("e:\(hem)", SUT.raw("e:\(hem.uppercased())"))
XCTAssertEqual("p:\(hem)", SUT.raw("p: \(hem)"))
XCTAssertEqual(OptableIdentifier.ipv4Address(" 8.8.8.8 ").extendedIdentifier, SUT.raw("i4: 8.8.8.8 "))
XCTAssertEqual(OptableIdentifier.custom(3, " abc ").extendedIdentifier, SUT.raw("c3: abc "))
}

func test_raw_rejectsPlaintextEmailAndPhoneNumber() {
// A plaintext Email or Phone number must never reach the wire.
XCTAssertEqual("", SUT.raw("e:test@foobarbaz.com"))
XCTAssertEqual("", SUT.raw("p:+15551234567"))
}

func test_raw_rejectsMalformedValues() {
XCTAssertEqual("", SUT.raw("xyz"))
XCTAssertEqual("", SUT.raw("xyz:"))
XCTAssertEqual("", SUT.raw(":abc"))
XCTAssertEqual("", SUT.raw(""))
XCTAssertEqual("", SUT.raw("xyz: "))
XCTAssertEqual("", SUT.raw(" :abc"))
}

func test_raw_prefix() {
XCTAssertEqual("xyz", OptableIdentifier.raw("xyz:abc").prefix)
XCTAssertEqual("e", OptableIdentifier.raw("e:abc").prefix)
XCTAssertEqual("", OptableIdentifier.raw(":abc").prefix)
}

// MARK: Legacy
func test_eidFromURL_isCorrect() {
let url = "http://some.domain.com/some/path?some=query&something=else&oeid=a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3&foo=bar&baz"
Expand Down
12 changes: 12 additions & 0 deletions docs/usage-swift.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,18 @@ The value must be a 64-character hexadecimal SHA-256 digest, computed with the s

Do not pass a hash to `.emailAddress` or `.phoneNumber`: those cases always hash their value, so the DCN would receive a hash of the hash. Already-hashed identifiers work the same way with `targeting()`.

#### Pre-encoded identifiers

To send an identifier type that has no case in `OptableIdentifier`, pass it already encoded as `"type:value"` with `.raw`:

```swift
let ids: [OptableIdentifier] = [
.raw("xyz:some-value")
]
```

If the type is one the SDK knows, the value is checked and normalized as its case would be: `e` and `p` values must already be SHA-256 digests, like `.hem` and `.hashedPhoneNumber`, so a plain-text email address or phone number is dropped and never sent. A value of an unknown type is sent as given. Values without both a type and a value, or where either is only whitespace, are dropped. `.raw` works the same way with `identify()`, `targeting()` and its `hids`.

### Profile API

> :information_source: For more info check:
Expand Down
Loading