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
@@ -1,14 +1,27 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
3
4
  import time
4
- from typing import Callable
5
+ from typing import Callable, Optional
5
6
 
6
7
  from fastapi import HTTPException
7
8
  from starlette.requests import Request
8
9
 
9
- from svc_infra.api.fastapi.middleware.ratelimit_store import InMemoryRateLimitStore, RateLimitStore
10
+ from svc_infra.api.fastapi.middleware.ratelimit_store import (
11
+ InMemoryRateLimitStore,
12
+ RateLimitStore,
13
+ )
10
14
  from svc_infra.obs.metrics import emit_rate_limited
11
15
 
16
+ logger = logging.getLogger(__name__)
17
+
18
+ try:
19
+ from svc_infra.api.fastapi.tenancy.context import (
20
+ resolve_tenant_id as _resolve_tenant_id,
21
+ )
22
+ except Exception: # pragma: no cover - minimal builds
23
+ _resolve_tenant_id = None # type: ignore[assignment]
24
+
12
25
 
13
26
  class RateLimiter:
14
27
  def __init__(
@@ -17,24 +30,52 @@ class RateLimiter:
17
30
  limit: int,
18
31
  window: int = 60,
19
32
  key_fn: Callable = lambda r: "global",
33
+ limit_resolver: Optional[
34
+ Callable[[Request, Optional[str]], Optional[int]]
35
+ ] = None,
36
+ scope_by_tenant: bool = False,
20
37
  store: RateLimitStore | None = None,
21
38
  ):
22
39
  self.limit = limit
23
40
  self.window = window
24
41
  self.key_fn = key_fn
42
+ self._limit_resolver = limit_resolver
43
+ self.scope_by_tenant = scope_by_tenant
25
44
  self.store = store or InMemoryRateLimitStore(limit=limit)
26
45
 
27
46
  async def __call__(self, request: Request):
47
+ # Try resolving tenant when asked
48
+ tenant_id = None
49
+ if self.scope_by_tenant or self._limit_resolver:
50
+ try:
51
+ if _resolve_tenant_id is not None:
52
+ tenant_id = await _resolve_tenant_id(request)
53
+ except Exception:
54
+ tenant_id = None
55
+
28
56
  key = self.key_fn(request)
29
- count, limit, reset = self.store.incr(str(key), self.window)
30
- if count > limit:
31
- retry = max(0, reset - int(time.time()))
57
+ if self.scope_by_tenant and tenant_id:
58
+ key = f"{key}:tenant:{tenant_id}"
59
+
60
+ eff_limit = self.limit
61
+ if self._limit_resolver:
32
62
  try:
33
- emit_rate_limited(str(key), limit, retry)
63
+ v = self._limit_resolver(request, tenant_id)
64
+ eff_limit = int(v) if v is not None else self.limit
34
65
  except Exception:
35
- pass
66
+ eff_limit = self.limit
67
+
68
+ count, store_limit, reset = self.store.incr(str(key), self.window)
69
+ if count > eff_limit:
70
+ retry = max(0, reset - int(time.time()))
71
+ try:
72
+ emit_rate_limited(str(key), eff_limit, retry)
73
+ except Exception as e:
74
+ logger.warning("Failed to emit rate limit metric: %s", e)
36
75
  raise HTTPException(
37
- status_code=429, detail="Rate limit exceeded", headers={"Retry-After": str(retry)}
76
+ status_code=429,
77
+ detail="Rate limit exceeded",
78
+ headers={"Retry-After": str(retry)},
38
79
  )
39
80
 
40
81
 
@@ -46,21 +87,44 @@ def rate_limiter(
46
87
  limit: int,
47
88
  window: int = 60,
48
89
  key_fn: Callable = lambda r: "global",
90
+ limit_resolver: Optional[Callable[[Request, Optional[str]], Optional[int]]] = None,
91
+ scope_by_tenant: bool = False,
49
92
  store: RateLimitStore | None = None,
50
93
  ):
51
94
  store_ = store or InMemoryRateLimitStore(limit=limit)
52
95
 
53
96
  async def dep(request: Request):
97
+ tenant_id = None
98
+ if scope_by_tenant or limit_resolver:
99
+ try:
100
+ if _resolve_tenant_id is not None:
101
+ tenant_id = await _resolve_tenant_id(request)
102
+ except Exception:
103
+ tenant_id = None
104
+
54
105
  key = key_fn(request)
55
- count, lim, reset = store_.incr(str(key), window)
56
- if count > lim:
57
- retry = max(0, reset - int(time.time()))
106
+ if scope_by_tenant and tenant_id:
107
+ key = f"{key}:tenant:{tenant_id}"
108
+
109
+ eff_limit = limit
110
+ if limit_resolver:
58
111
  try:
59
- emit_rate_limited(str(key), lim, retry)
112
+ v = limit_resolver(request, tenant_id)
113
+ eff_limit = int(v) if v is not None else limit
60
114
  except Exception:
61
- pass
115
+ eff_limit = limit
116
+
117
+ count, _store_limit, reset = store_.incr(str(key), window)
118
+ if count > eff_limit:
119
+ retry = max(0, reset - int(time.time()))
120
+ try:
121
+ emit_rate_limited(str(key), eff_limit, retry)
122
+ except Exception as e:
123
+ logger.warning("Failed to emit rate limit metric: %s", e)
62
124
  raise HTTPException(
63
- status_code=429, detail="Rate limit exceeded", headers={"Retry-After": str(retry)}
125
+ status_code=429,
126
+ detail="Rate limit exceeded",
127
+ headers={"Retry-After": str(retry)},
64
128
  )
65
129
 
66
130
  return dep
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Callable, Optional
6
+
7
+ from fastapi import FastAPI, Request
8
+ from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
9
+ from fastapi.responses import HTMLResponse, JSONResponse
10
+
11
+ from .landing import CardSpec, DocTargets, render_index_html
12
+ from .scoped import DOC_SCOPES
13
+
14
+
15
+ def add_docs(
16
+ app: FastAPI,
17
+ *,
18
+ redoc_url: str = "/redoc",
19
+ swagger_url: str = "/docs",
20
+ openapi_url: str = "/openapi.json",
21
+ export_openapi_to: Optional[str] = None,
22
+ # Landing page options
23
+ landing_url: str = "/",
24
+ include_landing: bool = True,
25
+ ) -> None:
26
+ """Enable docs endpoints and optionally export OpenAPI schema to disk on startup.
27
+
28
+ We mount docs and OpenAPI routes explicitly so this works even when configured post-init.
29
+ """
30
+
31
+ # OpenAPI JSON route
32
+ async def openapi_handler() -> JSONResponse: # noqa: ANN201
33
+ return JSONResponse(app.openapi())
34
+
35
+ app.add_api_route(
36
+ openapi_url, openapi_handler, methods=["GET"], include_in_schema=False
37
+ )
38
+
39
+ # Swagger UI route
40
+ async def swagger_ui(request: Request) -> HTMLResponse: # noqa: ANN201
41
+ resp = get_swagger_ui_html(openapi_url=openapi_url, title="API Docs")
42
+ theme = request.query_params.get("theme")
43
+ if theme == "dark":
44
+ return _with_dark_mode(resp)
45
+ return resp
46
+
47
+ app.add_api_route(swagger_url, swagger_ui, methods=["GET"], include_in_schema=False)
48
+
49
+ # Redoc route
50
+ async def redoc_ui(request: Request) -> HTMLResponse: # noqa: ANN201
51
+ resp = get_redoc_html(openapi_url=openapi_url, title="API ReDoc")
52
+ theme = request.query_params.get("theme")
53
+ if theme == "dark":
54
+ return _with_dark_mode(resp)
55
+ return resp
56
+
57
+ app.add_api_route(redoc_url, redoc_ui, methods=["GET"], include_in_schema=False)
58
+
59
+ # Optional export to disk on startup
60
+ if export_openapi_to:
61
+ export_path = Path(export_openapi_to)
62
+
63
+ async def _export_docs() -> None:
64
+ # Startup export
65
+ spec = app.openapi()
66
+ export_path.parent.mkdir(parents=True, exist_ok=True)
67
+ export_path.write_text(json.dumps(spec, indent=2))
68
+
69
+ app.add_event_handler("startup", _export_docs)
70
+
71
+ # Optional landing page with the same look/feel as setup_service_api
72
+ if include_landing:
73
+ # Avoid path collision; if landing_url is already taken for GET, fallback to "/_docs"
74
+ existing_paths = {
75
+ (getattr(r, "path", None) or getattr(r, "path_format", None))
76
+ for r in getattr(app, "routes", [])
77
+ if getattr(r, "methods", None) and "GET" in r.methods
78
+ }
79
+ landing_path = landing_url or "/"
80
+ if landing_path in existing_paths:
81
+ landing_path = "/_docs"
82
+
83
+ async def _landing() -> HTMLResponse: # noqa: ANN201
84
+ cards: list[CardSpec] = []
85
+ # Root docs card using the provided paths
86
+ cards.append(
87
+ CardSpec(
88
+ tag="",
89
+ docs=DocTargets(
90
+ swagger=swagger_url, redoc=redoc_url, openapi_json=openapi_url
91
+ ),
92
+ )
93
+ )
94
+ # Scoped docs (if any were registered via add_prefixed_docs)
95
+ for scope, swagger, redoc, openapi_json, _title in DOC_SCOPES:
96
+ cards.append(
97
+ CardSpec(
98
+ tag=scope.strip("/"),
99
+ docs=DocTargets(
100
+ swagger=swagger, redoc=redoc, openapi_json=openapi_json
101
+ ),
102
+ )
103
+ )
104
+ html = render_index_html(
105
+ service_name=app.title or "API", release=app.version or "", cards=cards
106
+ )
107
+ return HTMLResponse(html)
108
+
109
+ app.add_api_route(
110
+ landing_path, _landing, methods=["GET"], include_in_schema=False
111
+ )
112
+
113
+
114
+ def _with_dark_mode(resp: HTMLResponse) -> HTMLResponse:
115
+ """Return a copy of the HTMLResponse with a minimal dark-theme CSS injected.
116
+
117
+ We avoid depending on custom Swagger/ReDoc builds; this works by inlining a small CSS
118
+ block and toggling a `.dark` class on the body element.
119
+ """
120
+ try:
121
+ raw_body = resp.body
122
+ if isinstance(raw_body, memoryview):
123
+ raw_body = raw_body.tobytes()
124
+ body = raw_body.decode("utf-8", errors="ignore")
125
+ except Exception: # pragma: no cover - very unlikely
126
+ return resp
127
+
128
+ css = _DARK_CSS
129
+ if "</head>" in body:
130
+ body = body.replace("</head>", f"<style>\n{css}\n</style></head>", 1)
131
+ # add class to body to allow stronger selectors
132
+ body = body.replace("<body>", '<body class="dark">', 1)
133
+ return HTMLResponse(
134
+ content=body, status_code=resp.status_code, headers=dict(resp.headers)
135
+ )
136
+
137
+
138
+ _DARK_CSS = """
139
+ /* Minimal dark mode override for Swagger/ReDoc */
140
+ @media (prefers-color-scheme: dark) { :root { color-scheme: dark; } }
141
+ html.dark, body.dark { background: #0b0e14; color: #e0e6f1; }
142
+ #swagger, .redoc-wrap { background: transparent; }
143
+ a { color: #62aef7; }
144
+ """
145
+
146
+
147
+ def add_sdk_generation_stub(
148
+ app: FastAPI,
149
+ *,
150
+ on_generate: Optional[Callable[[], None]] = None,
151
+ openapi_path: str = "/openapi.json",
152
+ ) -> None:
153
+ """Hook to add an SDK generation stub.
154
+
155
+ Provide `on_generate()` to run generation (e.g., openapi-generator). This is a stub only; we
156
+ don't ship a hard dependency. If `on_generate` is provided, we expose `/_docs/generate-sdk`.
157
+ """
158
+ from svc_infra.api.fastapi.dual.public import public_router
159
+
160
+ if not on_generate:
161
+ return
162
+
163
+ router = public_router(prefix="/_docs", include_in_schema=False)
164
+
165
+ @router.post("/generate-sdk")
166
+ async def _generate() -> dict: # noqa: ANN201
167
+ on_generate()
168
+ return {"status": "ok"}
169
+
170
+ app.include_router(router)
171
+
172
+
173
+ __all__ = ["add_docs", "add_sdk_generation_stub"]
@@ -50,7 +50,9 @@ def _card(spec: CardSpec) -> str:
50
50
  """.strip()
51
51
 
52
52
 
53
- def render_index_html(*, service_name: str, release: str, cards: Iterable[CardSpec]) -> str:
53
+ def render_index_html(
54
+ *, service_name: str, release: str, cards: Iterable[CardSpec]
55
+ ) -> str:
54
56
  grid = "\n".join(_card(c) for c in cards)
55
57
  return f"""
56
58
  <!doctype html>
@@ -115,7 +117,7 @@ def render_index_html(*, service_name: str, release: str, cards: Iterable[CardSp
115
117
  <section class="grid">
116
118
  {grid}
117
119
  </section>
118
- <footer>Tip: each card exposes Swagger, ReDoc, and a pretty JSON view.</footer>
120
+ <footer>Tip: each card exposes Swagger, ReDoc, and a JSON view.</footer>
119
121
  </div>
120
122
  </body>
121
123
  </html>
@@ -65,11 +65,18 @@ def _close_over_component_refs(
65
65
 
66
66
 
67
67
  def _prune_to_paths(
68
- full_schema: Dict, keep_paths: Dict[str, dict], title_suffix: Optional[str]
68
+ full_schema: Dict,
69
+ keep_paths: Dict[str, dict],
70
+ title_suffix: Optional[str],
71
+ server_prefix: Optional[str] = None,
69
72
  ) -> Dict:
70
73
  schema = copy.deepcopy(full_schema)
71
74
  schema["paths"] = keep_paths
72
75
 
76
+ # Set server URL for scoped docs
77
+ if server_prefix is not None:
78
+ schema["servers"] = [{"url": server_prefix}]
79
+
73
80
  used_tags: Set[str] = set()
74
81
  direct_refs: Set[Tuple[str, str]] = set()
75
82
  used_security_schemes: Set[str] = set()
@@ -103,7 +110,9 @@ def _prune_to_paths(
103
110
 
104
111
  if "tags" in schema and isinstance(schema["tags"], list):
105
112
  schema["tags"] = [
106
- t for t in schema["tags"] if isinstance(t, dict) and t.get("name") in used_tags
113
+ t
114
+ for t in schema["tags"]
115
+ if isinstance(t, dict) and t.get("name") in used_tags
107
116
  ]
108
117
 
109
118
  info = dict(schema.get("info") or {})
@@ -122,30 +131,55 @@ def _build_filtered_schema(
122
131
  ) -> Dict:
123
132
  paths = full_schema.get("paths", {}) or {}
124
133
  keep_paths = {
125
- p: v for p, v in paths.items() if _path_included(p, include_prefixes, exclude_prefixes)
134
+ p: v
135
+ for p, v in paths.items()
136
+ if _path_included(p, include_prefixes, exclude_prefixes)
126
137
  }
127
- return _prune_to_paths(full_schema, keep_paths, title_suffix)
138
+
139
+ # Determine the server prefix for scoped docs
140
+ server_prefix = None
141
+ if include_prefixes and len(include_prefixes) == 1:
142
+ # Single include prefix = scoped docs
143
+ server_prefix = include_prefixes[0].rstrip("/") or "/"
144
+
145
+ # Strip prefix from paths to make them relative to the server
146
+ stripped_paths = {}
147
+ for path, spec in keep_paths.items():
148
+ if path.startswith(server_prefix) and path != server_prefix:
149
+ # Remove prefix, keeping the leading slash
150
+ relative_path = path[len(server_prefix) :]
151
+ stripped_paths[relative_path] = spec
152
+ else:
153
+ # Path equals prefix or doesn't start with it
154
+ stripped_paths[path] = spec
155
+ keep_paths = stripped_paths
156
+
157
+ return _prune_to_paths(
158
+ full_schema, keep_paths, title_suffix, server_prefix=server_prefix
159
+ )
128
160
 
129
161
 
130
162
  def _ensure_original_openapi_saved(app: FastAPI) -> None:
131
163
  if not hasattr(app.state, "_scoped_original_openapi"):
132
- app.state._scoped_original_openapi = app.openapi # type: ignore[attr-defined]
164
+ app.state._scoped_original_openapi = app.openapi
133
165
 
134
166
 
135
167
  def _get_full_schema_from_original(app: FastAPI) -> Dict:
136
168
  _ensure_original_openapi_saved(app)
137
- return copy.deepcopy(app.state._scoped_original_openapi()) # type: ignore[attr-defined]
169
+ return copy.deepcopy(app.state._scoped_original_openapi())
138
170
 
139
171
 
140
172
  def _install_root_filter(app: FastAPI, exclude_prefixes: List[str]) -> None:
141
173
  _ensure_original_openapi_saved(app)
142
- app.state._scoped_root_exclusions = sorted(set(exclude_prefixes)) # type: ignore[attr-defined]
174
+ app.state._scoped_root_exclusions = sorted(set(exclude_prefixes))
143
175
 
144
176
  def root_filtered_openapi():
145
177
  full_schema = _get_full_schema_from_original(app)
146
- return _build_filtered_schema(full_schema, exclude_prefixes=app.state._scoped_root_exclusions) # type: ignore[attr-defined]
178
+ return _build_filtered_schema(
179
+ full_schema, exclude_prefixes=app.state._scoped_root_exclusions
180
+ )
147
181
 
148
- app.openapi = root_filtered_openapi
182
+ setattr(app, "openapi", root_filtered_openapi)
149
183
 
150
184
 
151
185
  def _current_registered_scopes() -> List[str]:
@@ -158,7 +192,9 @@ def _ensure_root_excludes_registered_scopes(app: FastAPI) -> None:
158
192
  _install_root_filter(app, scopes)
159
193
 
160
194
 
161
- def _normalize_envs(envs: Optional[Iterable[Environment | str]]) -> Optional[set[Environment]]:
195
+ def _normalize_envs(
196
+ envs: Optional[Iterable[Environment | str]],
197
+ ) -> Optional[set[Environment]]:
162
198
  if envs is None:
163
199
  return None
164
200
  out: set[Environment] = set()
@@ -175,11 +211,23 @@ def add_prefixed_docs(
175
211
  auto_exclude_from_root: bool = True,
176
212
  visible_envs: Optional[Iterable[Environment | str]] = (LOCAL_ENV, DEV_ENV),
177
213
  ) -> None:
214
+ scope = prefix.rstrip("/") or "/"
215
+
216
+ # Always exclude from root if requested, regardless of environment
217
+ if auto_exclude_from_root:
218
+ _ensure_original_openapi_saved(app)
219
+ # Add to exclusion list for root docs
220
+ if not hasattr(app.state, "_scoped_root_exclusions"):
221
+ app.state._scoped_root_exclusions = []
222
+ if scope not in app.state._scoped_root_exclusions:
223
+ app.state._scoped_root_exclusions.append(scope)
224
+ _install_root_filter(app, app.state._scoped_root_exclusions)
225
+
226
+ # Only create scoped docs in allowed environments
178
227
  allow = _normalize_envs(visible_envs)
179
228
  if allow is not None and CURRENT_ENVIRONMENT not in allow:
180
229
  return
181
230
 
182
- scope = prefix.rstrip("/") or "/"
183
231
  openapi_path = f"{scope}/openapi.json"
184
232
  swagger_path = f"{scope}/docs"
185
233
  redoc_path = f"{scope}/redoc"
@@ -211,9 +259,8 @@ def add_prefixed_docs(
211
259
 
212
260
  DOC_SCOPES.append((scope, swagger_path, redoc_path, openapi_path, title))
213
261
 
214
- if auto_exclude_from_root:
215
- _ensure_root_excludes_registered_scopes(app)
216
-
217
262
 
218
- def replace_root_openapi_with_exclusions(app: FastAPI, *, exclude_prefixes: List[str]) -> None:
263
+ def replace_root_openapi_with_exclusions(
264
+ app: FastAPI, *, exclude_prefixes: List[str]
265
+ ) -> None:
219
266
  _install_root_filter(app, exclude_prefixes)
@@ -1,12 +1,16 @@
1
1
  from .dualize import dualize_protected, dualize_public, dualize_service, dualize_user
2
- from .protected import (
2
+ from .protected import ( # WebSocket routers with auth (DualAPIRouter with JWT auth, no DB required)
3
3
  optional_identity_router,
4
4
  protected_router,
5
5
  roles_router,
6
6
  service_router,
7
7
  user_router,
8
+ ws_optional_router,
9
+ ws_protected_router,
10
+ ws_scopes_router,
11
+ ws_user_router,
8
12
  )
9
- from .public import public_router
13
+ from .public import public_router, ws_public_router
10
14
  from .router import DualAPIRouter
11
15
 
12
16
  __all__ = [
@@ -21,4 +25,10 @@ __all__ = [
21
25
  "user_router",
22
26
  "service_router",
23
27
  "roles_router",
28
+ # WebSocket routers
29
+ "ws_public_router",
30
+ "ws_protected_router",
31
+ "ws_user_router",
32
+ "ws_scopes_router",
33
+ "ws_optional_router",
24
34
  ]
@@ -27,7 +27,7 @@ def dualize_into(
27
27
  prefix="", # prevent double-prefixing on include_router
28
28
  tags=list(src.tags or []),
29
29
  dependencies=list(src.dependencies or []),
30
- default_response_class=src.default_response_class, # type: ignore[arg-type]
30
+ default_response_class=src.default_response_class,
31
31
  responses=dict(src.responses or {}),
32
32
  callbacks=list(src.callbacks or []),
33
33
  routes=[], # start empty
@@ -10,8 +10,14 @@ from ..auth.security import (
10
10
  RequireService,
11
11
  RequireUser,
12
12
  )
13
+ from ..auth.ws_security import AllowWSIdentity, RequireWSIdentity, RequireWSScopes
13
14
  from ..openapi.apply import apply_default_responses, apply_default_security
14
- from ..openapi.responses import DEFAULT_PROTECTED, DEFAULT_PUBLIC, DEFAULT_SERVICE, DEFAULT_USER
15
+ from ..openapi.responses import (
16
+ DEFAULT_PROTECTED,
17
+ DEFAULT_PUBLIC,
18
+ DEFAULT_SERVICE,
19
+ DEFAULT_USER,
20
+ )
15
21
  from .router import DualAPIRouter
16
22
 
17
23
 
@@ -52,7 +58,9 @@ def protected_router(
52
58
 
53
59
 
54
60
  # USER-ONLY (no API-key-only access)
55
- def user_router(*, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any) -> DualAPIRouter:
61
+ def user_router(
62
+ *, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any
63
+ ) -> DualAPIRouter:
56
64
  r = DualAPIRouter(dependencies=_merge([RequireUser()], dependencies), **kwargs)
57
65
  apply_default_security(
58
66
  r, default_security=[{"OAuth2PasswordBearer": []}, {"SessionCookie": []}]
@@ -62,7 +70,9 @@ def user_router(*, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any)
62
70
 
63
71
 
64
72
  # SERVICE-ONLY (API key required)
65
- def service_router(*, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any) -> DualAPIRouter:
73
+ def service_router(
74
+ *, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any
75
+ ) -> DualAPIRouter:
66
76
  r = DualAPIRouter(dependencies=_merge([RequireService()], dependencies), **kwargs)
67
77
  apply_default_security(r, default_security=[{"APIKeyHeader": []}])
68
78
  apply_default_responses(r, DEFAULT_SERVICE)
@@ -87,10 +97,122 @@ def scopes_router(*scopes: str, **kwargs: Any) -> DualAPIRouter:
87
97
  # ROLE-GATED (example using roles attribute or resolver passed by caller)
88
98
  def roles_router(*roles: str, role_resolver=None, **kwargs):
89
99
  r = DualAPIRouter(
90
- dependencies=[RequireUser(), RequireRoles(*roles, resolver=role_resolver)], **kwargs
100
+ dependencies=[RequireUser(), RequireRoles(*roles, resolver=role_resolver)],
101
+ **kwargs,
91
102
  )
92
103
  apply_default_security(
93
104
  r, default_security=[{"OAuth2PasswordBearer": []}, {"SessionCookie": []}]
94
105
  )
95
106
  apply_default_responses(r, DEFAULT_USER)
96
107
  return r
108
+
109
+
110
+ # ---------- WebSocket Routers (Lightweight JWT, no DB required) ----------
111
+
112
+
113
+ def ws_protected_router(
114
+ *, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any
115
+ ) -> DualAPIRouter:
116
+ """
117
+ Protected WebSocket router - requires valid JWT token.
118
+
119
+ Uses lightweight JWT validation (no database access required).
120
+ Token can be passed via:
121
+ - Query param: ?token=<jwt>
122
+ - Header: Authorization: Bearer <jwt>
123
+ - Cookie: auth cookie
124
+ - Subprotocol: access_token.<jwt>
125
+
126
+ Example:
127
+ router = ws_protected_router()
128
+
129
+ @router.websocket("/ws")
130
+ async def ws_endpoint(websocket: WebSocket, principal: WSIdentity):
131
+ user_id = str(principal.id)
132
+ await websocket.accept()
133
+ ...
134
+ """
135
+ r = DualAPIRouter(dependencies=_merge([RequireWSIdentity], dependencies), **kwargs)
136
+ # WebSocket doesn't have OpenAPI security, but we set it for documentation
137
+ apply_default_security(
138
+ r, default_security=[{"OAuth2PasswordBearer": []}, {"SessionCookie": []}]
139
+ )
140
+ return r
141
+
142
+
143
+ def ws_optional_router(
144
+ *, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any
145
+ ) -> DualAPIRouter:
146
+ """
147
+ Optional auth WebSocket router - allows anonymous connections.
148
+
149
+ If a valid JWT is provided, principal will be set.
150
+ If no token or invalid token, principal will be None.
151
+
152
+ Example:
153
+ router = ws_optional_router()
154
+
155
+ @router.websocket("/ws/public")
156
+ async def ws_endpoint(websocket: WebSocket, principal: WSOptionalIdentity):
157
+ user_id = str(principal.id) if principal else "anonymous"
158
+ await websocket.accept()
159
+ ...
160
+ """
161
+ r = DualAPIRouter(dependencies=_merge([AllowWSIdentity], dependencies), **kwargs)
162
+ apply_default_security(r, default_security=[])
163
+ return r
164
+
165
+
166
+ def ws_user_router(
167
+ *, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any
168
+ ) -> DualAPIRouter:
169
+ """
170
+ User-only WebSocket router - requires valid user JWT (no API key).
171
+
172
+ Uses lightweight JWT validation (no database access required).
173
+ This is the WebSocket equivalent of `user_router()`.
174
+
175
+ Example:
176
+ router = ws_user_router()
177
+
178
+ @router.websocket("/ws/user")
179
+ async def ws_endpoint(websocket: WebSocket, principal: WSIdentity):
180
+ # principal.id, principal.email, principal.scopes from JWT
181
+ await websocket.accept()
182
+ ...
183
+ """
184
+ r = DualAPIRouter(dependencies=_merge([RequireWSIdentity], dependencies), **kwargs)
185
+ apply_default_security(
186
+ r, default_security=[{"OAuth2PasswordBearer": []}, {"SessionCookie": []}]
187
+ )
188
+ return r
189
+
190
+
191
+ def ws_scopes_router(
192
+ *scopes: str, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any
193
+ ) -> DualAPIRouter:
194
+ """
195
+ Scope-gated WebSocket router - requires valid JWT with specific scopes.
196
+
197
+ Uses lightweight JWT validation (no database access required).
198
+ This is the WebSocket equivalent of `scopes_router()`.
199
+
200
+ Example:
201
+ router = ws_scopes_router("chat:read", "chat:write")
202
+
203
+ @router.websocket("/ws/chat")
204
+ async def ws_endpoint(websocket: WebSocket, principal: WSIdentity):
205
+ # principal has verified scopes
206
+ await websocket.accept()
207
+ ...
208
+ """
209
+ r = DualAPIRouter(
210
+ dependencies=_merge(
211
+ [RequireWSIdentity, RequireWSScopes(*scopes)], dependencies
212
+ ),
213
+ **kwargs,
214
+ )
215
+ apply_default_security(
216
+ r, default_security=[{"OAuth2PasswordBearer": []}, {"SessionCookie": []}]
217
+ )
218
+ return r