svc-infra 0.1.595__py3-none-any.whl → 0.1.706__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of svc-infra might be problematic. Click here for more details.
- svc_infra/__init__.py +58 -2
- svc_infra/apf_payments/models.py +133 -42
- svc_infra/apf_payments/provider/aiydan.py +121 -47
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +18 -9
- svc_infra/apf_payments/service.py +98 -41
- svc_infra/apf_payments/settings.py +5 -1
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +15 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +245 -0
- svc_infra/api/fastapi/apf_payments/router.py +128 -70
- svc_infra/api/fastapi/apf_payments/setup.py +13 -6
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +6 -2
- svc_infra/api/fastapi/auth/add.py +17 -14
- svc_infra/api/fastapi/auth/gaurd.py +45 -16
- svc_infra/api/fastapi/auth/mfa/models.py +3 -1
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
- svc_infra/api/fastapi/auth/mfa/router.py +15 -8
- svc_infra/api/fastapi/auth/mfa/security.py +1 -2
- svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
- svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -1
- svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
- svc_infra/api/fastapi/auth/routers/oauth_router.py +146 -52
- svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- svc_infra/api/fastapi/auth/state.py +3 -1
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +73 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/__init__.py +5 -1
- svc_infra/api/fastapi/db/http.py +3 -1
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +71 -26
- svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
- svc_infra/api/fastapi/db/sql/health.py +3 -1
- svc_infra/api/fastapi/db/sql/session.py +18 -0
- svc_infra/api/fastapi/db/sql/users.py +18 -6
- svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
- svc_infra/api/fastapi/docs/add.py +173 -0
- svc_infra/api/fastapi/docs/landing.py +4 -2
- svc_infra/api/fastapi/docs/scoped.py +62 -15
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +126 -4
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +40 -13
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +10 -2
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +3 -1
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
- svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
- svc_infra/api/fastapi/middleware/idempotency.py +197 -70
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
- svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
- svc_infra/api/fastapi/middleware/timeout.py +177 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -3
- svc_infra/api/fastapi/openapi/conventions.py +9 -2
- svc_infra/api/fastapi/openapi/mutators.py +165 -20
- svc_infra/api/fastapi/openapi/pipeline.py +1 -1
- svc_infra/api/fastapi/openapi/security.py +3 -1
- svc_infra/api/fastapi/ops/add.py +75 -0
- svc_infra/api/fastapi/pagination.py +47 -20
- svc_infra/api/fastapi/routers/__init__.py +43 -15
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +188 -57
- 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/app/__init__.py +3 -1
- svc_infra/app/env.py +69 -1
- svc_infra/app/logging/add.py +9 -2
- svc_infra/app/logging/formats.py +12 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +241 -0
- svc_infra/billing/models.py +177 -0
- svc_infra/billing/quotas.py +103 -0
- svc_infra/billing/schemas.py +36 -0
- svc_infra/billing/service.py +123 -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 +9 -0
- svc_infra/cache/add.py +170 -0
- svc_infra/cache/backend.py +7 -6
- svc_infra/cache/decorators.py +81 -15
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +24 -4
- svc_infra/cache/recache.py +26 -14
- svc_infra/cache/resources.py +14 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/utils.py +3 -1
- svc_infra/cli/__init__.py +52 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +39 -2
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
- svc_infra/cli/cmds/db/ops_cmds.py +270 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
- svc_infra/cli/cmds/health/__init__.py +179 -0
- svc_infra/cli/cmds/health/health_cmds.py +8 -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 +47 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
- svc_infra/cli/foundation/runner.py +6 -2
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +58 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +61 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +9 -9
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +3 -0
- svc_infra/db/nosql/core.py +30 -9
- svc_infra/db/nosql/indexes.py +3 -1
- svc_infra/db/nosql/management.py +1 -1
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +19 -2
- svc_infra/db/nosql/mongo/settings.py +6 -2
- svc_infra/db/nosql/repository.py +35 -15
- svc_infra/db/nosql/resource.py +20 -3
- svc_infra/db/nosql/scaffold.py +9 -3
- svc_infra/db/nosql/service.py +3 -1
- svc_infra/db/nosql/types.py +6 -2
- svc_infra/db/ops.py +384 -0
- svc_infra/db/outbox.py +108 -0
- svc_infra/db/sql/apikey.py +37 -9
- svc_infra/db/sql/authref.py +9 -3
- svc_infra/db/sql/constants.py +12 -8
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/management.py +11 -8
- svc_infra/db/sql/repository.py +99 -26
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +6 -2
- svc_infra/db/sql/service.py +15 -5
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
- svc_infra/db/sql/tenant.py +88 -0
- svc_infra/db/sql/uniq_hooks.py +9 -3
- svc_infra/db/sql/utils.py +138 -51
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/deploy/__init__.py +538 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +264 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +264 -0
- svc_infra/dx/add.py +65 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +68 -0
- svc_infra/exceptions.py +141 -0
- svc_infra/health/__init__.py +864 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +105 -0
- svc_infra/jobs/builtins/outbox_processor.py +40 -0
- svc_infra/jobs/builtins/webhook_delivery.py +95 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +50 -0
- svc_infra/jobs/queue.py +116 -0
- svc_infra/jobs/redis_queue.py +256 -0
- svc_infra/jobs/runner.py +79 -0
- svc_infra/jobs/scheduler.py +53 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/loaders/__init__.py +186 -0
- svc_infra/loaders/base.py +142 -0
- svc_infra/loaders/github.py +311 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +235 -0
- svc_infra/logging/__init__.py +374 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +65 -9
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +3 -4
- svc_infra/obs/metrics/asgi.py +13 -7
- svc_infra/obs/metrics/http.py +9 -5
- svc_infra/obs/metrics/sqlalchemy.py +13 -9
- svc_infra/obs/metrics.py +6 -5
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +92 -10
- svc_infra/security/audit_service.py +4 -3
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +14 -4
- svc_infra/security/jwt_rotation.py +74 -22
- svc_infra/security/lockout.py +11 -5
- svc_infra/security/models.py +54 -12
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +5 -3
- svc_infra/security/passwords.py +3 -1
- svc_infra/security/permissions.py +25 -2
- svc_infra/security/session.py +1 -1
- svc_infra/security/signed_cookies.py +21 -1
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +253 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +339 -0
- svc_infra/storage/backends/memory.py +216 -0
- svc_infra/storage/backends/s3.py +353 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +185 -0
- svc_infra/storage/settings.py +195 -0
- svc_infra/testing/__init__.py +685 -0
- svc_infra/utils.py +7 -3
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +339 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +39 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +70 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +140 -0
- svc_infra/websocket/client.py +282 -0
- svc_infra/websocket/config.py +69 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +344 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-0.1.706.dist-info/LICENSE +21 -0
- svc_infra-0.1.706.dist-info/METADATA +356 -0
- svc_infra-0.1.706.dist-info/RECORD +357 -0
- svc_infra-0.1.595.dist-info/METADATA +0 -80
- svc_infra-0.1.595.dist-info/RECORD +0 -253
- {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
|
@@ -1,14 +1,27 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import logging
|
|
3
4
|
import time
|
|
4
|
-
from typing import Callable
|
|
5
|
+
from typing import Callable, Optional
|
|
5
6
|
|
|
6
7
|
from fastapi import HTTPException
|
|
7
8
|
from starlette.requests import Request
|
|
8
9
|
|
|
9
|
-
from svc_infra.api.fastapi.middleware.ratelimit_store import
|
|
10
|
+
from svc_infra.api.fastapi.middleware.ratelimit_store import (
|
|
11
|
+
InMemoryRateLimitStore,
|
|
12
|
+
RateLimitStore,
|
|
13
|
+
)
|
|
10
14
|
from svc_infra.obs.metrics import emit_rate_limited
|
|
11
15
|
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from svc_infra.api.fastapi.tenancy.context import (
|
|
20
|
+
resolve_tenant_id as _resolve_tenant_id,
|
|
21
|
+
)
|
|
22
|
+
except Exception: # pragma: no cover - minimal builds
|
|
23
|
+
_resolve_tenant_id = None # type: ignore[assignment]
|
|
24
|
+
|
|
12
25
|
|
|
13
26
|
class RateLimiter:
|
|
14
27
|
def __init__(
|
|
@@ -17,24 +30,52 @@ class RateLimiter:
|
|
|
17
30
|
limit: int,
|
|
18
31
|
window: int = 60,
|
|
19
32
|
key_fn: Callable = lambda r: "global",
|
|
33
|
+
limit_resolver: Optional[
|
|
34
|
+
Callable[[Request, Optional[str]], Optional[int]]
|
|
35
|
+
] = None,
|
|
36
|
+
scope_by_tenant: bool = False,
|
|
20
37
|
store: RateLimitStore | None = None,
|
|
21
38
|
):
|
|
22
39
|
self.limit = limit
|
|
23
40
|
self.window = window
|
|
24
41
|
self.key_fn = key_fn
|
|
42
|
+
self._limit_resolver = limit_resolver
|
|
43
|
+
self.scope_by_tenant = scope_by_tenant
|
|
25
44
|
self.store = store or InMemoryRateLimitStore(limit=limit)
|
|
26
45
|
|
|
27
46
|
async def __call__(self, request: Request):
|
|
47
|
+
# Try resolving tenant when asked
|
|
48
|
+
tenant_id = None
|
|
49
|
+
if self.scope_by_tenant or self._limit_resolver:
|
|
50
|
+
try:
|
|
51
|
+
if _resolve_tenant_id is not None:
|
|
52
|
+
tenant_id = await _resolve_tenant_id(request)
|
|
53
|
+
except Exception:
|
|
54
|
+
tenant_id = None
|
|
55
|
+
|
|
28
56
|
key = self.key_fn(request)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
57
|
+
if self.scope_by_tenant and tenant_id:
|
|
58
|
+
key = f"{key}:tenant:{tenant_id}"
|
|
59
|
+
|
|
60
|
+
eff_limit = self.limit
|
|
61
|
+
if self._limit_resolver:
|
|
32
62
|
try:
|
|
33
|
-
|
|
63
|
+
v = self._limit_resolver(request, tenant_id)
|
|
64
|
+
eff_limit = int(v) if v is not None else self.limit
|
|
34
65
|
except Exception:
|
|
35
|
-
|
|
66
|
+
eff_limit = self.limit
|
|
67
|
+
|
|
68
|
+
count, store_limit, reset = self.store.incr(str(key), self.window)
|
|
69
|
+
if count > eff_limit:
|
|
70
|
+
retry = max(0, reset - int(time.time()))
|
|
71
|
+
try:
|
|
72
|
+
emit_rate_limited(str(key), eff_limit, retry)
|
|
73
|
+
except Exception as e:
|
|
74
|
+
logger.warning("Failed to emit rate limit metric: %s", e)
|
|
36
75
|
raise HTTPException(
|
|
37
|
-
status_code=429,
|
|
76
|
+
status_code=429,
|
|
77
|
+
detail="Rate limit exceeded",
|
|
78
|
+
headers={"Retry-After": str(retry)},
|
|
38
79
|
)
|
|
39
80
|
|
|
40
81
|
|
|
@@ -46,21 +87,44 @@ def rate_limiter(
|
|
|
46
87
|
limit: int,
|
|
47
88
|
window: int = 60,
|
|
48
89
|
key_fn: Callable = lambda r: "global",
|
|
90
|
+
limit_resolver: Optional[Callable[[Request, Optional[str]], Optional[int]]] = None,
|
|
91
|
+
scope_by_tenant: bool = False,
|
|
49
92
|
store: RateLimitStore | None = None,
|
|
50
93
|
):
|
|
51
94
|
store_ = store or InMemoryRateLimitStore(limit=limit)
|
|
52
95
|
|
|
53
96
|
async def dep(request: Request):
|
|
97
|
+
tenant_id = None
|
|
98
|
+
if scope_by_tenant or limit_resolver:
|
|
99
|
+
try:
|
|
100
|
+
if _resolve_tenant_id is not None:
|
|
101
|
+
tenant_id = await _resolve_tenant_id(request)
|
|
102
|
+
except Exception:
|
|
103
|
+
tenant_id = None
|
|
104
|
+
|
|
54
105
|
key = key_fn(request)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
106
|
+
if scope_by_tenant and tenant_id:
|
|
107
|
+
key = f"{key}:tenant:{tenant_id}"
|
|
108
|
+
|
|
109
|
+
eff_limit = limit
|
|
110
|
+
if limit_resolver:
|
|
58
111
|
try:
|
|
59
|
-
|
|
112
|
+
v = limit_resolver(request, tenant_id)
|
|
113
|
+
eff_limit = int(v) if v is not None else limit
|
|
60
114
|
except Exception:
|
|
61
|
-
|
|
115
|
+
eff_limit = limit
|
|
116
|
+
|
|
117
|
+
count, _store_limit, reset = store_.incr(str(key), window)
|
|
118
|
+
if count > eff_limit:
|
|
119
|
+
retry = max(0, reset - int(time.time()))
|
|
120
|
+
try:
|
|
121
|
+
emit_rate_limited(str(key), eff_limit, retry)
|
|
122
|
+
except Exception as e:
|
|
123
|
+
logger.warning("Failed to emit rate limit metric: %s", e)
|
|
62
124
|
raise HTTPException(
|
|
63
|
-
status_code=429,
|
|
125
|
+
status_code=429,
|
|
126
|
+
detail="Rate limit exceeded",
|
|
127
|
+
headers={"Retry-After": str(retry)},
|
|
64
128
|
)
|
|
65
129
|
|
|
66
130
|
return dep
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Callable, Optional
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI, Request
|
|
8
|
+
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
|
|
9
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
10
|
+
|
|
11
|
+
from .landing import CardSpec, DocTargets, render_index_html
|
|
12
|
+
from .scoped import DOC_SCOPES
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def add_docs(
|
|
16
|
+
app: FastAPI,
|
|
17
|
+
*,
|
|
18
|
+
redoc_url: str = "/redoc",
|
|
19
|
+
swagger_url: str = "/docs",
|
|
20
|
+
openapi_url: str = "/openapi.json",
|
|
21
|
+
export_openapi_to: Optional[str] = None,
|
|
22
|
+
# Landing page options
|
|
23
|
+
landing_url: str = "/",
|
|
24
|
+
include_landing: bool = True,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Enable docs endpoints and optionally export OpenAPI schema to disk on startup.
|
|
27
|
+
|
|
28
|
+
We mount docs and OpenAPI routes explicitly so this works even when configured post-init.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
# OpenAPI JSON route
|
|
32
|
+
async def openapi_handler() -> JSONResponse: # noqa: ANN201
|
|
33
|
+
return JSONResponse(app.openapi())
|
|
34
|
+
|
|
35
|
+
app.add_api_route(
|
|
36
|
+
openapi_url, openapi_handler, methods=["GET"], include_in_schema=False
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Swagger UI route
|
|
40
|
+
async def swagger_ui(request: Request) -> HTMLResponse: # noqa: ANN201
|
|
41
|
+
resp = get_swagger_ui_html(openapi_url=openapi_url, title="API Docs")
|
|
42
|
+
theme = request.query_params.get("theme")
|
|
43
|
+
if theme == "dark":
|
|
44
|
+
return _with_dark_mode(resp)
|
|
45
|
+
return resp
|
|
46
|
+
|
|
47
|
+
app.add_api_route(swagger_url, swagger_ui, methods=["GET"], include_in_schema=False)
|
|
48
|
+
|
|
49
|
+
# Redoc route
|
|
50
|
+
async def redoc_ui(request: Request) -> HTMLResponse: # noqa: ANN201
|
|
51
|
+
resp = get_redoc_html(openapi_url=openapi_url, title="API ReDoc")
|
|
52
|
+
theme = request.query_params.get("theme")
|
|
53
|
+
if theme == "dark":
|
|
54
|
+
return _with_dark_mode(resp)
|
|
55
|
+
return resp
|
|
56
|
+
|
|
57
|
+
app.add_api_route(redoc_url, redoc_ui, methods=["GET"], include_in_schema=False)
|
|
58
|
+
|
|
59
|
+
# Optional export to disk on startup
|
|
60
|
+
if export_openapi_to:
|
|
61
|
+
export_path = Path(export_openapi_to)
|
|
62
|
+
|
|
63
|
+
async def _export_docs() -> None:
|
|
64
|
+
# Startup export
|
|
65
|
+
spec = app.openapi()
|
|
66
|
+
export_path.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
export_path.write_text(json.dumps(spec, indent=2))
|
|
68
|
+
|
|
69
|
+
app.add_event_handler("startup", _export_docs)
|
|
70
|
+
|
|
71
|
+
# Optional landing page with the same look/feel as setup_service_api
|
|
72
|
+
if include_landing:
|
|
73
|
+
# Avoid path collision; if landing_url is already taken for GET, fallback to "/_docs"
|
|
74
|
+
existing_paths = {
|
|
75
|
+
(getattr(r, "path", None) or getattr(r, "path_format", None))
|
|
76
|
+
for r in getattr(app, "routes", [])
|
|
77
|
+
if getattr(r, "methods", None) and "GET" in r.methods
|
|
78
|
+
}
|
|
79
|
+
landing_path = landing_url or "/"
|
|
80
|
+
if landing_path in existing_paths:
|
|
81
|
+
landing_path = "/_docs"
|
|
82
|
+
|
|
83
|
+
async def _landing() -> HTMLResponse: # noqa: ANN201
|
|
84
|
+
cards: list[CardSpec] = []
|
|
85
|
+
# Root docs card using the provided paths
|
|
86
|
+
cards.append(
|
|
87
|
+
CardSpec(
|
|
88
|
+
tag="",
|
|
89
|
+
docs=DocTargets(
|
|
90
|
+
swagger=swagger_url, redoc=redoc_url, openapi_json=openapi_url
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
# Scoped docs (if any were registered via add_prefixed_docs)
|
|
95
|
+
for scope, swagger, redoc, openapi_json, _title in DOC_SCOPES:
|
|
96
|
+
cards.append(
|
|
97
|
+
CardSpec(
|
|
98
|
+
tag=scope.strip("/"),
|
|
99
|
+
docs=DocTargets(
|
|
100
|
+
swagger=swagger, redoc=redoc, openapi_json=openapi_json
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
html = render_index_html(
|
|
105
|
+
service_name=app.title or "API", release=app.version or "", cards=cards
|
|
106
|
+
)
|
|
107
|
+
return HTMLResponse(html)
|
|
108
|
+
|
|
109
|
+
app.add_api_route(
|
|
110
|
+
landing_path, _landing, methods=["GET"], include_in_schema=False
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _with_dark_mode(resp: HTMLResponse) -> HTMLResponse:
|
|
115
|
+
"""Return a copy of the HTMLResponse with a minimal dark-theme CSS injected.
|
|
116
|
+
|
|
117
|
+
We avoid depending on custom Swagger/ReDoc builds; this works by inlining a small CSS
|
|
118
|
+
block and toggling a `.dark` class on the body element.
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
raw_body = resp.body
|
|
122
|
+
if isinstance(raw_body, memoryview):
|
|
123
|
+
raw_body = raw_body.tobytes()
|
|
124
|
+
body = raw_body.decode("utf-8", errors="ignore")
|
|
125
|
+
except Exception: # pragma: no cover - very unlikely
|
|
126
|
+
return resp
|
|
127
|
+
|
|
128
|
+
css = _DARK_CSS
|
|
129
|
+
if "</head>" in body:
|
|
130
|
+
body = body.replace("</head>", f"<style>\n{css}\n</style></head>", 1)
|
|
131
|
+
# add class to body to allow stronger selectors
|
|
132
|
+
body = body.replace("<body>", '<body class="dark">', 1)
|
|
133
|
+
return HTMLResponse(
|
|
134
|
+
content=body, status_code=resp.status_code, headers=dict(resp.headers)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
_DARK_CSS = """
|
|
139
|
+
/* Minimal dark mode override for Swagger/ReDoc */
|
|
140
|
+
@media (prefers-color-scheme: dark) { :root { color-scheme: dark; } }
|
|
141
|
+
html.dark, body.dark { background: #0b0e14; color: #e0e6f1; }
|
|
142
|
+
#swagger, .redoc-wrap { background: transparent; }
|
|
143
|
+
a { color: #62aef7; }
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def add_sdk_generation_stub(
|
|
148
|
+
app: FastAPI,
|
|
149
|
+
*,
|
|
150
|
+
on_generate: Optional[Callable[[], None]] = None,
|
|
151
|
+
openapi_path: str = "/openapi.json",
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Hook to add an SDK generation stub.
|
|
154
|
+
|
|
155
|
+
Provide `on_generate()` to run generation (e.g., openapi-generator). This is a stub only; we
|
|
156
|
+
don't ship a hard dependency. If `on_generate` is provided, we expose `/_docs/generate-sdk`.
|
|
157
|
+
"""
|
|
158
|
+
from svc_infra.api.fastapi.dual.public import public_router
|
|
159
|
+
|
|
160
|
+
if not on_generate:
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
router = public_router(prefix="/_docs", include_in_schema=False)
|
|
164
|
+
|
|
165
|
+
@router.post("/generate-sdk")
|
|
166
|
+
async def _generate() -> dict: # noqa: ANN201
|
|
167
|
+
on_generate()
|
|
168
|
+
return {"status": "ok"}
|
|
169
|
+
|
|
170
|
+
app.include_router(router)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
__all__ = ["add_docs", "add_sdk_generation_stub"]
|
|
@@ -50,7 +50,9 @@ def _card(spec: CardSpec) -> str:
|
|
|
50
50
|
""".strip()
|
|
51
51
|
|
|
52
52
|
|
|
53
|
-
def render_index_html(
|
|
53
|
+
def render_index_html(
|
|
54
|
+
*, service_name: str, release: str, cards: Iterable[CardSpec]
|
|
55
|
+
) -> str:
|
|
54
56
|
grid = "\n".join(_card(c) for c in cards)
|
|
55
57
|
return f"""
|
|
56
58
|
<!doctype html>
|
|
@@ -115,7 +117,7 @@ def render_index_html(*, service_name: str, release: str, cards: Iterable[CardSp
|
|
|
115
117
|
<section class="grid">
|
|
116
118
|
{grid}
|
|
117
119
|
</section>
|
|
118
|
-
<footer>Tip: each card exposes Swagger, ReDoc, and a
|
|
120
|
+
<footer>Tip: each card exposes Swagger, ReDoc, and a JSON view.</footer>
|
|
119
121
|
</div>
|
|
120
122
|
</body>
|
|
121
123
|
</html>
|
|
@@ -65,11 +65,18 @@ def _close_over_component_refs(
|
|
|
65
65
|
|
|
66
66
|
|
|
67
67
|
def _prune_to_paths(
|
|
68
|
-
full_schema: Dict,
|
|
68
|
+
full_schema: Dict,
|
|
69
|
+
keep_paths: Dict[str, dict],
|
|
70
|
+
title_suffix: Optional[str],
|
|
71
|
+
server_prefix: Optional[str] = None,
|
|
69
72
|
) -> Dict:
|
|
70
73
|
schema = copy.deepcopy(full_schema)
|
|
71
74
|
schema["paths"] = keep_paths
|
|
72
75
|
|
|
76
|
+
# Set server URL for scoped docs
|
|
77
|
+
if server_prefix is not None:
|
|
78
|
+
schema["servers"] = [{"url": server_prefix}]
|
|
79
|
+
|
|
73
80
|
used_tags: Set[str] = set()
|
|
74
81
|
direct_refs: Set[Tuple[str, str]] = set()
|
|
75
82
|
used_security_schemes: Set[str] = set()
|
|
@@ -103,7 +110,9 @@ def _prune_to_paths(
|
|
|
103
110
|
|
|
104
111
|
if "tags" in schema and isinstance(schema["tags"], list):
|
|
105
112
|
schema["tags"] = [
|
|
106
|
-
t
|
|
113
|
+
t
|
|
114
|
+
for t in schema["tags"]
|
|
115
|
+
if isinstance(t, dict) and t.get("name") in used_tags
|
|
107
116
|
]
|
|
108
117
|
|
|
109
118
|
info = dict(schema.get("info") or {})
|
|
@@ -122,30 +131,55 @@ def _build_filtered_schema(
|
|
|
122
131
|
) -> Dict:
|
|
123
132
|
paths = full_schema.get("paths", {}) or {}
|
|
124
133
|
keep_paths = {
|
|
125
|
-
p: v
|
|
134
|
+
p: v
|
|
135
|
+
for p, v in paths.items()
|
|
136
|
+
if _path_included(p, include_prefixes, exclude_prefixes)
|
|
126
137
|
}
|
|
127
|
-
|
|
138
|
+
|
|
139
|
+
# Determine the server prefix for scoped docs
|
|
140
|
+
server_prefix = None
|
|
141
|
+
if include_prefixes and len(include_prefixes) == 1:
|
|
142
|
+
# Single include prefix = scoped docs
|
|
143
|
+
server_prefix = include_prefixes[0].rstrip("/") or "/"
|
|
144
|
+
|
|
145
|
+
# Strip prefix from paths to make them relative to the server
|
|
146
|
+
stripped_paths = {}
|
|
147
|
+
for path, spec in keep_paths.items():
|
|
148
|
+
if path.startswith(server_prefix) and path != server_prefix:
|
|
149
|
+
# Remove prefix, keeping the leading slash
|
|
150
|
+
relative_path = path[len(server_prefix) :]
|
|
151
|
+
stripped_paths[relative_path] = spec
|
|
152
|
+
else:
|
|
153
|
+
# Path equals prefix or doesn't start with it
|
|
154
|
+
stripped_paths[path] = spec
|
|
155
|
+
keep_paths = stripped_paths
|
|
156
|
+
|
|
157
|
+
return _prune_to_paths(
|
|
158
|
+
full_schema, keep_paths, title_suffix, server_prefix=server_prefix
|
|
159
|
+
)
|
|
128
160
|
|
|
129
161
|
|
|
130
162
|
def _ensure_original_openapi_saved(app: FastAPI) -> None:
|
|
131
163
|
if not hasattr(app.state, "_scoped_original_openapi"):
|
|
132
|
-
app.state._scoped_original_openapi = app.openapi
|
|
164
|
+
app.state._scoped_original_openapi = app.openapi
|
|
133
165
|
|
|
134
166
|
|
|
135
167
|
def _get_full_schema_from_original(app: FastAPI) -> Dict:
|
|
136
168
|
_ensure_original_openapi_saved(app)
|
|
137
|
-
return copy.deepcopy(app.state._scoped_original_openapi())
|
|
169
|
+
return copy.deepcopy(app.state._scoped_original_openapi())
|
|
138
170
|
|
|
139
171
|
|
|
140
172
|
def _install_root_filter(app: FastAPI, exclude_prefixes: List[str]) -> None:
|
|
141
173
|
_ensure_original_openapi_saved(app)
|
|
142
|
-
app.state._scoped_root_exclusions = sorted(set(exclude_prefixes))
|
|
174
|
+
app.state._scoped_root_exclusions = sorted(set(exclude_prefixes))
|
|
143
175
|
|
|
144
176
|
def root_filtered_openapi():
|
|
145
177
|
full_schema = _get_full_schema_from_original(app)
|
|
146
|
-
return _build_filtered_schema(
|
|
178
|
+
return _build_filtered_schema(
|
|
179
|
+
full_schema, exclude_prefixes=app.state._scoped_root_exclusions
|
|
180
|
+
)
|
|
147
181
|
|
|
148
|
-
app
|
|
182
|
+
setattr(app, "openapi", root_filtered_openapi)
|
|
149
183
|
|
|
150
184
|
|
|
151
185
|
def _current_registered_scopes() -> List[str]:
|
|
@@ -158,7 +192,9 @@ def _ensure_root_excludes_registered_scopes(app: FastAPI) -> None:
|
|
|
158
192
|
_install_root_filter(app, scopes)
|
|
159
193
|
|
|
160
194
|
|
|
161
|
-
def _normalize_envs(
|
|
195
|
+
def _normalize_envs(
|
|
196
|
+
envs: Optional[Iterable[Environment | str]],
|
|
197
|
+
) -> Optional[set[Environment]]:
|
|
162
198
|
if envs is None:
|
|
163
199
|
return None
|
|
164
200
|
out: set[Environment] = set()
|
|
@@ -175,11 +211,23 @@ def add_prefixed_docs(
|
|
|
175
211
|
auto_exclude_from_root: bool = True,
|
|
176
212
|
visible_envs: Optional[Iterable[Environment | str]] = (LOCAL_ENV, DEV_ENV),
|
|
177
213
|
) -> None:
|
|
214
|
+
scope = prefix.rstrip("/") or "/"
|
|
215
|
+
|
|
216
|
+
# Always exclude from root if requested, regardless of environment
|
|
217
|
+
if auto_exclude_from_root:
|
|
218
|
+
_ensure_original_openapi_saved(app)
|
|
219
|
+
# Add to exclusion list for root docs
|
|
220
|
+
if not hasattr(app.state, "_scoped_root_exclusions"):
|
|
221
|
+
app.state._scoped_root_exclusions = []
|
|
222
|
+
if scope not in app.state._scoped_root_exclusions:
|
|
223
|
+
app.state._scoped_root_exclusions.append(scope)
|
|
224
|
+
_install_root_filter(app, app.state._scoped_root_exclusions)
|
|
225
|
+
|
|
226
|
+
# Only create scoped docs in allowed environments
|
|
178
227
|
allow = _normalize_envs(visible_envs)
|
|
179
228
|
if allow is not None and CURRENT_ENVIRONMENT not in allow:
|
|
180
229
|
return
|
|
181
230
|
|
|
182
|
-
scope = prefix.rstrip("/") or "/"
|
|
183
231
|
openapi_path = f"{scope}/openapi.json"
|
|
184
232
|
swagger_path = f"{scope}/docs"
|
|
185
233
|
redoc_path = f"{scope}/redoc"
|
|
@@ -211,9 +259,8 @@ def add_prefixed_docs(
|
|
|
211
259
|
|
|
212
260
|
DOC_SCOPES.append((scope, swagger_path, redoc_path, openapi_path, title))
|
|
213
261
|
|
|
214
|
-
if auto_exclude_from_root:
|
|
215
|
-
_ensure_root_excludes_registered_scopes(app)
|
|
216
|
-
|
|
217
262
|
|
|
218
|
-
def replace_root_openapi_with_exclusions(
|
|
263
|
+
def replace_root_openapi_with_exclusions(
|
|
264
|
+
app: FastAPI, *, exclude_prefixes: List[str]
|
|
265
|
+
) -> None:
|
|
219
266
|
_install_root_filter(app, exclude_prefixes)
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
from .dualize import dualize_protected, dualize_public, dualize_service, dualize_user
|
|
2
|
-
from .protected import (
|
|
2
|
+
from .protected import ( # WebSocket routers with auth (DualAPIRouter with JWT auth, no DB required)
|
|
3
3
|
optional_identity_router,
|
|
4
4
|
protected_router,
|
|
5
5
|
roles_router,
|
|
6
6
|
service_router,
|
|
7
7
|
user_router,
|
|
8
|
+
ws_optional_router,
|
|
9
|
+
ws_protected_router,
|
|
10
|
+
ws_scopes_router,
|
|
11
|
+
ws_user_router,
|
|
8
12
|
)
|
|
9
|
-
from .public import public_router
|
|
13
|
+
from .public import public_router, ws_public_router
|
|
10
14
|
from .router import DualAPIRouter
|
|
11
15
|
|
|
12
16
|
__all__ = [
|
|
@@ -21,4 +25,10 @@ __all__ = [
|
|
|
21
25
|
"user_router",
|
|
22
26
|
"service_router",
|
|
23
27
|
"roles_router",
|
|
28
|
+
# WebSocket routers
|
|
29
|
+
"ws_public_router",
|
|
30
|
+
"ws_protected_router",
|
|
31
|
+
"ws_user_router",
|
|
32
|
+
"ws_scopes_router",
|
|
33
|
+
"ws_optional_router",
|
|
24
34
|
]
|
|
@@ -27,7 +27,7 @@ def dualize_into(
|
|
|
27
27
|
prefix="", # prevent double-prefixing on include_router
|
|
28
28
|
tags=list(src.tags or []),
|
|
29
29
|
dependencies=list(src.dependencies or []),
|
|
30
|
-
default_response_class=src.default_response_class,
|
|
30
|
+
default_response_class=src.default_response_class,
|
|
31
31
|
responses=dict(src.responses or {}),
|
|
32
32
|
callbacks=list(src.callbacks or []),
|
|
33
33
|
routes=[], # start empty
|
|
@@ -10,8 +10,14 @@ from ..auth.security import (
|
|
|
10
10
|
RequireService,
|
|
11
11
|
RequireUser,
|
|
12
12
|
)
|
|
13
|
+
from ..auth.ws_security import AllowWSIdentity, RequireWSIdentity, RequireWSScopes
|
|
13
14
|
from ..openapi.apply import apply_default_responses, apply_default_security
|
|
14
|
-
from ..openapi.responses import
|
|
15
|
+
from ..openapi.responses import (
|
|
16
|
+
DEFAULT_PROTECTED,
|
|
17
|
+
DEFAULT_PUBLIC,
|
|
18
|
+
DEFAULT_SERVICE,
|
|
19
|
+
DEFAULT_USER,
|
|
20
|
+
)
|
|
15
21
|
from .router import DualAPIRouter
|
|
16
22
|
|
|
17
23
|
|
|
@@ -52,7 +58,9 @@ def protected_router(
|
|
|
52
58
|
|
|
53
59
|
|
|
54
60
|
# USER-ONLY (no API-key-only access)
|
|
55
|
-
def user_router(
|
|
61
|
+
def user_router(
|
|
62
|
+
*, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any
|
|
63
|
+
) -> DualAPIRouter:
|
|
56
64
|
r = DualAPIRouter(dependencies=_merge([RequireUser()], dependencies), **kwargs)
|
|
57
65
|
apply_default_security(
|
|
58
66
|
r, default_security=[{"OAuth2PasswordBearer": []}, {"SessionCookie": []}]
|
|
@@ -62,7 +70,9 @@ def user_router(*, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any)
|
|
|
62
70
|
|
|
63
71
|
|
|
64
72
|
# SERVICE-ONLY (API key required)
|
|
65
|
-
def service_router(
|
|
73
|
+
def service_router(
|
|
74
|
+
*, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any
|
|
75
|
+
) -> DualAPIRouter:
|
|
66
76
|
r = DualAPIRouter(dependencies=_merge([RequireService()], dependencies), **kwargs)
|
|
67
77
|
apply_default_security(r, default_security=[{"APIKeyHeader": []}])
|
|
68
78
|
apply_default_responses(r, DEFAULT_SERVICE)
|
|
@@ -87,10 +97,122 @@ def scopes_router(*scopes: str, **kwargs: Any) -> DualAPIRouter:
|
|
|
87
97
|
# ROLE-GATED (example using roles attribute or resolver passed by caller)
|
|
88
98
|
def roles_router(*roles: str, role_resolver=None, **kwargs):
|
|
89
99
|
r = DualAPIRouter(
|
|
90
|
-
dependencies=[RequireUser(), RequireRoles(*roles, resolver=role_resolver)],
|
|
100
|
+
dependencies=[RequireUser(), RequireRoles(*roles, resolver=role_resolver)],
|
|
101
|
+
**kwargs,
|
|
91
102
|
)
|
|
92
103
|
apply_default_security(
|
|
93
104
|
r, default_security=[{"OAuth2PasswordBearer": []}, {"SessionCookie": []}]
|
|
94
105
|
)
|
|
95
106
|
apply_default_responses(r, DEFAULT_USER)
|
|
96
107
|
return r
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------- WebSocket Routers (Lightweight JWT, no DB required) ----------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def ws_protected_router(
|
|
114
|
+
*, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any
|
|
115
|
+
) -> DualAPIRouter:
|
|
116
|
+
"""
|
|
117
|
+
Protected WebSocket router - requires valid JWT token.
|
|
118
|
+
|
|
119
|
+
Uses lightweight JWT validation (no database access required).
|
|
120
|
+
Token can be passed via:
|
|
121
|
+
- Query param: ?token=<jwt>
|
|
122
|
+
- Header: Authorization: Bearer <jwt>
|
|
123
|
+
- Cookie: auth cookie
|
|
124
|
+
- Subprotocol: access_token.<jwt>
|
|
125
|
+
|
|
126
|
+
Example:
|
|
127
|
+
router = ws_protected_router()
|
|
128
|
+
|
|
129
|
+
@router.websocket("/ws")
|
|
130
|
+
async def ws_endpoint(websocket: WebSocket, principal: WSIdentity):
|
|
131
|
+
user_id = str(principal.id)
|
|
132
|
+
await websocket.accept()
|
|
133
|
+
...
|
|
134
|
+
"""
|
|
135
|
+
r = DualAPIRouter(dependencies=_merge([RequireWSIdentity], dependencies), **kwargs)
|
|
136
|
+
# WebSocket doesn't have OpenAPI security, but we set it for documentation
|
|
137
|
+
apply_default_security(
|
|
138
|
+
r, default_security=[{"OAuth2PasswordBearer": []}, {"SessionCookie": []}]
|
|
139
|
+
)
|
|
140
|
+
return r
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def ws_optional_router(
|
|
144
|
+
*, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any
|
|
145
|
+
) -> DualAPIRouter:
|
|
146
|
+
"""
|
|
147
|
+
Optional auth WebSocket router - allows anonymous connections.
|
|
148
|
+
|
|
149
|
+
If a valid JWT is provided, principal will be set.
|
|
150
|
+
If no token or invalid token, principal will be None.
|
|
151
|
+
|
|
152
|
+
Example:
|
|
153
|
+
router = ws_optional_router()
|
|
154
|
+
|
|
155
|
+
@router.websocket("/ws/public")
|
|
156
|
+
async def ws_endpoint(websocket: WebSocket, principal: WSOptionalIdentity):
|
|
157
|
+
user_id = str(principal.id) if principal else "anonymous"
|
|
158
|
+
await websocket.accept()
|
|
159
|
+
...
|
|
160
|
+
"""
|
|
161
|
+
r = DualAPIRouter(dependencies=_merge([AllowWSIdentity], dependencies), **kwargs)
|
|
162
|
+
apply_default_security(r, default_security=[])
|
|
163
|
+
return r
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def ws_user_router(
|
|
167
|
+
*, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any
|
|
168
|
+
) -> DualAPIRouter:
|
|
169
|
+
"""
|
|
170
|
+
User-only WebSocket router - requires valid user JWT (no API key).
|
|
171
|
+
|
|
172
|
+
Uses lightweight JWT validation (no database access required).
|
|
173
|
+
This is the WebSocket equivalent of `user_router()`.
|
|
174
|
+
|
|
175
|
+
Example:
|
|
176
|
+
router = ws_user_router()
|
|
177
|
+
|
|
178
|
+
@router.websocket("/ws/user")
|
|
179
|
+
async def ws_endpoint(websocket: WebSocket, principal: WSIdentity):
|
|
180
|
+
# principal.id, principal.email, principal.scopes from JWT
|
|
181
|
+
await websocket.accept()
|
|
182
|
+
...
|
|
183
|
+
"""
|
|
184
|
+
r = DualAPIRouter(dependencies=_merge([RequireWSIdentity], dependencies), **kwargs)
|
|
185
|
+
apply_default_security(
|
|
186
|
+
r, default_security=[{"OAuth2PasswordBearer": []}, {"SessionCookie": []}]
|
|
187
|
+
)
|
|
188
|
+
return r
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def ws_scopes_router(
|
|
192
|
+
*scopes: str, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any
|
|
193
|
+
) -> DualAPIRouter:
|
|
194
|
+
"""
|
|
195
|
+
Scope-gated WebSocket router - requires valid JWT with specific scopes.
|
|
196
|
+
|
|
197
|
+
Uses lightweight JWT validation (no database access required).
|
|
198
|
+
This is the WebSocket equivalent of `scopes_router()`.
|
|
199
|
+
|
|
200
|
+
Example:
|
|
201
|
+
router = ws_scopes_router("chat:read", "chat:write")
|
|
202
|
+
|
|
203
|
+
@router.websocket("/ws/chat")
|
|
204
|
+
async def ws_endpoint(websocket: WebSocket, principal: WSIdentity):
|
|
205
|
+
# principal has verified scopes
|
|
206
|
+
await websocket.accept()
|
|
207
|
+
...
|
|
208
|
+
"""
|
|
209
|
+
r = DualAPIRouter(
|
|
210
|
+
dependencies=_merge(
|
|
211
|
+
[RequireWSIdentity, RequireWSScopes(*scopes)], dependencies
|
|
212
|
+
),
|
|
213
|
+
**kwargs,
|
|
214
|
+
)
|
|
215
|
+
apply_default_security(
|
|
216
|
+
r, default_security=[{"OAuth2PasswordBearer": []}, {"SessionCookie": []}]
|
|
217
|
+
)
|
|
218
|
+
return r
|