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.
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/alembic.py +11 -0
- svc_infra/apf_payments/models.py +339 -0
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +797 -0
- svc_infra/apf_payments/provider/base.py +270 -0
- svc_infra/apf_payments/provider/registry.py +31 -0
- svc_infra/apf_payments/provider/stripe.py +873 -0
- svc_infra/apf_payments/schemas.py +333 -0
- svc_infra/apf_payments/service.py +892 -0
- svc_infra/apf_payments/settings.py +67 -0
- svc_infra/api/fastapi/__init__.py +6 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/apf_payments/__init__.py +0 -0
- svc_infra/api/fastapi/apf_payments/router.py +1082 -0
- svc_infra/api/fastapi/apf_payments/setup.py +73 -0
- svc_infra/api/fastapi/auth/add.py +15 -6
- svc_infra/api/fastapi/auth/gaurd.py +67 -5
- svc_infra/api/fastapi/auth/mfa/router.py +18 -9
- svc_infra/api/fastapi/auth/routers/account.py +3 -2
- svc_infra/api/fastapi/auth/routers/apikey_router.py +11 -5
- svc_infra/api/fastapi/auth/routers/oauth_router.py +82 -37
- svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
- svc_infra/api/fastapi/auth/security.py +3 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/auth/state.py +1 -1
- svc_infra/api/fastapi/billing/router.py +64 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
- svc_infra/api/fastapi/db/sql/add.py +40 -18
- svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
- svc_infra/api/fastapi/db/sql/session.py +16 -0
- svc_infra/api/fastapi/db/sql/users.py +14 -2
- svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
- svc_infra/api/fastapi/docs/add.py +160 -0
- svc_infra/api/fastapi/docs/landing.py +1 -1
- svc_infra/api/fastapi/docs/scoped.py +254 -0
- svc_infra/api/fastapi/dual/dualize.py +38 -33
- svc_infra/api/fastapi/dual/router.py +48 -1
- svc_infra/api/fastapi/dx.py +3 -3
- svc_infra/api/fastapi/http/__init__.py +0 -0
- svc_infra/api/fastapi/http/concurrency.py +14 -0
- svc_infra/api/fastapi/http/conditional.py +33 -0
- svc_infra/api/fastapi/http/deprecation.py +21 -0
- svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
- svc_infra/api/fastapi/middleware/idempotency.py +116 -0
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +119 -0
- svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
- svc_infra/api/fastapi/middleware/request_id.py +23 -0
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +148 -0
- svc_infra/api/fastapi/openapi/mutators.py +768 -55
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +363 -0
- svc_infra/api/fastapi/paths/auth.py +14 -14
- svc_infra/api/fastapi/paths/prefix.py +0 -1
- svc_infra/api/fastapi/paths/user.py +1 -1
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +48 -15
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +230 -0
- svc_infra/billing/models.py +131 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +33 -0
- svc_infra/billing/service.py +115 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +4 -0
- svc_infra/cache/add.py +158 -0
- svc_infra/cache/backend.py +5 -2
- svc_infra/cache/decorators.py +19 -1
- svc_infra/cache/keys.py +24 -4
- svc_infra/cli/__init__.py +32 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +10 -0
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +120 -14
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +5 -4
- svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +53 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +40 -0
- svc_infra/data/retention.py +55 -0
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/outbox.py +104 -0
- svc_infra/db/sql/apikey.py +1 -1
- svc_infra/db/sql/authref.py +61 -0
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/repository.py +52 -12
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +16 -4
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +199 -76
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +231 -79
- svc_infra/db/sql/tenant.py +79 -0
- svc_infra/db/sql/utils.py +18 -4
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/docs/acceptance-matrix.md +71 -0
- svc_infra/docs/acceptance.md +44 -0
- svc_infra/docs/admin.md +425 -0
- svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
- svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
- svc_infra/docs/adr/0004-tenancy-model.md +42 -0
- svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
- svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
- svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
- svc_infra/docs/adr/0008-billing-primitives.md +143 -0
- svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
- svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
- svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
- svc_infra/docs/api.md +59 -0
- svc_infra/docs/auth.md +11 -0
- svc_infra/docs/billing.md +190 -0
- svc_infra/docs/cache.md +76 -0
- svc_infra/docs/cli.md +74 -0
- svc_infra/docs/contributing.md +34 -0
- svc_infra/docs/data-lifecycle.md +52 -0
- svc_infra/docs/database.md +14 -0
- svc_infra/docs/docs-and-sdks.md +62 -0
- svc_infra/docs/environment.md +114 -0
- svc_infra/docs/getting-started.md +63 -0
- svc_infra/docs/idempotency.md +111 -0
- svc_infra/docs/jobs.md +67 -0
- svc_infra/docs/observability.md +16 -0
- svc_infra/docs/ops.md +37 -0
- svc_infra/docs/rate-limiting.md +125 -0
- svc_infra/docs/repo-review.md +48 -0
- svc_infra/docs/security.md +176 -0
- svc_infra/docs/tenancy.md +35 -0
- svc_infra/docs/timeouts-and-resource-limits.md +147 -0
- svc_infra/docs/versioned-integrations.md +146 -0
- svc_infra/docs/webhooks.md +112 -0
- svc_infra/dx/add.py +63 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +67 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +72 -0
- svc_infra/jobs/builtins/outbox_processor.py +38 -0
- svc_infra/jobs/builtins/webhook_delivery.py +90 -0
- svc_infra/jobs/easy.py +32 -0
- svc_infra/jobs/loader.py +45 -0
- svc_infra/jobs/queue.py +81 -0
- svc_infra/jobs/redis_queue.py +191 -0
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/scheduler.py +41 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/mcp/svc_infra_mcp.py +85 -28
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +54 -7
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +53 -0
- svc_infra/obs/metrics.py +52 -0
- svc_infra/security/add.py +201 -0
- svc_infra/security/audit.py +130 -0
- svc_infra/security/audit_service.py +73 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +95 -0
- svc_infra/security/jwt_rotation.py +53 -0
- svc_infra/security/lockout.py +96 -0
- svc_infra/security/models.py +255 -0
- svc_infra/security/org_invites.py +128 -0
- svc_infra/security/passwords.py +77 -0
- svc_infra/security/permissions.py +149 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +80 -0
- svc_infra/webhooks/__init__.py +16 -0
- svc_infra/webhooks/add.py +322 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +67 -0
- svc_infra/webhooks/signing.py +30 -0
- svc_infra-0.1.654.dist-info/METADATA +154 -0
- svc_infra-0.1.654.dist-info/RECORD +352 -0
- svc_infra/api/fastapi/deps.py +0 -3
- svc_infra-0.1.506.dist-info/METADATA +0 -78
- svc_infra-0.1.506.dist-info/RECORD +0 -213
- /svc_infra/{api/fastapi/schemas → apf_payments}/__init__.py +0 -0
- {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
- {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
|
-
|
|
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,
|
|
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
|
|
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)
|