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,163 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Callable
5
+ from pathlib import Path
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: str | None = 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:
33
+ return JSONResponse(app.openapi())
34
+
35
+ app.add_api_route(openapi_url, openapi_handler, methods=["GET"], include_in_schema=False)
36
+
37
+ # Swagger UI route
38
+ async def swagger_ui(request: Request) -> HTMLResponse:
39
+ resp = get_swagger_ui_html(openapi_url=openapi_url, title="API Docs")
40
+ theme = request.query_params.get("theme")
41
+ if theme == "dark":
42
+ return _with_dark_mode(resp)
43
+ return resp
44
+
45
+ app.add_api_route(swagger_url, swagger_ui, methods=["GET"], include_in_schema=False)
46
+
47
+ # Redoc route
48
+ async def redoc_ui(request: Request) -> HTMLResponse:
49
+ resp = get_redoc_html(openapi_url=openapi_url, title="API ReDoc")
50
+ theme = request.query_params.get("theme")
51
+ if theme == "dark":
52
+ return _with_dark_mode(resp)
53
+ return resp
54
+
55
+ app.add_api_route(redoc_url, redoc_ui, methods=["GET"], include_in_schema=False)
56
+
57
+ # Optional export to disk on startup
58
+ if export_openapi_to:
59
+ export_path = Path(export_openapi_to)
60
+
61
+ async def _export_docs() -> None:
62
+ # Startup export
63
+ spec = app.openapi()
64
+ export_path.parent.mkdir(parents=True, exist_ok=True)
65
+ export_path.write_text(json.dumps(spec, indent=2))
66
+
67
+ app.add_event_handler("startup", _export_docs)
68
+
69
+ # Optional landing page with the same look/feel as setup_service_api
70
+ if include_landing:
71
+ # Avoid path collision; if landing_url is already taken for GET, fallback to "/_docs"
72
+ existing_paths = {
73
+ (getattr(r, "path", None) or getattr(r, "path_format", None))
74
+ for r in getattr(app, "routes", [])
75
+ if getattr(r, "methods", None) and "GET" in r.methods
76
+ }
77
+ landing_path = landing_url or "/"
78
+ if landing_path in existing_paths:
79
+ landing_path = "/_docs"
80
+
81
+ async def _landing() -> HTMLResponse:
82
+ cards: list[CardSpec] = []
83
+ # Root docs card using the provided paths
84
+ cards.append(
85
+ CardSpec(
86
+ tag="",
87
+ docs=DocTargets(swagger=swagger_url, redoc=redoc_url, openapi_json=openapi_url),
88
+ )
89
+ )
90
+ # Scoped docs (if any were registered via add_prefixed_docs)
91
+ for scope, swagger, redoc, openapi_json, _title in DOC_SCOPES:
92
+ cards.append(
93
+ CardSpec(
94
+ tag=scope.strip("/"),
95
+ docs=DocTargets(swagger=swagger, redoc=redoc, openapi_json=openapi_json),
96
+ )
97
+ )
98
+ html = render_index_html(
99
+ service_name=app.title or "API", release=app.version or "", cards=cards
100
+ )
101
+ return HTMLResponse(html)
102
+
103
+ app.add_api_route(landing_path, _landing, methods=["GET"], include_in_schema=False)
104
+
105
+
106
+ def _with_dark_mode(resp: HTMLResponse) -> HTMLResponse:
107
+ """Return a copy of the HTMLResponse with a minimal dark-theme CSS injected.
108
+
109
+ We avoid depending on custom Swagger/ReDoc builds; this works by inlining a small CSS
110
+ block and toggling a `.dark` class on the body element.
111
+ """
112
+ try:
113
+ raw_body = resp.body
114
+ if isinstance(raw_body, memoryview):
115
+ raw_body = raw_body.tobytes()
116
+ body = raw_body.decode("utf-8", errors="ignore")
117
+ except Exception: # pragma: no cover - very unlikely
118
+ return resp
119
+
120
+ css = _DARK_CSS
121
+ if "</head>" in body:
122
+ body = body.replace("</head>", f"<style>\n{css}\n</style></head>", 1)
123
+ # add class to body to allow stronger selectors
124
+ body = body.replace("<body>", '<body class="dark">', 1)
125
+ return HTMLResponse(content=body, status_code=resp.status_code, headers=dict(resp.headers))
126
+
127
+
128
+ _DARK_CSS = """
129
+ /* Minimal dark mode override for Swagger/ReDoc */
130
+ @media (prefers-color-scheme: dark) { :root { color-scheme: dark; } }
131
+ html.dark, body.dark { background: #0b0e14; color: #e0e6f1; }
132
+ #swagger, .redoc-wrap { background: transparent; }
133
+ a { color: #62aef7; }
134
+ """
135
+
136
+
137
+ def add_sdk_generation_stub(
138
+ app: FastAPI,
139
+ *,
140
+ on_generate: Callable[[], None] | None = None,
141
+ openapi_path: str = "/openapi.json",
142
+ ) -> None:
143
+ """Hook to add an SDK generation stub.
144
+
145
+ Provide `on_generate()` to run generation (e.g., openapi-generator). This is a stub only; we
146
+ don't ship a hard dependency. If `on_generate` is provided, we expose `/_docs/generate-sdk`.
147
+ """
148
+ from svc_infra.api.fastapi.dual.public import public_router
149
+
150
+ if not on_generate:
151
+ return
152
+
153
+ router = public_router(prefix="/_docs", include_in_schema=False)
154
+
155
+ @router.post("/generate-sdk")
156
+ async def _generate() -> dict:
157
+ on_generate()
158
+ return {"status": "ok"}
159
+
160
+ app.include_router(router)
161
+
162
+
163
+ __all__ = ["add_docs", "add_sdk_generation_stub"]
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Iterable
3
4
  from dataclasses import dataclass
4
- from typing import Iterable, List, Optional
5
5
 
6
6
  FAVICON_DATA_URI = (
7
7
  "data:image/svg+xml,"
@@ -14,9 +14,9 @@ FAVICON_DATA_URI = (
14
14
 
15
15
  @dataclass(frozen=True)
16
16
  class DocTargets:
17
- swagger: Optional[str] = None
18
- redoc: Optional[str] = None
19
- openapi_json: Optional[str] = None
17
+ swagger: str | None = None
18
+ redoc: str | None = None
19
+ openapi_json: str | None = None
20
20
 
21
21
 
22
22
  @dataclass(frozen=True)
@@ -31,7 +31,7 @@ def _btn(label: str, href: str) -> str:
31
31
 
32
32
  def _card(spec: CardSpec) -> str:
33
33
  tag = "/" if spec.tag.strip("/") == "" else f"/{spec.tag.strip('/')}"
34
- links: List[str] = []
34
+ links: list[str] = []
35
35
  if spec.docs.swagger:
36
36
  links.append(_btn("Swagger", spec.docs.swagger))
37
37
  if spec.docs.redoc:
@@ -115,7 +115,7 @@ def render_index_html(*, service_name: str, release: str, cards: Iterable[CardSp
115
115
  <section class="grid">
116
116
  {grid}
117
117
  </section>
118
- <footer>Tip: each card exposes Swagger, ReDoc, and a pretty JSON view.</footer>
118
+ <footer>Tip: each card exposes Swagger, ReDoc, and a JSON view.</footer>
119
119
  </div>
120
120
  </body>
121
121
  </html>
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import copy
4
- from typing import Dict, Iterable, List, Optional, Set, Tuple
4
+ from collections.abc import Iterable
5
5
 
6
6
  from fastapi import FastAPI
7
7
  from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
@@ -10,15 +10,15 @@ from fastapi.responses import HTMLResponse
10
10
  from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV, Environment
11
11
 
12
12
  # (prefix, swagger_path, redoc_path, openapi_path, title)
13
- DOC_SCOPES: List[Tuple[str, str, str, str, str]] = []
13
+ DOC_SCOPES: list[tuple[str, str, str, str, str]] = []
14
14
 
15
15
  _HTTP_METHODS = {"get", "put", "post", "delete", "patch", "options", "head", "trace"}
16
16
 
17
17
 
18
18
  def _path_included(
19
19
  path: str,
20
- include_prefixes: Optional[Iterable[str]] = None,
21
- exclude_prefixes: Optional[Iterable[str]] = None,
20
+ include_prefixes: Iterable[str] | None = None,
21
+ exclude_prefixes: Iterable[str] | None = None,
22
22
  ) -> bool:
23
23
  def _match(pfx: str) -> bool:
24
24
  pfx = pfx.rstrip("/") or "/"
@@ -31,7 +31,7 @@ def _path_included(
31
31
  return True
32
32
 
33
33
 
34
- def _collect_refs(obj, refset: Set[Tuple[str, str]]):
34
+ def _collect_refs(obj, refset: set[tuple[str, str]]):
35
35
  if isinstance(obj, dict):
36
36
  for k, v in obj.items():
37
37
  if k == "$ref" and isinstance(v, str) and v.startswith("#/components/"):
@@ -46,8 +46,8 @@ def _collect_refs(obj, refset: Set[Tuple[str, str]]):
46
46
 
47
47
 
48
48
  def _close_over_component_refs(
49
- full_components: Dict, initial: Set[Tuple[str, str]]
50
- ) -> Set[Tuple[str, str]]:
49
+ full_components: dict, initial: set[tuple[str, str]]
50
+ ) -> set[tuple[str, str]]:
51
51
  to_visit = list(initial)
52
52
  seen = set(initial)
53
53
  while to_visit:
@@ -55,7 +55,7 @@ def _close_over_component_refs(
55
55
  comp = (full_components or {}).get(section, {}).get(name)
56
56
  if not isinstance(comp, dict):
57
57
  continue
58
- nested: Set[Tuple[str, str]] = set()
58
+ nested: set[tuple[str, str]] = set()
59
59
  _collect_refs(comp, nested)
60
60
  for ref in nested:
61
61
  if ref not in seen:
@@ -65,14 +65,21 @@ 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]
69
- ) -> Dict:
68
+ full_schema: dict,
69
+ keep_paths: dict[str, dict],
70
+ title_suffix: str | None,
71
+ server_prefix: str | None = None,
72
+ ) -> dict:
70
73
  schema = copy.deepcopy(full_schema)
71
74
  schema["paths"] = keep_paths
72
75
 
73
- used_tags: Set[str] = set()
74
- direct_refs: Set[Tuple[str, str]] = set()
75
- used_security_schemes: Set[str] = set()
76
+ # Set server URL for scoped docs
77
+ if server_prefix is not None:
78
+ schema["servers"] = [{"url": server_prefix}]
79
+
80
+ used_tags: set[str] = set()
81
+ direct_refs: set[tuple[str, str]] = set()
82
+ used_security_schemes: set[str] = set()
76
83
 
77
84
  for path_item in keep_paths.values():
78
85
  for method, op in path_item.items():
@@ -88,7 +95,7 @@ def _prune_to_paths(
88
95
  comps = schema.get("components") or {}
89
96
  all_refs = _close_over_component_refs(comps, direct_refs)
90
97
 
91
- pruned_components: Dict[str, Dict] = {}
98
+ pruned_components: dict[str, dict] = {}
92
99
  if isinstance(comps, dict):
93
100
  for section, items in comps.items():
94
101
  keep_names = {name for (sec, name) in all_refs if sec == section}
@@ -114,41 +121,62 @@ def _prune_to_paths(
114
121
 
115
122
 
116
123
  def _build_filtered_schema(
117
- full_schema: Dict,
124
+ full_schema: dict,
118
125
  *,
119
- include_prefixes: Optional[List[str]] = None,
120
- exclude_prefixes: Optional[List[str]] = None,
121
- title_suffix: Optional[str] = None,
122
- ) -> Dict:
126
+ include_prefixes: list[str] | None = None,
127
+ exclude_prefixes: list[str] | None = None,
128
+ title_suffix: str | None = None,
129
+ ) -> dict:
123
130
  paths = full_schema.get("paths", {}) or {}
124
131
  keep_paths = {
125
132
  p: v for p, v in paths.items() if _path_included(p, include_prefixes, exclude_prefixes)
126
133
  }
127
- return _prune_to_paths(full_schema, keep_paths, title_suffix)
134
+
135
+ # Determine the server prefix for scoped docs
136
+ server_prefix = None
137
+ if include_prefixes and len(include_prefixes) == 1:
138
+ # Single include prefix = scoped docs
139
+ server_prefix = include_prefixes[0].rstrip("/") or "/"
140
+
141
+ # Strip prefix from paths to make them relative to the server
142
+ stripped_paths = {}
143
+ for path, spec in keep_paths.items():
144
+ if path.startswith(server_prefix) and path != server_prefix:
145
+ # Remove prefix, keeping the leading slash
146
+ relative_path = path[len(server_prefix) :]
147
+ stripped_paths[relative_path] = spec
148
+ else:
149
+ # Path equals prefix or doesn't start with it
150
+ stripped_paths[path] = spec
151
+ keep_paths = stripped_paths
152
+
153
+ return _prune_to_paths(full_schema, keep_paths, title_suffix, server_prefix=server_prefix)
128
154
 
129
155
 
130
156
  def _ensure_original_openapi_saved(app: FastAPI) -> None:
131
157
  if not hasattr(app.state, "_scoped_original_openapi"):
132
- app.state._scoped_original_openapi = app.openapi # type: ignore[attr-defined]
158
+ app.state._scoped_original_openapi = app.openapi
133
159
 
134
160
 
135
- def _get_full_schema_from_original(app: FastAPI) -> Dict:
161
+ def _get_full_schema_from_original(app: FastAPI) -> dict:
136
162
  _ensure_original_openapi_saved(app)
137
- return copy.deepcopy(app.state._scoped_original_openapi()) # type: ignore[attr-defined]
163
+ return copy.deepcopy(app.state._scoped_original_openapi())
138
164
 
139
165
 
140
- def _install_root_filter(app: FastAPI, exclude_prefixes: List[str]) -> None:
166
+ def _install_root_filter(app: FastAPI, exclude_prefixes: list[str]) -> None:
141
167
  _ensure_original_openapi_saved(app)
142
- app.state._scoped_root_exclusions = sorted(set(exclude_prefixes)) # type: ignore[attr-defined]
168
+ app.state._scoped_root_exclusions = sorted(set(exclude_prefixes))
143
169
 
144
170
  def root_filtered_openapi():
145
171
  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]
172
+ return _build_filtered_schema(
173
+ full_schema, exclude_prefixes=app.state._scoped_root_exclusions
174
+ )
147
175
 
148
- app.openapi = root_filtered_openapi
176
+ app.openapi = root_filtered_openapi # type: ignore[method-assign]
149
177
 
150
178
 
151
- def _current_registered_scopes() -> List[str]:
179
+ def _current_registered_scopes() -> list[str]:
152
180
  return [scope for (scope, *_rest) in DOC_SCOPES]
153
181
 
154
182
 
@@ -158,7 +186,9 @@ def _ensure_root_excludes_registered_scopes(app: FastAPI) -> None:
158
186
  _install_root_filter(app, scopes)
159
187
 
160
188
 
161
- def _normalize_envs(envs: Optional[Iterable[Environment | str]]) -> Optional[set[Environment]]:
189
+ def _normalize_envs(
190
+ envs: Iterable[Environment | str] | None,
191
+ ) -> set[Environment] | None:
162
192
  if envs is None:
163
193
  return None
164
194
  out: set[Environment] = set()
@@ -173,19 +203,31 @@ def add_prefixed_docs(
173
203
  prefix: str,
174
204
  title: str,
175
205
  auto_exclude_from_root: bool = True,
176
- visible_envs: Optional[Iterable[Environment | str]] = (LOCAL_ENV, DEV_ENV),
206
+ visible_envs: Iterable[Environment | str] | None = (LOCAL_ENV, DEV_ENV),
177
207
  ) -> None:
208
+ scope = prefix.rstrip("/") or "/"
209
+
210
+ # Always exclude from root if requested, regardless of environment
211
+ if auto_exclude_from_root:
212
+ _ensure_original_openapi_saved(app)
213
+ # Add to exclusion list for root docs
214
+ if not hasattr(app.state, "_scoped_root_exclusions"):
215
+ app.state._scoped_root_exclusions = []
216
+ if scope not in app.state._scoped_root_exclusions:
217
+ app.state._scoped_root_exclusions.append(scope)
218
+ _install_root_filter(app, app.state._scoped_root_exclusions)
219
+
220
+ # Only create scoped docs in allowed environments
178
221
  allow = _normalize_envs(visible_envs)
179
222
  if allow is not None and CURRENT_ENVIRONMENT not in allow:
180
223
  return
181
224
 
182
- scope = prefix.rstrip("/") or "/"
183
225
  openapi_path = f"{scope}/openapi.json"
184
226
  swagger_path = f"{scope}/docs"
185
227
  redoc_path = f"{scope}/redoc"
186
228
 
187
229
  _ensure_original_openapi_saved(app)
188
- _scope_cache: Dict | None = None
230
+ _scope_cache: dict | None = None
189
231
 
190
232
  def _scoped_schema():
191
233
  nonlocal _scope_cache
@@ -211,9 +253,6 @@ def add_prefixed_docs(
211
253
 
212
254
  DOC_SCOPES.append((scope, swagger_path, redoc_path, openapi_path, title))
213
255
 
214
- if auto_exclude_from_root:
215
- _ensure_root_excludes_registered_scopes(app)
216
-
217
256
 
218
- def replace_root_openapi_with_exclusions(app: FastAPI, *, exclude_prefixes: List[str]) -> None:
257
+ def replace_root_openapi_with_exclusions(app: FastAPI, *, exclude_prefixes: list[str]) -> None:
219
258
  _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
  ]
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Callable
3
+ from collections.abc import Callable
4
4
 
5
5
  from fastapi import APIRouter
6
6
 
@@ -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
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Optional, Sequence
3
+ from collections.abc import Sequence
4
+ from typing import Any
4
5
 
5
6
  from ..auth.security import (
6
7
  AllowIdentity,
@@ -10,12 +11,18 @@ from ..auth.security import (
10
11
  RequireService,
11
12
  RequireUser,
12
13
  )
14
+ from ..auth.ws_security import AllowWSIdentity, RequireWSIdentity, RequireWSScopes
13
15
  from ..openapi.apply import apply_default_responses, apply_default_security
14
- from ..openapi.responses import DEFAULT_PROTECTED, DEFAULT_PUBLIC, DEFAULT_SERVICE, DEFAULT_USER
16
+ from ..openapi.responses import (
17
+ DEFAULT_PROTECTED,
18
+ DEFAULT_PUBLIC,
19
+ DEFAULT_SERVICE,
20
+ DEFAULT_USER,
21
+ )
15
22
  from .router import DualAPIRouter
16
23
 
17
24
 
18
- def _merge(base: Optional[Sequence[Any]], extra: Optional[Sequence[Any]]) -> list[Any]:
25
+ def _merge(base: Sequence[Any] | None, extra: Sequence[Any] | None) -> list[Any]:
19
26
  out: list[Any] = []
20
27
  if base:
21
28
  out.extend(base)
@@ -26,7 +33,7 @@ def _merge(base: Optional[Sequence[Any]], extra: Optional[Sequence[Any]]) -> lis
26
33
 
27
34
  # PUBLIC (but attach OptionalIdentity for convenience)
28
35
  def optional_identity_router(
29
- *, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any
36
+ *, dependencies: Sequence[Any] | None = None, **kwargs: Any
30
37
  ) -> DualAPIRouter:
31
38
  r = DualAPIRouter(dependencies=_merge([AllowIdentity], dependencies), **kwargs)
32
39
  apply_default_security(r, default_security=[]) # public looking in docs
@@ -35,9 +42,7 @@ def optional_identity_router(
35
42
 
36
43
 
37
44
  # PROTECTED: any auth (JWT/cookie OR API key)
38
- def protected_router(
39
- *, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any
40
- ) -> DualAPIRouter:
45
+ def protected_router(*, dependencies: Sequence[Any] | None = None, **kwargs: Any) -> DualAPIRouter:
41
46
  r = DualAPIRouter(dependencies=_merge([RequireIdentity], dependencies), **kwargs)
42
47
  apply_default_security(
43
48
  r,
@@ -52,7 +57,7 @@ def protected_router(
52
57
 
53
58
 
54
59
  # USER-ONLY (no API-key-only access)
55
- def user_router(*, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any) -> DualAPIRouter:
60
+ def user_router(*, dependencies: Sequence[Any] | None = None, **kwargs: Any) -> DualAPIRouter:
56
61
  r = DualAPIRouter(dependencies=_merge([RequireUser()], dependencies), **kwargs)
57
62
  apply_default_security(
58
63
  r, default_security=[{"OAuth2PasswordBearer": []}, {"SessionCookie": []}]
@@ -62,7 +67,7 @@ def user_router(*, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any)
62
67
 
63
68
 
64
69
  # SERVICE-ONLY (API key required)
65
- def service_router(*, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any) -> DualAPIRouter:
70
+ def service_router(*, dependencies: Sequence[Any] | None = None, **kwargs: Any) -> DualAPIRouter:
66
71
  r = DualAPIRouter(dependencies=_merge([RequireService()], dependencies), **kwargs)
67
72
  apply_default_security(r, default_security=[{"APIKeyHeader": []}])
68
73
  apply_default_responses(r, DEFAULT_SERVICE)
@@ -87,10 +92,118 @@ def scopes_router(*scopes: str, **kwargs: Any) -> DualAPIRouter:
87
92
  # ROLE-GATED (example using roles attribute or resolver passed by caller)
88
93
  def roles_router(*roles: str, role_resolver=None, **kwargs):
89
94
  r = DualAPIRouter(
90
- dependencies=[RequireUser(), RequireRoles(*roles, resolver=role_resolver)], **kwargs
95
+ dependencies=[RequireUser(), RequireRoles(*roles, resolver=role_resolver)],
96
+ **kwargs,
91
97
  )
92
98
  apply_default_security(
93
99
  r, default_security=[{"OAuth2PasswordBearer": []}, {"SessionCookie": []}]
94
100
  )
95
101
  apply_default_responses(r, DEFAULT_USER)
96
102
  return r
103
+
104
+
105
+ # ---------- WebSocket Routers (Lightweight JWT, no DB required) ----------
106
+
107
+
108
+ def ws_protected_router(
109
+ *, dependencies: Sequence[Any] | None = None, **kwargs: Any
110
+ ) -> DualAPIRouter:
111
+ """
112
+ Protected WebSocket router - requires valid JWT token.
113
+
114
+ Uses lightweight JWT validation (no database access required).
115
+ Token can be passed via:
116
+ - Query param: ?token=<jwt>
117
+ - Header: Authorization: Bearer <jwt>
118
+ - Cookie: auth cookie
119
+ - Subprotocol: access_token.<jwt>
120
+
121
+ Example:
122
+ router = ws_protected_router()
123
+
124
+ @router.websocket("/ws")
125
+ async def ws_endpoint(websocket: WebSocket, principal: WSIdentity):
126
+ user_id = str(principal.id)
127
+ await websocket.accept()
128
+ ...
129
+ """
130
+ r = DualAPIRouter(dependencies=_merge([RequireWSIdentity], dependencies), **kwargs)
131
+ # WebSocket doesn't have OpenAPI security, but we set it for documentation
132
+ apply_default_security(
133
+ r, default_security=[{"OAuth2PasswordBearer": []}, {"SessionCookie": []}]
134
+ )
135
+ return r
136
+
137
+
138
+ def ws_optional_router(
139
+ *, dependencies: Sequence[Any] | None = None, **kwargs: Any
140
+ ) -> DualAPIRouter:
141
+ """
142
+ Optional auth WebSocket router - allows anonymous connections.
143
+
144
+ If a valid JWT is provided, principal will be set.
145
+ If no token or invalid token, principal will be None.
146
+
147
+ Example:
148
+ router = ws_optional_router()
149
+
150
+ @router.websocket("/ws/public")
151
+ async def ws_endpoint(websocket: WebSocket, principal: WSOptionalIdentity):
152
+ user_id = str(principal.id) if principal else "anonymous"
153
+ await websocket.accept()
154
+ ...
155
+ """
156
+ r = DualAPIRouter(dependencies=_merge([AllowWSIdentity], dependencies), **kwargs)
157
+ apply_default_security(r, default_security=[])
158
+ return r
159
+
160
+
161
+ def ws_user_router(*, dependencies: Sequence[Any] | None = None, **kwargs: Any) -> DualAPIRouter:
162
+ """
163
+ User-only WebSocket router - requires valid user JWT (no API key).
164
+
165
+ Uses lightweight JWT validation (no database access required).
166
+ This is the WebSocket equivalent of `user_router()`.
167
+
168
+ Example:
169
+ router = ws_user_router()
170
+
171
+ @router.websocket("/ws/user")
172
+ async def ws_endpoint(websocket: WebSocket, principal: WSIdentity):
173
+ # principal.id, principal.email, principal.scopes from JWT
174
+ await websocket.accept()
175
+ ...
176
+ """
177
+ r = DualAPIRouter(dependencies=_merge([RequireWSIdentity], dependencies), **kwargs)
178
+ apply_default_security(
179
+ r, default_security=[{"OAuth2PasswordBearer": []}, {"SessionCookie": []}]
180
+ )
181
+ return r
182
+
183
+
184
+ def ws_scopes_router(
185
+ *scopes: str, dependencies: Sequence[Any] | None = None, **kwargs: Any
186
+ ) -> DualAPIRouter:
187
+ """
188
+ Scope-gated WebSocket router - requires valid JWT with specific scopes.
189
+
190
+ Uses lightweight JWT validation (no database access required).
191
+ This is the WebSocket equivalent of `scopes_router()`.
192
+
193
+ Example:
194
+ router = ws_scopes_router("chat:read", "chat:write")
195
+
196
+ @router.websocket("/ws/chat")
197
+ async def ws_endpoint(websocket: WebSocket, principal: WSIdentity):
198
+ # principal has verified scopes
199
+ await websocket.accept()
200
+ ...
201
+ """
202
+ r = DualAPIRouter(
203
+ dependencies=_merge([RequireWSIdentity, RequireWSScopes(*scopes)], dependencies),
204
+ **kwargs,
205
+ )
206
+ apply_default_security(
207
+ r, default_security=[{"OAuth2PasswordBearer": []}, {"SessionCookie": []}]
208
+ )
209
+ return r
@@ -20,3 +20,28 @@ def public_router(**kwargs: Any) -> DualAPIRouter:
20
20
  apply_default_responses(r, DEFAULT_PUBLIC)
21
21
 
22
22
  return r
23
+
24
+
25
+ def ws_public_router(**kwargs: Any) -> DualAPIRouter:
26
+ """
27
+ Public WebSocket router: no auth dependencies.
28
+
29
+ Use this for WebSocket endpoints that don't require authentication.
30
+ This is the WebSocket equivalent of `public_router()`.
31
+
32
+ Example:
33
+ router = ws_public_router(prefix="/api")
34
+
35
+ @router.websocket("/ws/public")
36
+ async def ws_endpoint(websocket: WebSocket):
37
+ await websocket.accept()
38
+ # No auth required - anyone can connect
39
+ async for msg in websocket.iter_json():
40
+ await websocket.send_json({"echo": msg})
41
+ """
42
+ r = DualAPIRouter(**kwargs)
43
+
44
+ # Keep OpenAPI consistent - no security requirement
45
+ apply_default_security(r, default_security=[])
46
+
47
+ return r