svc-infra 0.1.595__py3-none-any.whl → 0.1.706__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 (256) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +133 -42
  3. svc_infra/apf_payments/provider/aiydan.py +121 -47
  4. svc_infra/apf_payments/provider/base.py +30 -9
  5. svc_infra/apf_payments/provider/stripe.py +156 -62
  6. svc_infra/apf_payments/schemas.py +18 -9
  7. svc_infra/apf_payments/service.py +98 -41
  8. svc_infra/apf_payments/settings.py +5 -1
  9. svc_infra/api/__init__.py +61 -0
  10. svc_infra/api/fastapi/__init__.py +15 -0
  11. svc_infra/api/fastapi/admin/__init__.py +3 -0
  12. svc_infra/api/fastapi/admin/add.py +245 -0
  13. svc_infra/api/fastapi/apf_payments/router.py +128 -70
  14. svc_infra/api/fastapi/apf_payments/setup.py +13 -6
  15. svc_infra/api/fastapi/auth/__init__.py +65 -0
  16. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  17. svc_infra/api/fastapi/auth/add.py +17 -14
  18. svc_infra/api/fastapi/auth/gaurd.py +45 -16
  19. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  20. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  21. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  22. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  23. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  24. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  25. svc_infra/api/fastapi/auth/policy.py +0 -1
  26. svc_infra/api/fastapi/auth/providers.py +3 -1
  27. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  28. svc_infra/api/fastapi/auth/routers/oauth_router.py +146 -52
  29. svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
  30. svc_infra/api/fastapi/auth/security.py +31 -10
  31. svc_infra/api/fastapi/auth/sender.py +8 -1
  32. svc_infra/api/fastapi/auth/state.py +3 -1
  33. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  34. svc_infra/api/fastapi/billing/router.py +73 -0
  35. svc_infra/api/fastapi/billing/setup.py +19 -0
  36. svc_infra/api/fastapi/cache/add.py +9 -5
  37. svc_infra/api/fastapi/db/__init__.py +5 -1
  38. svc_infra/api/fastapi/db/http.py +3 -1
  39. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  40. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  41. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  42. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  43. svc_infra/api/fastapi/db/sql/add.py +71 -26
  44. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  45. svc_infra/api/fastapi/db/sql/health.py +3 -1
  46. svc_infra/api/fastapi/db/sql/session.py +18 -0
  47. svc_infra/api/fastapi/db/sql/users.py +18 -6
  48. svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
  49. svc_infra/api/fastapi/docs/add.py +173 -0
  50. svc_infra/api/fastapi/docs/landing.py +4 -2
  51. svc_infra/api/fastapi/docs/scoped.py +62 -15
  52. svc_infra/api/fastapi/dual/__init__.py +12 -2
  53. svc_infra/api/fastapi/dual/dualize.py +1 -1
  54. svc_infra/api/fastapi/dual/protected.py +126 -4
  55. svc_infra/api/fastapi/dual/public.py +25 -0
  56. svc_infra/api/fastapi/dual/router.py +40 -13
  57. svc_infra/api/fastapi/dx.py +33 -2
  58. svc_infra/api/fastapi/ease.py +10 -2
  59. svc_infra/api/fastapi/http/concurrency.py +2 -1
  60. svc_infra/api/fastapi/http/conditional.py +3 -1
  61. svc_infra/api/fastapi/middleware/debug.py +4 -1
  62. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
  71. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  74. svc_infra/api/fastapi/openapi/apply.py +5 -3
  75. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  76. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  77. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  78. svc_infra/api/fastapi/openapi/security.py +3 -1
  79. svc_infra/api/fastapi/ops/add.py +75 -0
  80. svc_infra/api/fastapi/pagination.py +47 -20
  81. svc_infra/api/fastapi/routers/__init__.py +43 -15
  82. svc_infra/api/fastapi/routers/ping.py +1 -0
  83. svc_infra/api/fastapi/setup.py +188 -57
  84. svc_infra/api/fastapi/tenancy/add.py +19 -0
  85. svc_infra/api/fastapi/tenancy/context.py +112 -0
  86. svc_infra/api/fastapi/versioned.py +101 -0
  87. svc_infra/app/README.md +5 -5
  88. svc_infra/app/__init__.py +3 -1
  89. svc_infra/app/env.py +69 -1
  90. svc_infra/app/logging/add.py +9 -2
  91. svc_infra/app/logging/formats.py +12 -5
  92. svc_infra/billing/__init__.py +23 -0
  93. svc_infra/billing/async_service.py +147 -0
  94. svc_infra/billing/jobs.py +241 -0
  95. svc_infra/billing/models.py +177 -0
  96. svc_infra/billing/quotas.py +103 -0
  97. svc_infra/billing/schemas.py +36 -0
  98. svc_infra/billing/service.py +123 -0
  99. svc_infra/bundled_docs/README.md +5 -0
  100. svc_infra/bundled_docs/__init__.py +1 -0
  101. svc_infra/bundled_docs/getting-started.md +6 -0
  102. svc_infra/cache/__init__.py +9 -0
  103. svc_infra/cache/add.py +170 -0
  104. svc_infra/cache/backend.py +7 -6
  105. svc_infra/cache/decorators.py +81 -15
  106. svc_infra/cache/demo.py +2 -2
  107. svc_infra/cache/keys.py +24 -4
  108. svc_infra/cache/recache.py +26 -14
  109. svc_infra/cache/resources.py +14 -5
  110. svc_infra/cache/tags.py +19 -44
  111. svc_infra/cache/utils.py +3 -1
  112. svc_infra/cli/__init__.py +52 -8
  113. svc_infra/cli/__main__.py +4 -0
  114. svc_infra/cli/cmds/__init__.py +39 -2
  115. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  116. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  117. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  118. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  119. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  120. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  121. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  122. svc_infra/cli/cmds/dx/__init__.py +12 -0
  123. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  124. svc_infra/cli/cmds/health/__init__.py +179 -0
  125. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  126. svc_infra/cli/cmds/help.py +4 -0
  127. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  128. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  129. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  130. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  131. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  132. svc_infra/cli/foundation/runner.py +6 -2
  133. svc_infra/data/add.py +61 -0
  134. svc_infra/data/backup.py +58 -0
  135. svc_infra/data/erasure.py +45 -0
  136. svc_infra/data/fixtures.py +42 -0
  137. svc_infra/data/retention.py +61 -0
  138. svc_infra/db/__init__.py +15 -0
  139. svc_infra/db/crud_schema.py +9 -9
  140. svc_infra/db/inbox.py +67 -0
  141. svc_infra/db/nosql/__init__.py +3 -0
  142. svc_infra/db/nosql/core.py +30 -9
  143. svc_infra/db/nosql/indexes.py +3 -1
  144. svc_infra/db/nosql/management.py +1 -1
  145. svc_infra/db/nosql/mongo/README.md +13 -13
  146. svc_infra/db/nosql/mongo/client.py +19 -2
  147. svc_infra/db/nosql/mongo/settings.py +6 -2
  148. svc_infra/db/nosql/repository.py +35 -15
  149. svc_infra/db/nosql/resource.py +20 -3
  150. svc_infra/db/nosql/scaffold.py +9 -3
  151. svc_infra/db/nosql/service.py +3 -1
  152. svc_infra/db/nosql/types.py +6 -2
  153. svc_infra/db/ops.py +384 -0
  154. svc_infra/db/outbox.py +108 -0
  155. svc_infra/db/sql/apikey.py +37 -9
  156. svc_infra/db/sql/authref.py +9 -3
  157. svc_infra/db/sql/constants.py +12 -8
  158. svc_infra/db/sql/core.py +2 -2
  159. svc_infra/db/sql/management.py +11 -8
  160. svc_infra/db/sql/repository.py +99 -26
  161. svc_infra/db/sql/resource.py +5 -0
  162. svc_infra/db/sql/scaffold.py +6 -2
  163. svc_infra/db/sql/service.py +15 -5
  164. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  165. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  166. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  167. svc_infra/db/sql/tenant.py +88 -0
  168. svc_infra/db/sql/uniq_hooks.py +9 -3
  169. svc_infra/db/sql/utils.py +138 -51
  170. svc_infra/db/sql/versioning.py +14 -0
  171. svc_infra/deploy/__init__.py +538 -0
  172. svc_infra/documents/__init__.py +100 -0
  173. svc_infra/documents/add.py +264 -0
  174. svc_infra/documents/ease.py +233 -0
  175. svc_infra/documents/models.py +114 -0
  176. svc_infra/documents/storage.py +264 -0
  177. svc_infra/dx/add.py +65 -0
  178. svc_infra/dx/changelog.py +74 -0
  179. svc_infra/dx/checks.py +68 -0
  180. svc_infra/exceptions.py +141 -0
  181. svc_infra/health/__init__.py +864 -0
  182. svc_infra/http/__init__.py +13 -0
  183. svc_infra/http/client.py +105 -0
  184. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  185. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  186. svc_infra/jobs/easy.py +33 -0
  187. svc_infra/jobs/loader.py +50 -0
  188. svc_infra/jobs/queue.py +116 -0
  189. svc_infra/jobs/redis_queue.py +256 -0
  190. svc_infra/jobs/runner.py +79 -0
  191. svc_infra/jobs/scheduler.py +53 -0
  192. svc_infra/jobs/worker.py +40 -0
  193. svc_infra/loaders/__init__.py +186 -0
  194. svc_infra/loaders/base.py +142 -0
  195. svc_infra/loaders/github.py +311 -0
  196. svc_infra/loaders/models.py +147 -0
  197. svc_infra/loaders/url.py +235 -0
  198. svc_infra/logging/__init__.py +374 -0
  199. svc_infra/mcp/svc_infra_mcp.py +91 -33
  200. svc_infra/obs/README.md +2 -0
  201. svc_infra/obs/add.py +65 -9
  202. svc_infra/obs/cloud_dash.py +2 -1
  203. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  204. svc_infra/obs/metrics/__init__.py +3 -4
  205. svc_infra/obs/metrics/asgi.py +13 -7
  206. svc_infra/obs/metrics/http.py +9 -5
  207. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  208. svc_infra/obs/metrics.py +6 -5
  209. svc_infra/obs/settings.py +6 -2
  210. svc_infra/security/add.py +217 -0
  211. svc_infra/security/audit.py +92 -10
  212. svc_infra/security/audit_service.py +4 -3
  213. svc_infra/security/headers.py +15 -2
  214. svc_infra/security/hibp.py +14 -4
  215. svc_infra/security/jwt_rotation.py +74 -22
  216. svc_infra/security/lockout.py +11 -5
  217. svc_infra/security/models.py +54 -12
  218. svc_infra/security/oauth_models.py +73 -0
  219. svc_infra/security/org_invites.py +5 -3
  220. svc_infra/security/passwords.py +3 -1
  221. svc_infra/security/permissions.py +25 -2
  222. svc_infra/security/session.py +1 -1
  223. svc_infra/security/signed_cookies.py +21 -1
  224. svc_infra/storage/__init__.py +93 -0
  225. svc_infra/storage/add.py +253 -0
  226. svc_infra/storage/backends/__init__.py +11 -0
  227. svc_infra/storage/backends/local.py +339 -0
  228. svc_infra/storage/backends/memory.py +216 -0
  229. svc_infra/storage/backends/s3.py +353 -0
  230. svc_infra/storage/base.py +239 -0
  231. svc_infra/storage/easy.py +185 -0
  232. svc_infra/storage/settings.py +195 -0
  233. svc_infra/testing/__init__.py +685 -0
  234. svc_infra/utils.py +7 -3
  235. svc_infra/webhooks/__init__.py +69 -0
  236. svc_infra/webhooks/add.py +339 -0
  237. svc_infra/webhooks/encryption.py +115 -0
  238. svc_infra/webhooks/fastapi.py +39 -0
  239. svc_infra/webhooks/router.py +55 -0
  240. svc_infra/webhooks/service.py +70 -0
  241. svc_infra/webhooks/signing.py +34 -0
  242. svc_infra/websocket/__init__.py +79 -0
  243. svc_infra/websocket/add.py +140 -0
  244. svc_infra/websocket/client.py +282 -0
  245. svc_infra/websocket/config.py +69 -0
  246. svc_infra/websocket/easy.py +76 -0
  247. svc_infra/websocket/exceptions.py +61 -0
  248. svc_infra/websocket/manager.py +344 -0
  249. svc_infra/websocket/models.py +49 -0
  250. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  251. svc_infra-0.1.706.dist-info/METADATA +356 -0
  252. svc_infra-0.1.706.dist-info/RECORD +357 -0
  253. svc_infra-0.1.595.dist-info/METADATA +0 -80
  254. svc_infra-0.1.595.dist-info/RECORD +0 -253
  255. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  256. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,275 @@
1
+ """WebSocket authentication primitives.
2
+
3
+ This module provides lightweight JWT-based authentication for WebSocket endpoints.
4
+ Unlike HTTP auth which requires DB access, WS auth uses JWT claims only, making it
5
+ suitable for high-frequency real-time connections.
6
+
7
+ Usage:
8
+ from svc_infra.api.fastapi.auth.ws_security import WSIdentity
9
+
10
+ @router.websocket("/ws")
11
+ async def ws_handler(websocket: WebSocket, user: WSIdentity):
12
+ # user.id, user.email, user.scopes available from JWT claims
13
+ await websocket.accept()
14
+ ...
15
+
16
+ For router-level dependencies (protects all endpoints):
17
+ from svc_infra.api.fastapi.auth.ws_security import RequireWSIdentity
18
+
19
+ router = DualAPIRouter(dependencies=[RequireWSIdentity])
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass, field
25
+ from typing import Annotated, Any, cast
26
+
27
+ import jwt
28
+ from fastapi import Depends, WebSocket, WebSocketException, status
29
+
30
+ from svc_infra.api.fastapi.auth.settings import get_auth_settings
31
+
32
+
33
+ # ---------- WSPrincipal ----------
34
+ @dataclass
35
+ class WSPrincipal:
36
+ """Lightweight principal for WebSocket connections.
37
+
38
+ Unlike the HTTP `Principal` which loads the full user from DB,
39
+ `WSPrincipal` contains only JWT claims. This makes it suitable
40
+ for high-frequency real-time connections without DB overhead.
41
+
42
+ Attributes:
43
+ id: User ID from JWT 'sub' claim (typically UUID string)
44
+ email: User email from JWT 'email' claim (if present)
45
+ scopes: List of scopes/permissions from JWT 'scopes' claim
46
+ claims: Full JWT payload for custom claim access
47
+ via: Authentication method ('query', 'header', 'subprotocol')
48
+ """
49
+
50
+ id: str
51
+ email: str | None = None
52
+ scopes: list[str] = field(default_factory=list)
53
+ claims: dict = field(default_factory=dict)
54
+ via: str = "query" # 'query' | 'header' | 'subprotocol'
55
+
56
+
57
+ # ---------- Token extraction ----------
58
+ def _extract_token(websocket: WebSocket) -> tuple[str | None, str]:
59
+ """Extract JWT token from WebSocket connection.
60
+
61
+ Tries extraction in order:
62
+ 1. Query parameter: ?token=xxx
63
+ 2. Authorization header: Bearer xxx
64
+ 3. Sec-WebSocket-Protocol header (for browser clients that can't set headers)
65
+
66
+ Returns:
67
+ Tuple of (token, source) where source is 'query', 'header', or 'subprotocol'
68
+ """
69
+ # 1. Query parameter (most common for WebSocket)
70
+ token = websocket.query_params.get("token")
71
+ if token:
72
+ return token.strip(), "query"
73
+
74
+ # 2. Authorization header
75
+ auth_header = websocket.headers.get("authorization", "")
76
+ if auth_header.lower().startswith("bearer "):
77
+ token = auth_header.split(" ", 1)[1].strip()
78
+ if token:
79
+ return token, "header"
80
+
81
+ # 3. Sec-WebSocket-Protocol (browser workaround)
82
+ # Some clients send token as: Sec-WebSocket-Protocol: bearer, <token>
83
+ protocol = websocket.headers.get("sec-websocket-protocol", "")
84
+ if protocol:
85
+ parts = [p.strip() for p in protocol.split(",")]
86
+ # Look for token after 'bearer' protocol
87
+ for i, part in enumerate(parts):
88
+ if part.lower() == "bearer" and i + 1 < len(parts):
89
+ return parts[i + 1], "subprotocol"
90
+
91
+ return None, ""
92
+
93
+
94
+ def _decode_jwt(token: str) -> dict:
95
+ """Decode and validate JWT token.
96
+
97
+ Uses the same JWT settings as HTTP auth (AUTH_JWT__SECRET).
98
+ Supports key rotation via old_secrets.
99
+
100
+ Returns:
101
+ JWT payload dict
102
+
103
+ Raises:
104
+ WebSocketException: If token is invalid or expired
105
+ """
106
+ settings = get_auth_settings()
107
+
108
+ if not settings.jwt:
109
+ raise WebSocketException(
110
+ code=status.WS_1008_POLICY_VIOLATION,
111
+ reason="JWT not configured",
112
+ )
113
+
114
+ secret = settings.jwt.secret.get_secret_value()
115
+ old_secrets = [s.get_secret_value() for s in (settings.jwt.old_secrets or [])]
116
+ all_secrets = [secret] + old_secrets
117
+
118
+ last_error: Exception | None = None
119
+
120
+ for s in all_secrets:
121
+ try:
122
+ payload = jwt.decode(
123
+ token,
124
+ s,
125
+ algorithms=["HS256"],
126
+ options={"require": ["sub", "exp"]},
127
+ )
128
+ return cast(dict[Any, Any], payload)
129
+ except jwt.ExpiredSignatureError:
130
+ raise WebSocketException(
131
+ code=status.WS_1008_POLICY_VIOLATION,
132
+ reason="Token expired",
133
+ )
134
+ except jwt.InvalidTokenError as e:
135
+ last_error = e
136
+ continue
137
+
138
+ # None of the secrets worked
139
+ raise WebSocketException(
140
+ code=status.WS_1008_POLICY_VIOLATION,
141
+ reason=f"Invalid token: {last_error}",
142
+ )
143
+
144
+
145
+ # ---------- Resolvers ----------
146
+ async def resolve_ws_bearer_principal(websocket: WebSocket) -> WSPrincipal | None:
147
+ """Extract and validate JWT from WebSocket, returning WSPrincipal or None.
148
+
149
+ This is the optional resolver - returns None if no token present.
150
+ Use `_ws_current_principal` for required authentication.
151
+
152
+ Token sources (in order):
153
+ 1. Query parameter: ?token=xxx
154
+ 2. Authorization header: Bearer xxx
155
+ 3. Sec-WebSocket-Protocol: bearer, xxx
156
+ """
157
+ token, source = _extract_token(websocket)
158
+ if not token:
159
+ return None
160
+
161
+ payload = _decode_jwt(token)
162
+
163
+ return WSPrincipal(
164
+ id=str(payload.get("sub", "")),
165
+ email=payload.get("email"),
166
+ scopes=payload.get("scopes", []) or payload.get("scope", "").split(),
167
+ claims=payload,
168
+ via=source,
169
+ )
170
+
171
+
172
+ async def _ws_current_principal(
173
+ websocket: WebSocket,
174
+ principal: WSPrincipal | None = Depends(resolve_ws_bearer_principal),
175
+ ) -> WSPrincipal:
176
+ """Require authenticated WebSocket connection.
177
+
178
+ Use this as a dependency to require authentication.
179
+ Closes connection with 1008 (Policy Violation) if no valid token.
180
+ """
181
+ if not principal:
182
+ raise WebSocketException(
183
+ code=status.WS_1008_POLICY_VIOLATION,
184
+ reason="Missing or invalid authentication",
185
+ )
186
+ return principal
187
+
188
+
189
+ async def _ws_optional_principal(
190
+ websocket: WebSocket,
191
+ principal: WSPrincipal | None = Depends(resolve_ws_bearer_principal),
192
+ ) -> WSPrincipal | None:
193
+ """Optional WebSocket authentication.
194
+
195
+ Returns None if no token present, WSPrincipal if valid token.
196
+ """
197
+ return principal
198
+
199
+
200
+ # ---------- DX: types for endpoint params ----------
201
+ WSIdentity = Annotated[WSPrincipal, Depends(_ws_current_principal)]
202
+ """Annotated type for required WebSocket authentication.
203
+
204
+ Usage:
205
+ @router.websocket("/ws")
206
+ async def handler(websocket: WebSocket, user: WSIdentity):
207
+ # user.id, user.email, user.scopes available
208
+ ...
209
+ """
210
+
211
+ OptionalWSIdentity = Annotated[WSPrincipal | None, Depends(_ws_optional_principal)]
212
+ """Annotated type for optional WebSocket authentication.
213
+
214
+ Usage:
215
+ @router.websocket("/ws")
216
+ async def handler(websocket: WebSocket, user: OptionalWSIdentity):
217
+ if user:
218
+ # authenticated
219
+ else:
220
+ # anonymous
221
+ ...
222
+ """
223
+
224
+
225
+ # ---------- DX: constants for router-level dependencies ----------
226
+ RequireWSIdentity = Depends(_ws_current_principal)
227
+ """Router-level dependency for required WebSocket authentication.
228
+
229
+ Usage:
230
+ router = DualAPIRouter(dependencies=[RequireWSIdentity])
231
+ """
232
+
233
+ AllowWSIdentity = Depends(_ws_optional_principal)
234
+ """Router-level dependency for optional WebSocket authentication.
235
+
236
+ Usage:
237
+ router = DualAPIRouter(dependencies=[AllowWSIdentity])
238
+ """
239
+
240
+
241
+ # ---------- DX: guard factories ----------
242
+ def RequireWSScopes(*needed: str):
243
+ """Require specific scopes for WebSocket connection.
244
+
245
+ Usage:
246
+ router = DualAPIRouter(dependencies=[RequireWSScopes("chat:read", "chat:write")])
247
+ """
248
+
249
+ async def _guard(principal: WSIdentity) -> WSPrincipal:
250
+ if not set(needed).issubset(set(principal.scopes or [])):
251
+ raise WebSocketException(
252
+ code=status.WS_1008_POLICY_VIOLATION,
253
+ reason="Insufficient scope",
254
+ )
255
+ return principal
256
+
257
+ return Depends(_guard)
258
+
259
+
260
+ def RequireWSAnyScope(*candidates: str):
261
+ """Require at least one of the specified scopes.
262
+
263
+ Usage:
264
+ router = DualAPIRouter(dependencies=[RequireWSAnyScope("admin", "moderator")])
265
+ """
266
+
267
+ async def _guard(principal: WSIdentity) -> WSPrincipal:
268
+ if not set(principal.scopes or []) & set(candidates):
269
+ raise WebSocketException(
270
+ code=status.WS_1008_POLICY_VIOLATION,
271
+ reason="Insufficient scope",
272
+ )
273
+ return principal
274
+
275
+ return Depends(_guard)
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Annotated, Optional
5
+
6
+ from fastapi import APIRouter, Depends, Response, status
7
+
8
+ from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
9
+ from svc_infra.api.fastapi.middleware.idempotency import require_idempotency_key
10
+ from svc_infra.api.fastapi.tenancy.context import TenantId
11
+ from svc_infra.billing.async_service import AsyncBillingService
12
+ from svc_infra.billing.schemas import (
13
+ UsageAckOut,
14
+ UsageAggregateRow,
15
+ UsageAggregatesOut,
16
+ UsageIn,
17
+ )
18
+
19
+ router = APIRouter(prefix="/_billing", tags=["Billing"])
20
+
21
+
22
+ def get_service(tenant_id: TenantId, session: SqlSessionDep) -> AsyncBillingService:
23
+ return AsyncBillingService(session=session, tenant_id=tenant_id)
24
+
25
+
26
+ @router.post(
27
+ "/usage",
28
+ name="billing_record_usage",
29
+ status_code=status.HTTP_202_ACCEPTED,
30
+ response_model=UsageAckOut,
31
+ dependencies=[Depends(require_idempotency_key)],
32
+ )
33
+ async def record_usage(
34
+ data: UsageIn,
35
+ svc: Annotated[AsyncBillingService, Depends(get_service)],
36
+ response: Response,
37
+ ):
38
+ at = data.at or datetime.now(tz=timezone.utc)
39
+ evt_id = await svc.record_usage(
40
+ metric=data.metric,
41
+ amount=int(data.amount),
42
+ at=at,
43
+ idempotency_key=data.idempotency_key,
44
+ metadata=data.metadata,
45
+ )
46
+ # For 202, no Location header is required, but we can surface the id in the body
47
+ return UsageAckOut(id=evt_id, accepted=True)
48
+
49
+
50
+ @router.get(
51
+ "/usage",
52
+ name="billing_list_aggregates",
53
+ response_model=UsageAggregatesOut,
54
+ )
55
+ async def list_aggregates(
56
+ metric: str,
57
+ date_from: Optional[datetime] = None,
58
+ date_to: Optional[datetime] = None,
59
+ svc: Annotated[AsyncBillingService, Depends(get_service)] = None, # type: ignore[assignment]
60
+ ):
61
+ rows = await svc.list_daily_aggregates(
62
+ metric=metric, date_from=date_from, date_to=date_to
63
+ )
64
+ items = [
65
+ UsageAggregateRow(
66
+ period_start=r.period_start,
67
+ granularity=r.granularity,
68
+ metric=r.metric,
69
+ total=int(r.total),
70
+ )
71
+ for r in rows
72
+ ]
73
+ return UsageAggregatesOut(items=items, next_cursor=None)
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import FastAPI
4
+
5
+ from .router import router as billing_router
6
+
7
+
8
+ def add_billing(app: FastAPI, *, prefix: str = "/_billing") -> None:
9
+ # Mount under the chosen prefix; default is /_billing
10
+ if prefix and prefix != "/_billing":
11
+ # If a custom prefix is desired, clone router with new prefix
12
+ from fastapi import APIRouter
13
+
14
+ custom = APIRouter(prefix=prefix, tags=["Billing"])
15
+ for route in billing_router.routes:
16
+ custom.routes.append(route)
17
+ app.include_router(custom)
18
+ else:
19
+ app.include_router(billing_router)
@@ -1,3 +1,5 @@
1
+ from contextlib import asynccontextmanager
2
+
1
3
  from fastapi import FastAPI
2
4
 
3
5
  from svc_infra.cache.backend import shutdown_cache
@@ -5,10 +7,12 @@ from svc_infra.cache.decorators import init_cache
5
7
 
6
8
 
7
9
  def setup_caching(app: FastAPI) -> None:
8
- @app.on_event("startup")
9
- async def _startup():
10
+ @asynccontextmanager
11
+ async def lifespan(_app: FastAPI):
10
12
  init_cache()
13
+ try:
14
+ yield
15
+ finally:
16
+ await shutdown_cache()
11
17
 
12
- @app.on_event("shutdown")
13
- async def _shutdown():
14
- await shutdown_cache()
18
+ app.router.lifespan_context = lifespan
@@ -1,4 +1,8 @@
1
- from svc_infra.api.fastapi.db.nosql import add_mongo_db, add_mongo_health, add_mongo_resources
1
+ from svc_infra.api.fastapi.db.nosql import (
2
+ add_mongo_db,
3
+ add_mongo_health,
4
+ add_mongo_resources,
5
+ )
2
6
  from svc_infra.api.fastapi.db.sql import add_sql_db, add_sql_health, add_sql_resources
3
7
 
4
8
  __all__ = [
@@ -25,7 +25,9 @@ class OrderParams(BaseModel):
25
25
 
26
26
 
27
27
  def dep_order(
28
- order_by: Optional[str] = Query(None, description="Comma-separated fields; '-' for DESC"),
28
+ order_by: Optional[str] = Query(
29
+ None, description="Comma-separated fields; '-' for DESC"
30
+ ),
29
31
  ) -> OrderParams:
30
32
  return OrderParams(order_by=order_by)
31
33
 
@@ -1,4 +1,42 @@
1
- from .mongo.add import add_mongo_db, add_mongo_health, add_mongo_resources
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from fastapi import FastAPI
8
+
9
+ from svc_infra.db.nosql.resource import NoSqlResource
10
+
11
+
12
+ def _missing_mongo_dependency() -> ModuleNotFoundError:
13
+ return ModuleNotFoundError(
14
+ "MongoDB support is an optional dependency. Install pymongo (and motor) to use "
15
+ "Mongo helpers like add_mongo_db/add_mongo_health/add_mongo_resources."
16
+ )
17
+
18
+
19
+ try:
20
+ from .mongo.add import add_mongo_db, add_mongo_health, add_mongo_resources
21
+ except ModuleNotFoundError as exc:
22
+ mongo_import_error = exc
23
+
24
+ # NOTE: pymongo provides `bson`, which can be absent in minimal installs/CI.
25
+ # We keep imports working for non-mongo users/tests by providing stubs.
26
+ def add_mongo_db(app: FastAPI, *, dsn_env: str = "MONGO_URL") -> None:
27
+ raise _missing_mongo_dependency() from mongo_import_error
28
+
29
+ def add_mongo_health(
30
+ app: FastAPI,
31
+ *,
32
+ prefix: str = "/_mongo/health",
33
+ include_in_schema: bool = False,
34
+ ) -> None:
35
+ raise _missing_mongo_dependency() from mongo_import_error
36
+
37
+ def add_mongo_resources(app: FastAPI, resources: Sequence[NoSqlResource]) -> None:
38
+ raise _missing_mongo_dependency() from mongo_import_error
39
+
2
40
 
3
41
  __all__ = [
4
42
  # MongoDB
@@ -24,12 +24,15 @@ from .health import make_mongo_health_router
24
24
  def add_mongo_db_with_url(app: FastAPI, url: str, db_name: str) -> None:
25
25
  @asynccontextmanager
26
26
  async def lifespan(_app: FastAPI):
27
- await init_mongo(MongoSettings(url=url, db_name=db_name))
27
+ # MongoSettings expects url as AnyUrl, which can be constructed from str via Pydantic
28
+ await init_mongo(MongoSettings(url=url, db_name=db_name)) # type: ignore[arg-type] # Pydantic coerces str to AnyUrl
28
29
  try:
29
30
  expected = get_mongo_dbname_from_env(required=False)
30
31
  db = await acquire_db()
31
32
  if expected and db.name != expected:
32
- raise RuntimeError(f"Connected to Mongo DB '{db.name}', expected '{expected}'.")
33
+ raise RuntimeError(
34
+ f"Connected to Mongo DB '{db.name}', expected '{expected}'."
35
+ )
33
36
  yield
34
37
  finally:
35
38
  await close_mongo()
@@ -38,19 +41,23 @@ def add_mongo_db_with_url(app: FastAPI, url: str, db_name: str) -> None:
38
41
 
39
42
 
40
43
  def add_mongo_db(app: FastAPI, *, dsn_env: str = "MONGO_URL") -> None:
41
- @app.on_event("startup")
42
- async def _startup() -> None:
44
+ @asynccontextmanager
45
+ async def lifespan(_app: FastAPI):
43
46
  if not os.getenv(dsn_env):
44
47
  raise RuntimeError(f"Missing environment variable {dsn_env} for Mongo URL")
45
48
  await init_mongo()
46
49
  expected = get_mongo_dbname_from_env(required=False)
47
50
  db = await acquire_db()
48
51
  if expected and db.name != expected:
49
- raise RuntimeError(f"Connected to Mongo DB '{db.name}', expected '{expected}'.")
52
+ raise RuntimeError(
53
+ f"Connected to Mongo DB '{db.name}', expected '{expected}'."
54
+ )
55
+ try:
56
+ yield
57
+ finally:
58
+ await close_mongo()
50
59
 
51
- @app.on_event("shutdown")
52
- async def _shutdown() -> None:
53
- await close_mongo()
60
+ app.router.lifespan_context = lifespan
54
61
 
55
62
 
56
63
  def add_mongo_health(
@@ -58,50 +65,58 @@ def add_mongo_health(
58
65
  ) -> None:
59
66
  if include_in_schema is None:
60
67
  include_in_schema = CURRENT_ENVIRONMENT == LOCAL_ENV
61
- app.include_router(make_mongo_health_router(prefix=prefix, include_in_schema=include_in_schema))
68
+ app.include_router(
69
+ make_mongo_health_router(prefix=prefix, include_in_schema=include_in_schema)
70
+ )
62
71
 
63
72
 
64
73
  def add_mongo_resources(app: FastAPI, resources: Sequence[NoSqlResource]) -> None:
65
- for r in resources:
74
+ for resource in resources:
66
75
  repo = NoSqlRepository(
67
- collection_name=r.resolved_collection(),
68
- id_field=r.id_field,
69
- soft_delete=r.soft_delete,
70
- soft_delete_field=r.soft_delete_field,
71
- soft_delete_flag_field=r.soft_delete_flag_field,
76
+ collection_name=resource.resolved_collection(),
77
+ id_field=resource.id_field,
78
+ soft_delete=resource.soft_delete,
79
+ soft_delete_field=resource.soft_delete_field,
80
+ soft_delete_flag_field=resource.soft_delete_flag_field,
81
+ )
82
+ svc = (
83
+ resource.service_factory(repo)
84
+ if resource.service_factory
85
+ else NoSqlService(repo)
72
86
  )
73
- svc = r.service_factory(repo) if r.service_factory else NoSqlService(repo)
74
87
 
75
- if r.read_schema and r.create_schema and r.update_schema:
76
- Read, Create, Update = r.read_schema, r.create_schema, r.update_schema
77
- elif r.document_model is not None:
88
+ if resource.read_schema and resource.create_schema and resource.update_schema:
89
+ Read, Create, Update = (
90
+ resource.read_schema,
91
+ resource.create_schema,
92
+ resource.update_schema,
93
+ )
94
+ elif resource.document_model is not None:
78
95
  # CRITICAL: teach Pydantic to dump ObjectId/PyObjectId
79
96
  Read, Create, Update = make_document_crud_schemas(
80
- r.document_model,
81
- create_exclude=r.create_exclude,
82
- read_name=r.read_name,
83
- create_name=r.create_name,
84
- update_name=r.update_name,
85
- read_exclude=r.read_exclude,
86
- update_exclude=r.update_exclude,
97
+ resource.document_model,
98
+ create_exclude=resource.create_exclude,
99
+ read_name=resource.read_name,
100
+ create_name=resource.create_name,
101
+ update_name=resource.update_name,
102
+ read_exclude=resource.read_exclude,
103
+ update_exclude=resource.update_exclude,
87
104
  json_encoders={ObjectId: str, PyObjectId: str},
88
105
  )
89
106
  else:
90
107
  raise RuntimeError(
91
- f"Resource for collection '{r.collection}' requires either explicit schemas "
108
+ f"Resource for collection '{resource.collection}' requires either explicit schemas "
92
109
  f"(read/create/update) or a 'document_model' to derive them."
93
110
  )
94
111
 
95
112
  router = make_crud_router_plus_mongo(
96
- collection=r.resolved_collection(),
97
- repo=repo,
98
113
  service=svc,
99
114
  read_schema=Read,
100
115
  create_schema=Create,
101
116
  update_schema=Update,
102
- prefix=r.prefix,
103
- tags=r.tags,
104
- search_fields=r.search_fields,
117
+ prefix=resource.prefix,
118
+ tags=resource.tags,
119
+ search_fields=resource.search_fields,
105
120
  default_ordering=None,
106
121
  allowed_order_fields=None,
107
122
  )
@@ -1,7 +1,14 @@
1
1
  from typing import Annotated, Any, Optional, Sequence, Type, cast
2
2
 
3
3
  from fastapi import APIRouter, Body, Depends, HTTPException
4
- from motor.motor_asyncio import AsyncIOMotorDatabase
4
+
5
+ try:
6
+ from motor.motor_asyncio import AsyncIOMotorDatabase
7
+
8
+ HAS_MOTOR = True
9
+ except ImportError: # pragma: no cover
10
+ HAS_MOTOR = False
11
+ AsyncIOMotorDatabase = Any # type: ignore[assignment, misc]
5
12
 
6
13
  from svc_infra.api.fastapi.db.http import (
7
14
  LimitOffsetParams,
@@ -46,6 +53,9 @@ def make_crud_router_plus_mongo(
46
53
  allowed_order_fields: Optional[list[str]] = None,
47
54
  mount_under_db_prefix: bool = True,
48
55
  ) -> APIRouter:
56
+ read_model = cast(Any, read_schema)
57
+ page_model = cast(Any, Page[read_schema]) # type: ignore[valid-type]
58
+
49
59
  router_prefix = ("/_mongo" + prefix) if mount_under_db_prefix else prefix
50
60
  router = public_router(
51
61
  prefix=router_prefix,
@@ -56,7 +66,7 @@ def make_crud_router_plus_mongo(
56
66
  # LIST
57
67
  @router.get(
58
68
  "",
59
- response_model=cast(Any, Page[read_schema]),
69
+ response_model=page_model,
60
70
  description=f"List items in {prefix} collection",
61
71
  )
62
72
  async def list_items(
@@ -68,20 +78,25 @@ def make_crud_router_plus_mongo(
68
78
  sort = _parse_sort(op.order_by or default_ordering, allowed_order_fields)
69
79
  if sp.q and search_fields:
70
80
  items = await service.search(
71
- db, q=sp.q, fields=search_fields, limit=lp.limit, offset=lp.offset, sort=sort
81
+ db,
82
+ q=sp.q,
83
+ fields=search_fields,
84
+ limit=lp.limit,
85
+ offset=lp.offset,
86
+ sort=sort,
72
87
  )
73
88
  total = await service.count_filtered(db, q=sp.q, fields=search_fields)
74
89
  else:
75
90
  items = await service.list(db, limit=lp.limit, offset=lp.offset, sort=sort)
76
91
  total = await service.count(db)
77
- return Page[read_schema].from_items(
92
+ return Page[Any].from_items(
78
93
  total=total, items=items, limit=lp.limit, offset=lp.offset
79
94
  )
80
95
 
81
96
  # GET by id
82
97
  @router.get(
83
98
  "/{item_id}",
84
- response_model=cast(Any, read_schema),
99
+ response_model=read_model,
85
100
  description=f"Get item from {prefix} collection",
86
101
  )
87
102
  async def get_item(db: DBDep, item_id: Any):
@@ -93,22 +108,26 @@ def make_crud_router_plus_mongo(
93
108
  # CREATE
94
109
  @router.post(
95
110
  "",
96
- response_model=cast(Any, read_schema),
111
+ response_model=read_model,
97
112
  status_code=201,
98
113
  description=f"Create item in {prefix} collection",
99
114
  )
100
- async def create_item(db: DBDep, payload: create_schema = Body(...)):
101
- data = payload.model_dump(exclude_unset=True)
115
+ async def create_item(db: DBDep, payload: create_schema = Body(...)): # type: ignore[valid-type]
116
+ data = cast(Any, payload).model_dump(exclude_unset=True)
102
117
  return await service.create(db, data)
103
118
 
104
119
  # UPDATE
105
120
  @router.patch(
106
121
  "/{item_id}",
107
- response_model=cast(Any, read_schema),
122
+ response_model=read_model,
108
123
  description=f"Update item in {prefix} collection",
109
124
  )
110
- async def update_item(db: DBDep, item_id: Any, payload: update_schema = Body(...)):
111
- data = payload.model_dump(exclude_unset=True)
125
+ async def update_item(
126
+ db: DBDep,
127
+ item_id: Any,
128
+ payload: update_schema = Body(...), # type: ignore[valid-type]
129
+ ):
130
+ data = cast(Any, payload).model_dump(exclude_unset=True)
112
131
  row = await service.update(db, item_id, data)
113
132
  if not row:
114
133
  raise HTTPException(404, "Not found")