otok 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- otok/__init__.py +187 -0
- otok/_http.py +316 -0
- otok/_version.py +1 -0
- otok/client.py +86 -0
- otok/commerce.py +205 -0
- otok/errors.py +43 -0
- otok/py.typed +0 -0
- otok/resources.py +477 -0
- otok/types.py +703 -0
- otok/webhooks.py +163 -0
- otok-0.1.0.dist-info/METADATA +297 -0
- otok-0.1.0.dist-info/RECORD +14 -0
- otok-0.1.0.dist-info/WHEEL +4 -0
- otok-0.1.0.dist-info/licenses/LICENSE +21 -0
otok/__init__.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Official Python SDK for the oToK marketing platform public API (/v1)."""
|
|
2
|
+
|
|
3
|
+
from ._http import (
|
|
4
|
+
DEFAULT_BASE_URL,
|
|
5
|
+
HttpClient,
|
|
6
|
+
Transport,
|
|
7
|
+
TransportRequest,
|
|
8
|
+
TransportResponse,
|
|
9
|
+
UrllibTransport,
|
|
10
|
+
compute_backoff_ms,
|
|
11
|
+
)
|
|
12
|
+
from ._version import __version__
|
|
13
|
+
from .client import OtokClient
|
|
14
|
+
from .commerce import (
|
|
15
|
+
CommerceAddress,
|
|
16
|
+
CommerceApi,
|
|
17
|
+
CommerceCustomer,
|
|
18
|
+
CommerceOrder,
|
|
19
|
+
CommerceReceipt,
|
|
20
|
+
TrackOrderResult,
|
|
21
|
+
customer_to_contact_params,
|
|
22
|
+
order_external_reference,
|
|
23
|
+
order_receipt_idempotency_key,
|
|
24
|
+
)
|
|
25
|
+
from .errors import OtokAPIError, OtokTimeoutError, OtokWebhookVerificationError
|
|
26
|
+
from .types import (
|
|
27
|
+
EMAIL_WEBHOOK_EVENT_TYPES,
|
|
28
|
+
Booking,
|
|
29
|
+
BookingCreateParams,
|
|
30
|
+
BookingInvitee,
|
|
31
|
+
BookingListParams,
|
|
32
|
+
BookingReassignParams,
|
|
33
|
+
BookingRescheduleParams,
|
|
34
|
+
BookingStatus,
|
|
35
|
+
Campaign,
|
|
36
|
+
CampaignCreateParams,
|
|
37
|
+
CampaignUpdateParams,
|
|
38
|
+
Contact,
|
|
39
|
+
ContactGroup,
|
|
40
|
+
ContactGroupCreateParams,
|
|
41
|
+
ContactGroupUpdateParams,
|
|
42
|
+
ContactSource,
|
|
43
|
+
ContactUpsertParams,
|
|
44
|
+
Deal,
|
|
45
|
+
DealCreateParams,
|
|
46
|
+
DealListParams,
|
|
47
|
+
DealMoveStageParams,
|
|
48
|
+
DealSetStatusParams,
|
|
49
|
+
DealStatus,
|
|
50
|
+
DealUpdateParams,
|
|
51
|
+
EmailBouncedEvent,
|
|
52
|
+
EmailClickedEvent,
|
|
53
|
+
EmailComplainedEvent,
|
|
54
|
+
EmailDeliveredEvent,
|
|
55
|
+
EmailFailedEvent,
|
|
56
|
+
EmailOpenedEvent,
|
|
57
|
+
EmailSendParams,
|
|
58
|
+
EmailSendResult,
|
|
59
|
+
EmailTracking,
|
|
60
|
+
EmailWebhookEventType,
|
|
61
|
+
LifecycleStage,
|
|
62
|
+
ListParams,
|
|
63
|
+
MeetingType,
|
|
64
|
+
MessageTemplate,
|
|
65
|
+
Note,
|
|
66
|
+
OtokWebhookEvent,
|
|
67
|
+
Paginated,
|
|
68
|
+
Payment,
|
|
69
|
+
PaymentCreateParams,
|
|
70
|
+
PaymentEntryStatus,
|
|
71
|
+
PaymentInterval,
|
|
72
|
+
PaymentListParams,
|
|
73
|
+
PaymentMethod,
|
|
74
|
+
PaymentRefundParams,
|
|
75
|
+
PaymentType,
|
|
76
|
+
PaymentUpdateParams,
|
|
77
|
+
Pipeline,
|
|
78
|
+
PipelineStage,
|
|
79
|
+
SlotsParams,
|
|
80
|
+
Tag,
|
|
81
|
+
TagCreateParams,
|
|
82
|
+
TagUpdateParams,
|
|
83
|
+
TemplateSendParams,
|
|
84
|
+
WebhookEndpoint,
|
|
85
|
+
WebhookEndpointCreated,
|
|
86
|
+
WebhookEndpointCreateParams,
|
|
87
|
+
WebhookEndpointList,
|
|
88
|
+
)
|
|
89
|
+
from .webhooks import (
|
|
90
|
+
DEFAULT_TOLERANCE_SECONDS,
|
|
91
|
+
ParsedSignatureHeader,
|
|
92
|
+
compute_webhook_signature,
|
|
93
|
+
construct_event,
|
|
94
|
+
parse_signature_header,
|
|
95
|
+
verify_webhook_signature,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
__all__ = [
|
|
99
|
+
"DEFAULT_BASE_URL",
|
|
100
|
+
"DEFAULT_TOLERANCE_SECONDS",
|
|
101
|
+
"EMAIL_WEBHOOK_EVENT_TYPES",
|
|
102
|
+
"Booking",
|
|
103
|
+
"BookingCreateParams",
|
|
104
|
+
"BookingInvitee",
|
|
105
|
+
"BookingListParams",
|
|
106
|
+
"BookingReassignParams",
|
|
107
|
+
"BookingRescheduleParams",
|
|
108
|
+
"BookingStatus",
|
|
109
|
+
"Campaign",
|
|
110
|
+
"CampaignCreateParams",
|
|
111
|
+
"CampaignUpdateParams",
|
|
112
|
+
"CommerceAddress",
|
|
113
|
+
"CommerceApi",
|
|
114
|
+
"CommerceCustomer",
|
|
115
|
+
"CommerceOrder",
|
|
116
|
+
"CommerceReceipt",
|
|
117
|
+
"Contact",
|
|
118
|
+
"ContactGroup",
|
|
119
|
+
"ContactGroupCreateParams",
|
|
120
|
+
"ContactGroupUpdateParams",
|
|
121
|
+
"ContactSource",
|
|
122
|
+
"ContactUpsertParams",
|
|
123
|
+
"Deal",
|
|
124
|
+
"DealCreateParams",
|
|
125
|
+
"DealListParams",
|
|
126
|
+
"DealMoveStageParams",
|
|
127
|
+
"DealSetStatusParams",
|
|
128
|
+
"DealStatus",
|
|
129
|
+
"DealUpdateParams",
|
|
130
|
+
"EmailBouncedEvent",
|
|
131
|
+
"EmailClickedEvent",
|
|
132
|
+
"EmailComplainedEvent",
|
|
133
|
+
"EmailDeliveredEvent",
|
|
134
|
+
"EmailFailedEvent",
|
|
135
|
+
"EmailOpenedEvent",
|
|
136
|
+
"EmailSendParams",
|
|
137
|
+
"EmailSendResult",
|
|
138
|
+
"EmailTracking",
|
|
139
|
+
"EmailWebhookEventType",
|
|
140
|
+
"HttpClient",
|
|
141
|
+
"LifecycleStage",
|
|
142
|
+
"ListParams",
|
|
143
|
+
"MeetingType",
|
|
144
|
+
"MessageTemplate",
|
|
145
|
+
"Note",
|
|
146
|
+
"OtokAPIError",
|
|
147
|
+
"OtokClient",
|
|
148
|
+
"OtokTimeoutError",
|
|
149
|
+
"OtokWebhookEvent",
|
|
150
|
+
"OtokWebhookVerificationError",
|
|
151
|
+
"Paginated",
|
|
152
|
+
"ParsedSignatureHeader",
|
|
153
|
+
"Payment",
|
|
154
|
+
"PaymentCreateParams",
|
|
155
|
+
"PaymentEntryStatus",
|
|
156
|
+
"PaymentInterval",
|
|
157
|
+
"PaymentListParams",
|
|
158
|
+
"PaymentMethod",
|
|
159
|
+
"PaymentRefundParams",
|
|
160
|
+
"PaymentType",
|
|
161
|
+
"PaymentUpdateParams",
|
|
162
|
+
"Pipeline",
|
|
163
|
+
"PipelineStage",
|
|
164
|
+
"SlotsParams",
|
|
165
|
+
"Tag",
|
|
166
|
+
"TagCreateParams",
|
|
167
|
+
"TagUpdateParams",
|
|
168
|
+
"TemplateSendParams",
|
|
169
|
+
"TrackOrderResult",
|
|
170
|
+
"Transport",
|
|
171
|
+
"TransportRequest",
|
|
172
|
+
"TransportResponse",
|
|
173
|
+
"UrllibTransport",
|
|
174
|
+
"WebhookEndpoint",
|
|
175
|
+
"WebhookEndpointCreateParams",
|
|
176
|
+
"WebhookEndpointCreated",
|
|
177
|
+
"WebhookEndpointList",
|
|
178
|
+
"__version__",
|
|
179
|
+
"compute_backoff_ms",
|
|
180
|
+
"compute_webhook_signature",
|
|
181
|
+
"construct_event",
|
|
182
|
+
"customer_to_contact_params",
|
|
183
|
+
"order_external_reference",
|
|
184
|
+
"order_receipt_idempotency_key",
|
|
185
|
+
"parse_signature_header",
|
|
186
|
+
"verify_webhook_signature",
|
|
187
|
+
]
|
otok/_http.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""HTTP layer: transport abstraction, retries, backoff, and error mapping.
|
|
2
|
+
|
|
3
|
+
The transport is a tiny protocol (one ``send`` method) so the SDK stays
|
|
4
|
+
zero-dependency (the default transport wraps ``urllib.request``) and tests
|
|
5
|
+
can inject a scripted fake.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import random
|
|
13
|
+
import socket
|
|
14
|
+
import time
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.parse
|
|
17
|
+
import urllib.request
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import timezone
|
|
21
|
+
from email.utils import parsedate_to_datetime
|
|
22
|
+
from typing import Any, Callable, Optional, Protocol, Union
|
|
23
|
+
|
|
24
|
+
from ._version import __version__
|
|
25
|
+
from .errors import OtokAPIError, OtokTimeoutError
|
|
26
|
+
|
|
27
|
+
#: Canonical base URL of the oToK API (endpoint paths ``/v1/...`` are
|
|
28
|
+
#: appended to it).
|
|
29
|
+
DEFAULT_BASE_URL = "https://app.otok.io/api"
|
|
30
|
+
|
|
31
|
+
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
32
|
+
DEFAULT_MAX_RETRIES = 2
|
|
33
|
+
#: Base backoff delay; grows exponentially per retry with full jitter.
|
|
34
|
+
BACKOFF_BASE_MS = 500
|
|
35
|
+
BACKOFF_CAP_MS = 30_000
|
|
36
|
+
|
|
37
|
+
QueryValue = Union[str, int, float, bool, None]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class TransportRequest:
|
|
42
|
+
"""A single outgoing HTTP request handed to the transport."""
|
|
43
|
+
|
|
44
|
+
method: str
|
|
45
|
+
url: str
|
|
46
|
+
headers: Mapping[str, str]
|
|
47
|
+
body: Optional[bytes]
|
|
48
|
+
#: Timeout in seconds. With the default urllib transport this bounds
|
|
49
|
+
#: each blocking socket operation (connect, each read) rather than the
|
|
50
|
+
#: attempt's total wall-clock time.
|
|
51
|
+
timeout: float
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class TransportResponse:
|
|
56
|
+
"""The transport's response. ``headers`` keys must be lowercased."""
|
|
57
|
+
|
|
58
|
+
status: int
|
|
59
|
+
headers: Mapping[str, str]
|
|
60
|
+
body: bytes
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Transport(Protocol):
|
|
64
|
+
"""Minimal transport contract.
|
|
65
|
+
|
|
66
|
+
Implementations perform exactly one HTTP round trip per ``send`` call
|
|
67
|
+
(no retries and no redirect following — the client owns retry policy,
|
|
68
|
+
and a 3xx is a terminal response), return a ``TransportResponse`` for
|
|
69
|
+
ANY HTTP status (including 3xx/4xx/5xx), and raise ``OtokTimeoutError``
|
|
70
|
+
when the request times out.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def send(self, request: TransportRequest) -> TransportResponse: # pragma: no cover
|
|
74
|
+
...
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _NoRedirectFollowHandler(urllib.request.HTTPRedirectHandler):
|
|
78
|
+
"""Treat every 3xx as a terminal response instead of following it.
|
|
79
|
+
|
|
80
|
+
urllib's default redirect handler re-sends the original headers —
|
|
81
|
+
including ``Authorization: Bearer otok_live_…`` — to the redirect
|
|
82
|
+
target, even cross-origin. The API never redirects, so the safe
|
|
83
|
+
behavior is to surface the 3xx to the caller (the same posture as
|
|
84
|
+
oToK's own outbound webhook dispatcher, which follows no redirects).
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def redirect_request(
|
|
88
|
+
self,
|
|
89
|
+
req: urllib.request.Request,
|
|
90
|
+
fp: Any,
|
|
91
|
+
code: int,
|
|
92
|
+
msg: str,
|
|
93
|
+
headers: Any,
|
|
94
|
+
newurl: str,
|
|
95
|
+
) -> None:
|
|
96
|
+
# Returning None makes HTTPRedirectHandler raise HTTPError for the
|
|
97
|
+
# 3xx, which UrllibTransport converts into a TransportResponse.
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class UrllibTransport:
|
|
102
|
+
"""Default zero-dependency transport built on ``urllib.request``.
|
|
103
|
+
|
|
104
|
+
Redirects are NOT followed — a 3xx comes back as a plain
|
|
105
|
+
``TransportResponse`` — so the bearer API key is never re-sent to a
|
|
106
|
+
redirect target. The per-request ``timeout`` bounds each socket
|
|
107
|
+
operation (connect, each read), not the whole attempt.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(self) -> None:
|
|
111
|
+
self._opener = urllib.request.build_opener(_NoRedirectFollowHandler())
|
|
112
|
+
|
|
113
|
+
def send(self, request: TransportRequest) -> TransportResponse:
|
|
114
|
+
req = urllib.request.Request(
|
|
115
|
+
request.url,
|
|
116
|
+
data=request.body,
|
|
117
|
+
headers=dict(request.headers),
|
|
118
|
+
method=request.method,
|
|
119
|
+
)
|
|
120
|
+
try:
|
|
121
|
+
with self._opener.open(req, timeout=request.timeout) as response:
|
|
122
|
+
return TransportResponse(
|
|
123
|
+
status=response.status,
|
|
124
|
+
headers=_lower_headers(response.headers.items()),
|
|
125
|
+
body=response.read(),
|
|
126
|
+
)
|
|
127
|
+
except urllib.error.HTTPError as err:
|
|
128
|
+
# Non-2xx responses (including unfollowed 3xx) are data, not
|
|
129
|
+
# exceptions: the client decides whether to retry or raise
|
|
130
|
+
# OtokAPIError.
|
|
131
|
+
return TransportResponse(
|
|
132
|
+
status=err.code,
|
|
133
|
+
headers=_lower_headers(err.headers.items()),
|
|
134
|
+
body=err.read(),
|
|
135
|
+
)
|
|
136
|
+
except (TimeoutError, socket.timeout) as err: # socket.timeout: Python 3.9
|
|
137
|
+
raise OtokTimeoutError(request.timeout) from err
|
|
138
|
+
except urllib.error.URLError as err:
|
|
139
|
+
if isinstance(err.reason, (TimeoutError, socket.timeout)):
|
|
140
|
+
raise OtokTimeoutError(request.timeout) from err
|
|
141
|
+
raise
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _lower_headers(items: Any) -> dict[str, str]:
|
|
145
|
+
return {str(key).lower(): str(value) for key, value in items}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def compute_backoff_ms(
|
|
149
|
+
attempt: int,
|
|
150
|
+
retry_after_header: Optional[str] = None,
|
|
151
|
+
random_func: Callable[[], float] = random.random,
|
|
152
|
+
) -> float:
|
|
153
|
+
"""Compute the delay (in milliseconds) before the next retry attempt.
|
|
154
|
+
|
|
155
|
+
A ``Retry-After`` header (delta-seconds or HTTP-date), when present and
|
|
156
|
+
parseable, wins over the computed backoff. Otherwise exponential backoff
|
|
157
|
+
with full jitter: ``random(0, min(cap, base * 2**attempt))``.
|
|
158
|
+
|
|
159
|
+
``attempt`` is the 0-based index of the retry being scheduled.
|
|
160
|
+
"""
|
|
161
|
+
if retry_after_header:
|
|
162
|
+
header = retry_after_header.strip()
|
|
163
|
+
seconds: Optional[float]
|
|
164
|
+
try:
|
|
165
|
+
seconds = float(header)
|
|
166
|
+
except ValueError:
|
|
167
|
+
seconds = None
|
|
168
|
+
if seconds is not None and math.isfinite(seconds) and seconds >= 0:
|
|
169
|
+
return min(seconds * 1000.0, float(BACKOFF_CAP_MS))
|
|
170
|
+
date_ms = _parse_http_date_ms(header)
|
|
171
|
+
if date_ms is not None:
|
|
172
|
+
return min(max(0.0, date_ms - time.time() * 1000.0), float(BACKOFF_CAP_MS))
|
|
173
|
+
cap = min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * (2**attempt))
|
|
174
|
+
return float(math.floor(random_func() * cap))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _parse_http_date_ms(value: str) -> Optional[float]:
|
|
178
|
+
try:
|
|
179
|
+
parsed = parsedate_to_datetime(value)
|
|
180
|
+
except (TypeError, ValueError):
|
|
181
|
+
return None
|
|
182
|
+
if parsed is None: # Python 3.9 returns None for some malformed input
|
|
183
|
+
return None
|
|
184
|
+
if parsed.tzinfo is None:
|
|
185
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
186
|
+
return parsed.timestamp() * 1000.0
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _is_retryable_status(status: int) -> bool:
|
|
190
|
+
return status == 429 or status >= 500
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class HttpClient:
|
|
194
|
+
"""Minimal HTTP client: auth header injection, JSON (de)serialization,
|
|
195
|
+
request timeouts, and exponential-backoff retries on 429/5xx respecting
|
|
196
|
+
``Retry-After``.
|
|
197
|
+
|
|
198
|
+
``timeout`` is passed to the transport per attempt; with the default
|
|
199
|
+
urllib transport it bounds each socket operation (connect, each read),
|
|
200
|
+
not the attempt's total wall-clock time.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def __init__(
|
|
204
|
+
self,
|
|
205
|
+
api_key: str,
|
|
206
|
+
*,
|
|
207
|
+
base_url: Optional[str] = None,
|
|
208
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
209
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
210
|
+
transport: Optional[Transport] = None,
|
|
211
|
+
) -> None:
|
|
212
|
+
if not api_key:
|
|
213
|
+
raise ValueError("otok: api_key is required")
|
|
214
|
+
self._api_key = api_key
|
|
215
|
+
self._base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
216
|
+
self._timeout = timeout
|
|
217
|
+
self._max_retries = max_retries
|
|
218
|
+
self._transport: Transport = transport if transport is not None else UrllibTransport()
|
|
219
|
+
|
|
220
|
+
def request(
|
|
221
|
+
self,
|
|
222
|
+
method: str,
|
|
223
|
+
path: str,
|
|
224
|
+
*,
|
|
225
|
+
query: Optional[Mapping[str, QueryValue]] = None,
|
|
226
|
+
body: Any = None,
|
|
227
|
+
) -> Any:
|
|
228
|
+
url = self._build_url(path, query)
|
|
229
|
+
headers: dict[str, str] = {
|
|
230
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
231
|
+
"Accept": "application/json",
|
|
232
|
+
"User-Agent": f"otok-python/{__version__}",
|
|
233
|
+
}
|
|
234
|
+
data: Optional[bytes] = None
|
|
235
|
+
if body is not None:
|
|
236
|
+
headers["Content-Type"] = "application/json"
|
|
237
|
+
data = json.dumps(body).encode("utf-8")
|
|
238
|
+
|
|
239
|
+
for attempt in range(self._max_retries + 1):
|
|
240
|
+
# Network failures and timeouts are not retried in v0.1 — not
|
|
241
|
+
# every endpoint is idempotent, and a request may have reached
|
|
242
|
+
# the server.
|
|
243
|
+
response = self._transport.send(
|
|
244
|
+
TransportRequest(
|
|
245
|
+
method=method,
|
|
246
|
+
url=url,
|
|
247
|
+
headers=headers,
|
|
248
|
+
body=data,
|
|
249
|
+
timeout=self._timeout,
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
if 200 <= response.status < 300:
|
|
254
|
+
return _parse_body(response)
|
|
255
|
+
|
|
256
|
+
if _is_retryable_status(response.status) and attempt < self._max_retries:
|
|
257
|
+
delay_ms = compute_backoff_ms(attempt, response.headers.get("retry-after"))
|
|
258
|
+
time.sleep(delay_ms / 1000.0)
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
raise _to_api_error(response)
|
|
262
|
+
|
|
263
|
+
# Unreachable: the final loop iteration always returns or raises.
|
|
264
|
+
raise RuntimeError("otok: retry loop exited unexpectedly")
|
|
265
|
+
|
|
266
|
+
def _build_url(self, path: str, query: Optional[Mapping[str, QueryValue]]) -> str:
|
|
267
|
+
url = self._base_url + path
|
|
268
|
+
if query:
|
|
269
|
+
pairs = [
|
|
270
|
+
(key, _serialize_query_value(value))
|
|
271
|
+
for key, value in query.items()
|
|
272
|
+
if value is not None
|
|
273
|
+
]
|
|
274
|
+
if pairs:
|
|
275
|
+
url = url + "?" + urllib.parse.urlencode(pairs)
|
|
276
|
+
return url
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _serialize_query_value(value: Union[str, int, float, bool]) -> str:
|
|
280
|
+
if isinstance(value, bool):
|
|
281
|
+
return "true" if value else "false"
|
|
282
|
+
return str(value)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _parse_body(response: TransportResponse) -> Any:
|
|
286
|
+
if response.status == 204:
|
|
287
|
+
return None
|
|
288
|
+
if not response.body:
|
|
289
|
+
return None
|
|
290
|
+
text = response.body.decode("utf-8", errors="replace")
|
|
291
|
+
if not text:
|
|
292
|
+
return None
|
|
293
|
+
try:
|
|
294
|
+
return json.loads(text)
|
|
295
|
+
except ValueError:
|
|
296
|
+
return text
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _to_api_error(response: TransportResponse) -> OtokAPIError:
|
|
300
|
+
body = _parse_body(response)
|
|
301
|
+
code: Optional[str] = None
|
|
302
|
+
message = f"HTTP {response.status}"
|
|
303
|
+
if isinstance(body, dict):
|
|
304
|
+
error = body.get("error")
|
|
305
|
+
if isinstance(error, dict):
|
|
306
|
+
# Domain envelope: { "error": { "code", "message" } }
|
|
307
|
+
if isinstance(error.get("code"), str):
|
|
308
|
+
code = error["code"]
|
|
309
|
+
if isinstance(error.get("message"), str):
|
|
310
|
+
message = error["message"]
|
|
311
|
+
elif isinstance(body.get("message"), str):
|
|
312
|
+
# Framework shape: { "statusCode", "message", "error" }
|
|
313
|
+
message = body["message"]
|
|
314
|
+
elif isinstance(body.get("message"), list):
|
|
315
|
+
message = "; ".join(str(item) for item in body["message"])
|
|
316
|
+
return OtokAPIError(response.status, message, code=code, body=body)
|
otok/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
otok/client.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""The oToK API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ._http import (
|
|
8
|
+
DEFAULT_MAX_RETRIES,
|
|
9
|
+
DEFAULT_TIMEOUT_SECONDS,
|
|
10
|
+
HttpClient,
|
|
11
|
+
Transport,
|
|
12
|
+
)
|
|
13
|
+
from .commerce import CommerceApi
|
|
14
|
+
from .resources import (
|
|
15
|
+
BookingsApi,
|
|
16
|
+
CampaignsApi,
|
|
17
|
+
ContactGroupsApi,
|
|
18
|
+
ContactsApi,
|
|
19
|
+
DealsApi,
|
|
20
|
+
EmailsApi,
|
|
21
|
+
MeetingTypesApi,
|
|
22
|
+
PaymentsApi,
|
|
23
|
+
PipelinesApi,
|
|
24
|
+
TagsApi,
|
|
25
|
+
TemplatesApi,
|
|
26
|
+
WebhookEndpointsApi,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OtokClient:
|
|
31
|
+
"""Client for the oToK public API (/v1).
|
|
32
|
+
|
|
33
|
+
::
|
|
34
|
+
|
|
35
|
+
from otok import OtokClient
|
|
36
|
+
|
|
37
|
+
client = OtokClient(api_key=os.environ["OTOK_API_KEY"]) # "otok_live_…"
|
|
38
|
+
contact = client.contacts.upsert({"email": "jane@example.com"})
|
|
39
|
+
|
|
40
|
+
Rate limits: requests are throttled per API key (default 100/min; POST
|
|
41
|
+
/v1/emails allows 300/min). The client retries 429 and 5xx responses
|
|
42
|
+
with exponential backoff + jitter, honoring ``Retry-After``.
|
|
43
|
+
|
|
44
|
+
:param api_key: API key (``otok_live_…``), sent as
|
|
45
|
+
``Authorization: Bearer <key>``.
|
|
46
|
+
:param timeout: Request timeout in seconds. Default 30. With the default
|
|
47
|
+
urllib transport this bounds each socket operation (connect, each
|
|
48
|
+
read), not a whole attempt's wall-clock time.
|
|
49
|
+
:param max_retries: Retry attempts after the first request (429 and 5xx
|
|
50
|
+
responses only). Default 2 (i.e. up to 3 requests total). Set 0 to
|
|
51
|
+
disable retries.
|
|
52
|
+
:param transport: Injectable transport implementation (used by tests).
|
|
53
|
+
Defaults to the built-in ``urllib``-based transport.
|
|
54
|
+
:param base_url: Internal/testing override only — leave unset.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
api_key: str,
|
|
60
|
+
*,
|
|
61
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
62
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
63
|
+
transport: Optional[Transport] = None,
|
|
64
|
+
base_url: Optional[str] = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
self._http = HttpClient(
|
|
67
|
+
api_key,
|
|
68
|
+
base_url=base_url,
|
|
69
|
+
timeout=timeout,
|
|
70
|
+
max_retries=max_retries,
|
|
71
|
+
transport=transport,
|
|
72
|
+
)
|
|
73
|
+
self.contacts = ContactsApi(self._http)
|
|
74
|
+
self.tags = TagsApi(self._http)
|
|
75
|
+
self.contact_groups = ContactGroupsApi(self._http)
|
|
76
|
+
self.pipelines = PipelinesApi(self._http)
|
|
77
|
+
self.deals = DealsApi(self._http)
|
|
78
|
+
self.emails = EmailsApi(self._http)
|
|
79
|
+
self.campaigns = CampaignsApi(self._http)
|
|
80
|
+
self.templates = TemplatesApi(self._http)
|
|
81
|
+
self.payments = PaymentsApi(self._http)
|
|
82
|
+
self.meeting_types = MeetingTypesApi(self._http)
|
|
83
|
+
self.bookings = BookingsApi(self._http)
|
|
84
|
+
self.webhook_endpoints = WebhookEndpointsApi(self._http)
|
|
85
|
+
#: High-level e-commerce helpers (identify_customer, track_order).
|
|
86
|
+
self.commerce = CommerceApi(self.contacts, self.deals, self.emails)
|