fastapi-microservices-platform-sdk 0.1.1__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.
@@ -0,0 +1,391 @@
1
+ """Synchronous producer and management clients with safe failures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any, Literal
7
+
8
+ import httpx
9
+
10
+ EnvelopeMode = Literal["native", "cloudevents"]
11
+ QueryValue = str | int | float | bool | None
12
+
13
+ # Sentinel distinguishing "argument not passed" from an explicit None,
14
+ # which the PATCH endpoint treats as "clear this field".
15
+ _UNSET: Any = object()
16
+
17
+
18
+ class WebhookPlatformError(Exception):
19
+ """Base class that never embeds credentials or response bodies."""
20
+
21
+
22
+ class TransportError(WebhookPlatformError):
23
+ """The service could not be reached within the configured timeout."""
24
+
25
+ def __init__(self) -> None:
26
+ super().__init__("Webhook Platform request failed before a response")
27
+
28
+
29
+ class ApiError(WebhookPlatformError):
30
+ """A bounded HTTP failure without raw response content."""
31
+
32
+ def __init__(
33
+ self,
34
+ status_code: int,
35
+ code: str = "HTTP_ERROR",
36
+ retry_after: int | None = None,
37
+ ) -> None:
38
+ self.status_code = status_code
39
+ self.code = code
40
+ self.retry_after = retry_after
41
+ super().__init__(
42
+ f"Webhook Platform request failed "
43
+ f"(status={status_code}, code={code})"
44
+ )
45
+
46
+
47
+ def _safe_error(response: httpx.Response) -> ApiError:
48
+ code = "HTTP_ERROR"
49
+ try:
50
+ document = response.json()
51
+ candidate = document.get("error", {}).get("code")
52
+ if isinstance(candidate, str) and candidate.isascii():
53
+ if 1 <= len(candidate) <= 64:
54
+ code = candidate
55
+ except (ValueError, AttributeError):
56
+ pass
57
+ retry_after: int | None = None
58
+ raw_retry_after = response.headers.get("Retry-After")
59
+ if raw_retry_after and raw_retry_after.isdigit():
60
+ retry_after = min(int(raw_retry_after), 86_400)
61
+ return ApiError(response.status_code, code, retry_after)
62
+
63
+
64
+ class _JsonClient:
65
+ def __init__(
66
+ self,
67
+ base_url: str,
68
+ headers: Mapping[str, str],
69
+ *,
70
+ timeout: httpx.Timeout | float | None = None,
71
+ client: httpx.Client | None = None,
72
+ ) -> None:
73
+ self._owns_client = client is None
74
+ self._headers = dict(headers)
75
+ self._client = client or httpx.Client(
76
+ base_url=base_url.rstrip("/"),
77
+ timeout=timeout
78
+ or httpx.Timeout(10.0, connect=5.0, write=10.0, pool=5.0),
79
+ follow_redirects=False,
80
+ )
81
+
82
+ def close(self) -> None:
83
+ if self._owns_client:
84
+ self._client.close()
85
+
86
+ def __enter__(self):
87
+ return self
88
+
89
+ def __exit__(self, *_: object) -> None:
90
+ self.close()
91
+
92
+ def _request(
93
+ self,
94
+ method: str,
95
+ path: str,
96
+ *,
97
+ json: object | None = None,
98
+ data: Mapping[str, str] | None = None,
99
+ headers: Mapping[str, str] | None = None,
100
+ params: Mapping[str, QueryValue] | None = None,
101
+ ) -> Any:
102
+ try:
103
+ request_headers = self._headers | dict(headers or {})
104
+ response = self._client.request(
105
+ method,
106
+ path,
107
+ json=json,
108
+ data=data,
109
+ headers=request_headers,
110
+ params=params,
111
+ )
112
+ except httpx.HTTPError as exc:
113
+ raise TransportError() from exc
114
+ if response.status_code not in {200, 201, 202, 204}:
115
+ raise _safe_error(response)
116
+ if response.status_code == 204:
117
+ return None
118
+ try:
119
+ return response.json()
120
+ except ValueError as exc:
121
+ raise ApiError(response.status_code, "INVALID_RESPONSE") from exc
122
+
123
+
124
+ class AuthClient(_JsonClient):
125
+ """Register users and exchange passwords without retaining credentials."""
126
+
127
+ def __init__(
128
+ self,
129
+ base_url: str,
130
+ *,
131
+ timeout: httpx.Timeout | float | None = None,
132
+ client: httpx.Client | None = None,
133
+ ) -> None:
134
+ super().__init__(
135
+ base_url,
136
+ {"User-Agent": "webhook-platform-python/0.1"},
137
+ timeout=timeout,
138
+ client=client,
139
+ )
140
+
141
+ def register(
142
+ self, email: str, name: str, password: str
143
+ ) -> dict[str, Any]:
144
+ result = self._request(
145
+ "POST",
146
+ "/users/",
147
+ json={"email": email, "name": name, "password": password},
148
+ )
149
+ if not isinstance(result, dict):
150
+ raise ApiError(201, "INVALID_RESPONSE")
151
+ return result
152
+
153
+ def login(self, email: str, password: str) -> str:
154
+ result = self._request(
155
+ "POST",
156
+ "/auth/login",
157
+ data={"username": email, "password": password},
158
+ )
159
+ token = result.get("access_token") if isinstance(result, dict) else None
160
+ if not isinstance(token, str) or not token:
161
+ raise ApiError(200, "INVALID_RESPONSE")
162
+ return token
163
+
164
+
165
+ class Producer(_JsonClient):
166
+ """Send events without implicit retries.
167
+
168
+ A caller may retry only with the same idempotency key. The client never
169
+ retries automatically, so an ambiguous transport failure cannot create a
170
+ second logical event under a different key.
171
+ """
172
+
173
+ def __init__(
174
+ self,
175
+ base_url: str,
176
+ api_key: str,
177
+ *,
178
+ timeout: httpx.Timeout | float | None = None,
179
+ client: httpx.Client | None = None,
180
+ ) -> None:
181
+ if not api_key:
182
+ raise ValueError("api_key is required")
183
+ super().__init__(
184
+ base_url,
185
+ {
186
+ "X-API-Key": api_key,
187
+ "User-Agent": "webhook-platform-python/0.1",
188
+ },
189
+ timeout=timeout,
190
+ client=client,
191
+ )
192
+
193
+ def send_event(
194
+ self,
195
+ event_type: str,
196
+ payload: object,
197
+ idempotency_key: str,
198
+ *,
199
+ envelope_mode: EnvelopeMode = "native",
200
+ ) -> dict[str, Any]:
201
+ if not event_type or not idempotency_key:
202
+ raise ValueError("event_type and idempotency_key are required")
203
+ result = self._request(
204
+ "POST",
205
+ "/v1/events",
206
+ json={
207
+ "type": event_type,
208
+ "payload": payload,
209
+ "envelope_mode": envelope_mode,
210
+ },
211
+ headers={"Idempotency-Key": idempotency_key},
212
+ )
213
+ if not isinstance(result, dict):
214
+ raise ApiError(202, "INVALID_RESPONSE")
215
+ return result
216
+
217
+
218
+ class ManagementClient(_JsonClient):
219
+ """Bearer-authenticated control-plane operations used by the CLI."""
220
+
221
+ def __init__(
222
+ self,
223
+ base_url: str,
224
+ token: str,
225
+ *,
226
+ timeout: httpx.Timeout | float | None = None,
227
+ client: httpx.Client | None = None,
228
+ ) -> None:
229
+ if not token:
230
+ raise ValueError("token is required")
231
+ super().__init__(
232
+ base_url,
233
+ {
234
+ "Authorization": f"Bearer {token}",
235
+ "User-Agent": "webhook-platform-python/0.1",
236
+ },
237
+ timeout=timeout,
238
+ client=client,
239
+ )
240
+
241
+ def list_organizations(self) -> list[dict[str, Any]]:
242
+ return self._request("GET", "/v1/organizations")
243
+
244
+ def create_organization(self, name: str) -> dict[str, Any]:
245
+ return self._request("POST", "/v1/organizations", json={"name": name})
246
+
247
+ def list_projects(self, organization_id: str) -> list[dict[str, Any]]:
248
+ return self._request(
249
+ "GET", f"/v1/organizations/{organization_id}/projects"
250
+ )
251
+
252
+ def create_project(
253
+ self, organization_id: str, name: str
254
+ ) -> dict[str, Any]:
255
+ return self._request(
256
+ "POST",
257
+ f"/v1/organizations/{organization_id}/projects",
258
+ json={"name": name},
259
+ )
260
+
261
+ def list_api_keys(self, project_id: str) -> list[dict[str, Any]]:
262
+ return self._request("GET", f"/v1/projects/{project_id}/api-keys")
263
+
264
+ def create_api_key(
265
+ self,
266
+ project_id: str,
267
+ name: str,
268
+ *,
269
+ expires_in_days: int | None = None,
270
+ ) -> dict[str, Any]:
271
+ return self._request(
272
+ "POST",
273
+ f"/v1/projects/{project_id}/api-keys",
274
+ json={
275
+ "name": name,
276
+ "scopes": ["events:write"],
277
+ "expires_in_days": expires_in_days,
278
+ },
279
+ )
280
+
281
+ def revoke_api_key(
282
+ self, project_id: str, api_key_id: str
283
+ ) -> dict[str, Any]:
284
+ return self._request(
285
+ "DELETE", f"/v1/projects/{project_id}/api-keys/{api_key_id}"
286
+ )
287
+
288
+ def list_endpoints(self, project_id: str) -> list[dict[str, Any]]:
289
+ return self._request("GET", f"/v1/projects/{project_id}/endpoints")
290
+
291
+ def create_endpoint(
292
+ self,
293
+ project_id: str,
294
+ url: str,
295
+ description: str | None = None,
296
+ *,
297
+ event_types: list[str] | None = None,
298
+ signature_scheme: str | None = None,
299
+ ) -> dict[str, Any]:
300
+ """Create an endpoint, optionally subscribed to specific types.
301
+
302
+ ``event_types`` accepts exact event types and trailing ``prefix.*``
303
+ wildcards. ``signature_scheme`` selects ``legacy`` or ``standard``
304
+ wire signatures (ADR 0002). Optional fields are left off the
305
+ request when unset so the call also works against servers that
306
+ predate them.
307
+ """
308
+ body: dict[str, Any] = {"url": url, "description": description}
309
+ if event_types is not None:
310
+ body["event_types"] = list(event_types)
311
+ if signature_scheme is not None:
312
+ body["signature_scheme"] = signature_scheme
313
+ return self._request(
314
+ "POST",
315
+ f"/v1/projects/{project_id}/endpoints",
316
+ json=body,
317
+ )
318
+
319
+ def update_endpoint(
320
+ self,
321
+ project_id: str,
322
+ endpoint_id: str,
323
+ *,
324
+ url: str = _UNSET,
325
+ description: str | None = _UNSET,
326
+ is_active: bool = _UNSET,
327
+ event_types: list[str] | None = _UNSET,
328
+ signature_scheme: str = _UNSET,
329
+ ) -> dict[str, Any]:
330
+ """Partially update an endpoint.
331
+
332
+ Only keyword arguments that are passed are sent, matching the API's
333
+ PATCH semantics. Pass ``event_types=None`` explicitly to clear a
334
+ subscription filter. Changing ``signature_scheme`` to ``standard``
335
+ returns a one-time ``signing_secret`` usable with any Standard
336
+ Webhooks library; capture it directly into a secret manager.
337
+ """
338
+ changes: dict[str, Any] = {}
339
+ for field, value in (
340
+ ("url", url),
341
+ ("description", description),
342
+ ("is_active", is_active),
343
+ ("event_types", event_types),
344
+ ("signature_scheme", signature_scheme),
345
+ ):
346
+ if value is not _UNSET:
347
+ changes[field] = value
348
+ if not changes:
349
+ raise ValueError("update_endpoint requires at least one change")
350
+ return self._request(
351
+ "PATCH",
352
+ f"/v1/projects/{project_id}/endpoints/{endpoint_id}",
353
+ json=changes,
354
+ )
355
+
356
+ def list_deliveries(
357
+ self, project_id: str, *, offset: int = 0, limit: int = 50
358
+ ) -> list[dict[str, Any]]:
359
+ return self._request(
360
+ "GET",
361
+ f"/v1/projects/{project_id}/deliveries",
362
+ params={"offset": offset, "limit": limit},
363
+ )
364
+
365
+ def get_delivery(
366
+ self, project_id: str, delivery_id: str
367
+ ) -> dict[str, Any]:
368
+ return self._request(
369
+ "GET", f"/v1/projects/{project_id}/deliveries/{delivery_id}"
370
+ )
371
+
372
+ def replay_delivery(
373
+ self, project_id: str, delivery_id: str
374
+ ) -> dict[str, Any]:
375
+ return self._request(
376
+ "POST",
377
+ f"/v1/projects/{project_id}/deliveries/{delivery_id}/replay",
378
+ )
379
+
380
+ def replay_deliveries(
381
+ self,
382
+ project_id: str,
383
+ delivery_ids: list[str],
384
+ idempotency_key: str,
385
+ ) -> dict[str, Any]:
386
+ return self._request(
387
+ "POST",
388
+ f"/v1/projects/{project_id}/replays",
389
+ json={"delivery_ids": delivery_ids},
390
+ headers={"Idempotency-Key": idempotency_key},
391
+ )
@@ -0,0 +1,281 @@
1
+ """Raw-byte receiver verification and durable deduplication helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import hashlib
8
+ import hmac
9
+ import json
10
+ import time
11
+ from collections.abc import Mapping
12
+ from dataclasses import dataclass
13
+ from typing import Any, Protocol
14
+
15
+
16
+ class ReceiverVerificationError(ValueError):
17
+ """Base class for bounded receiver verification failures."""
18
+
19
+
20
+ class InvalidSignature(ReceiverVerificationError):
21
+ def __init__(self) -> None:
22
+ super().__init__("Webhook signature is invalid")
23
+
24
+
25
+ class SignatureExpired(ReceiverVerificationError):
26
+ def __init__(self) -> None:
27
+ super().__init__("Webhook timestamp is outside the allowed tolerance")
28
+
29
+
30
+ class DuplicateEvent(ReceiverVerificationError):
31
+ def __init__(self, event_id: str) -> None:
32
+ self.event_id = event_id
33
+ super().__init__("Webhook event was already accepted")
34
+
35
+
36
+ class DurableDeduplicator(Protocol):
37
+ """Atomically reserve an event ID in durable receiver storage."""
38
+
39
+ def claim(self, event_id: str) -> bool:
40
+ """Return true only for the first durable reservation."""
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class ReceiverEvent:
45
+ event_id: str
46
+ event_type: str
47
+ timestamp: int
48
+ body: dict[str, Any]
49
+ raw_body: bytes
50
+
51
+
52
+ def _signature_parts(header: str) -> tuple[int, list[str]]:
53
+ """Parse the legacy ``t=<seconds>,v1=<hex>`` header format.
54
+
55
+ Only tokens in the legacy shape participate; unrecognized
56
+ space-delimited tokens (for example Standard Webhooks ``v1,<base64>``
57
+ entries) are ignored rather than treated as malformed, so a receiver on
58
+ this SDK survives header formats it does not use.
59
+ """
60
+ timestamp: int | None = None
61
+ signatures: list[str] = []
62
+ for token in header.split():
63
+ if "=" not in token.partition(",")[0]:
64
+ continue
65
+ for item in token.split(","):
66
+ name, separator, value = item.strip().partition("=")
67
+ if not separator or not value:
68
+ raise InvalidSignature()
69
+ if name == "t":
70
+ if timestamp is not None or not value.isdigit():
71
+ raise InvalidSignature()
72
+ timestamp = int(value)
73
+ elif name == "v1":
74
+ if len(value) == 64:
75
+ try:
76
+ bytes.fromhex(value)
77
+ except ValueError as exc:
78
+ raise InvalidSignature() from exc
79
+ signatures.append(value.lower())
80
+ if timestamp is None or not signatures:
81
+ raise InvalidSignature()
82
+ return timestamp, signatures
83
+
84
+
85
+ def _standard_signatures(header: str) -> list[str]:
86
+ """Collect Standard Webhooks ``v1,<base64>`` tokens, ignoring others.
87
+
88
+ Non-ASCII values are dropped here so a malformed or malicious header
89
+ fails verification (``InvalidSignature``) instead of raising
90
+ ``TypeError`` from the constant-time comparison.
91
+ """
92
+ signatures: list[str] = []
93
+ for token in header.split():
94
+ version, separator, value = token.partition(",")
95
+ if separator and version == "v1" and value and value.isascii():
96
+ signatures.append(value)
97
+ return signatures
98
+
99
+
100
+ def _secret_key_bytes(secret: str) -> bytes | None:
101
+ """Decode either secret serialization to the raw digest bytes.
102
+
103
+ Accepts ``whsec_`` + padded standard base64 (the standard form) and
104
+ ``whsec_`` + unpadded base64url (the legacy form). Returns ``None``
105
+ when the secret does not decode, in which case only legacy
106
+ verification (which keys on the ASCII string itself) is possible.
107
+
108
+ ``validate=True`` is load-bearing: without it ``b64decode`` silently
109
+ drops characters outside its alphabet, so a legacy-form (base64url)
110
+ secret containing ``-``/``_`` would "decode" through the standard
111
+ decoder into wrong key bytes instead of falling through to the
112
+ urlsafe decoder.
113
+ """
114
+ if not secret.startswith("whsec_"):
115
+ return None
116
+ encoded = secret.removeprefix("whsec_")
117
+ padded = encoded + "=" * (-len(encoded) % 4)
118
+ # urlsafe_b64decode has no validate parameter and would also silently
119
+ # accept the wrong alphabet, so translate explicitly and validate both.
120
+ candidates = (padded, padded.replace("-", "+").replace("_", "/"))
121
+ for candidate in candidates:
122
+ try:
123
+ return base64.b64decode(candidate, validate=True)
124
+ except (ValueError, binascii.Error):
125
+ continue
126
+ return None
127
+
128
+
129
+ def _header_has_legacy_token(header: str) -> bool:
130
+ return any(
131
+ "=" in token.partition(",")[0] for token in header.split()
132
+ )
133
+
134
+
135
+ def verify_signature(
136
+ raw_body: bytes,
137
+ signature_header: str,
138
+ secret: str,
139
+ *,
140
+ tolerance_seconds: int = 300,
141
+ now: int | None = None,
142
+ ) -> int:
143
+ """Verify a legacy HMAC over timestamp + period + exact body bytes."""
144
+ if tolerance_seconds < 0:
145
+ raise ValueError("tolerance_seconds must be non-negative")
146
+ timestamp, signatures = _signature_parts(signature_header)
147
+ current_time = int(time.time()) if now is None else now
148
+ if abs(current_time - timestamp) > tolerance_seconds:
149
+ raise SignatureExpired()
150
+ signed = str(timestamp).encode("ascii") + b"." + raw_body
151
+ expected = hmac.new(
152
+ secret.encode("utf-8"), signed, hashlib.sha256
153
+ ).hexdigest()
154
+ if not any(hmac.compare_digest(expected, item) for item in signatures):
155
+ raise InvalidSignature()
156
+ return timestamp
157
+
158
+
159
+ def verify_signature_standard(
160
+ raw_body: bytes,
161
+ event_id: str,
162
+ timestamp_header: str | None,
163
+ signature_header: str,
164
+ secret: str,
165
+ *,
166
+ tolerance_seconds: int = 300,
167
+ now: int | None = None,
168
+ ) -> int:
169
+ """Verify a Standard Webhooks signature (``v1,<base64>``).
170
+
171
+ Signed content is ``event_id.timestamp.body``; the key is the decoded
172
+ secret digest. Either secret serialization is accepted.
173
+ """
174
+ if tolerance_seconds < 0:
175
+ raise ValueError("tolerance_seconds must be non-negative")
176
+ if not timestamp_header or not timestamp_header.isdigit():
177
+ raise InvalidSignature()
178
+ timestamp = int(timestamp_header)
179
+ current_time = int(time.time()) if now is None else now
180
+ if abs(current_time - timestamp) > tolerance_seconds:
181
+ raise SignatureExpired()
182
+ signatures = _standard_signatures(signature_header)
183
+ key = _secret_key_bytes(secret)
184
+ if not signatures or key is None:
185
+ raise InvalidSignature()
186
+ signed = f"{event_id}.{timestamp}.".encode("ascii") + raw_body
187
+ expected = base64.b64encode(
188
+ hmac.new(key, signed, hashlib.sha256).digest()
189
+ ).decode()
190
+ if not any(hmac.compare_digest(expected, item) for item in signatures):
191
+ raise InvalidSignature()
192
+ return timestamp
193
+
194
+
195
+ def verify_request(
196
+ raw_body: bytes,
197
+ headers: Mapping[str, str],
198
+ secret: str,
199
+ *,
200
+ tolerance_seconds: int = 300,
201
+ now: int | None = None,
202
+ ) -> ReceiverEvent:
203
+ """Verify headers and raw bytes before parsing the JSON envelope.
204
+
205
+ Scheme auto-detection (ADR 0002): a header containing a legacy
206
+ ``t=…,v1=…`` token verifies as legacy; otherwise ``v1,<base64>``
207
+ tokens verify as Standard Webhooks. Either secret serialization is
208
+ accepted, so a receiver can upgrade this SDK before its endpoint
209
+ switches schemes.
210
+ """
211
+ normalized = {name.lower(): value for name, value in headers.items()}
212
+ signature = normalized.get("webhook-signature")
213
+ event_id = normalized.get("webhook-id")
214
+ event_type = normalized.get("webhook-event")
215
+ if not signature or not event_id or not event_type:
216
+ raise InvalidSignature()
217
+ timestamp_header = normalized.get("webhook-timestamp")
218
+ if _header_has_legacy_token(signature):
219
+ timestamp = verify_signature(
220
+ raw_body,
221
+ signature,
222
+ secret,
223
+ tolerance_seconds=tolerance_seconds,
224
+ now=now,
225
+ )
226
+ if timestamp_header != str(timestamp):
227
+ raise InvalidSignature()
228
+ else:
229
+ timestamp = verify_signature_standard(
230
+ raw_body,
231
+ event_id,
232
+ timestamp_header,
233
+ signature,
234
+ secret,
235
+ tolerance_seconds=tolerance_seconds,
236
+ now=now,
237
+ )
238
+ try:
239
+ document = json.loads(raw_body)
240
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
241
+ raise ReceiverVerificationError("Webhook body is not valid JSON") from exc
242
+ if not isinstance(document, dict):
243
+ raise ReceiverVerificationError("Webhook body must be a JSON object")
244
+ if document.get("id") != event_id or document.get("type") != event_type:
245
+ raise ReceiverVerificationError(
246
+ "Webhook headers do not match the signed body"
247
+ )
248
+ return ReceiverEvent(
249
+ event_id=event_id,
250
+ event_type=event_type,
251
+ timestamp=timestamp,
252
+ body=document,
253
+ raw_body=raw_body,
254
+ )
255
+
256
+
257
+ def verify_and_claim(
258
+ raw_body: bytes,
259
+ headers: Mapping[str, str],
260
+ secret: str,
261
+ deduplicator: DurableDeduplicator,
262
+ *,
263
+ tolerance_seconds: int = 300,
264
+ now: int | None = None,
265
+ ) -> ReceiverEvent:
266
+ """Verify then atomically reserve the signed event ID.
267
+
268
+ The deduplicator should reserve the ID in the same durable transaction as
269
+ receiver acceptance or application state changes. Return a successful 2xx
270
+ for DuplicateEvent so platform retries stop.
271
+ """
272
+ event = verify_request(
273
+ raw_body,
274
+ headers,
275
+ secret,
276
+ tolerance_seconds=tolerance_seconds,
277
+ now=now,
278
+ )
279
+ if not deduplicator.claim(event.event_id):
280
+ raise DuplicateEvent(event.event_id)
281
+ return event