diff --git a/Source/Core/EdgeAPI.swift b/Source/Core/EdgeAPI.swift index 5e61c85..f5f35e9 100644 --- a/Source/Core/EdgeAPI.swift +++ b/Source/Core/EdgeAPI.swift @@ -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) diff --git a/Source/Core/OptableIdentifierEncoder.swift b/Source/Core/OptableIdentifierEncoder.swift index 8a65694..79920c8 100644 --- a/Source/Core/OptableIdentifierEncoder.swift +++ b/Source/Core/OptableIdentifierEncoder.swift @@ -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. diff --git a/Source/Public/OptableIdentifier.swift b/Source/Public/OptableIdentifier.swift index 2748120..fb5e49c 100644 --- a/Source/Public/OptableIdentifier.swift +++ b/Source/Public/OptableIdentifier.swift @@ -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" @@ -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) ?? "" } } diff --git a/Tests/Unit/EdgeAPITests.swift b/Tests/Unit/EdgeAPITests.swift index 0894270..8f4561d 100644 --- a/Tests/Unit/EdgeAPITests.swift +++ b/Tests/Unit/EdgeAPITests.swift @@ -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. diff --git a/Tests/Unit/OptableIdentifierEncoderTests.swift b/Tests/Unit/OptableIdentifierEncoderTests.swift index ea44ed1..c0d2067 100644 --- a/Tests/Unit/OptableIdentifierEncoderTests.swift +++ b/Tests/Unit/OptableIdentifierEncoderTests.swift @@ -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" diff --git a/docs/usage-swift.md b/docs/usage-swift.md index e318bb9..ac1c093 100644 --- a/docs/usage-swift.md +++ b/docs/usage-swift.md @@ -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: