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