svc-infra 0.1.706__py3-none-any.whl → 1.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.
Potentially problematic release.
This version of svc-infra might be problematic. Click here for more details.
- svc_infra/apf_payments/models.py +47 -108
- svc_infra/apf_payments/provider/__init__.py +2 -2
- svc_infra/apf_payments/provider/aiydan.py +42 -100
- svc_infra/apf_payments/provider/base.py +10 -26
- svc_infra/apf_payments/provider/registry.py +3 -5
- svc_infra/apf_payments/provider/stripe.py +63 -135
- svc_infra/apf_payments/schemas.py +82 -90
- svc_infra/apf_payments/service.py +40 -86
- svc_infra/apf_payments/settings.py +10 -13
- svc_infra/api/__init__.py +13 -13
- svc_infra/api/fastapi/__init__.py +19 -0
- svc_infra/api/fastapi/admin/add.py +13 -18
- svc_infra/api/fastapi/apf_payments/router.py +47 -84
- svc_infra/api/fastapi/apf_payments/setup.py +7 -13
- svc_infra/api/fastapi/auth/__init__.py +1 -1
- svc_infra/api/fastapi/auth/_cookies.py +3 -9
- svc_infra/api/fastapi/auth/add.py +4 -8
- svc_infra/api/fastapi/auth/gaurd.py +9 -26
- svc_infra/api/fastapi/auth/mfa/models.py +4 -7
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +3 -3
- svc_infra/api/fastapi/auth/mfa/router.py +9 -15
- svc_infra/api/fastapi/auth/mfa/security.py +3 -5
- svc_infra/api/fastapi/auth/mfa/utils.py +3 -2
- svc_infra/api/fastapi/auth/mfa/verify.py +2 -9
- svc_infra/api/fastapi/auth/providers.py +4 -6
- svc_infra/api/fastapi/auth/routers/apikey_router.py +16 -18
- svc_infra/api/fastapi/auth/routers/oauth_router.py +37 -85
- svc_infra/api/fastapi/auth/routers/session_router.py +3 -6
- svc_infra/api/fastapi/auth/security.py +17 -28
- svc_infra/api/fastapi/auth/sender.py +1 -3
- svc_infra/api/fastapi/auth/settings.py +18 -19
- svc_infra/api/fastapi/auth/state.py +6 -7
- svc_infra/api/fastapi/auth/ws_security.py +2 -2
- svc_infra/api/fastapi/billing/router.py +6 -8
- svc_infra/api/fastapi/db/http.py +10 -11
- svc_infra/api/fastapi/db/nosql/mongo/add.py +5 -15
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +14 -15
- svc_infra/api/fastapi/db/sql/add.py +6 -14
- svc_infra/api/fastapi/db/sql/crud_router.py +27 -40
- svc_infra/api/fastapi/db/sql/health.py +1 -3
- svc_infra/api/fastapi/db/sql/session.py +4 -5
- svc_infra/api/fastapi/db/sql/users.py +8 -11
- svc_infra/api/fastapi/dependencies/ratelimit.py +4 -6
- svc_infra/api/fastapi/docs/add.py +13 -23
- svc_infra/api/fastapi/docs/landing.py +6 -8
- svc_infra/api/fastapi/docs/scoped.py +34 -42
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +12 -21
- svc_infra/api/fastapi/dual/router.py +14 -31
- svc_infra/api/fastapi/ease.py +57 -13
- svc_infra/api/fastapi/http/conditional.py +3 -5
- svc_infra/api/fastapi/middleware/errors/catchall.py +2 -6
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -4
- svc_infra/api/fastapi/middleware/errors/handlers.py +12 -18
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +4 -13
- svc_infra/api/fastapi/middleware/idempotency.py +11 -16
- svc_infra/api/fastapi/middleware/idempotency_store.py +14 -14
- svc_infra/api/fastapi/middleware/optimistic_lock.py +5 -8
- svc_infra/api/fastapi/middleware/ratelimit.py +8 -8
- svc_infra/api/fastapi/middleware/ratelimit_store.py +7 -8
- svc_infra/api/fastapi/middleware/request_id.py +1 -3
- svc_infra/api/fastapi/middleware/timeout.py +9 -10
- svc_infra/api/fastapi/object_router.py +1060 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -6
- svc_infra/api/fastapi/openapi/conventions.py +4 -4
- svc_infra/api/fastapi/openapi/mutators.py +13 -31
- svc_infra/api/fastapi/openapi/pipeline.py +2 -2
- svc_infra/api/fastapi/openapi/responses.py +4 -6
- svc_infra/api/fastapi/openapi/security.py +1 -3
- svc_infra/api/fastapi/ops/add.py +7 -9
- svc_infra/api/fastapi/pagination.py +25 -37
- svc_infra/api/fastapi/routers/__init__.py +16 -38
- svc_infra/api/fastapi/setup.py +13 -31
- svc_infra/api/fastapi/tenancy/add.py +3 -2
- svc_infra/api/fastapi/tenancy/context.py +8 -7
- svc_infra/api/fastapi/versioned.py +3 -2
- svc_infra/app/env.py +5 -7
- svc_infra/app/logging/add.py +2 -1
- svc_infra/app/logging/filter.py +1 -1
- svc_infra/app/logging/formats.py +3 -2
- svc_infra/app/root.py +3 -3
- svc_infra/billing/__init__.py +19 -2
- svc_infra/billing/async_service.py +27 -7
- svc_infra/billing/jobs.py +23 -33
- svc_infra/billing/models.py +21 -52
- svc_infra/billing/quotas.py +5 -7
- svc_infra/billing/schemas.py +4 -6
- svc_infra/cache/__init__.py +12 -5
- svc_infra/cache/add.py +6 -9
- svc_infra/cache/backend.py +6 -5
- svc_infra/cache/decorators.py +17 -28
- svc_infra/cache/keys.py +2 -2
- svc_infra/cache/recache.py +22 -35
- svc_infra/cache/resources.py +8 -16
- svc_infra/cache/ttl.py +2 -3
- svc_infra/cache/utils.py +5 -6
- svc_infra/cli/__init__.py +4 -12
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +11 -10
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +6 -9
- svc_infra/cli/cmds/db/ops_cmds.py +3 -6
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +24 -41
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +9 -17
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +10 -10
- svc_infra/cli/cmds/docs/docs_cmds.py +7 -10
- svc_infra/cli/cmds/dx/dx_cmds.py +5 -11
- svc_infra/cli/cmds/jobs/jobs_cmds.py +2 -7
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -7
- svc_infra/cli/cmds/sdk/sdk_cmds.py +5 -15
- svc_infra/cli/foundation/runner.py +6 -11
- svc_infra/cli/foundation/typer_bootstrap.py +1 -2
- svc_infra/data/__init__.py +83 -0
- svc_infra/data/add.py +5 -5
- svc_infra/data/backup.py +8 -10
- svc_infra/data/erasure.py +3 -2
- svc_infra/data/fixtures.py +3 -3
- svc_infra/data/retention.py +8 -13
- svc_infra/db/crud_schema.py +9 -8
- svc_infra/db/nosql/__init__.py +0 -1
- svc_infra/db/nosql/constants.py +1 -1
- svc_infra/db/nosql/core.py +7 -14
- svc_infra/db/nosql/indexes.py +11 -10
- svc_infra/db/nosql/management.py +3 -3
- svc_infra/db/nosql/mongo/client.py +3 -3
- svc_infra/db/nosql/mongo/settings.py +2 -6
- svc_infra/db/nosql/repository.py +27 -28
- svc_infra/db/nosql/resource.py +15 -20
- svc_infra/db/nosql/scaffold.py +13 -17
- svc_infra/db/nosql/service.py +3 -4
- svc_infra/db/nosql/service_with_hooks.py +4 -3
- svc_infra/db/nosql/types.py +2 -6
- svc_infra/db/nosql/utils.py +4 -4
- svc_infra/db/ops.py +14 -18
- svc_infra/db/outbox.py +15 -18
- svc_infra/db/sql/apikey.py +12 -21
- svc_infra/db/sql/authref.py +3 -7
- svc_infra/db/sql/constants.py +9 -9
- svc_infra/db/sql/core.py +11 -11
- svc_infra/db/sql/management.py +2 -6
- svc_infra/db/sql/repository.py +17 -24
- svc_infra/db/sql/resource.py +14 -13
- svc_infra/db/sql/scaffold.py +13 -17
- svc_infra/db/sql/service.py +7 -16
- svc_infra/db/sql/service_with_hooks.py +4 -3
- svc_infra/db/sql/tenant.py +6 -14
- svc_infra/db/sql/uniq.py +8 -7
- svc_infra/db/sql/uniq_hooks.py +14 -19
- svc_infra/db/sql/utils.py +24 -53
- svc_infra/db/utils.py +3 -3
- svc_infra/deploy/__init__.py +8 -15
- svc_infra/documents/add.py +7 -8
- svc_infra/documents/ease.py +8 -8
- svc_infra/documents/models.py +3 -3
- svc_infra/documents/storage.py +11 -13
- svc_infra/dx/__init__.py +58 -0
- svc_infra/dx/add.py +1 -3
- svc_infra/dx/changelog.py +2 -2
- svc_infra/dx/checks.py +1 -1
- svc_infra/health/__init__.py +15 -16
- svc_infra/http/client.py +10 -14
- svc_infra/jobs/__init__.py +79 -0
- svc_infra/jobs/builtins/outbox_processor.py +3 -5
- svc_infra/jobs/builtins/webhook_delivery.py +1 -3
- svc_infra/jobs/loader.py +4 -5
- svc_infra/jobs/queue.py +14 -24
- svc_infra/jobs/redis_queue.py +20 -34
- svc_infra/jobs/runner.py +7 -11
- svc_infra/jobs/scheduler.py +5 -5
- svc_infra/jobs/worker.py +1 -1
- svc_infra/loaders/base.py +5 -4
- svc_infra/loaders/github.py +1 -3
- svc_infra/loaders/url.py +3 -9
- svc_infra/logging/__init__.py +7 -6
- svc_infra/mcp/__init__.py +82 -0
- svc_infra/mcp/svc_infra_mcp.py +2 -2
- svc_infra/obs/add.py +4 -3
- svc_infra/obs/cloud_dash.py +1 -1
- svc_infra/obs/metrics/__init__.py +3 -3
- svc_infra/obs/metrics/asgi.py +9 -14
- svc_infra/obs/metrics/base.py +13 -13
- svc_infra/obs/metrics/http.py +5 -9
- svc_infra/obs/metrics/sqlalchemy.py +9 -12
- svc_infra/obs/metrics.py +3 -3
- svc_infra/obs/settings.py +2 -6
- svc_infra/resilience/__init__.py +44 -0
- svc_infra/resilience/circuit_breaker.py +328 -0
- svc_infra/resilience/retry.py +289 -0
- svc_infra/security/__init__.py +167 -0
- svc_infra/security/add.py +5 -9
- svc_infra/security/audit.py +14 -17
- svc_infra/security/audit_service.py +9 -9
- svc_infra/security/hibp.py +3 -6
- svc_infra/security/jwt_rotation.py +7 -10
- svc_infra/security/lockout.py +12 -11
- svc_infra/security/models.py +37 -46
- svc_infra/security/oauth_models.py +8 -8
- svc_infra/security/org_invites.py +11 -13
- svc_infra/security/passwords.py +4 -6
- svc_infra/security/permissions.py +8 -7
- svc_infra/security/session.py +6 -7
- svc_infra/security/signed_cookies.py +9 -9
- svc_infra/storage/add.py +5 -8
- svc_infra/storage/backends/local.py +13 -21
- svc_infra/storage/backends/memory.py +4 -7
- svc_infra/storage/backends/s3.py +17 -36
- svc_infra/storage/base.py +2 -2
- svc_infra/storage/easy.py +4 -8
- svc_infra/storage/settings.py +16 -18
- svc_infra/testing/__init__.py +36 -39
- svc_infra/utils.py +169 -8
- svc_infra/webhooks/__init__.py +1 -1
- svc_infra/webhooks/add.py +17 -29
- svc_infra/webhooks/encryption.py +2 -2
- svc_infra/webhooks/fastapi.py +2 -4
- svc_infra/webhooks/router.py +3 -3
- svc_infra/webhooks/service.py +5 -6
- svc_infra/webhooks/signing.py +5 -5
- svc_infra/websocket/add.py +2 -3
- svc_infra/websocket/client.py +3 -2
- svc_infra/websocket/config.py +6 -18
- svc_infra/websocket/manager.py +9 -10
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/METADATA +11 -5
- svc_infra-1.1.0.dist-info/RECORD +364 -0
- svc_infra/billing/service.py +0 -123
- svc_infra-0.1.706.dist-info/RECORD +0 -357
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/LICENSE +0 -0
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
svc_infra/webhooks/add.py
CHANGED
|
@@ -19,7 +19,7 @@ import json
|
|
|
19
19
|
import logging
|
|
20
20
|
import os
|
|
21
21
|
from collections.abc import Callable, Iterable, Mapping
|
|
22
|
-
from datetime import
|
|
22
|
+
from datetime import UTC, datetime
|
|
23
23
|
from typing import Any, Protocol, TypeGuard, TypeVar, cast
|
|
24
24
|
|
|
25
25
|
from fastapi import FastAPI
|
|
@@ -59,7 +59,7 @@ class RedisOutboxStore(OutboxStore):
|
|
|
59
59
|
for environments where a fully fledged SQL implementation is unavailable.
|
|
60
60
|
"""
|
|
61
61
|
|
|
62
|
-
def __init__(self, client:
|
|
62
|
+
def __init__(self, client: Redis, *, prefix: str = "webhooks:outbox"):
|
|
63
63
|
if Redis is None: # pragma: no cover - defensive guard
|
|
64
64
|
raise RuntimeError("redis-py is required for RedisOutboxStore")
|
|
65
65
|
self._client = client
|
|
@@ -79,13 +79,13 @@ class RedisOutboxStore(OutboxStore):
|
|
|
79
79
|
|
|
80
80
|
# Protocol methods --------------------------------------------------
|
|
81
81
|
def enqueue(self, topic: str, payload: dict[str, Any]) -> OutboxMessage:
|
|
82
|
-
incr_result = cast(Any, self._client.incr(self._seq_key))
|
|
82
|
+
incr_result = cast("Any", self._client.incr(self._seq_key))
|
|
83
83
|
# Redis incr always returns an int for the sync client. Be defensive for mocks.
|
|
84
84
|
try:
|
|
85
85
|
msg_id = int(incr_result)
|
|
86
86
|
except (TypeError, ValueError):
|
|
87
87
|
msg_id = 0
|
|
88
|
-
created_at = datetime.now(
|
|
88
|
+
created_at = datetime.now(UTC)
|
|
89
89
|
record: dict[str, str] = {
|
|
90
90
|
"id": str(msg_id),
|
|
91
91
|
"topic": topic,
|
|
@@ -96,29 +96,21 @@ class RedisOutboxStore(OutboxStore):
|
|
|
96
96
|
}
|
|
97
97
|
self._client.hset(self._msg_key(msg_id), mapping=record)
|
|
98
98
|
self._client.rpush(self._queue_key, msg_id)
|
|
99
|
-
return OutboxMessage(
|
|
100
|
-
id=msg_id, topic=topic, payload=payload, created_at=created_at
|
|
101
|
-
)
|
|
99
|
+
return OutboxMessage(id=msg_id, topic=topic, payload=payload, created_at=created_at)
|
|
102
100
|
|
|
103
101
|
def fetch_next(self, topics: Iterable[str] | None = None) -> OutboxMessage | None:
|
|
104
102
|
allowed = set(topics) if topics else None
|
|
105
|
-
ids = cast(list[Any], self._client.lrange(self._queue_key, 0, -1))
|
|
103
|
+
ids = cast("list[Any]", self._client.lrange(self._queue_key, 0, -1))
|
|
106
104
|
for raw_id in ids:
|
|
107
|
-
raw_id_str = (
|
|
108
|
-
raw_id.decode()
|
|
109
|
-
if isinstance(raw_id, (bytes, bytearray))
|
|
110
|
-
else str(raw_id)
|
|
111
|
-
)
|
|
105
|
+
raw_id_str = raw_id.decode() if isinstance(raw_id, (bytes, bytearray)) else str(raw_id)
|
|
112
106
|
msg_id = int(raw_id_str)
|
|
113
|
-
msg = cast(dict[Any, Any], self._client.hgetall(self._msg_key(msg_id)))
|
|
107
|
+
msg = cast("dict[Any, Any]", self._client.hgetall(self._msg_key(msg_id)))
|
|
114
108
|
if not msg:
|
|
115
109
|
continue
|
|
116
110
|
topic = msg.get(b"topic")
|
|
117
111
|
if topic is None:
|
|
118
112
|
continue
|
|
119
|
-
topic_str = (
|
|
120
|
-
topic.decode() if isinstance(topic, (bytes, bytearray)) else str(topic)
|
|
121
|
-
)
|
|
113
|
+
topic_str = topic.decode() if isinstance(topic, (bytes, bytearray)) else str(topic)
|
|
122
114
|
if allowed is not None and topic_str not in allowed:
|
|
123
115
|
continue
|
|
124
116
|
attempts = int(msg.get(b"attempts", 0))
|
|
@@ -142,7 +134,7 @@ class RedisOutboxStore(OutboxStore):
|
|
|
142
134
|
else str(created_raw)
|
|
143
135
|
)
|
|
144
136
|
if created_raw
|
|
145
|
-
else datetime.now(
|
|
137
|
+
else datetime.now(UTC)
|
|
146
138
|
)
|
|
147
139
|
return OutboxMessage(
|
|
148
140
|
id=msg_id,
|
|
@@ -157,7 +149,7 @@ class RedisOutboxStore(OutboxStore):
|
|
|
157
149
|
key = self._msg_key(msg_id)
|
|
158
150
|
if not self._client.exists(key):
|
|
159
151
|
return
|
|
160
|
-
self._client.hset(key, "processed_at", datetime.now(
|
|
152
|
+
self._client.hset(key, "processed_at", datetime.now(UTC).isoformat())
|
|
161
153
|
|
|
162
154
|
def mark_failed(self, msg_id: int) -> None:
|
|
163
155
|
key = self._msg_key(msg_id)
|
|
@@ -167,7 +159,7 @@ class RedisOutboxStore(OutboxStore):
|
|
|
167
159
|
class RedisInboxStore(InboxStore):
|
|
168
160
|
"""Lightweight Redis dedupe store for webhook deliveries."""
|
|
169
161
|
|
|
170
|
-
def __init__(self, client:
|
|
162
|
+
def __init__(self, client: Redis, *, prefix: str = "webhooks:inbox"):
|
|
171
163
|
if Redis is None: # pragma: no cover - defensive guard
|
|
172
164
|
raise RuntimeError("redis-py is required for RedisInboxStore")
|
|
173
165
|
self._client = client
|
|
@@ -191,17 +183,15 @@ def _is_factory(obj: Any) -> TypeGuard[Callable[[], Any]]:
|
|
|
191
183
|
return callable(obj) and not isinstance(obj, (str, bytes, bytearray))
|
|
192
184
|
|
|
193
185
|
|
|
194
|
-
def _resolve_value(
|
|
195
|
-
value: T_co | _Factory[T_co] | None, default_factory: _Factory[T_co]
|
|
196
|
-
) -> T_co:
|
|
186
|
+
def _resolve_value(value: T_co | _Factory[T_co] | None, default_factory: _Factory[T_co]) -> T_co:
|
|
197
187
|
if value is None:
|
|
198
188
|
return default_factory()
|
|
199
189
|
if _is_factory(value):
|
|
200
|
-
return cast(T_co, value())
|
|
201
|
-
return cast(T_co, value)
|
|
190
|
+
return cast("T_co", value())
|
|
191
|
+
return cast("T_co", value)
|
|
202
192
|
|
|
203
193
|
|
|
204
|
-
def _build_redis_client(env: Mapping[str, str]) ->
|
|
194
|
+
def _build_redis_client(env: Mapping[str, str]) -> Redis | None:
|
|
205
195
|
if Redis is None:
|
|
206
196
|
logger.warning(
|
|
207
197
|
"Redis backend requested but redis-py is not installed; falling back to in-memory stores"
|
|
@@ -331,9 +321,7 @@ def add_webhooks(
|
|
|
331
321
|
)
|
|
332
322
|
app.state.webhooks_delivery_handler = handler
|
|
333
323
|
elif scheduler is not None and schedule_tick:
|
|
334
|
-
logger.warning(
|
|
335
|
-
"Scheduler provided without queue; skipping outbox tick registration"
|
|
336
|
-
)
|
|
324
|
+
logger.warning("Scheduler provided without queue; skipping outbox tick registration")
|
|
337
325
|
|
|
338
326
|
|
|
339
327
|
__all__ = ["add_webhooks"]
|
svc_infra/webhooks/encryption.py
CHANGED
|
@@ -75,7 +75,7 @@ def encrypt_secret(plaintext: str) -> str:
|
|
|
75
75
|
return plaintext
|
|
76
76
|
|
|
77
77
|
encrypted = fernet.encrypt(plaintext.encode())
|
|
78
|
-
return _ENCRYPTED_PREFIX + cast(str, encrypted.decode())
|
|
78
|
+
return _ENCRYPTED_PREFIX + cast("str", encrypted.decode())
|
|
79
79
|
|
|
80
80
|
|
|
81
81
|
def decrypt_secret(ciphertext: str) -> str:
|
|
@@ -107,7 +107,7 @@ def decrypt_secret(ciphertext: str) -> str:
|
|
|
107
107
|
return ciphertext
|
|
108
108
|
|
|
109
109
|
encrypted = ciphertext[len(_ENCRYPTED_PREFIX) :].encode()
|
|
110
|
-
return cast(str, fernet.decrypt(encrypted).decode())
|
|
110
|
+
return cast("str", fernet.decrypt(encrypted).decode())
|
|
111
111
|
|
|
112
112
|
|
|
113
113
|
def is_encrypted(value: str) -> bool:
|
svc_infra/webhooks/fastapi.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from collections.abc import Callable, Sequence
|
|
4
4
|
|
|
5
5
|
from fastapi import HTTPException, Request, status
|
|
6
6
|
|
|
@@ -21,9 +21,7 @@ def require_signature(
|
|
|
21
21
|
try:
|
|
22
22
|
body = await request.json()
|
|
23
23
|
except Exception:
|
|
24
|
-
raise HTTPException(
|
|
25
|
-
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid JSON body"
|
|
26
|
-
)
|
|
24
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid JSON body")
|
|
27
25
|
secrets = secrets_provider()
|
|
28
26
|
ok = False
|
|
29
27
|
if isinstance(secrets, str):
|
svc_infra/webhooks/router.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import Any
|
|
3
|
+
from typing import Any
|
|
4
4
|
|
|
5
5
|
from fastapi import APIRouter, Depends, HTTPException
|
|
6
6
|
|
|
@@ -30,7 +30,7 @@ def get_service(
|
|
|
30
30
|
|
|
31
31
|
@router.post("/subscriptions")
|
|
32
32
|
def add_subscription(
|
|
33
|
-
body:
|
|
33
|
+
body: dict[str, Any],
|
|
34
34
|
subs: InMemoryWebhookSubscriptions = Depends(get_subs),
|
|
35
35
|
):
|
|
36
36
|
topic = body.get("topic")
|
|
@@ -44,7 +44,7 @@ def add_subscription(
|
|
|
44
44
|
|
|
45
45
|
@router.post("/test-fire")
|
|
46
46
|
def test_fire(
|
|
47
|
-
body:
|
|
47
|
+
body: dict[str, Any],
|
|
48
48
|
svc: WebhookService = Depends(get_service),
|
|
49
49
|
):
|
|
50
50
|
topic = body.get("topic")
|
svc_infra/webhooks/service.py
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass, field
|
|
4
|
-
from datetime import
|
|
5
|
-
from typing import Dict, List
|
|
4
|
+
from datetime import UTC, datetime
|
|
6
5
|
from uuid import uuid4
|
|
7
6
|
|
|
8
7
|
from svc_infra.db.outbox import OutboxStore
|
|
@@ -19,7 +18,7 @@ class WebhookSubscription:
|
|
|
19
18
|
|
|
20
19
|
class InMemoryWebhookSubscriptions:
|
|
21
20
|
def __init__(self):
|
|
22
|
-
self._subs:
|
|
21
|
+
self._subs: dict[str, list[WebhookSubscription]] = {}
|
|
23
22
|
|
|
24
23
|
def add(self, topic: str, url: str, secret: str) -> None:
|
|
25
24
|
# Upsert semantics per (topic, url): if a subscription already exists
|
|
@@ -33,7 +32,7 @@ class InMemoryWebhookSubscriptions:
|
|
|
33
32
|
return
|
|
34
33
|
lst.append(WebhookSubscription(topic, url, secret))
|
|
35
34
|
|
|
36
|
-
def get_for_topic(self, topic: str) ->
|
|
35
|
+
def get_for_topic(self, topic: str) -> list[WebhookSubscription]:
|
|
37
36
|
return list(self._subs.get(topic, []))
|
|
38
37
|
|
|
39
38
|
|
|
@@ -42,8 +41,8 @@ class WebhookService:
|
|
|
42
41
|
self._outbox = outbox
|
|
43
42
|
self._subs = subs
|
|
44
43
|
|
|
45
|
-
def publish(self, topic: str, payload:
|
|
46
|
-
created_at = datetime.now(
|
|
44
|
+
def publish(self, topic: str, payload: dict, *, version: int = 1) -> int:
|
|
45
|
+
created_at = datetime.now(UTC).isoformat()
|
|
47
46
|
base_event = {
|
|
48
47
|
"topic": topic,
|
|
49
48
|
"payload": payload,
|
svc_infra/webhooks/signing.py
CHANGED
|
@@ -4,21 +4,21 @@ import hashlib
|
|
|
4
4
|
import hmac
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
|
-
from
|
|
7
|
+
from collections.abc import Iterable
|
|
8
8
|
|
|
9
9
|
logger = logging.getLogger(__name__)
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
def canonical_body(payload:
|
|
12
|
+
def canonical_body(payload: dict) -> bytes:
|
|
13
13
|
return json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
def sign(secret: str, payload:
|
|
16
|
+
def sign(secret: str, payload: dict) -> str:
|
|
17
17
|
body = canonical_body(payload)
|
|
18
18
|
return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
def verify(secret: str, payload:
|
|
21
|
+
def verify(secret: str, payload: dict, signature: str) -> bool:
|
|
22
22
|
expected = sign(secret, payload)
|
|
23
23
|
try:
|
|
24
24
|
return hmac.compare_digest(expected, signature)
|
|
@@ -27,7 +27,7 @@ def verify(secret: str, payload: Dict, signature: str) -> bool:
|
|
|
27
27
|
return False
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
def verify_any(secrets: Iterable[str], payload:
|
|
30
|
+
def verify_any(secrets: Iterable[str], payload: dict, signature: str) -> bool:
|
|
31
31
|
for s in secrets:
|
|
32
32
|
if verify(s, payload, signature):
|
|
33
33
|
return True
|
svc_infra/websocket/add.py
CHANGED
|
@@ -116,10 +116,9 @@ def get_ws_manager(app_or_request: FastAPI | Request) -> ConnectionManager:
|
|
|
116
116
|
manager = getattr(app.state, _WS_MANAGER_ATTR, None)
|
|
117
117
|
if manager is None:
|
|
118
118
|
raise RuntimeError(
|
|
119
|
-
"WebSocket manager not found. "
|
|
120
|
-
"Did you forget to call add_websocket_manager(app)?"
|
|
119
|
+
"WebSocket manager not found. Did you forget to call add_websocket_manager(app)?"
|
|
121
120
|
)
|
|
122
|
-
return cast(ConnectionManager, manager)
|
|
121
|
+
return cast("ConnectionManager", manager)
|
|
123
122
|
|
|
124
123
|
|
|
125
124
|
def get_ws_manager_dependency(request: Request) -> ConnectionManager:
|
svc_infra/websocket/client.py
CHANGED
|
@@ -18,8 +18,9 @@ from __future__ import annotations
|
|
|
18
18
|
|
|
19
19
|
import json
|
|
20
20
|
import logging
|
|
21
|
+
from collections.abc import AsyncIterator
|
|
21
22
|
from contextlib import asynccontextmanager
|
|
22
|
-
from typing import TYPE_CHECKING, Any
|
|
23
|
+
from typing import TYPE_CHECKING, Any
|
|
23
24
|
|
|
24
25
|
from websockets.asyncio.client import connect
|
|
25
26
|
from websockets.exceptions import ConnectionClosed, ConnectionClosedOK
|
|
@@ -73,7 +74,7 @@ class WebSocketClient:
|
|
|
73
74
|
self._connection: ClientConnection | None = None
|
|
74
75
|
self._closed = False
|
|
75
76
|
|
|
76
|
-
async def __aenter__(self) ->
|
|
77
|
+
async def __aenter__(self) -> WebSocketClient:
|
|
77
78
|
await self.connect()
|
|
78
79
|
return self
|
|
79
80
|
|
svc_infra/websocket/config.py
CHANGED
|
@@ -27,20 +27,14 @@ class WebSocketConfig(BaseSettings):
|
|
|
27
27
|
model_config = SettingsConfigDict(env_prefix="WS_")
|
|
28
28
|
|
|
29
29
|
# Connection settings
|
|
30
|
-
open_timeout: float = Field(
|
|
31
|
-
|
|
32
|
-
)
|
|
33
|
-
close_timeout: float = Field(
|
|
34
|
-
default=10.0, description="Close handshake timeout in seconds"
|
|
35
|
-
)
|
|
30
|
+
open_timeout: float = Field(default=10.0, description="Connection timeout in seconds")
|
|
31
|
+
close_timeout: float = Field(default=10.0, description="Close handshake timeout in seconds")
|
|
36
32
|
|
|
37
33
|
# Keepalive (ping/pong)
|
|
38
34
|
ping_interval: float | None = Field(
|
|
39
35
|
default=20.0, description="Ping interval in seconds (None to disable)"
|
|
40
36
|
)
|
|
41
|
-
ping_timeout: float | None = Field(
|
|
42
|
-
default=20.0, description="Pong response timeout in seconds"
|
|
43
|
-
)
|
|
37
|
+
ping_timeout: float | None = Field(default=20.0, description="Pong response timeout in seconds")
|
|
44
38
|
|
|
45
39
|
# Message limits
|
|
46
40
|
max_message_size: int = Field(
|
|
@@ -49,18 +43,12 @@ class WebSocketConfig(BaseSettings):
|
|
|
49
43
|
max_queue_size: int = Field(default=16, description="Max queued messages")
|
|
50
44
|
|
|
51
45
|
# Reconnection policy
|
|
52
|
-
reconnect_enabled: bool = Field(
|
|
53
|
-
default=True, description="Enable auto-reconnection"
|
|
54
|
-
)
|
|
46
|
+
reconnect_enabled: bool = Field(default=True, description="Enable auto-reconnection")
|
|
55
47
|
reconnect_max_attempts: int = Field(
|
|
56
48
|
default=5, description="Max reconnect attempts (0=infinite)"
|
|
57
49
|
)
|
|
58
|
-
reconnect_backoff_base: float = Field(
|
|
59
|
-
|
|
60
|
-
)
|
|
61
|
-
reconnect_backoff_max: float = Field(
|
|
62
|
-
default=60.0, description="Max backoff in seconds"
|
|
63
|
-
)
|
|
50
|
+
reconnect_backoff_base: float = Field(default=1.0, description="Base backoff in seconds")
|
|
51
|
+
reconnect_backoff_max: float = Field(default=60.0, description="Max backoff in seconds")
|
|
64
52
|
reconnect_jitter: float = Field(default=0.1, description="Jitter factor (0-1)")
|
|
65
53
|
|
|
66
54
|
|
svc_infra/websocket/manager.py
CHANGED
|
@@ -27,8 +27,9 @@ import asyncio
|
|
|
27
27
|
import logging
|
|
28
28
|
import uuid
|
|
29
29
|
from collections import defaultdict
|
|
30
|
-
from
|
|
31
|
-
from
|
|
30
|
+
from collections.abc import Awaitable, Callable
|
|
31
|
+
from datetime import UTC, datetime
|
|
32
|
+
from typing import TYPE_CHECKING, Any
|
|
32
33
|
|
|
33
34
|
from .models import ConnectionInfo
|
|
34
35
|
|
|
@@ -64,8 +65,8 @@ class ConnectionManager:
|
|
|
64
65
|
def __init__(self) -> None:
|
|
65
66
|
self._lock = asyncio.Lock()
|
|
66
67
|
# user_id -> list of (connection_id, WebSocket, ConnectionInfo)
|
|
67
|
-
self._connections: dict[str, list[tuple[str, WebSocket, ConnectionInfo]]] = (
|
|
68
|
-
|
|
68
|
+
self._connections: dict[str, list[tuple[str, WebSocket, ConnectionInfo]]] = defaultdict(
|
|
69
|
+
list
|
|
69
70
|
)
|
|
70
71
|
# room -> set of user_ids
|
|
71
72
|
self._rooms: dict[str, set[str]] = defaultdict(set)
|
|
@@ -97,7 +98,7 @@ class ConnectionManager:
|
|
|
97
98
|
await websocket.accept()
|
|
98
99
|
|
|
99
100
|
connection_id = str(uuid.uuid4())
|
|
100
|
-
now = datetime.now(
|
|
101
|
+
now = datetime.now(UTC)
|
|
101
102
|
info = ConnectionInfo(
|
|
102
103
|
user_id=user_id,
|
|
103
104
|
connection_id=connection_id,
|
|
@@ -121,9 +122,7 @@ class ConnectionManager:
|
|
|
121
122
|
|
|
122
123
|
return connection_id
|
|
123
124
|
|
|
124
|
-
async def disconnect(
|
|
125
|
-
self, user_id: str, websocket: WebSocket | None = None
|
|
126
|
-
) -> None:
|
|
125
|
+
async def disconnect(self, user_id: str, websocket: WebSocket | None = None) -> None:
|
|
127
126
|
"""
|
|
128
127
|
Remove connection(s) for a user.
|
|
129
128
|
|
|
@@ -186,7 +185,7 @@ class ConnectionManager:
|
|
|
186
185
|
try:
|
|
187
186
|
await self._send_message(ws, message)
|
|
188
187
|
# Update last activity
|
|
189
|
-
info.last_activity = datetime.now(
|
|
188
|
+
info.last_activity = datetime.now(UTC)
|
|
190
189
|
sent += 1
|
|
191
190
|
except Exception as e:
|
|
192
191
|
logger.debug("Failed to send to user %s: %s", user_id, e)
|
|
@@ -216,7 +215,7 @@ class ConnectionManager:
|
|
|
216
215
|
for uid, ws, info in all_connections:
|
|
217
216
|
try:
|
|
218
217
|
await self._send_message(ws, message)
|
|
219
|
-
info.last_activity = datetime.now(
|
|
218
|
+
info.last_activity = datetime.now(UTC)
|
|
220
219
|
sent += 1
|
|
221
220
|
except Exception as e:
|
|
222
221
|
logger.debug("Failed to broadcast to user %s: %s", uid, e)
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: svc-infra
|
|
3
|
-
Version:
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Infrastructure for building and deploying prod-ready services
|
|
5
5
|
License: MIT
|
|
6
6
|
Keywords: fastapi,sqlalchemy,alembic,auth,infra,async,pydantic
|
|
7
7
|
Author: Ali Khatami
|
|
8
8
|
Author-email: aliikhatami94@gmail.com
|
|
9
9
|
Requires-Python: >=3.11,<4.0
|
|
10
|
-
Classifier: Development Status ::
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
11
|
Classifier: Framework :: FastAPI
|
|
12
12
|
Classifier: Intended Audience :: Developers
|
|
13
13
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -86,13 +86,19 @@ Description-Content-Type: text/markdown
|
|
|
86
86
|
|
|
87
87
|
# svc-infra
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
[](CHANGELOG.md)
|
|
90
|
+
[](https://github.com/nfraxlab/svc-infra/actions/workflows/ci.yml)
|
|
91
|
+
[](https://pypi.org/project/svc-infra/)
|
|
92
|
+
[](https://pypi.org/project/svc-infra/)
|
|
93
|
+
[](LICENSE)
|
|
94
|
+
[](https://pypi.org/project/svc-infra/)
|
|
95
|
+
[](https://codecov.io/gh/nfraxlab/svc-infra)
|
|
90
96
|
|
|
91
|
-
|
|
97
|
+
### Production-ready FastAPI infrastructure in one import
|
|
92
98
|
|
|
93
99
|
**Stop rebuilding auth, billing, webhooks, and background jobs for every project.**
|
|
94
100
|
|
|
95
|
-
[Documentation](docs/) · [Examples](examples/) · [PyPI](https://pypi.org/project/svc-infra/)
|
|
101
|
+
[Documentation](docs/) · [Examples](examples/) · [PyPI](https://pypi.org/project/svc-infra/) · [Changelog](CHANGELOG.md)
|
|
96
102
|
|
|
97
103
|
</div>
|
|
98
104
|
|