svc-infra 0.1.595__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.

Files changed (274) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +68 -38
  3. svc_infra/apf_payments/provider/__init__.py +2 -2
  4. svc_infra/apf_payments/provider/aiydan.py +39 -23
  5. svc_infra/apf_payments/provider/base.py +8 -3
  6. svc_infra/apf_payments/provider/registry.py +3 -5
  7. svc_infra/apf_payments/provider/stripe.py +74 -52
  8. svc_infra/apf_payments/schemas.py +84 -83
  9. svc_infra/apf_payments/service.py +27 -16
  10. svc_infra/apf_payments/settings.py +12 -11
  11. svc_infra/api/__init__.py +61 -0
  12. svc_infra/api/fastapi/__init__.py +34 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +240 -0
  15. svc_infra/api/fastapi/apf_payments/router.py +94 -73
  16. svc_infra/api/fastapi/apf_payments/setup.py +10 -9
  17. svc_infra/api/fastapi/auth/__init__.py +65 -0
  18. svc_infra/api/fastapi/auth/_cookies.py +1 -3
  19. svc_infra/api/fastapi/auth/add.py +14 -15
  20. svc_infra/api/fastapi/auth/gaurd.py +32 -20
  21. svc_infra/api/fastapi/auth/mfa/models.py +3 -4
  22. svc_infra/api/fastapi/auth/mfa/pre_auth.py +13 -9
  23. svc_infra/api/fastapi/auth/mfa/router.py +9 -8
  24. svc_infra/api/fastapi/auth/mfa/security.py +4 -7
  25. svc_infra/api/fastapi/auth/mfa/utils.py +5 -3
  26. svc_infra/api/fastapi/auth/policy.py +0 -1
  27. svc_infra/api/fastapi/auth/providers.py +3 -3
  28. svc_infra/api/fastapi/auth/routers/apikey_router.py +19 -21
  29. svc_infra/api/fastapi/auth/routers/oauth_router.py +98 -52
  30. svc_infra/api/fastapi/auth/routers/session_router.py +6 -5
  31. svc_infra/api/fastapi/auth/security.py +25 -15
  32. svc_infra/api/fastapi/auth/sender.py +5 -0
  33. svc_infra/api/fastapi/auth/settings.py +18 -19
  34. svc_infra/api/fastapi/auth/state.py +5 -4
  35. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  36. svc_infra/api/fastapi/billing/router.py +71 -0
  37. svc_infra/api/fastapi/billing/setup.py +19 -0
  38. svc_infra/api/fastapi/cache/add.py +9 -5
  39. svc_infra/api/fastapi/db/__init__.py +5 -1
  40. svc_infra/api/fastapi/db/http.py +10 -9
  41. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  42. svc_infra/api/fastapi/db/nosql/mongo/add.py +35 -30
  43. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +39 -21
  44. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  45. svc_infra/api/fastapi/db/sql/add.py +62 -25
  46. svc_infra/api/fastapi/db/sql/crud_router.py +205 -30
  47. svc_infra/api/fastapi/db/sql/session.py +19 -2
  48. svc_infra/api/fastapi/db/sql/users.py +18 -9
  49. svc_infra/api/fastapi/dependencies/ratelimit.py +76 -14
  50. svc_infra/api/fastapi/docs/add.py +163 -0
  51. svc_infra/api/fastapi/docs/landing.py +6 -6
  52. svc_infra/api/fastapi/docs/scoped.py +75 -36
  53. svc_infra/api/fastapi/dual/__init__.py +12 -2
  54. svc_infra/api/fastapi/dual/dualize.py +2 -2
  55. svc_infra/api/fastapi/dual/protected.py +123 -10
  56. svc_infra/api/fastapi/dual/public.py +25 -0
  57. svc_infra/api/fastapi/dual/router.py +18 -8
  58. svc_infra/api/fastapi/dx.py +33 -2
  59. svc_infra/api/fastapi/ease.py +59 -7
  60. svc_infra/api/fastapi/http/concurrency.py +2 -1
  61. svc_infra/api/fastapi/http/conditional.py +2 -2
  62. svc_infra/api/fastapi/middleware/debug.py +4 -1
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +2 -5
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +50 -10
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +95 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +190 -68
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +39 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +45 -13
  71. svc_infra/api/fastapi/middleware/request_id.py +24 -10
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +176 -0
  74. svc_infra/api/fastapi/object_router.py +1060 -0
  75. svc_infra/api/fastapi/openapi/apply.py +4 -3
  76. svc_infra/api/fastapi/openapi/conventions.py +13 -6
  77. svc_infra/api/fastapi/openapi/mutators.py +144 -17
  78. svc_infra/api/fastapi/openapi/pipeline.py +2 -2
  79. svc_infra/api/fastapi/openapi/responses.py +4 -6
  80. svc_infra/api/fastapi/openapi/security.py +1 -1
  81. svc_infra/api/fastapi/ops/add.py +73 -0
  82. svc_infra/api/fastapi/pagination.py +47 -32
  83. svc_infra/api/fastapi/routers/__init__.py +16 -10
  84. svc_infra/api/fastapi/routers/ping.py +1 -0
  85. svc_infra/api/fastapi/setup.py +167 -54
  86. svc_infra/api/fastapi/tenancy/add.py +20 -0
  87. svc_infra/api/fastapi/tenancy/context.py +113 -0
  88. svc_infra/api/fastapi/versioned.py +102 -0
  89. svc_infra/app/README.md +5 -5
  90. svc_infra/app/__init__.py +3 -1
  91. svc_infra/app/env.py +70 -4
  92. svc_infra/app/logging/add.py +10 -2
  93. svc_infra/app/logging/filter.py +1 -1
  94. svc_infra/app/logging/formats.py +13 -5
  95. svc_infra/app/root.py +3 -3
  96. svc_infra/billing/__init__.py +40 -0
  97. svc_infra/billing/async_service.py +167 -0
  98. svc_infra/billing/jobs.py +231 -0
  99. svc_infra/billing/models.py +146 -0
  100. svc_infra/billing/quotas.py +101 -0
  101. svc_infra/billing/schemas.py +34 -0
  102. svc_infra/bundled_docs/README.md +5 -0
  103. svc_infra/bundled_docs/__init__.py +1 -0
  104. svc_infra/bundled_docs/getting-started.md +6 -0
  105. svc_infra/cache/__init__.py +21 -5
  106. svc_infra/cache/add.py +167 -0
  107. svc_infra/cache/backend.py +9 -7
  108. svc_infra/cache/decorators.py +75 -20
  109. svc_infra/cache/demo.py +2 -2
  110. svc_infra/cache/keys.py +26 -6
  111. svc_infra/cache/recache.py +26 -27
  112. svc_infra/cache/resources.py +6 -5
  113. svc_infra/cache/tags.py +19 -44
  114. svc_infra/cache/ttl.py +2 -3
  115. svc_infra/cache/utils.py +4 -3
  116. svc_infra/cli/__init__.py +44 -8
  117. svc_infra/cli/__main__.py +4 -0
  118. svc_infra/cli/cmds/__init__.py +39 -2
  119. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +18 -14
  120. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +9 -10
  121. svc_infra/cli/cmds/db/ops_cmds.py +267 -0
  122. svc_infra/cli/cmds/db/sql/alembic_cmds.py +97 -29
  123. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  124. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +13 -13
  125. svc_infra/cli/cmds/docs/docs_cmds.py +139 -0
  126. svc_infra/cli/cmds/dx/__init__.py +12 -0
  127. svc_infra/cli/cmds/dx/dx_cmds.py +110 -0
  128. svc_infra/cli/cmds/health/__init__.py +179 -0
  129. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  130. svc_infra/cli/cmds/help.py +4 -0
  131. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  132. svc_infra/cli/cmds/jobs/jobs_cmds.py +42 -0
  133. svc_infra/cli/cmds/obs/obs_cmds.py +31 -13
  134. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  135. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  136. svc_infra/cli/foundation/runner.py +4 -5
  137. svc_infra/cli/foundation/typer_bootstrap.py +1 -2
  138. svc_infra/data/__init__.py +83 -0
  139. svc_infra/data/add.py +61 -0
  140. svc_infra/data/backup.py +56 -0
  141. svc_infra/data/erasure.py +46 -0
  142. svc_infra/data/fixtures.py +42 -0
  143. svc_infra/data/retention.py +56 -0
  144. svc_infra/db/__init__.py +15 -0
  145. svc_infra/db/crud_schema.py +14 -13
  146. svc_infra/db/inbox.py +67 -0
  147. svc_infra/db/nosql/__init__.py +2 -0
  148. svc_infra/db/nosql/constants.py +1 -1
  149. svc_infra/db/nosql/core.py +19 -5
  150. svc_infra/db/nosql/indexes.py +12 -9
  151. svc_infra/db/nosql/management.py +4 -4
  152. svc_infra/db/nosql/mongo/README.md +13 -13
  153. svc_infra/db/nosql/mongo/client.py +21 -4
  154. svc_infra/db/nosql/mongo/settings.py +1 -1
  155. svc_infra/db/nosql/repository.py +46 -27
  156. svc_infra/db/nosql/resource.py +28 -16
  157. svc_infra/db/nosql/scaffold.py +14 -12
  158. svc_infra/db/nosql/service.py +2 -1
  159. svc_infra/db/nosql/service_with_hooks.py +4 -3
  160. svc_infra/db/nosql/utils.py +4 -4
  161. svc_infra/db/ops.py +380 -0
  162. svc_infra/db/outbox.py +105 -0
  163. svc_infra/db/sql/apikey.py +34 -15
  164. svc_infra/db/sql/authref.py +8 -6
  165. svc_infra/db/sql/constants.py +5 -1
  166. svc_infra/db/sql/core.py +13 -13
  167. svc_infra/db/sql/management.py +5 -6
  168. svc_infra/db/sql/repository.py +92 -26
  169. svc_infra/db/sql/resource.py +18 -12
  170. svc_infra/db/sql/scaffold.py +11 -11
  171. svc_infra/db/sql/service.py +2 -1
  172. svc_infra/db/sql/service_with_hooks.py +4 -3
  173. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  174. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  175. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  176. svc_infra/db/sql/tenant.py +80 -0
  177. svc_infra/db/sql/uniq.py +8 -7
  178. svc_infra/db/sql/uniq_hooks.py +12 -11
  179. svc_infra/db/sql/utils.py +105 -47
  180. svc_infra/db/sql/versioning.py +14 -0
  181. svc_infra/db/utils.py +3 -3
  182. svc_infra/deploy/__init__.py +531 -0
  183. svc_infra/documents/__init__.py +100 -0
  184. svc_infra/documents/add.py +263 -0
  185. svc_infra/documents/ease.py +233 -0
  186. svc_infra/documents/models.py +114 -0
  187. svc_infra/documents/storage.py +262 -0
  188. svc_infra/dx/__init__.py +58 -0
  189. svc_infra/dx/add.py +63 -0
  190. svc_infra/dx/changelog.py +74 -0
  191. svc_infra/dx/checks.py +68 -0
  192. svc_infra/exceptions.py +141 -0
  193. svc_infra/health/__init__.py +863 -0
  194. svc_infra/http/__init__.py +13 -0
  195. svc_infra/http/client.py +101 -0
  196. svc_infra/jobs/__init__.py +79 -0
  197. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  198. svc_infra/jobs/builtins/webhook_delivery.py +93 -0
  199. svc_infra/jobs/easy.py +33 -0
  200. svc_infra/jobs/loader.py +49 -0
  201. svc_infra/jobs/queue.py +106 -0
  202. svc_infra/jobs/redis_queue.py +242 -0
  203. svc_infra/jobs/runner.py +75 -0
  204. svc_infra/jobs/scheduler.py +53 -0
  205. svc_infra/jobs/worker.py +40 -0
  206. svc_infra/loaders/__init__.py +186 -0
  207. svc_infra/loaders/base.py +143 -0
  208. svc_infra/loaders/github.py +309 -0
  209. svc_infra/loaders/models.py +147 -0
  210. svc_infra/loaders/url.py +229 -0
  211. svc_infra/logging/__init__.py +375 -0
  212. svc_infra/mcp/__init__.py +82 -0
  213. svc_infra/mcp/svc_infra_mcp.py +91 -33
  214. svc_infra/obs/README.md +2 -0
  215. svc_infra/obs/add.py +68 -11
  216. svc_infra/obs/cloud_dash.py +2 -1
  217. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  218. svc_infra/obs/metrics/__init__.py +6 -7
  219. svc_infra/obs/metrics/asgi.py +8 -7
  220. svc_infra/obs/metrics/base.py +13 -13
  221. svc_infra/obs/metrics/http.py +3 -3
  222. svc_infra/obs/metrics/sqlalchemy.py +14 -13
  223. svc_infra/obs/metrics.py +9 -8
  224. svc_infra/resilience/__init__.py +44 -0
  225. svc_infra/resilience/circuit_breaker.py +328 -0
  226. svc_infra/resilience/retry.py +289 -0
  227. svc_infra/security/__init__.py +167 -0
  228. svc_infra/security/add.py +213 -0
  229. svc_infra/security/audit.py +97 -18
  230. svc_infra/security/audit_service.py +10 -9
  231. svc_infra/security/headers.py +15 -2
  232. svc_infra/security/hibp.py +14 -7
  233. svc_infra/security/jwt_rotation.py +78 -29
  234. svc_infra/security/lockout.py +23 -16
  235. svc_infra/security/models.py +77 -44
  236. svc_infra/security/oauth_models.py +73 -0
  237. svc_infra/security/org_invites.py +12 -12
  238. svc_infra/security/passwords.py +3 -3
  239. svc_infra/security/permissions.py +31 -7
  240. svc_infra/security/session.py +7 -8
  241. svc_infra/security/signed_cookies.py +26 -6
  242. svc_infra/storage/__init__.py +93 -0
  243. svc_infra/storage/add.py +250 -0
  244. svc_infra/storage/backends/__init__.py +11 -0
  245. svc_infra/storage/backends/local.py +331 -0
  246. svc_infra/storage/backends/memory.py +213 -0
  247. svc_infra/storage/backends/s3.py +334 -0
  248. svc_infra/storage/base.py +239 -0
  249. svc_infra/storage/easy.py +181 -0
  250. svc_infra/storage/settings.py +193 -0
  251. svc_infra/testing/__init__.py +682 -0
  252. svc_infra/utils.py +170 -5
  253. svc_infra/webhooks/__init__.py +69 -0
  254. svc_infra/webhooks/add.py +327 -0
  255. svc_infra/webhooks/encryption.py +115 -0
  256. svc_infra/webhooks/fastapi.py +37 -0
  257. svc_infra/webhooks/router.py +55 -0
  258. svc_infra/webhooks/service.py +69 -0
  259. svc_infra/webhooks/signing.py +34 -0
  260. svc_infra/websocket/__init__.py +79 -0
  261. svc_infra/websocket/add.py +139 -0
  262. svc_infra/websocket/client.py +283 -0
  263. svc_infra/websocket/config.py +57 -0
  264. svc_infra/websocket/easy.py +76 -0
  265. svc_infra/websocket/exceptions.py +61 -0
  266. svc_infra/websocket/manager.py +343 -0
  267. svc_infra/websocket/models.py +49 -0
  268. svc_infra-1.1.0.dist-info/LICENSE +21 -0
  269. svc_infra-1.1.0.dist-info/METADATA +362 -0
  270. svc_infra-1.1.0.dist-info/RECORD +364 -0
  271. svc_infra-0.1.595.dist-info/METADATA +0 -80
  272. svc_infra-0.1.595.dist-info/RECORD +0 -253
  273. {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
  274. {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Sequence
4
+
5
+ from fastapi import HTTPException, Request, status
6
+
7
+ from .signing import verify, verify_any
8
+
9
+
10
+ def require_signature(
11
+ secrets_provider: Callable[[], str | Sequence[str]],
12
+ *,
13
+ header_name: str = "X-Signature",
14
+ ):
15
+ async def _dep(request: Request):
16
+ sig = request.headers.get(header_name)
17
+ if not sig:
18
+ raise HTTPException(
19
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="missing signature"
20
+ )
21
+ try:
22
+ body = await request.json()
23
+ except Exception:
24
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid JSON body")
25
+ secrets = secrets_provider()
26
+ ok = False
27
+ if isinstance(secrets, str):
28
+ ok = verify(secrets, body, sig)
29
+ else:
30
+ ok = verify_any(secrets, body, sig)
31
+ if not ok:
32
+ raise HTTPException(
33
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid signature"
34
+ )
35
+ return body
36
+
37
+ return _dep
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException
6
+
7
+ from svc_infra.db.outbox import InMemoryOutboxStore, OutboxStore
8
+
9
+ from .service import InMemoryWebhookSubscriptions, WebhookService
10
+
11
+ router = APIRouter(prefix="/_webhooks", tags=["webhooks"])
12
+
13
+
14
+ def get_outbox() -> OutboxStore:
15
+ # For now expose an in-memory default. Apps can override via DI.
16
+ # In production, provide a proper store through dependency override.
17
+ return InMemoryOutboxStore()
18
+
19
+
20
+ def get_subs() -> InMemoryWebhookSubscriptions:
21
+ return InMemoryWebhookSubscriptions()
22
+
23
+
24
+ def get_service(
25
+ outbox: OutboxStore = Depends(get_outbox),
26
+ subs: InMemoryWebhookSubscriptions = Depends(get_subs),
27
+ ) -> WebhookService:
28
+ return WebhookService(outbox=outbox, subs=subs)
29
+
30
+
31
+ @router.post("/subscriptions")
32
+ def add_subscription(
33
+ body: dict[str, Any],
34
+ subs: InMemoryWebhookSubscriptions = Depends(get_subs),
35
+ ):
36
+ topic = body.get("topic")
37
+ url = body.get("url")
38
+ secret = body.get("secret")
39
+ if not topic or not url or not secret:
40
+ raise HTTPException(status_code=400, detail="Missing topic/url/secret")
41
+ subs.add(topic, url, secret)
42
+ return {"ok": True}
43
+
44
+
45
+ @router.post("/test-fire")
46
+ def test_fire(
47
+ body: dict[str, Any],
48
+ svc: WebhookService = Depends(get_service),
49
+ ):
50
+ topic = body.get("topic")
51
+ payload = body.get("payload") or {}
52
+ if not topic:
53
+ raise HTTPException(status_code=400, detail="Missing topic")
54
+ outbox_id = svc.publish(topic, payload)
55
+ return {"ok": True, "outbox_id": outbox_id}
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import UTC, datetime
5
+ from uuid import uuid4
6
+
7
+ from svc_infra.db.outbox import OutboxStore
8
+ from svc_infra.webhooks.encryption import encrypt_secret
9
+
10
+
11
+ @dataclass
12
+ class WebhookSubscription:
13
+ topic: str
14
+ url: str
15
+ secret: str
16
+ id: str = field(default_factory=lambda: uuid4().hex)
17
+
18
+
19
+ class InMemoryWebhookSubscriptions:
20
+ def __init__(self):
21
+ self._subs: dict[str, list[WebhookSubscription]] = {}
22
+
23
+ def add(self, topic: str, url: str, secret: str) -> None:
24
+ # Upsert semantics per (topic, url): if a subscription already exists
25
+ # for this topic and URL, rotate its secret instead of adding a new row.
26
+ # This mirrors typical real-world secret rotation flows where the
27
+ # endpoint remains the same but the signing secret changes.
28
+ lst = self._subs.setdefault(topic, [])
29
+ for sub in lst:
30
+ if sub.url == url:
31
+ sub.secret = secret
32
+ return
33
+ lst.append(WebhookSubscription(topic, url, secret))
34
+
35
+ def get_for_topic(self, topic: str) -> list[WebhookSubscription]:
36
+ return list(self._subs.get(topic, []))
37
+
38
+
39
+ class WebhookService:
40
+ def __init__(self, outbox: OutboxStore, subs: InMemoryWebhookSubscriptions):
41
+ self._outbox = outbox
42
+ self._subs = subs
43
+
44
+ def publish(self, topic: str, payload: dict, *, version: int = 1) -> int:
45
+ created_at = datetime.now(UTC).isoformat()
46
+ base_event = {
47
+ "topic": topic,
48
+ "payload": payload,
49
+ "version": version,
50
+ "created_at": created_at,
51
+ }
52
+ # For each subscription, enqueue an outbox message with subscriber identity
53
+ last_id = 0
54
+ for sub in self._subs.get_for_topic(topic):
55
+ event = dict(base_event)
56
+ # Encrypt secret before storing in outbox for security
57
+ encrypted_secret = encrypt_secret(sub.secret)
58
+ msg_payload = {
59
+ "event": event,
60
+ "subscription": {
61
+ "id": sub.id,
62
+ "topic": sub.topic,
63
+ "url": sub.url,
64
+ "secret": encrypted_secret,
65
+ },
66
+ }
67
+ msg = self._outbox.enqueue(topic, msg_payload)
68
+ last_id = msg.id
69
+ return last_id
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import logging
7
+ from collections.abc import Iterable
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def canonical_body(payload: dict) -> bytes:
13
+ return json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
14
+
15
+
16
+ def sign(secret: str, payload: dict) -> str:
17
+ body = canonical_body(payload)
18
+ return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
19
+
20
+
21
+ def verify(secret: str, payload: dict, signature: str) -> bool:
22
+ expected = sign(secret, payload)
23
+ try:
24
+ return hmac.compare_digest(expected, signature)
25
+ except Exception as e:
26
+ logger.warning("Webhook signature verification failed: %s", e)
27
+ return False
28
+
29
+
30
+ def verify_any(secrets: Iterable[str], payload: dict, signature: str) -> bool:
31
+ for s in secrets:
32
+ if verify(s, payload, signature):
33
+ return True
34
+ return False
@@ -0,0 +1,79 @@
1
+ """
2
+ WebSocket infrastructure for svc-infra.
3
+
4
+ Provides client and server-side WebSocket utilities.
5
+
6
+ Quick Start (Client):
7
+ from svc_infra.websocket import websocket_client
8
+
9
+ async with websocket_client("wss://api.example.com") as ws:
10
+ await ws.send_json({"hello": "world"})
11
+ async for message in ws:
12
+ print(message)
13
+
14
+ Quick Start (Server):
15
+ from fastapi import FastAPI, WebSocket
16
+ from svc_infra.websocket import add_websocket_manager
17
+
18
+ app = FastAPI()
19
+ manager = add_websocket_manager(app)
20
+
21
+ @app.websocket("/ws/{user_id}")
22
+ async def ws_endpoint(websocket: WebSocket, user_id: str):
23
+ await manager.connect(user_id, websocket)
24
+ try:
25
+ async for msg in websocket.iter_json():
26
+ await manager.broadcast(msg)
27
+ finally:
28
+ await manager.disconnect(user_id, websocket)
29
+
30
+ Quick Start (Auth):
31
+ Use the dual router system for WebSocket authentication:
32
+
33
+ from svc_infra.api.fastapi.dual import ws_protected_router
34
+ from svc_infra.api.fastapi.auth.ws_security import WSIdentity
35
+
36
+ router = ws_protected_router()
37
+
38
+ @router.websocket("/ws")
39
+ async def ws_endpoint(websocket: WebSocket, user: WSIdentity):
40
+ await manager.connect(user.id, websocket)
41
+ ...
42
+ """
43
+
44
+ from .add import add_websocket_manager, get_ws_manager
45
+ from .client import WebSocketClient
46
+ from .config import WebSocketConfig
47
+ from .easy import easy_websocket_client, websocket_client
48
+ from .exceptions import (
49
+ AuthenticationError,
50
+ ConnectionClosedError,
51
+ ConnectionFailedError,
52
+ MessageTooLargeError,
53
+ WebSocketError,
54
+ )
55
+ from .manager import ConnectionManager
56
+ from .models import ConnectionInfo, ConnectionState, WebSocketMessage
57
+
58
+ __all__ = [
59
+ # Main API (simple)
60
+ "websocket_client",
61
+ "add_websocket_manager",
62
+ "get_ws_manager",
63
+ # Core classes (when you need more control)
64
+ "WebSocketClient",
65
+ "ConnectionManager",
66
+ "WebSocketConfig",
67
+ # Models
68
+ "ConnectionState",
69
+ "WebSocketMessage",
70
+ "ConnectionInfo",
71
+ # Exceptions
72
+ "WebSocketError",
73
+ "ConnectionClosedError",
74
+ "ConnectionFailedError",
75
+ "AuthenticationError",
76
+ "MessageTooLargeError",
77
+ # Backward compat
78
+ "easy_websocket_client",
79
+ ]
@@ -0,0 +1,139 @@
1
+ """
2
+ FastAPI integration for WebSocket infrastructure.
3
+
4
+ Provides:
5
+ - add_websocket_manager: Add a connection manager to a FastAPI app
6
+ - get_ws_manager: Dependency to retrieve the manager
7
+
8
+ Example:
9
+ from fastapi import FastAPI, WebSocket, Depends
10
+ from svc_infra.websocket import add_websocket_manager, get_ws_manager, ConnectionManager
11
+
12
+ app = FastAPI()
13
+ add_websocket_manager(app)
14
+
15
+ @app.websocket("/ws/{user_id}")
16
+ async def ws_endpoint(websocket: WebSocket, user_id: str):
17
+ manager = get_ws_manager(app)
18
+ await manager.connect(user_id, websocket)
19
+ try:
20
+ async for msg in websocket.iter_json():
21
+ await manager.broadcast(msg)
22
+ finally:
23
+ await manager.disconnect(user_id, websocket)
24
+
25
+ @app.get("/ws/stats")
26
+ async def ws_stats(manager: ConnectionManager = Depends(get_ws_manager)):
27
+ return {"connections": manager.connection_count}
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from typing import TYPE_CHECKING, cast
33
+
34
+ from .manager import ConnectionManager
35
+
36
+ if TYPE_CHECKING:
37
+ from fastapi import FastAPI, Request
38
+
39
+ _WS_MANAGER_ATTR = "_svc_infra_ws_manager"
40
+
41
+
42
+ def add_websocket_manager(
43
+ app: FastAPI,
44
+ manager: ConnectionManager | None = None,
45
+ ) -> ConnectionManager:
46
+ """
47
+ Add a WebSocket connection manager to a FastAPI app.
48
+
49
+ The manager is stored on app.state and can be retrieved via get_ws_manager().
50
+
51
+ Args:
52
+ app: FastAPI application instance
53
+ manager: Optional pre-configured ConnectionManager.
54
+ If not provided, a new one is created.
55
+
56
+ Returns:
57
+ The ConnectionManager instance (created or provided)
58
+
59
+ Example:
60
+ app = FastAPI()
61
+ manager = add_websocket_manager(app)
62
+
63
+ @app.websocket("/ws/{user_id}")
64
+ async def ws_endpoint(websocket: WebSocket, user_id: str):
65
+ await manager.connect(user_id, websocket)
66
+ try:
67
+ async for msg in websocket.iter_json():
68
+ await manager.broadcast(msg)
69
+ finally:
70
+ await manager.disconnect(user_id, websocket)
71
+ """
72
+ if manager is None:
73
+ manager = ConnectionManager()
74
+
75
+ setattr(app.state, _WS_MANAGER_ATTR, manager)
76
+ return manager
77
+
78
+
79
+ def get_ws_manager(app_or_request: FastAPI | Request) -> ConnectionManager:
80
+ """
81
+ Get the WebSocket manager from a FastAPI app or request.
82
+
83
+ Can be used as a FastAPI dependency or called directly.
84
+
85
+ Args:
86
+ app_or_request: Either a FastAPI app instance or a Request object
87
+
88
+ Returns:
89
+ The ConnectionManager instance
90
+
91
+ Raises:
92
+ RuntimeError: If no manager has been added to the app
93
+
94
+ Example (as dependency):
95
+ @app.get("/ws/stats")
96
+ async def ws_stats(manager: ConnectionManager = Depends(get_ws_manager)):
97
+ return {
98
+ "connections": manager.connection_count,
99
+ "users": manager.active_users,
100
+ }
101
+
102
+ Example (direct call):
103
+ @app.websocket("/ws/{user_id}")
104
+ async def ws_endpoint(websocket: WebSocket, user_id: str):
105
+ manager = get_ws_manager(websocket.app)
106
+ await manager.connect(user_id, websocket)
107
+ """
108
+ # Handle both FastAPI app and Request objects
109
+ if hasattr(app_or_request, "app"):
110
+ # It's a Request object
111
+ app = app_or_request.app
112
+ else:
113
+ # It's a FastAPI app
114
+ app = app_or_request
115
+
116
+ manager = getattr(app.state, _WS_MANAGER_ATTR, None)
117
+ if manager is None:
118
+ raise RuntimeError(
119
+ "WebSocket manager not found. Did you forget to call add_websocket_manager(app)?"
120
+ )
121
+ return cast("ConnectionManager", manager)
122
+
123
+
124
+ def get_ws_manager_dependency(request: Request) -> ConnectionManager:
125
+ """
126
+ FastAPI dependency to get the WebSocket manager.
127
+
128
+ This is an alternative to get_ws_manager that works directly as a Depends().
129
+
130
+ Example:
131
+ from fastapi import Depends
132
+
133
+ @app.get("/ws/stats")
134
+ async def ws_stats(
135
+ manager: ConnectionManager = Depends(get_ws_manager_dependency)
136
+ ):
137
+ return {"connections": manager.connection_count}
138
+ """
139
+ return get_ws_manager(request)
@@ -0,0 +1,283 @@
1
+ """
2
+ WebSocket client for connecting to external services.
3
+
4
+ Provides:
5
+ - WebSocketClient: Async WebSocket client with context manager support
6
+ - websocket_connect: Context manager/async iterator for connections
7
+
8
+ Example:
9
+ from svc_infra.websocket import WebSocketClient
10
+
11
+ async with WebSocketClient("wss://api.example.com") as ws:
12
+ await ws.send_json({"type": "hello"})
13
+ async for message in ws:
14
+ print(message)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ from collections.abc import AsyncIterator
22
+ from contextlib import asynccontextmanager
23
+ from typing import TYPE_CHECKING, Any
24
+
25
+ from websockets.asyncio.client import connect
26
+ from websockets.exceptions import ConnectionClosed, ConnectionClosedOK
27
+ from websockets.typing import Subprotocol
28
+
29
+ from .config import WebSocketConfig, get_default_config
30
+ from .exceptions import ConnectionClosedError, ConnectionFailedError, WebSocketError
31
+
32
+ if TYPE_CHECKING:
33
+ from websockets.asyncio.client import ClientConnection
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ class WebSocketClient:
39
+ """
40
+ Async WebSocket client for connecting to external services.
41
+
42
+ Features:
43
+ - Async context manager support
44
+ - Auto-reconnection with exponential backoff (via websocket_connect)
45
+ - Configurable ping/pong keepalive
46
+ - Send text, bytes, or JSON
47
+ - Async iterator for receiving messages
48
+
49
+ Example:
50
+ async with WebSocketClient("wss://api.example.com") as ws:
51
+ await ws.send_json({"type": "hello"})
52
+ async for message in ws:
53
+ print(message)
54
+
55
+ Args:
56
+ url: WebSocket URL (ws:// or wss://)
57
+ config: WebSocket configuration (timeouts, ping/pong, etc.)
58
+ headers: Additional HTTP headers for the handshake
59
+ subprotocols: List of subprotocols to negotiate
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ url: str,
65
+ *,
66
+ config: WebSocketConfig | None = None,
67
+ headers: dict[str, str] | None = None,
68
+ subprotocols: list[str] | None = None,
69
+ ):
70
+ self.url = url
71
+ self.config = config or get_default_config()
72
+ self.headers = headers or {}
73
+ self.subprotocols = subprotocols
74
+ self._connection: ClientConnection | None = None
75
+ self._closed = False
76
+
77
+ async def __aenter__(self) -> WebSocketClient:
78
+ await self.connect()
79
+ return self
80
+
81
+ async def __aexit__(self, *args: object) -> None:
82
+ await self.close()
83
+
84
+ async def connect(self) -> None:
85
+ """Establish WebSocket connection.
86
+
87
+ Raises:
88
+ ConnectionFailedError: If connection cannot be established
89
+ """
90
+ try:
91
+ # Cast subprotocols to Subprotocol type for type safety
92
+ subprotocols_typed: list[Subprotocol] | None = None
93
+ if self.subprotocols:
94
+ subprotocols_typed = [Subprotocol(s) for s in self.subprotocols]
95
+
96
+ self._connection = await connect(
97
+ self.url,
98
+ additional_headers=self.headers,
99
+ subprotocols=subprotocols_typed,
100
+ open_timeout=self.config.open_timeout,
101
+ ping_interval=self.config.ping_interval,
102
+ ping_timeout=self.config.ping_timeout,
103
+ close_timeout=self.config.close_timeout,
104
+ max_size=self.config.max_message_size,
105
+ max_queue=self.config.max_queue_size,
106
+ )
107
+ self._closed = False
108
+ logger.debug("Connected to %s", self.url)
109
+ except Exception as e:
110
+ raise ConnectionFailedError(f"Failed to connect to {self.url}: {e}") from e
111
+
112
+ async def close(self, code: int = 1000, reason: str = "") -> None:
113
+ """Close the connection gracefully.
114
+
115
+ Args:
116
+ code: WebSocket close code (default: 1000 = normal closure)
117
+ reason: Close reason message
118
+ """
119
+ if self._connection and not self._closed:
120
+ self._closed = True
121
+ try:
122
+ await self._connection.close(code=code, reason=reason)
123
+ logger.debug("Closed connection to %s", self.url)
124
+ except Exception as e:
125
+ logger.warning("Error closing connection to %s: %s", self.url, e)
126
+
127
+ async def send(self, data: str | bytes) -> None:
128
+ """Send text or binary message.
129
+
130
+ Args:
131
+ data: Message content (str for text, bytes for binary)
132
+
133
+ Raises:
134
+ WebSocketError: If not connected
135
+ ConnectionClosedError: If connection is closed
136
+ """
137
+ if not self._connection:
138
+ raise WebSocketError("Not connected")
139
+ try:
140
+ await self._connection.send(data)
141
+ except ConnectionClosedOK:
142
+ raise ConnectionClosedError(1000, "Normal closure")
143
+ except ConnectionClosed as e:
144
+ raise ConnectionClosedError(e.code, e.reason) from e
145
+
146
+ async def send_json(self, data: Any) -> None:
147
+ """Send JSON-serialized message.
148
+
149
+ Args:
150
+ data: Object to serialize and send
151
+
152
+ Raises:
153
+ WebSocketError: If not connected
154
+ ConnectionClosedError: If connection is closed
155
+ TypeError/ValueError: If data cannot be serialized
156
+ """
157
+ await self.send(json.dumps(data))
158
+
159
+ async def recv(self) -> str | bytes:
160
+ """Receive next message.
161
+
162
+ Returns:
163
+ Message content (str for text frames, bytes for binary)
164
+
165
+ Raises:
166
+ WebSocketError: If not connected
167
+ ConnectionClosedError: If connection is closed
168
+ """
169
+ if not self._connection:
170
+ raise WebSocketError("Not connected")
171
+ try:
172
+ result = await self._connection.recv()
173
+ return str(result) if isinstance(result, str) else bytes(result)
174
+ except ConnectionClosedOK:
175
+ raise ConnectionClosedError(1000, "Normal closure")
176
+ except ConnectionClosed as e:
177
+ raise ConnectionClosedError(e.code, e.reason) from e
178
+
179
+ async def recv_json(self) -> Any:
180
+ """Receive and parse JSON message.
181
+
182
+ Returns:
183
+ Parsed JSON object
184
+
185
+ Raises:
186
+ WebSocketError: If not connected
187
+ ConnectionClosedError: If connection is closed
188
+ json.JSONDecodeError: If message is not valid JSON
189
+ """
190
+ data = await self.recv()
191
+ if isinstance(data, bytes):
192
+ data = data.decode("utf-8")
193
+ return json.loads(data)
194
+
195
+ async def __aiter__(self) -> AsyncIterator[str | bytes]:
196
+ """Iterate over incoming messages until closed.
197
+
198
+ Yields:
199
+ Message content (str for text, bytes for binary)
200
+
201
+ Raises:
202
+ ConnectionClosedError: If connection is closed abnormally
203
+ """
204
+ if not self._connection:
205
+ raise WebSocketError("Not connected")
206
+ try:
207
+ async for message in self._connection:
208
+ yield message
209
+ except ConnectionClosedOK:
210
+ return
211
+ except ConnectionClosed as e:
212
+ raise ConnectionClosedError(e.code, e.reason) from e
213
+
214
+ @property
215
+ def is_connected(self) -> bool:
216
+ """Check if connection is open."""
217
+ return self._connection is not None and not self._closed
218
+
219
+ @property
220
+ def latency(self) -> float:
221
+ """Connection latency in seconds (from ping/pong).
222
+
223
+ Returns 0.0 if not connected or no ping has been sent.
224
+ """
225
+ return self._connection.latency if self._connection else 0.0
226
+
227
+
228
+ @asynccontextmanager
229
+ async def websocket_connect(
230
+ url: str,
231
+ *,
232
+ config: WebSocketConfig | None = None,
233
+ headers: dict[str, str] | None = None,
234
+ auto_reconnect: bool = False,
235
+ ) -> AsyncIterator[WebSocketClient]:
236
+ """
237
+ Context manager for WebSocket connections.
238
+
239
+ Args:
240
+ url: WebSocket URL (ws:// or wss://)
241
+ config: WebSocket configuration
242
+ headers: Additional HTTP headers
243
+ auto_reconnect: If True, auto-reconnects on connection loss
244
+
245
+ Yields:
246
+ WebSocketClient instance
247
+
248
+ Example (simple):
249
+ async with websocket_connect("wss://api.example.com") as ws:
250
+ await ws.send_json({"hello": "world"})
251
+
252
+ Example (with auto-reconnect):
253
+ # Note: with auto_reconnect=True, this becomes an async iterator
254
+ async for ws in websocket_connect(url, auto_reconnect=True):
255
+ try:
256
+ async for msg in ws:
257
+ process(msg)
258
+ except ConnectionClosedError:
259
+ continue # Will reconnect
260
+ """
261
+ if auto_reconnect:
262
+ # Use websockets' built-in reconnection iterator
263
+ cfg = config or get_default_config()
264
+ async for connection in connect(
265
+ url,
266
+ additional_headers=headers or {},
267
+ open_timeout=cfg.open_timeout,
268
+ ping_interval=cfg.ping_interval,
269
+ ping_timeout=cfg.ping_timeout,
270
+ close_timeout=cfg.close_timeout,
271
+ max_size=cfg.max_message_size,
272
+ ):
273
+ client = WebSocketClient(url, config=config, headers=headers)
274
+ client._connection = connection
275
+ client._closed = False
276
+ yield client
277
+ else:
278
+ client = WebSocketClient(url, config=config, headers=headers)
279
+ await client.connect()
280
+ try:
281
+ yield client
282
+ finally:
283
+ await client.close()