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
|
@@ -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
|
|
@@ -37,7 +37,11 @@ class DualAPIRouter(APIRouter):
|
|
|
37
37
|
if is_rootish:
|
|
38
38
|
# primary root
|
|
39
39
|
self.add_api_route(
|
|
40
|
-
"",
|
|
40
|
+
"",
|
|
41
|
+
func,
|
|
42
|
+
methods=methods,
|
|
43
|
+
include_in_schema=show_in_schema,
|
|
44
|
+
**kwargs,
|
|
41
45
|
)
|
|
42
46
|
# only add the "/" twin for *safe* methods
|
|
43
47
|
if set(m.upper() for m in methods) <= safe_methods:
|
|
@@ -48,10 +52,16 @@ class DualAPIRouter(APIRouter):
|
|
|
48
52
|
|
|
49
53
|
# non-root unchanged
|
|
50
54
|
self.add_api_route(
|
|
51
|
-
primary,
|
|
55
|
+
primary,
|
|
56
|
+
func,
|
|
57
|
+
methods=methods,
|
|
58
|
+
include_in_schema=show_in_schema,
|
|
59
|
+
**kwargs,
|
|
52
60
|
)
|
|
53
61
|
if alt != primary:
|
|
54
|
-
self.add_api_route(
|
|
62
|
+
self.add_api_route(
|
|
63
|
+
alt, func, methods=methods, include_in_schema=False, **kwargs
|
|
64
|
+
)
|
|
55
65
|
return func
|
|
56
66
|
|
|
57
67
|
return decorator
|
|
@@ -68,25 +78,39 @@ class DualAPIRouter(APIRouter):
|
|
|
68
78
|
# ---------- HTTP method shorthands ----------
|
|
69
79
|
|
|
70
80
|
def get(self, path: str, *_, show_in_schema: bool = True, **kwargs: Any):
|
|
71
|
-
return self._dual_decorator(
|
|
81
|
+
return self._dual_decorator(
|
|
82
|
+
path, ["GET"], show_in_schema=show_in_schema, **kwargs
|
|
83
|
+
)
|
|
72
84
|
|
|
73
85
|
def post(self, path: str, *_, show_in_schema: bool = True, **kwargs: Any):
|
|
74
|
-
return self._dual_decorator(
|
|
86
|
+
return self._dual_decorator(
|
|
87
|
+
path, ["POST"], show_in_schema=show_in_schema, **kwargs
|
|
88
|
+
)
|
|
75
89
|
|
|
76
90
|
def patch(self, path: str, *_, show_in_schema: bool = True, **kwargs: Any):
|
|
77
|
-
return self._dual_decorator(
|
|
91
|
+
return self._dual_decorator(
|
|
92
|
+
path, ["PATCH"], show_in_schema=show_in_schema, **kwargs
|
|
93
|
+
)
|
|
78
94
|
|
|
79
95
|
def delete(self, path: str, *_, show_in_schema: bool = True, **kwargs: Any):
|
|
80
|
-
return self._dual_decorator(
|
|
96
|
+
return self._dual_decorator(
|
|
97
|
+
path, ["DELETE"], show_in_schema=show_in_schema, **kwargs
|
|
98
|
+
)
|
|
81
99
|
|
|
82
100
|
def put(self, path: str, *_, show_in_schema: bool = True, **kwargs: Any):
|
|
83
|
-
return self._dual_decorator(
|
|
101
|
+
return self._dual_decorator(
|
|
102
|
+
path, ["PUT"], show_in_schema=show_in_schema, **kwargs
|
|
103
|
+
)
|
|
84
104
|
|
|
85
105
|
def options(self, path: str, *_, show_in_schema: bool = True, **kwargs: Any):
|
|
86
|
-
return self._dual_decorator(
|
|
106
|
+
return self._dual_decorator(
|
|
107
|
+
path, ["OPTIONS"], show_in_schema=show_in_schema, **kwargs
|
|
108
|
+
)
|
|
87
109
|
|
|
88
110
|
def head(self, path: str, *_, show_in_schema: bool = True, **kwargs: Any):
|
|
89
|
-
return self._dual_decorator(
|
|
111
|
+
return self._dual_decorator(
|
|
112
|
+
path, ["HEAD"], show_in_schema=show_in_schema, **kwargs
|
|
113
|
+
)
|
|
90
114
|
|
|
91
115
|
def list(
|
|
92
116
|
self,
|
|
@@ -110,10 +134,11 @@ class DualAPIRouter(APIRouter):
|
|
|
110
134
|
- Per-route opt-out of OpenAPI param auto-attach: openapi_extra={"x_no_auto_pagination": True}
|
|
111
135
|
"""
|
|
112
136
|
# pick response model
|
|
137
|
+
response_model: Any
|
|
113
138
|
if envelope:
|
|
114
|
-
response_model = Paginated[model] # type: ignore[
|
|
139
|
+
response_model = Paginated[model] # type: ignore[valid-type]
|
|
115
140
|
else:
|
|
116
|
-
response_model = list[model] # type: ignore[
|
|
141
|
+
response_model = list[model] # type: ignore[valid-type]
|
|
117
142
|
|
|
118
143
|
injector = make_pagination_injector(
|
|
119
144
|
envelope=envelope,
|
|
@@ -130,7 +155,9 @@ class DualAPIRouter(APIRouter):
|
|
|
130
155
|
kwargs["response_model"] = kwargs.get("response_model") or response_model
|
|
131
156
|
|
|
132
157
|
# we still want the dual-registration behavior
|
|
133
|
-
return self._dual_decorator(
|
|
158
|
+
return self._dual_decorator(
|
|
159
|
+
path, ["GET"], show_in_schema=show_in_schema, **kwargs
|
|
160
|
+
)
|
|
134
161
|
|
|
135
162
|
# ---------- WebSocket ----------
|
|
136
163
|
|
svc_infra/api/fastapi/dx.py
CHANGED
|
@@ -59,19 +59,36 @@ from svc_infra.api.fastapi.auth.security import (
|
|
|
59
59
|
RequireUser,
|
|
60
60
|
)
|
|
61
61
|
from svc_infra.api.fastapi.auth.settings import AuthSettings, get_auth_settings
|
|
62
|
-
|
|
62
|
+
|
|
63
|
+
# ----------------
|
|
64
|
+
# WebSocket identity primitives (lightweight JWT, no DB required)
|
|
65
|
+
# ----------------
|
|
66
|
+
from svc_infra.api.fastapi.auth.ws_security import (
|
|
67
|
+
AllowWSIdentity,
|
|
68
|
+
OptionalWSIdentity,
|
|
69
|
+
RequireWSAnyScope,
|
|
70
|
+
RequireWSIdentity,
|
|
71
|
+
RequireWSScopes,
|
|
72
|
+
WSIdentity,
|
|
73
|
+
WSPrincipal,
|
|
74
|
+
)
|
|
75
|
+
from svc_infra.api.fastapi.dual.protected import ( # WebSocket routers (DualAPIRouter with JWT auth, no DB required)
|
|
63
76
|
optional_identity_router,
|
|
64
77
|
protected_router,
|
|
65
78
|
roles_router,
|
|
66
79
|
scopes_router,
|
|
67
80
|
service_router,
|
|
68
81
|
user_router,
|
|
82
|
+
ws_optional_router,
|
|
83
|
+
ws_protected_router,
|
|
84
|
+
ws_scopes_router,
|
|
85
|
+
ws_user_router,
|
|
69
86
|
)
|
|
70
87
|
|
|
71
88
|
# ----------------
|
|
72
89
|
# Pre-wired routers (OpenAPI security auto-injected)
|
|
73
90
|
# ----------------
|
|
74
|
-
from svc_infra.api.fastapi.dual.public import public_router
|
|
91
|
+
from svc_infra.api.fastapi.dual.public import public_router, ws_public_router
|
|
75
92
|
|
|
76
93
|
# ----------------
|
|
77
94
|
# App bootstrap
|
|
@@ -127,6 +144,20 @@ __all__ = [
|
|
|
127
144
|
"service_router",
|
|
128
145
|
"scopes_router",
|
|
129
146
|
"roles_router",
|
|
147
|
+
# WebSocket identity
|
|
148
|
+
"WSPrincipal",
|
|
149
|
+
"WSIdentity",
|
|
150
|
+
"OptionalWSIdentity",
|
|
151
|
+
"RequireWSIdentity",
|
|
152
|
+
"AllowWSIdentity",
|
|
153
|
+
"RequireWSScopes",
|
|
154
|
+
"RequireWSAnyScope",
|
|
155
|
+
# WebSocket routers
|
|
156
|
+
"ws_public_router",
|
|
157
|
+
"ws_protected_router",
|
|
158
|
+
"ws_user_router",
|
|
159
|
+
"ws_scopes_router",
|
|
160
|
+
"ws_optional_router",
|
|
130
161
|
# Feature routers
|
|
131
162
|
"apikey_router",
|
|
132
163
|
"mfa_router",
|
svc_infra/api/fastapi/ease.py
CHANGED
|
@@ -104,9 +104,13 @@ class EasyAppOptions(BaseModel):
|
|
|
104
104
|
else self.logging.enable
|
|
105
105
|
),
|
|
106
106
|
level=(
|
|
107
|
-
override.logging.level
|
|
107
|
+
override.logging.level
|
|
108
|
+
if override.logging.level is not None
|
|
109
|
+
else self.logging.level
|
|
108
110
|
),
|
|
109
|
-
fmt=override.logging.fmt
|
|
111
|
+
fmt=override.logging.fmt
|
|
112
|
+
if override.logging.fmt is not None
|
|
113
|
+
else self.logging.fmt,
|
|
110
114
|
)
|
|
111
115
|
|
|
112
116
|
# observability
|
|
@@ -148,6 +152,7 @@ def easy_service_api(
|
|
|
148
152
|
public_cors_origins: list[str] | str | None = None,
|
|
149
153
|
root_public_base_url: str | None = None,
|
|
150
154
|
root_include_api_key: bool | None = None,
|
|
155
|
+
**fastapi_kwargs, # Forward all other FastAPI kwargs
|
|
151
156
|
) -> FastAPI:
|
|
152
157
|
service = ServiceInfo(name=name, release=release)
|
|
153
158
|
specs = [
|
|
@@ -161,6 +166,7 @@ def easy_service_api(
|
|
|
161
166
|
public_cors_origins=public_cors_origins,
|
|
162
167
|
root_public_base_url=root_public_base_url,
|
|
163
168
|
root_include_api_key=root_include_api_key,
|
|
169
|
+
**fastapi_kwargs, # Forward to setup_service_api
|
|
164
170
|
)
|
|
165
171
|
|
|
166
172
|
|
|
@@ -176,6 +182,7 @@ def easy_service_app(
|
|
|
176
182
|
options: EasyAppOptions | None = None,
|
|
177
183
|
enable_logging: bool | None = None,
|
|
178
184
|
enable_observability: bool | None = None,
|
|
185
|
+
**fastapi_kwargs, # Forward all other FastAPI kwargs (lifespan, etc.)
|
|
179
186
|
) -> FastAPI:
|
|
180
187
|
"""
|
|
181
188
|
One-call bootstrap with env + options + flags:
|
|
@@ -226,6 +233,7 @@ def easy_service_app(
|
|
|
226
233
|
public_cors_origins=public_cors_origins,
|
|
227
234
|
root_public_base_url=root_public_base_url,
|
|
228
235
|
root_include_api_key=root_include_api_key,
|
|
236
|
+
**fastapi_kwargs, # Forward FastAPI kwargs (lifespan, etc.)
|
|
229
237
|
)
|
|
230
238
|
|
|
231
239
|
# 5) Observability
|
|
@@ -10,5 +10,6 @@ def require_if_match(request: Request, current_etag: str):
|
|
|
10
10
|
)
|
|
11
11
|
if current_etag not in [t.strip() for t in val.split(",")]:
|
|
12
12
|
raise HTTPException(
|
|
13
|
-
status_code=status.HTTP_412_PRECONDITION_FAILED,
|
|
13
|
+
status_code=status.HTTP_412_PRECONDITION_FAILED,
|
|
14
|
+
detail="ETag precondition failed.",
|
|
14
15
|
)
|
|
@@ -20,7 +20,9 @@ def set_conditional_headers(
|
|
|
20
20
|
resp.headers["Last-Modified"] = format_datetime(last_modified)
|
|
21
21
|
|
|
22
22
|
|
|
23
|
-
def maybe_not_modified(
|
|
23
|
+
def maybe_not_modified(
|
|
24
|
+
request: Request, etag: str | None, last_modified: datetime | None
|
|
25
|
+
) -> bool:
|
|
24
26
|
inm = request.headers.get("If-None-Match")
|
|
25
27
|
ims = request.headers.get("If-Modified-Since")
|
|
26
28
|
etag_ok = etag and inm and etag in [t.strip() for t in inm.split(",")]
|
|
@@ -15,6 +15,9 @@ class RouteDebugMiddleware(BaseHTTPMiddleware):
|
|
|
15
15
|
route = request.scope.get("route")
|
|
16
16
|
ep = getattr(route, "endpoint", None) if route else None
|
|
17
17
|
log.info(
|
|
18
|
-
"MATCHED %s %s -> %s",
|
|
18
|
+
"MATCHED %s %s -> %s",
|
|
19
|
+
request.method,
|
|
20
|
+
request.url.path,
|
|
21
|
+
getattr(ep, "__name__", ep),
|
|
19
22
|
)
|
|
20
23
|
return response
|
|
@@ -31,7 +31,9 @@ class CatchAllExceptionMiddleware:
|
|
|
31
31
|
|
|
32
32
|
if response_started:
|
|
33
33
|
try:
|
|
34
|
-
await send(
|
|
34
|
+
await send(
|
|
35
|
+
{"type": "http.response.body", "body": b"", "more_body": False}
|
|
36
|
+
)
|
|
35
37
|
except Exception:
|
|
36
38
|
pass
|
|
37
39
|
else:
|
|
@@ -52,4 +54,6 @@ class CatchAllExceptionMiddleware:
|
|
|
52
54
|
"headers": [(b"content-type", PROBLEM_MT.encode("ascii"))],
|
|
53
55
|
}
|
|
54
56
|
)
|
|
55
|
-
await send(
|
|
57
|
+
await send(
|
|
58
|
+
{"type": "http.response.body", "body": body, "more_body": False}
|
|
59
|
+
)
|
|
@@ -2,6 +2,7 @@ import logging
|
|
|
2
2
|
import traceback
|
|
3
3
|
from typing import Any, Dict, Optional
|
|
4
4
|
|
|
5
|
+
import httpx
|
|
5
6
|
from fastapi import Request
|
|
6
7
|
from fastapi.exceptions import HTTPException, RequestValidationError
|
|
7
8
|
from fastapi.responses import JSONResponse, Response
|
|
@@ -46,6 +47,7 @@ def problem_response(
|
|
|
46
47
|
code: str | None = None,
|
|
47
48
|
errors: list[dict] | None = None,
|
|
48
49
|
trace_id: str | None = None,
|
|
50
|
+
headers: dict[str, str] | None = None,
|
|
49
51
|
) -> Response:
|
|
50
52
|
body: Dict[str, Any] = {
|
|
51
53
|
"type": type_uri,
|
|
@@ -62,10 +64,30 @@ def problem_response(
|
|
|
62
64
|
body["errors"] = errors
|
|
63
65
|
if trace_id:
|
|
64
66
|
body["trace_id"] = trace_id
|
|
65
|
-
return JSONResponse(
|
|
67
|
+
return JSONResponse(
|
|
68
|
+
status_code=status, content=body, media_type=PROBLEM_MT, headers=headers
|
|
69
|
+
)
|
|
66
70
|
|
|
67
71
|
|
|
68
72
|
def register_error_handlers(app):
|
|
73
|
+
@app.exception_handler(httpx.TimeoutException)
|
|
74
|
+
async def handle_httpx_timeout(request: Request, exc: httpx.TimeoutException):
|
|
75
|
+
trace_id = _trace_id_from_request(request)
|
|
76
|
+
# Map outbound HTTP client timeouts to 504 Gateway Timeout
|
|
77
|
+
# Keep details generic in prod
|
|
78
|
+
return problem_response(
|
|
79
|
+
status=504,
|
|
80
|
+
title="Gateway Timeout",
|
|
81
|
+
detail=(
|
|
82
|
+
"Upstream request timed out."
|
|
83
|
+
if IS_PROD
|
|
84
|
+
else (str(exc) or "httpx timeout")
|
|
85
|
+
),
|
|
86
|
+
code="GATEWAY_TIMEOUT",
|
|
87
|
+
instance=str(request.url),
|
|
88
|
+
trace_id=trace_id,
|
|
89
|
+
)
|
|
90
|
+
|
|
69
91
|
@app.exception_handler(FastApiException)
|
|
70
92
|
async def handle_app_exception(request: Request, exc: FastApiException):
|
|
71
93
|
trace_id = _trace_id_from_request(request)
|
|
@@ -104,14 +126,25 @@ def register_error_handlers(app):
|
|
|
104
126
|
@app.exception_handler(HTTPException)
|
|
105
127
|
async def handle_http_exception(request: Request, exc: HTTPException):
|
|
106
128
|
trace_id = _trace_id_from_request(request)
|
|
107
|
-
title = {
|
|
108
|
-
|
|
109
|
-
|
|
129
|
+
title = {
|
|
130
|
+
401: "Unauthorized",
|
|
131
|
+
403: "Forbidden",
|
|
132
|
+
404: "Not Found",
|
|
133
|
+
429: "Too Many Requests",
|
|
134
|
+
}.get(exc.status_code, "Error")
|
|
110
135
|
detail = (
|
|
111
136
|
exc.detail
|
|
112
137
|
if not IS_PROD or exc.status_code < 500
|
|
113
138
|
else "Something went wrong. Please contact support."
|
|
114
139
|
)
|
|
140
|
+
# Preserve headers set on the exception (e.g., Retry-After for rate limits)
|
|
141
|
+
hdrs: dict[str, str] | None = None
|
|
142
|
+
try:
|
|
143
|
+
if getattr(exc, "headers", None):
|
|
144
|
+
# FastAPI/Starlette exceptions store headers as a dict[str, str]
|
|
145
|
+
hdrs = dict(getattr(exc, "headers"))
|
|
146
|
+
except Exception:
|
|
147
|
+
hdrs = None
|
|
115
148
|
return problem_response(
|
|
116
149
|
status=exc.status_code,
|
|
117
150
|
title=title,
|
|
@@ -119,19 +152,31 @@ def register_error_handlers(app):
|
|
|
119
152
|
code=title.replace(" ", "_").upper(),
|
|
120
153
|
instance=str(request.url),
|
|
121
154
|
trace_id=trace_id,
|
|
155
|
+
headers=hdrs,
|
|
122
156
|
)
|
|
123
157
|
|
|
124
158
|
@app.exception_handler(StarletteHTTPException)
|
|
125
|
-
async def handle_starlette_http_exception(
|
|
159
|
+
async def handle_starlette_http_exception(
|
|
160
|
+
request: Request, exc: StarletteHTTPException
|
|
161
|
+
):
|
|
126
162
|
trace_id = _trace_id_from_request(request)
|
|
127
|
-
title = {
|
|
128
|
-
|
|
129
|
-
|
|
163
|
+
title = {
|
|
164
|
+
401: "Unauthorized",
|
|
165
|
+
403: "Forbidden",
|
|
166
|
+
404: "Not Found",
|
|
167
|
+
429: "Too Many Requests",
|
|
168
|
+
}.get(exc.status_code, "Error")
|
|
130
169
|
detail = (
|
|
131
170
|
exc.detail
|
|
132
171
|
if not IS_PROD or exc.status_code < 500
|
|
133
172
|
else "Something went wrong. Please contact support."
|
|
134
173
|
)
|
|
174
|
+
hdrs: dict[str, str] | None = None
|
|
175
|
+
try:
|
|
176
|
+
if getattr(exc, "headers", None):
|
|
177
|
+
hdrs = dict(getattr(exc, "headers"))
|
|
178
|
+
except Exception:
|
|
179
|
+
hdrs = None
|
|
135
180
|
return problem_response(
|
|
136
181
|
status=exc.status_code,
|
|
137
182
|
title=title,
|
|
@@ -139,6 +184,7 @@ def register_error_handlers(app):
|
|
|
139
184
|
code=title.replace(" ", "_").upper(),
|
|
140
185
|
instance=str(request.url),
|
|
141
186
|
trace_id=trace_id,
|
|
187
|
+
headers=hdrs,
|
|
142
188
|
)
|
|
143
189
|
|
|
144
190
|
@app.exception_handler(IntegrityError)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI
|
|
10
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
11
|
+
|
|
12
|
+
from svc_infra.app.env import pick
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_grace_period_seconds() -> float:
|
|
18
|
+
default = pick(prod=20.0, nonprod=5.0)
|
|
19
|
+
raw = os.getenv("SHUTDOWN_GRACE_PERIOD_SECONDS")
|
|
20
|
+
if raw is None or raw == "":
|
|
21
|
+
return float(default)
|
|
22
|
+
try:
|
|
23
|
+
return float(raw)
|
|
24
|
+
except ValueError:
|
|
25
|
+
return float(default)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InflightTrackerMiddleware:
|
|
29
|
+
"""Tracks number of in-flight requests to support graceful shutdown drains."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, app: ASGIApp):
|
|
32
|
+
self.app = app
|
|
33
|
+
|
|
34
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
|
35
|
+
if scope.get("type") != "http":
|
|
36
|
+
await self.app(scope, receive, send)
|
|
37
|
+
return
|
|
38
|
+
app = scope.get("app")
|
|
39
|
+
if app is None:
|
|
40
|
+
await self.app(scope, receive, send)
|
|
41
|
+
return
|
|
42
|
+
state = getattr(app, "state", None)
|
|
43
|
+
if state is None:
|
|
44
|
+
await self.app(scope, receive, send)
|
|
45
|
+
return
|
|
46
|
+
state._inflight_requests = getattr(state, "_inflight_requests", 0) + 1
|
|
47
|
+
try:
|
|
48
|
+
await self.app(scope, receive, send)
|
|
49
|
+
finally:
|
|
50
|
+
state._inflight_requests = max(
|
|
51
|
+
0, getattr(state, "_inflight_requests", 1) - 1
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def _wait_for_drain(app: FastAPI, grace: float) -> None:
|
|
56
|
+
interval = 0.1
|
|
57
|
+
waited = 0.0
|
|
58
|
+
while waited < grace:
|
|
59
|
+
inflight = int(getattr(app.state, "_inflight_requests", 0))
|
|
60
|
+
if inflight <= 0:
|
|
61
|
+
return
|
|
62
|
+
await asyncio.sleep(interval)
|
|
63
|
+
waited += interval
|
|
64
|
+
inflight = int(getattr(app.state, "_inflight_requests", 0))
|
|
65
|
+
if inflight > 0:
|
|
66
|
+
logger.warning(
|
|
67
|
+
"Graceful shutdown timeout: %s in-flight request(s) after %.2fs",
|
|
68
|
+
inflight,
|
|
69
|
+
waited,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def install_graceful_shutdown(
|
|
74
|
+
app: FastAPI, *, grace_seconds: Optional[float] = None
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Install inflight tracking and lifespan hooks to wait for requests to drain.
|
|
77
|
+
|
|
78
|
+
- Adds InflightTrackerMiddleware
|
|
79
|
+
- Registers a lifespan handler that initializes state and waits up to grace_seconds on shutdown
|
|
80
|
+
"""
|
|
81
|
+
app.add_middleware(InflightTrackerMiddleware)
|
|
82
|
+
|
|
83
|
+
g = (
|
|
84
|
+
float(grace_seconds)
|
|
85
|
+
if grace_seconds is not None
|
|
86
|
+
else _get_grace_period_seconds()
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Preserve any existing lifespan and wrap it so our drain runs on shutdown.
|
|
90
|
+
previous_lifespan = getattr(app.router, "lifespan_context", None)
|
|
91
|
+
|
|
92
|
+
@asynccontextmanager
|
|
93
|
+
async def _lifespan(a: FastAPI): # noqa: ANN202
|
|
94
|
+
# Startup: initialize inflight counter
|
|
95
|
+
a.state._inflight_requests = 0
|
|
96
|
+
if previous_lifespan is not None:
|
|
97
|
+
async with previous_lifespan(a):
|
|
98
|
+
yield
|
|
99
|
+
else:
|
|
100
|
+
yield
|
|
101
|
+
# Shutdown: wait for in-flight requests to drain (up to grace period)
|
|
102
|
+
await _wait_for_drain(a, g)
|
|
103
|
+
|
|
104
|
+
app.router.lifespan_context = _lifespan
|