Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UID2-2760 Add IdentityMapClient #39

Merged
merged 9 commits into from
May 22, 2024
Merged
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
2 changes: 1 addition & 1 deletion Dockerfile.dev
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
FROM python:3.6
FROM python:3.8
COPY . /build
RUN pip install --no-cache-dir -e /build[dev]
23 changes: 4 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,12 @@ For documentation on usage, see the [UID2 SDK for Python Reference Guide](https:

## Example Usage

To run all the example applications:

```
make examples BASE_URL=https://prod.uidapi.com AUTH_KEY=my-auth-key SECRET_KEY=my-secret-key \
AD_TOKEN=AgAAAANRdREk+IWqqnQkZ2rZdK0TgSUP/owLryysSkUGZJT+Gy551L1WJMAZA/G2B1UMDQ20WAqwwTu6o9TexWyux0lg0HHIbmJjN6IYwo+42KC8ugaR+PX0y18qQ+3yzkxmJ/ee//4IGu/1Yq4AmO4ArXN6CeszPTxByTkysVqyQVNY2A== \
RAW_UID=JCqmlLXpbbu/jTdpB2a1cNAVs8O72eMXPaQzC9Ic9mE= \
DOMAIN=example.com
```

Alternatively, you can run specific examples:
You can run specific examples:

```
make example_client BASE_URL=https://prod.uidapi.com AUTH_KEY=my-auth-key SECRET_KEY=my-secret-key \
AD_TOKEN=AgAAAANRdREk+IWqqnQkZ2rZdK0TgSUP/owLryysSkUGZJT+Gy551L1WJMAZA/G2B1UMDQ20WAqwwTu6o9TexWyux0lg0HHIbmJjN6IYwo+42KC8ugaR+PX0y18qQ+3yzkxmJ/ee//4IGu/1Yq4AmO4ArXN6CeszPTxByTkysVqyQVNY2A==
make example_auto_refresh BASE_URL=https://prod.uidapi.com AUTH_KEY=my-auth-key SECRET_KEY=my-secret-key \
AD_TOKEN=AgAAAANRdREk+IWqqnQkZ2rZdK0TgSUP/owLryysSkUGZJT+Gy551L1WJMAZA/G2B1UMDQ20WAqwwTu6o9TexWyux0lg0HHIbmJjN6IYwo+42KC8ugaR+PX0y18qQ+3yzkxmJ/ee//4IGu/1Yq4AmO4ArXN6CeszPTxByTkysVqyQVNY2A==
python examples/sample_bidstream_client.py BASE_URL=https://operator-integ.uidapi.com AUTH_KEY=my-auth-key SECRET_KEY=my-secret-key
DOMAIN_NAME=domain-name AD_TOKEN=ad-token
```

## Development
Expand All @@ -52,12 +42,6 @@ First, build the Docker image with Python 3.6 and all dev dependencies. This is
make docker
```

Run unit tests:

```
make test
```

Build a bdist wheel:

```
Expand All @@ -69,3 +53,4 @@ Get access to an interactive shell within the Python 3.6 Docker image:
```
make shell
```
Run unit tests: Use PyCharm to run the test cases
38 changes: 38 additions & 0 deletions examples/sample_identity_map_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import sys

from uid2_client import IdentityMapClient, IdentityMapInput


# this sample client takes email addresses as input and generates an IdentityMapResponse object which contains raw uid
# or the reason why it is unmapped

def _usage():
print('Usage: python3 sample_identity_map_client.py <base_url> <api_key> <client_secret> <email_1> <email_2> ... <email_n>'
, file=sys.stderr)
sys.exit(1)


if len(sys.argv) <= 4:
_usage()

base_url = sys.argv[1]
api_key = sys.argv[2]
client_secret = sys.argv[3]
email_list = sys.argv[4:]
first_email = sys.argv[4]

client = IdentityMapClient(base_url, api_key, client_secret)

identity_map_response = client.generate_identity_map(IdentityMapInput.from_emails(email_list))

mapped_identities = identity_map_response.mapped_identities
unmapped_identities = identity_map_response.unmapped_identities

mapped_identity = mapped_identities.get(first_email)
if mapped_identity is not None:
raw_uid = mapped_identity.get_raw_uid()
print('raw_uid =', raw_uid)
else:
unmapped_identity = unmapped_identities.get(first_email)
reason = unmapped_identity.get_reason()
print('reason =', reason)
170 changes: 170 additions & 0 deletions tests/test_identity_map_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import os
jon8787 marked this conversation as resolved.
Show resolved Hide resolved
import unittest
from urllib.error import URLError, HTTPError

from uid2_client import IdentityMapClient, IdentityMapInput, normalize_and_hash_email, normalize_and_hash_phone


class IdentityMapIntegrationTests(unittest.TestCase):
UID2_BASE_URL = None
UID2_API_KEY = None
UID2_SECRET_KEY = None

identity_map_client = None

@classmethod
def setUpClass(cls):
cls.UID2_BASE_URL = os.getenv("UID2_BASE_URL")
cls.UID2_API_KEY = os.getenv("UID2_API_KEY")
cls.UID2_SECRET_KEY = os.getenv("UID2_SECRET_KEY")

if cls.UID2_BASE_URL and cls.UID2_API_KEY and cls.UID2_SECRET_KEY:
cls.identity_map_client = IdentityMapClient(cls.UID2_BASE_URL, cls.UID2_API_KEY, cls.UID2_SECRET_KEY)
else:
raise Exception("set the required UID2_BASE_URL/UID2_API_KEY/UID2_SECRET_KEY environment variables first")

def test_identity_map_emails(self):
identity_map_input = IdentityMapInput.from_emails(
["[email protected]", "[email protected]", "[email protected]"])
response = self.identity_map_client.generate_identity_map(identity_map_input)
self.assert_mapped(response, "[email protected]")
self.assert_mapped(response, "[email protected]")

self.assert_unmapped(response, "optout", "[email protected]")

def test_identity_map_nothing_unmapped(self):
identity_map_input = IdentityMapInput.from_emails(
["[email protected]", "[email protected]"])
response = self.identity_map_client.generate_identity_map(identity_map_input)
self.assert_mapped(response, "[email protected]")
self.assert_mapped(response, "[email protected]")

def test_identity_map_nothing_mapped(self):
identity_map_input = IdentityMapInput.from_emails(["[email protected]"])
response = self.identity_map_client.generate_identity_map(identity_map_input)
self.assert_unmapped(response, "optout", "[email protected]")

def test_identity_map_invalid_email(self):
self.assertRaises(ValueError, IdentityMapInput.from_emails,
["[email protected]", "this is not an email"])

def test_identity_map_invalid_phone(self):
self.assertRaises(ValueError, IdentityMapInput.from_phones,
["+12345678901", "this is not a phone number"])

def test_identity_map_invalid_hashed_email(self):
identity_map_input = IdentityMapInput.from_hashed_emails(["this is not a hashed email"])
response = self.identity_map_client.generate_identity_map(identity_map_input)
self.assert_unmapped(response, "invalid identifier", "this is not a hashed email")

def test_identity_map_invalid_hashed_phone(self):
identity_map_input = IdentityMapInput.from_hashed_emails(["this is not a hashed phone"])
response = self.identity_map_client.generate_identity_map(identity_map_input)
self.assert_unmapped(response, "invalid identifier", "this is not a hashed phone")

def test_identity_map_hashed_emails(self):
hashed_email1 = normalize_and_hash_email("[email protected]")
hashed_email2 = normalize_and_hash_email("[email protected]")
hashed_opted_out_email = normalize_and_hash_email("[email protected]")
identity_map_input = IdentityMapInput.from_hashed_emails([hashed_email1, hashed_email2, hashed_opted_out_email])

response = self.identity_map_client.generate_identity_map(identity_map_input)

self.assert_mapped(response, hashed_email1)
self.assert_mapped(response, hashed_email2)

self.assert_unmapped(response, "optout", hashed_opted_out_email)

def test_identity_map_duplicate_emails(self):
identity_map_input = IdentityMapInput.from_emails(
["[email protected]", "[email protected]", "[email protected]", "[email protected]",
"[email protected]"])
response = self.identity_map_client.generate_identity_map(identity_map_input)

mapped_identities = response.mapped_identities
self.assertEqual(4, len(mapped_identities))

raw_uid = mapped_identities.get("[email protected]").get_raw_uid()
self.assertEqual(raw_uid, mapped_identities.get("[email protected]").get_raw_uid())
self.assertEqual(raw_uid, mapped_identities.get("[email protected]").get_raw_uid())
self.assertEqual(raw_uid, mapped_identities.get("[email protected]").get_raw_uid())

def test_identity_map_duplicate_hashed_emails(self):
hashed_email = normalize_and_hash_email("[email protected]")
duplicate_hashed_email = hashed_email
hashed_opted_out_email = normalize_and_hash_email("[email protected]")
duplicate_hashed_opted_out_email = hashed_opted_out_email

identity_map_input = IdentityMapInput.from_hashed_emails(
[hashed_email, duplicate_hashed_email, hashed_opted_out_email, duplicate_hashed_opted_out_email])
response = self.identity_map_client.generate_identity_map(identity_map_input)

self.assert_mapped(response, hashed_email)
self.assert_mapped(response, duplicate_hashed_email)

self.assert_unmapped(response, "optout", hashed_opted_out_email)
self.assert_unmapped(response, "optout", duplicate_hashed_opted_out_email)

def test_identity_map_empty_input(self):
identity_map_input = IdentityMapInput.from_emails([])
response = self.identity_map_client.generate_identity_map(identity_map_input)
self.assertTrue(len(response.mapped_identities) == 0)
self.assertTrue(len(response.unmapped_identities) == 0)

def test_identity_map_phones(self):
identity_map_input = IdentityMapInput.from_phones(["+12345678901", "+98765432109", "+00000000000"])
response = self.identity_map_client.generate_identity_map(identity_map_input)
self.assert_mapped(response, "+12345678901")
self.assert_mapped(response, "+98765432109")

self.assert_unmapped(response, "optout", "+00000000000")

def test_identity_map_hashed_phones(self):
hashed_phone1 = normalize_and_hash_phone("+12345678901")
hashed_phone2 = normalize_and_hash_phone("+98765432109")
hashed_opted_out_phone = normalize_and_hash_phone("+00000000000")
identity_map_input = IdentityMapInput.from_hashed_phones([hashed_phone1, hashed_phone2, hashed_opted_out_phone])
response = self.identity_map_client.generate_identity_map(identity_map_input)
self.assert_mapped(response, hashed_phone1)
self.assert_mapped(response, hashed_phone2)

self.assert_unmapped(response, "optout", hashed_opted_out_phone)

def test_identity_map_bad_url(self):
identity_map_input = IdentityMapInput.from_emails(
["[email protected]", "[email protected]", "[email protected]"])
client = IdentityMapClient("https://operator-bad-url.uidapi.com", os.getenv("UID2_API_KEY"), os.getenv("UID2_SECRET_KEY"))
self.assertRaises(URLError, client.generate_identity_map, identity_map_input)

def test_identity_map_bad_api_key(self):
identity_map_input = IdentityMapInput.from_emails(
["[email protected]", "[email protected]", "[email protected]"])
client = IdentityMapClient(os.getenv("UID2_BASE_URL"), "bad-api-key", os.getenv("UID2_SECRET_KEY"))
self.assertRaises(HTTPError, client.generate_identity_map,identity_map_input)

def test_identity_map_bad_secret(self):
identity_map_input = IdentityMapInput.from_emails(
["[email protected]", "[email protected]", "[email protected]"])
client = IdentityMapClient(os.getenv("UID2_BASE_URL"), os.getenv("UID2_API_KEY"), "wJ0hP19QU4hmpB64Y3fV2dAed8t/mupw3sjN5jNRFzg=")
self.assertRaises(HTTPError, client.generate_identity_map,
identity_map_input)

def assert_mapped(self, response, dii):
mapped_identity = response.mapped_identities.get(dii)
self.assertIsNotNone(mapped_identity)
self.assertIsNotNone(mapped_identity.get_raw_uid())
self.assertIsNotNone(mapped_identity.get_bucket_id())

unmapped_identity = response.unmapped_identities.get(dii)
self.assertIsNone(unmapped_identity)

def assert_unmapped(self, response, reason, dii):
unmapped_identity = response.unmapped_identities.get(dii)
self.assertEqual(reason, unmapped_identity.get_reason())

mapped_identity = response.mapped_identities.get(dii)
self.assertIsNone(mapped_identity)


if __name__ == '__main__':
unittest.main()
5 changes: 3 additions & 2 deletions uid2_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@
from .encryption_data_response import *
from .refresh_response import *
from .uid2_token_generator import *


from .identity_map_client import *
from .identity_map_input import *
from .identity_map_response import *
40 changes: 40 additions & 0 deletions uid2_client/identity_map_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import base64
import datetime as dt
from datetime import timezone

from .identity_map_response import IdentityMapResponse

from uid2_client import auth_headers, make_v2_request, post, parse_v2_response


class IdentityMapClient:
"""Client for interacting with UID Identity Map services

You will need to have the base URL of the endpoint and a client API key
and secret to consume web services.

Methods:
generate_identity_map: Generate identity map
"""

def __init__(self, base_url, api_key, client_secret):
"""Create a new IdentityMapClient client.

Args:
base_url (str): base URL for all requests to UID services (e.g. 'https://prod.uidapi.com')
api_key (str): api key for consuming the UID services
client_secret (str): client secret for consuming the UID services

Note:
Your authorization key will determine which UID services you are allowed to use.
"""
self._base_url = base_url
self._api_key = api_key
self._client_secret = base64.b64decode(client_secret)

def generate_identity_map(self, identity_map_input):
req, nonce = make_v2_request(self._client_secret, dt.datetime.now(tz=timezone.utc),
identity_map_input.get_identity_map_input_as_json_string().encode())
resp = post(self._base_url, '/v2/identity/map', headers=auth_headers(self._api_key), data=req)
resp_body = parse_v2_response(self._client_secret, resp.read(), nonce)
return IdentityMapResponse(resp_body, identity_map_input)
62 changes: 62 additions & 0 deletions uid2_client/identity_map_input.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import json

from uid2_client import IdentityType, normalize_and_hash_email, normalize_and_hash_phone


class IdentityMapInput:
"""input for IdentityMapClient, such as email addresses or phone numbers"""

def __init__(self, identity_type, emails_or_phones, already_hashed):
self.hashed_dii_to_raw_diis = {}
self.hashed_normalized_emails = None
self.hashed_normalized_phones = None
if identity_type == IdentityType.Email:
if already_hashed:
self.hashed_normalized_emails = emails_or_phones
else:
self.hashed_normalized_emails = []
for email in emails_or_phones:
hashed_normalized_email = normalize_and_hash_email(email)
self._add_hashed_to_raw_dii_mapping(hashed_normalized_email, email)
self.hashed_normalized_emails.append(hashed_normalized_email)
else: # phone
if already_hashed:
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: if already_hashed block should not depend on whether it's an email or phone and should go outside the phone/email check

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm, it depends on the type. If it's a hashed email list, it gets assigned to hashed_normalized_emails, and if it's a hashed phone list, it's assigned to hashed_normalized_phones.

self.hashed_normalized_phones = emails_or_phones
else:
self.hashed_normalized_phones = []
for phone in emails_or_phones:
hashed_normalized_phone = normalize_and_hash_phone(phone)
self._add_hashed_to_raw_dii_mapping(hashed_normalized_phone, phone)
self.hashed_normalized_phones.append(hashed_normalized_phone)

@staticmethod
def from_emails(emails):
return IdentityMapInput(IdentityType.Email, emails, False)

@staticmethod
def from_phones(phones):
return IdentityMapInput(IdentityType.Phone, phones, False)

@staticmethod
def from_hashed_emails(hashed_emails):
return IdentityMapInput(IdentityType.Email, hashed_emails, True)

@staticmethod
def from_hashed_phones(hashed_phones):
return IdentityMapInput(IdentityType.Phone, hashed_phones, True)

def _add_hashed_to_raw_dii_mapping(self, hashed_dii, raw_dii):
self.hashed_dii_to_raw_diis.setdefault(hashed_dii, []).append(raw_dii)

def get_raw_diis(self, identifier):
if len(self.hashed_dii_to_raw_diis) <= 0:
return [identifier]
else:
return self.hashed_dii_to_raw_diis[identifier]

def get_identity_map_input_as_json_string(self):
json_object = {
"email_hash": self.hashed_normalized_emails,
"phone_hash": self.hashed_normalized_phones
}
return json.dumps({k: v for k, v in json_object.items() if v is not None})
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Initialize the variables hashed_normalized_emails and hashed_normalized_phones as None, and then set their values later. This approach allows us to create objects like {"email_hash":[]} instead of {} when the input is empty. Passing {} to the operator would result in a 400 bad request error.

Loading