Skip to content
Draft
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
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,43 @@ cio.track(customer_id="5", name="purchased")
cio.track(customer_id="5", name="purchased", data={"price": 23.45})
```

### Quick start

Create a client with your secret key (`ak_…`). It sends tracking calls to the Track API and transactional messages to the App API, and picks the US or EU region from the key.

```python
import os

from customerio import Client

cio = Client(api_key=os.environ["CIO_API_KEY"])
cio.identify(id="user_123", email="a@example.com")
cio.track(customer_id="user_123", name="order_completed")
cio.send_email(
{
"to": "a@example.com",
"identifiers": {"id": "user_123"},
"transactional_message_id": 5,
}
)
```

Pass `region` to override the region in the key. Legacy App API keys also work here; they default to `Regions.US` unless you pass `region`.

`identify`, `track`, `track_anonymous`, `pageview` and the `send_*` methods are on the client directly. The rest of the Track and App API are on `cio.track_client` and `cio.api_client`.

If the key can't do what you asked, the call raises a `KeyCapabilityError` (a subclass of `CustomerIOException`). A public key (`wk_…`) raises it before any request when you send a transactional message. A 401 or 403 from the server is re-raised as a `KeyCapabilityError` too, chained from the original error.

```python
from customerio import KeyCapabilityError

try:
cio.send_email(request)
except KeyCapabilityError as e:
# e.capability == "transactional", e.status_code == 401
print(e) # This key can't send transactional messages (401): ...
```

### Instantiating customer.io object

Create an instance of the client with your [Customer.io credentials](https://fly.customer.io/settings/api_credentials).
Expand Down
5 changes: 4 additions & 1 deletion customerio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,17 @@
SendSMSRequest,
SendWhatsAppRequest,
)
from customerio.client_base import CustomerIOException
from customerio.client import Client
from customerio.client_base import CustomerIOException, KeyCapabilityError
from customerio.regions import Regions
from customerio.track import CustomerIO

__all__ = [
"APIClient",
"Client",
"CustomerIO",
"CustomerIOException",
"KeyCapabilityError",
"Regions",
"SendEmailRequest",
"SendInAppRequest",
Expand Down
203 changes: 203 additions & 0 deletions customerio/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
"""
Implements a single client for the Track and App APIs, authenticated with one key.
"""

import re
from functools import wraps
from urllib.parse import urlsplit

from .api import APIClient
from .client_base import CustomerIOException, KeyCapabilityError
from .regions import Region, Regions
from .track import CustomerIO

# Keys in the shared format look like `{ak|wk}_{us|eu}_{random}_{checksum}`.
# The server checks the checksum; the client only reads the prefix to pick a host.
KEY_PREFIX = re.compile(r"^(ak|wk)_(us|eu)_")

CAPABILITY_ACTIONS = {
"track": "send tracking data",
"transactional": "send transactional messages",
}


class _BearerTrackClient(CustomerIO):
"""Track API client that sends the key as a Bearer token instead of Basic auth."""

def _build_session(self):
session = super()._build_session()
session.auth = None
session.headers["Authorization"] = f"Bearer {self.api_key}"
return session


def _guard(capability):
"""Re-raises a 401 or 403 as a KeyCapabilityError so a key mismatch is never a bare
auth failure."""

def decorator(method):
@wraps(method)
def wrapper(*args, **kwargs):
try:
return method(*args, **kwargs)
except KeyCapabilityError:
raise
except CustomerIOException as e:
status = getattr(e, "status_code", None)
if status not in (401, 403):
raise
message = (
f"This key can't {CAPABILITY_ACTIONS[capability]} "
f"({status}): {_server_message(e.response)}"
)
raise KeyCapabilityError(capability, message, status_code=status) from e

return wrapper

return decorator


def _server_message(response):
try:
return response.json()["meta"]["error"]
except Exception:
return response.text


class Client:
"""One client for tracking and transactional messages, authenticated with a single key.

Tracking methods (``identify``, ``track``, ``track_anonymous``, ``pageview``) go to
the Track API; ``send_*`` methods go to the App API. Both send the key as a Bearer
token. For ``ak_us_…`` / ``ak_eu_…`` keys the region comes from the key; an explicit
``region`` wins. Legacy keys default to ``Regions.US``.

When the key can't do what a method asks, the method raises a
:class:`KeyCapabilityError`: before the request when the key's type says so (a
public ``wk_`` key can't send transactional messages), otherwise when the server
answers 401 or 403.

Other Track and App API methods are on ``track_client`` and ``api_client``.
"""

def __init__(
self,
api_key,
region=None,
track_url=None,
api_url=None,
retries=3,
timeout=10,
backoff_factor=0.02,
use_connection_pooling=True,
):
if not api_key:
raise CustomerIOException("api_key is required")
if region is not None and not isinstance(region, Region):
raise CustomerIOException("invalid region provided")

match = KEY_PREFIX.match(api_key)
self._key_kind = match.group(1) if match else "legacy"
key_region = (Regions.EU if match.group(2) == "eu" else Regions.US) if match else None
self.region = region or key_region or Regions.US

options = dict(
retries=retries,
timeout=timeout,
backoff_factor=backoff_factor,
use_connection_pooling=use_connection_pooling,
)

track_host = track_port = track_prefix = None
if track_url:
parts = urlsplit(track_url)
track_host, track_port, track_prefix = parts.hostname, parts.port, parts.path or None

#: Track API client, authenticated with the key as a Bearer token.
self.track_client = _BearerTrackClient(
api_key=api_key,
host=track_host,
port=track_port,
url_prefix=track_prefix,
region=self.region,
**options,
)
self._api_client = APIClient(api_key, url=api_url, region=self.region, **options)

def __enter__(self):
return self

def __exit__(self, *args):
self.close()

def close(self):
try:
self.track_client.close()
finally:
self._api_client.close()

@property
def api_client(self):
"""App API client, authenticated with the key.

Raises :class:`KeyCapabilityError` if the key is a public ``wk_`` key.
"""
if self._key_kind == "wk":
raise KeyCapabilityError(
"transactional",
"This key can't use the App API: public keys (wk_) are for tracking only. "
"Use a secret key (ak_).",
)
return self._api_client

@_guard("track")
def identify(self, id, **kwargs):
"""Create or update a person. See :meth:`CustomerIO.identify`."""
return self.track_client.identify(id, **kwargs)

@_guard("track")
def track(self, customer_id, name, data=None, id=None, timestamp=None):
"""Track an event for a person. See :meth:`CustomerIO.track`."""
return self.track_client.track(customer_id, name, data=data, id=id, timestamp=timestamp)

@_guard("track")
def track_anonymous(self, anonymous_id, name, data=None, id=None, timestamp=None):
"""Track an event for an anonymous visitor. See :meth:`CustomerIO.track_anonymous`."""
return self.track_client.track_anonymous(
anonymous_id, name, data=data, id=id, timestamp=timestamp
)

@_guard("track")
def pageview(self, customer_id, page, **data):
"""Track a page view for a person. See :meth:`CustomerIO.pageview`."""
return self.track_client.pageview(customer_id, page, **data)

@_guard("transactional")
def send_email(self, request):
"""Send a transactional email. See :meth:`APIClient.send_email`."""
return self.api_client.send_email(request)

@_guard("transactional")
def send_push(self, request):
"""Send a transactional push. See :meth:`APIClient.send_push`."""
return self.api_client.send_push(request)

@_guard("transactional")
def send_sms(self, request):
"""Send a transactional SMS. See :meth:`APIClient.send_sms`."""
return self.api_client.send_sms(request)

@_guard("transactional")
def send_whatsapp(self, request):
"""Send a transactional WhatsApp message. See :meth:`APIClient.send_whatsapp`."""
return self.api_client.send_whatsapp(request)

@_guard("transactional")
def send_inbox_message(self, request):
"""Send a transactional inbox message. See :meth:`APIClient.send_inbox_message`."""
return self.api_client.send_inbox_message(request)

@_guard("transactional")
def send_in_app(self, request):
"""Send a transactional in-app message. See :meth:`APIClient.send_in_app`."""
return self.api_client.send_in_app(request)
22 changes: 21 additions & 1 deletion customerio/client_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,23 @@ class CustomerIOException(Exception):
pass


class KeyCapabilityError(CustomerIOException):
"""Raised by :class:`customerio.Client` when its key can't do what a method asks.

Raised before the request when the key's type rules it out (a public ``wk_``
key can't send transactional messages). Otherwise the server's 401 or 403 is
re-raised as this error, chained from the original :class:`CustomerIOException`.

``capability`` is ``"track"`` or ``"transactional"``. ``status_code`` is the
HTTP status when the server refused the key, else ``None``.
"""

def __init__(self, capability, message, status_code=None):
super().__init__(message)
self.capability = capability
self.status_code = status_code


class ClientBase:
def __init__(self, retries=3, timeout=10, backoff_factor=0.02, use_connection_pooling=True):
self.timeout = timeout
Expand Down Expand Up @@ -100,7 +117,10 @@ def send_request(self, method, url, data):

result_status = response.status_code
if result_status < 200 or result_status >= 300:
raise CustomerIOException(f"{result_status}: {url} {data} {response.text}")
error = CustomerIOException(f"{result_status}: {url} {data} {response.text}")
error.status_code = result_status
error.response = response
raise error
return response

except CustomerIOException:
Expand Down
Loading
Loading