Repository navigation
feat: one-key client for the new private key #137
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
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
851a0b7
feat: add one-key CustomerIO client
sydneycollins-cio 748f951
feat: rename API key prefixes to pk_/sk_
sydneycollins-cio c103316
fix: keep the /api/v1 prefix when track_url ends with a slash
sydneycollins-cio 72295df
fix: send the Bearer token through an auth handler and soften refusal…
sydneycollins-cio 63fe802
fix: keep IPv6 brackets in track_url and refuse non-https schemes
sydneycollins-cio 463aa47
fix: strip the key, refuse a mismatched region and non-https api_url,…
sydneycollins-cio a39fc9b
fix: accept a custom Region with a prefixed key, matching the Node cl…
sydneycollins-cio 1dce343
fix: treat only pk_/sk_ as prefixed keys, refuse malformed URLs, and …
sydneycollins-cio File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,263 @@ | ||
| """ | ||
| 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 requests.auth import AuthBase | ||
|
|
||
| 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 `{type}_{us|eu}_{random}_{checksum}`, where type | ||
| # is `pk` (public) or `sk` (secret). The server checks the checksum; the client only | ||
| # reads the prefix to pick a host and spot public keys. | ||
| KEY_PREFIX = re.compile(r"^(pk|sk)_(us|eu)_") | ||
|
|
||
| CAPABILITY_ACTIONS = { | ||
| "track": "send tracking data", | ||
| "transactional": "send transactional messages", | ||
| } | ||
|
|
||
|
|
||
| class _BearerAuth(AuthBase): | ||
| """Sends the key as a Bearer token. Set as ``session.auth`` so Requests never swaps | ||
| in Basic auth from a matching ``.netrc`` entry.""" | ||
|
|
||
| def __init__(self, key): | ||
| self.key = key | ||
|
|
||
| def __call__(self, request): | ||
| request.headers["Authorization"] = f"Bearer {self.key}" | ||
| return request | ||
|
|
||
|
|
||
| class _BearerAPIClient(APIClient): | ||
| """App API client that sends the key through :class:`_BearerAuth` as well, so a | ||
| matching ``.netrc`` entry can't replace the header either.""" | ||
|
|
||
| def _build_session(self): | ||
| session = super()._build_session() | ||
| session.auth = _BearerAuth(self.key) | ||
| return session | ||
|
|
||
|
|
||
| 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 = _BearerAuth(self.api_key) | ||
| return session | ||
|
|
||
|
|
||
| def _guard(capability): | ||
| """Re-raises a 401 or 403 as a KeyCapabilityError so a refusal 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"The request to {CAPABILITY_ACTIONS[capability]} was refused ({status}): " | ||
| f"{_server_message(e.response)}. Check the key, its permissions, the IP " | ||
| "allowlist and the workspace status." | ||
| ) | ||
| raise KeyCapabilityError(capability, message, status_code=status) from e | ||
|
|
||
| return wrapper | ||
|
|
||
| return decorator | ||
|
|
||
|
|
||
| def _server_message(response): | ||
| # The App API answers {"errors": [{"detail": ...}]}; the Track API uses meta.error. | ||
| try: | ||
| body = response.json() | ||
| if body.get("errors"): | ||
| return body["errors"][0]["detail"] | ||
| return body["meta"]["error"] | ||
| except Exception: | ||
| return response.text | ||
|
|
||
|
|
||
| def _require_https(name, url): | ||
| try: | ||
| parts = urlsplit(url) | ||
| # Reading the port is what checks it. | ||
| parts.port # noqa: B018 | ||
| except ValueError: | ||
| raise CustomerIOException(f"{name} is not a valid URL") from None | ||
| if parts.scheme != "https": | ||
| raise CustomerIOException(f"{name} must start with https://") | ||
| return parts | ||
|
|
||
|
|
||
| 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 ``sk_us_…`` / ``sk_eu_…`` keys the region comes from the key: passing the | ||
| other built-in region (``Regions.US`` or ``Regions.EU``) raises, and a custom | ||
| ``Region`` is accepted. Legacy keys default to ``Regions.US``. | ||
|
|
||
| When a call is refused, the method raises a | ||
| :class:`KeyCapabilityError`: before the request when the key's type says so (a | ||
| public ``pk_`` 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, | ||
| ): | ||
| # A key read from a file often ends in a newline, which Requests rejects and | ||
| # then copies, key included, into the exception message. | ||
| api_key = api_key.strip() if isinstance(api_key, str) else api_key | ||
| 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 | ||
| # A prefixed key belongs to one region, so a different built-in one can only fail. | ||
| # A custom Region (a proxy or test server) is left alone. | ||
| if key_region and region in (Regions.US, Regions.EU) and region != key_region: | ||
| raise CustomerIOException( | ||
| f"region {region.name} does not match the {key_region.name} region of this key" | ||
| ) | ||
| self.region = region or key_region or Regions.US | ||
|
sydneycollins-cio marked this conversation as resolved.
|
||
|
|
||
| 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 = _require_https("track_url", track_url) | ||
| track_host, track_port = parts.hostname, parts.port | ||
| # Requests rejects an IPv6 literal without its brackets. | ||
| if track_host and ":" in track_host: | ||
| track_host = f"[{track_host}]" | ||
| # A bare "/" path falls back to the default /api/v1 prefix. | ||
| track_prefix = parts.path.rstrip("/") 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, | ||
| ) | ||
| if api_url: | ||
| _require_https("api_url", api_url) | ||
| # The App API client appends paths that start with "/". | ||
| api_url = api_url.rstrip("/") | ||
| self._api_client = _BearerAPIClient(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 ``pk_`` key. | ||
| """ | ||
| if self._key_kind == "pk": | ||
| raise KeyCapabilityError( | ||
| "transactional", | ||
| "This key can't use the App API: public keys (pk_) are for tracking only. " | ||
| "Use a secret key (sk_).", | ||
| ) | ||
| 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) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.