svc-infra 0.1.506__py3-none-any.whl → 0.1.654__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.
Files changed (202) hide show
  1. svc_infra/apf_payments/README.md +732 -0
  2. svc_infra/apf_payments/alembic.py +11 -0
  3. svc_infra/apf_payments/models.py +339 -0
  4. svc_infra/apf_payments/provider/__init__.py +4 -0
  5. svc_infra/apf_payments/provider/aiydan.py +797 -0
  6. svc_infra/apf_payments/provider/base.py +270 -0
  7. svc_infra/apf_payments/provider/registry.py +31 -0
  8. svc_infra/apf_payments/provider/stripe.py +873 -0
  9. svc_infra/apf_payments/schemas.py +333 -0
  10. svc_infra/apf_payments/service.py +892 -0
  11. svc_infra/apf_payments/settings.py +67 -0
  12. svc_infra/api/fastapi/__init__.py +6 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +231 -0
  15. svc_infra/api/fastapi/apf_payments/__init__.py +0 -0
  16. svc_infra/api/fastapi/apf_payments/router.py +1082 -0
  17. svc_infra/api/fastapi/apf_payments/setup.py +73 -0
  18. svc_infra/api/fastapi/auth/add.py +15 -6
  19. svc_infra/api/fastapi/auth/gaurd.py +67 -5
  20. svc_infra/api/fastapi/auth/mfa/router.py +18 -9
  21. svc_infra/api/fastapi/auth/routers/account.py +3 -2
  22. svc_infra/api/fastapi/auth/routers/apikey_router.py +11 -5
  23. svc_infra/api/fastapi/auth/routers/oauth_router.py +82 -37
  24. svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
  25. svc_infra/api/fastapi/auth/security.py +3 -1
  26. svc_infra/api/fastapi/auth/settings.py +2 -0
  27. svc_infra/api/fastapi/auth/state.py +1 -1
  28. svc_infra/api/fastapi/billing/router.py +64 -0
  29. svc_infra/api/fastapi/billing/setup.py +19 -0
  30. svc_infra/api/fastapi/cache/add.py +9 -5
  31. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  32. svc_infra/api/fastapi/db/sql/add.py +40 -18
  33. svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
  34. svc_infra/api/fastapi/db/sql/session.py +16 -0
  35. svc_infra/api/fastapi/db/sql/users.py +14 -2
  36. svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
  37. svc_infra/api/fastapi/docs/add.py +160 -0
  38. svc_infra/api/fastapi/docs/landing.py +1 -1
  39. svc_infra/api/fastapi/docs/scoped.py +254 -0
  40. svc_infra/api/fastapi/dual/dualize.py +38 -33
  41. svc_infra/api/fastapi/dual/router.py +48 -1
  42. svc_infra/api/fastapi/dx.py +3 -3
  43. svc_infra/api/fastapi/http/__init__.py +0 -0
  44. svc_infra/api/fastapi/http/concurrency.py +14 -0
  45. svc_infra/api/fastapi/http/conditional.py +33 -0
  46. svc_infra/api/fastapi/http/deprecation.py +21 -0
  47. svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
  48. svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
  49. svc_infra/api/fastapi/middleware/idempotency.py +116 -0
  50. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  51. svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
  52. svc_infra/api/fastapi/middleware/ratelimit.py +119 -0
  53. svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
  54. svc_infra/api/fastapi/middleware/request_id.py +23 -0
  55. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  56. svc_infra/api/fastapi/middleware/timeout.py +148 -0
  57. svc_infra/api/fastapi/openapi/mutators.py +768 -55
  58. svc_infra/api/fastapi/ops/add.py +73 -0
  59. svc_infra/api/fastapi/pagination.py +363 -0
  60. svc_infra/api/fastapi/paths/auth.py +14 -14
  61. svc_infra/api/fastapi/paths/prefix.py +0 -1
  62. svc_infra/api/fastapi/paths/user.py +1 -1
  63. svc_infra/api/fastapi/routers/ping.py +1 -0
  64. svc_infra/api/fastapi/setup.py +48 -15
  65. svc_infra/api/fastapi/tenancy/add.py +19 -0
  66. svc_infra/api/fastapi/tenancy/context.py +112 -0
  67. svc_infra/api/fastapi/versioned.py +101 -0
  68. svc_infra/app/README.md +5 -5
  69. svc_infra/billing/__init__.py +23 -0
  70. svc_infra/billing/async_service.py +147 -0
  71. svc_infra/billing/jobs.py +230 -0
  72. svc_infra/billing/models.py +131 -0
  73. svc_infra/billing/quotas.py +101 -0
  74. svc_infra/billing/schemas.py +33 -0
  75. svc_infra/billing/service.py +115 -0
  76. svc_infra/bundled_docs/README.md +5 -0
  77. svc_infra/bundled_docs/__init__.py +1 -0
  78. svc_infra/bundled_docs/getting-started.md +6 -0
  79. svc_infra/cache/__init__.py +4 -0
  80. svc_infra/cache/add.py +158 -0
  81. svc_infra/cache/backend.py +5 -2
  82. svc_infra/cache/decorators.py +19 -1
  83. svc_infra/cache/keys.py +24 -4
  84. svc_infra/cli/__init__.py +32 -8
  85. svc_infra/cli/__main__.py +4 -0
  86. svc_infra/cli/cmds/__init__.py +10 -0
  87. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
  88. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
  89. svc_infra/cli/cmds/db/sql/alembic_cmds.py +120 -14
  90. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  91. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +5 -4
  92. svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
  93. svc_infra/cli/cmds/dx/__init__.py +12 -0
  94. svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
  95. svc_infra/cli/cmds/help.py +4 -0
  96. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  97. svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
  98. svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
  99. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  100. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  101. svc_infra/data/add.py +61 -0
  102. svc_infra/data/backup.py +53 -0
  103. svc_infra/data/erasure.py +45 -0
  104. svc_infra/data/fixtures.py +40 -0
  105. svc_infra/data/retention.py +55 -0
  106. svc_infra/db/inbox.py +67 -0
  107. svc_infra/db/nosql/mongo/README.md +13 -13
  108. svc_infra/db/outbox.py +104 -0
  109. svc_infra/db/sql/apikey.py +1 -1
  110. svc_infra/db/sql/authref.py +61 -0
  111. svc_infra/db/sql/core.py +2 -2
  112. svc_infra/db/sql/repository.py +52 -12
  113. svc_infra/db/sql/resource.py +5 -0
  114. svc_infra/db/sql/scaffold.py +16 -4
  115. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  116. svc_infra/db/sql/templates/setup/env_async.py.tmpl +199 -76
  117. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +231 -79
  118. svc_infra/db/sql/tenant.py +79 -0
  119. svc_infra/db/sql/utils.py +18 -4
  120. svc_infra/db/sql/versioning.py +14 -0
  121. svc_infra/docs/acceptance-matrix.md +71 -0
  122. svc_infra/docs/acceptance.md +44 -0
  123. svc_infra/docs/admin.md +425 -0
  124. svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
  125. svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
  126. svc_infra/docs/adr/0004-tenancy-model.md +42 -0
  127. svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
  128. svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
  129. svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
  130. svc_infra/docs/adr/0008-billing-primitives.md +143 -0
  131. svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
  132. svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
  133. svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
  134. svc_infra/docs/api.md +59 -0
  135. svc_infra/docs/auth.md +11 -0
  136. svc_infra/docs/billing.md +190 -0
  137. svc_infra/docs/cache.md +76 -0
  138. svc_infra/docs/cli.md +74 -0
  139. svc_infra/docs/contributing.md +34 -0
  140. svc_infra/docs/data-lifecycle.md +52 -0
  141. svc_infra/docs/database.md +14 -0
  142. svc_infra/docs/docs-and-sdks.md +62 -0
  143. svc_infra/docs/environment.md +114 -0
  144. svc_infra/docs/getting-started.md +63 -0
  145. svc_infra/docs/idempotency.md +111 -0
  146. svc_infra/docs/jobs.md +67 -0
  147. svc_infra/docs/observability.md +16 -0
  148. svc_infra/docs/ops.md +37 -0
  149. svc_infra/docs/rate-limiting.md +125 -0
  150. svc_infra/docs/repo-review.md +48 -0
  151. svc_infra/docs/security.md +176 -0
  152. svc_infra/docs/tenancy.md +35 -0
  153. svc_infra/docs/timeouts-and-resource-limits.md +147 -0
  154. svc_infra/docs/versioned-integrations.md +146 -0
  155. svc_infra/docs/webhooks.md +112 -0
  156. svc_infra/dx/add.py +63 -0
  157. svc_infra/dx/changelog.py +74 -0
  158. svc_infra/dx/checks.py +67 -0
  159. svc_infra/http/__init__.py +13 -0
  160. svc_infra/http/client.py +72 -0
  161. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  162. svc_infra/jobs/builtins/webhook_delivery.py +90 -0
  163. svc_infra/jobs/easy.py +32 -0
  164. svc_infra/jobs/loader.py +45 -0
  165. svc_infra/jobs/queue.py +81 -0
  166. svc_infra/jobs/redis_queue.py +191 -0
  167. svc_infra/jobs/runner.py +75 -0
  168. svc_infra/jobs/scheduler.py +41 -0
  169. svc_infra/jobs/worker.py +40 -0
  170. svc_infra/mcp/svc_infra_mcp.py +85 -28
  171. svc_infra/obs/README.md +2 -0
  172. svc_infra/obs/add.py +54 -7
  173. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  174. svc_infra/obs/metrics/__init__.py +53 -0
  175. svc_infra/obs/metrics.py +52 -0
  176. svc_infra/security/add.py +201 -0
  177. svc_infra/security/audit.py +130 -0
  178. svc_infra/security/audit_service.py +73 -0
  179. svc_infra/security/headers.py +52 -0
  180. svc_infra/security/hibp.py +95 -0
  181. svc_infra/security/jwt_rotation.py +53 -0
  182. svc_infra/security/lockout.py +96 -0
  183. svc_infra/security/models.py +255 -0
  184. svc_infra/security/org_invites.py +128 -0
  185. svc_infra/security/passwords.py +77 -0
  186. svc_infra/security/permissions.py +149 -0
  187. svc_infra/security/session.py +98 -0
  188. svc_infra/security/signed_cookies.py +80 -0
  189. svc_infra/webhooks/__init__.py +16 -0
  190. svc_infra/webhooks/add.py +322 -0
  191. svc_infra/webhooks/fastapi.py +37 -0
  192. svc_infra/webhooks/router.py +55 -0
  193. svc_infra/webhooks/service.py +67 -0
  194. svc_infra/webhooks/signing.py +30 -0
  195. svc_infra-0.1.654.dist-info/METADATA +154 -0
  196. svc_infra-0.1.654.dist-info/RECORD +352 -0
  197. svc_infra/api/fastapi/deps.py +0 -3
  198. svc_infra-0.1.506.dist-info/METADATA +0 -78
  199. svc_infra-0.1.506.dist-info/RECORD +0 -213
  200. /svc_infra/{api/fastapi/schemas → apf_payments}/__init__.py +0 -0
  201. {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
  202. {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
@@ -1,9 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
+ import os
4
5
  from typing import Annotated, AsyncIterator, Tuple
5
6
 
6
7
  from fastapi import Depends
8
+ from sqlalchemy import text
7
9
  from sqlalchemy.ext.asyncio import (
8
10
  AsyncEngine,
9
11
  AsyncSession,
@@ -53,6 +55,20 @@ async def get_session() -> AsyncIterator[AsyncSession]:
53
55
  if _SessionLocal is None:
54
56
  raise RuntimeError("Database not initialized. Call add_sql_db(app, ...) first.")
55
57
  async with _SessionLocal() as session:
58
+ # Optional: set a per-transaction statement timeout for Postgres if configured
59
+ raw_ms = os.getenv("DB_STATEMENT_TIMEOUT_MS")
60
+ if raw_ms:
61
+ try:
62
+ ms = int(raw_ms)
63
+ if ms > 0:
64
+ try:
65
+ # SET LOCAL applies for the duration of the current transaction only
66
+ await session.execute(text("SET LOCAL statement_timeout = :ms"), {"ms": ms})
67
+ except Exception:
68
+ # Non-PG dialects (e.g., SQLite) will error; ignore silently
69
+ pass
70
+ except ValueError:
71
+ pass
56
72
  try:
57
73
  yield session
58
74
  await session.commit()
@@ -12,6 +12,7 @@ from svc_infra.api.fastapi.auth.settings import get_auth_settings
12
12
  from svc_infra.api.fastapi.dual.dualize import dualize_public, dualize_user
13
13
  from svc_infra.api.fastapi.dual.router import DualAPIRouter
14
14
  from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV
15
+ from svc_infra.security.jwt_rotation import RotatingJWTStrategy
15
16
 
16
17
  from ...auth.security import auth_login_path
17
18
  from ...auth.sender import get_sender
@@ -94,7 +95,18 @@ def get_fastapi_users(
94
95
  lifetime = getattr(jwt_block, "lifetime_seconds", None) if jwt_block else None
95
96
  if not isinstance(lifetime, int) or lifetime <= 0:
96
97
  lifetime = 3600
97
- return JWTStrategy(secret=secret, lifetime_seconds=lifetime)
98
+ old = []
99
+ if jwt_block and getattr(jwt_block, "old_secrets", None):
100
+ old = [s.get_secret_value() for s in jwt_block.old_secrets or []]
101
+ audience = "fastapi-users:auth"
102
+ if old:
103
+ return RotatingJWTStrategy(
104
+ secret=secret,
105
+ lifetime_seconds=lifetime,
106
+ old_secrets=old,
107
+ token_audience=audience,
108
+ )
109
+ return JWTStrategy(secret=secret, lifetime_seconds=lifetime, token_audience=audience)
98
110
 
99
111
  bearer_transport = BearerTransport(tokenUrl=auth_login_path)
100
112
  auth_backend = AuthenticationBackend(
@@ -107,7 +119,7 @@ def get_fastapi_users(
107
119
  fastapi_users.get_auth_router(auth_backend, requires_verification=True)
108
120
  )
109
121
  users_router = dualize_user(
110
- fastapi_users.get_users_router(user_schema_read, user_schema_create, user_schema_update)
122
+ fastapi_users.get_users_router(user_schema_read, user_schema_update)
111
123
  )
112
124
  register_router = dualize_public(
113
125
  fastapi_users.get_register_router(user_schema_read, user_schema_create)
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Callable, Optional
5
+
6
+ from fastapi import HTTPException
7
+ from starlette.requests import Request
8
+
9
+ from svc_infra.api.fastapi.middleware.ratelimit_store import InMemoryRateLimitStore, RateLimitStore
10
+
11
+ try:
12
+ from svc_infra.api.fastapi.tenancy.context import resolve_tenant_id as _resolve_tenant_id
13
+ except Exception: # pragma: no cover - minimal builds
14
+ _resolve_tenant_id = None # type: ignore
15
+ from svc_infra.obs.metrics import emit_rate_limited
16
+
17
+
18
+ class RateLimiter:
19
+ def __init__(
20
+ self,
21
+ *,
22
+ limit: int,
23
+ window: int = 60,
24
+ key_fn: Callable = lambda r: "global",
25
+ limit_resolver: Optional[Callable[[Request, Optional[str]], Optional[int]]] = None,
26
+ scope_by_tenant: bool = False,
27
+ store: RateLimitStore | None = None,
28
+ ):
29
+ self.limit = limit
30
+ self.window = window
31
+ self.key_fn = key_fn
32
+ self._limit_resolver = limit_resolver
33
+ self.scope_by_tenant = scope_by_tenant
34
+ self.store = store or InMemoryRateLimitStore(limit=limit)
35
+
36
+ async def __call__(self, request: Request):
37
+ # Try resolving tenant when asked
38
+ tenant_id = None
39
+ if self.scope_by_tenant or self._limit_resolver:
40
+ try:
41
+ if _resolve_tenant_id is not None:
42
+ tenant_id = await _resolve_tenant_id(request)
43
+ except Exception:
44
+ tenant_id = None
45
+
46
+ key = self.key_fn(request)
47
+ if self.scope_by_tenant and tenant_id:
48
+ key = f"{key}:tenant:{tenant_id}"
49
+
50
+ eff_limit = self.limit
51
+ if self._limit_resolver:
52
+ try:
53
+ v = self._limit_resolver(request, tenant_id)
54
+ eff_limit = int(v) if v is not None else self.limit
55
+ except Exception:
56
+ eff_limit = self.limit
57
+
58
+ count, store_limit, reset = self.store.incr(str(key), self.window)
59
+ if count > eff_limit:
60
+ retry = max(0, reset - int(time.time()))
61
+ try:
62
+ emit_rate_limited(str(key), eff_limit, retry)
63
+ except Exception:
64
+ pass
65
+ raise HTTPException(
66
+ status_code=429, detail="Rate limit exceeded", headers={"Retry-After": str(retry)}
67
+ )
68
+
69
+
70
+ __all__ = ["RateLimiter"]
71
+
72
+
73
+ def rate_limiter(
74
+ *,
75
+ limit: int,
76
+ window: int = 60,
77
+ key_fn: Callable = lambda r: "global",
78
+ limit_resolver: Optional[Callable[[Request, Optional[str]], Optional[int]]] = None,
79
+ scope_by_tenant: bool = False,
80
+ store: RateLimitStore | None = None,
81
+ ):
82
+ store_ = store or InMemoryRateLimitStore(limit=limit)
83
+
84
+ async def dep(request: Request):
85
+ tenant_id = None
86
+ if scope_by_tenant or limit_resolver:
87
+ try:
88
+ if _resolve_tenant_id is not None:
89
+ tenant_id = await _resolve_tenant_id(request)
90
+ except Exception:
91
+ tenant_id = None
92
+
93
+ key = key_fn(request)
94
+ if scope_by_tenant and tenant_id:
95
+ key = f"{key}:tenant:{tenant_id}"
96
+
97
+ eff_limit = limit
98
+ if limit_resolver:
99
+ try:
100
+ v = limit_resolver(request, tenant_id)
101
+ eff_limit = int(v) if v is not None else limit
102
+ except Exception:
103
+ eff_limit = limit
104
+
105
+ count, _store_limit, reset = store_.incr(str(key), window)
106
+ if count > eff_limit:
107
+ retry = max(0, reset - int(time.time()))
108
+ try:
109
+ emit_rate_limited(str(key), eff_limit, retry)
110
+ except Exception:
111
+ pass
112
+ raise HTTPException(
113
+ status_code=429, detail="Rate limit exceeded", headers={"Retry-After": str(retry)}
114
+ )
115
+
116
+ return dep
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import 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(openapi_url, openapi_handler, methods=["GET"], include_in_schema=False)
36
+
37
+ # Swagger UI route
38
+ async def swagger_ui(request: Request) -> HTMLResponse: # noqa: ANN201
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: # noqa: ANN201
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: # noqa: ANN201
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
+ body = resp.body.decode("utf-8", errors="ignore")
114
+ except Exception: # pragma: no cover - very unlikely
115
+ return resp
116
+
117
+ css = _DARK_CSS
118
+ if "</head>" in body:
119
+ body = body.replace("</head>", f"<style>\n{css}\n</style></head>", 1)
120
+ # add class to body to allow stronger selectors
121
+ body = body.replace("<body>", '<body class="dark">', 1)
122
+ return HTMLResponse(content=body, status_code=resp.status_code, headers=dict(resp.headers))
123
+
124
+
125
+ _DARK_CSS = """
126
+ /* Minimal dark mode override for Swagger/ReDoc */
127
+ @media (prefers-color-scheme: dark) { :root { color-scheme: dark; } }
128
+ html.dark, body.dark { background: #0b0e14; color: #e0e6f1; }
129
+ #swagger, .redoc-wrap { background: transparent; }
130
+ a { color: #62aef7; }
131
+ """
132
+
133
+
134
+ def add_sdk_generation_stub(
135
+ app: FastAPI,
136
+ *,
137
+ on_generate: Optional[callable] = None,
138
+ openapi_path: str = "/openapi.json",
139
+ ) -> None:
140
+ """Hook to add an SDK generation stub.
141
+
142
+ Provide `on_generate()` to run generation (e.g., openapi-generator). This is a stub only; we
143
+ don't ship a hard dependency. If `on_generate` is provided, we expose `/_docs/generate-sdk`.
144
+ """
145
+ from svc_infra.api.fastapi.dual.public import public_router
146
+
147
+ if not on_generate:
148
+ return
149
+
150
+ router = public_router(prefix="/_docs", include_in_schema=False)
151
+
152
+ @router.post("/generate-sdk")
153
+ async def _generate() -> dict: # noqa: ANN201
154
+ on_generate()
155
+ return {"status": "ok"}
156
+
157
+ app.include_router(router)
158
+
159
+
160
+ __all__ = ["add_docs", "add_sdk_generation_stub"]
@@ -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>
@@ -0,0 +1,254 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from typing import Dict, Iterable, List, Optional, Set, Tuple
5
+
6
+ from fastapi import FastAPI
7
+ from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
8
+ from fastapi.responses import HTMLResponse
9
+
10
+ from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV, Environment
11
+
12
+ # (prefix, swagger_path, redoc_path, openapi_path, title)
13
+ DOC_SCOPES: List[Tuple[str, str, str, str, str]] = []
14
+
15
+ _HTTP_METHODS = {"get", "put", "post", "delete", "patch", "options", "head", "trace"}
16
+
17
+
18
+ def _path_included(
19
+ path: str,
20
+ include_prefixes: Optional[Iterable[str]] = None,
21
+ exclude_prefixes: Optional[Iterable[str]] = None,
22
+ ) -> bool:
23
+ def _match(pfx: str) -> bool:
24
+ pfx = pfx.rstrip("/") or "/"
25
+ return path == pfx or path.startswith(pfx + "/")
26
+
27
+ if include_prefixes and not any(_match(p) for p in include_prefixes):
28
+ return False
29
+ if exclude_prefixes and any(_match(p) for p in exclude_prefixes):
30
+ return False
31
+ return True
32
+
33
+
34
+ def _collect_refs(obj, refset: Set[Tuple[str, str]]):
35
+ if isinstance(obj, dict):
36
+ for k, v in obj.items():
37
+ if k == "$ref" and isinstance(v, str) and v.startswith("#/components/"):
38
+ parts = v.split("/")
39
+ if len(parts) >= 4:
40
+ refset.add((parts[2], parts[3]))
41
+ else:
42
+ _collect_refs(v, refset)
43
+ elif isinstance(obj, list):
44
+ for it in obj:
45
+ _collect_refs(it, refset)
46
+
47
+
48
+ def _close_over_component_refs(
49
+ full_components: Dict, initial: Set[Tuple[str, str]]
50
+ ) -> Set[Tuple[str, str]]:
51
+ to_visit = list(initial)
52
+ seen = set(initial)
53
+ while to_visit:
54
+ section, name = to_visit.pop()
55
+ comp = (full_components or {}).get(section, {}).get(name)
56
+ if not isinstance(comp, dict):
57
+ continue
58
+ nested: Set[Tuple[str, str]] = set()
59
+ _collect_refs(comp, nested)
60
+ for ref in nested:
61
+ if ref not in seen:
62
+ seen.add(ref)
63
+ to_visit.append(ref)
64
+ return seen
65
+
66
+
67
+ def _prune_to_paths(
68
+ full_schema: Dict,
69
+ keep_paths: Dict[str, dict],
70
+ title_suffix: Optional[str],
71
+ server_prefix: Optional[str] = None,
72
+ ) -> Dict:
73
+ schema = copy.deepcopy(full_schema)
74
+ schema["paths"] = keep_paths
75
+
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()
83
+
84
+ for path_item in keep_paths.values():
85
+ for method, op in path_item.items():
86
+ if method.lower() not in _HTTP_METHODS:
87
+ continue
88
+ for t in op.get("tags", []) or []:
89
+ used_tags.add(t)
90
+ _collect_refs(op, direct_refs)
91
+ for sec in op.get("security", []) or []:
92
+ for scheme_name in sec.keys():
93
+ used_security_schemes.add(scheme_name)
94
+
95
+ comps = schema.get("components") or {}
96
+ all_refs = _close_over_component_refs(comps, direct_refs)
97
+
98
+ pruned_components: Dict[str, Dict] = {}
99
+ if isinstance(comps, dict):
100
+ for section, items in comps.items():
101
+ keep_names = {name for (sec, name) in all_refs if sec == section}
102
+ if section == "securitySchemes":
103
+ keep_names |= used_security_schemes
104
+ if not keep_names:
105
+ continue
106
+ pruned = {name: items[name] for name in keep_names if name in items}
107
+ if pruned:
108
+ pruned_components[section] = pruned
109
+ schema["components"] = pruned_components if pruned_components else {}
110
+
111
+ if "tags" in schema and isinstance(schema["tags"], list):
112
+ schema["tags"] = [
113
+ t for t in schema["tags"] if isinstance(t, dict) and t.get("name") in used_tags
114
+ ]
115
+
116
+ info = dict(schema.get("info") or {})
117
+ if title_suffix:
118
+ info["title"] = f"{info.get('title') or 'API'} • {title_suffix}"
119
+ schema["info"] = info
120
+ return schema
121
+
122
+
123
+ def _build_filtered_schema(
124
+ full_schema: Dict,
125
+ *,
126
+ include_prefixes: Optional[List[str]] = None,
127
+ exclude_prefixes: Optional[List[str]] = None,
128
+ title_suffix: Optional[str] = None,
129
+ ) -> Dict:
130
+ paths = full_schema.get("paths", {}) or {}
131
+ keep_paths = {
132
+ p: v for p, v in paths.items() if _path_included(p, include_prefixes, exclude_prefixes)
133
+ }
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)
154
+
155
+
156
+ def _ensure_original_openapi_saved(app: FastAPI) -> None:
157
+ if not hasattr(app.state, "_scoped_original_openapi"):
158
+ app.state._scoped_original_openapi = app.openapi # type: ignore[attr-defined]
159
+
160
+
161
+ def _get_full_schema_from_original(app: FastAPI) -> Dict:
162
+ _ensure_original_openapi_saved(app)
163
+ return copy.deepcopy(app.state._scoped_original_openapi()) # type: ignore[attr-defined]
164
+
165
+
166
+ def _install_root_filter(app: FastAPI, exclude_prefixes: List[str]) -> None:
167
+ _ensure_original_openapi_saved(app)
168
+ app.state._scoped_root_exclusions = sorted(set(exclude_prefixes)) # type: ignore[attr-defined]
169
+
170
+ def root_filtered_openapi():
171
+ full_schema = _get_full_schema_from_original(app)
172
+ return _build_filtered_schema(full_schema, exclude_prefixes=app.state._scoped_root_exclusions) # type: ignore[attr-defined]
173
+
174
+ app.openapi = root_filtered_openapi
175
+
176
+
177
+ def _current_registered_scopes() -> List[str]:
178
+ return [scope for (scope, *_rest) in DOC_SCOPES]
179
+
180
+
181
+ def _ensure_root_excludes_registered_scopes(app: FastAPI) -> None:
182
+ scopes = _current_registered_scopes()
183
+ if scopes:
184
+ _install_root_filter(app, scopes)
185
+
186
+
187
+ def _normalize_envs(envs: Optional[Iterable[Environment | str]]) -> Optional[set[Environment]]:
188
+ if envs is None:
189
+ return None
190
+ out: set[Environment] = set()
191
+ for e in envs:
192
+ out.add(e if isinstance(e, Environment) else Environment(e))
193
+ return out
194
+
195
+
196
+ def add_prefixed_docs(
197
+ app: FastAPI,
198
+ *,
199
+ prefix: str,
200
+ title: str,
201
+ auto_exclude_from_root: bool = True,
202
+ visible_envs: Optional[Iterable[Environment | str]] = (LOCAL_ENV, DEV_ENV),
203
+ ) -> None:
204
+ scope = prefix.rstrip("/") or "/"
205
+
206
+ # Always exclude from root if requested, regardless of environment
207
+ if auto_exclude_from_root:
208
+ _ensure_original_openapi_saved(app)
209
+ # Add to exclusion list for root docs
210
+ if not hasattr(app.state, "_scoped_root_exclusions"):
211
+ app.state._scoped_root_exclusions = []
212
+ if scope not in app.state._scoped_root_exclusions:
213
+ app.state._scoped_root_exclusions.append(scope)
214
+ _install_root_filter(app, app.state._scoped_root_exclusions)
215
+
216
+ # Only create scoped docs in allowed environments
217
+ allow = _normalize_envs(visible_envs)
218
+ if allow is not None and CURRENT_ENVIRONMENT not in allow:
219
+ return
220
+
221
+ openapi_path = f"{scope}/openapi.json"
222
+ swagger_path = f"{scope}/docs"
223
+ redoc_path = f"{scope}/redoc"
224
+
225
+ _ensure_original_openapi_saved(app)
226
+ _scope_cache: Dict | None = None
227
+
228
+ def _scoped_schema():
229
+ nonlocal _scope_cache
230
+ if _scope_cache is None:
231
+ full = _get_full_schema_from_original(app)
232
+ _scope_cache = _build_filtered_schema(
233
+ full, include_prefixes=[scope], title_suffix=title
234
+ )
235
+ return _scope_cache
236
+
237
+ # --- Register directly on the app to ensure truly public & collision-proof ---
238
+ @app.get(openapi_path, include_in_schema=False)
239
+ def scoped_openapi():
240
+ return _scoped_schema()
241
+
242
+ @app.get(swagger_path, include_in_schema=False, response_class=HTMLResponse)
243
+ def scoped_swagger():
244
+ return get_swagger_ui_html(openapi_url=openapi_path, title=f"{title} • Swagger")
245
+
246
+ @app.get(redoc_path, include_in_schema=False, response_class=HTMLResponse)
247
+ def scoped_redoc():
248
+ return get_redoc_html(openapi_url=openapi_path, title=f"{title} • ReDoc")
249
+
250
+ DOC_SCOPES.append((scope, swagger_path, redoc_path, openapi_path, title))
251
+
252
+
253
+ def replace_root_openapi_with_exclusions(app: FastAPI, *, exclude_prefixes: List[str]) -> None:
254
+ _install_root_filter(app, exclude_prefixes)