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
|
@@ -4,12 +4,18 @@ import importlib
|
|
|
4
4
|
import logging
|
|
5
5
|
import pkgutil
|
|
6
6
|
from types import ModuleType
|
|
7
|
-
from typing import Optional
|
|
7
|
+
from typing import Any, Optional
|
|
8
8
|
|
|
9
9
|
from fastapi import FastAPI
|
|
10
10
|
from fastapi.routing import APIRoute
|
|
11
11
|
|
|
12
|
-
from svc_infra.app.env import
|
|
12
|
+
from svc_infra.app.env import (
|
|
13
|
+
ALL_ENVIRONMENTS,
|
|
14
|
+
CURRENT_ENVIRONMENT,
|
|
15
|
+
DEV_ENV,
|
|
16
|
+
LOCAL_ENV,
|
|
17
|
+
Environment,
|
|
18
|
+
)
|
|
13
19
|
|
|
14
20
|
logger = logging.getLogger(__name__)
|
|
15
21
|
|
|
@@ -49,7 +55,9 @@ def _validate_base_package(base_package: str) -> ModuleType:
|
|
|
49
55
|
try:
|
|
50
56
|
package_module: ModuleType = importlib.import_module(base_package)
|
|
51
57
|
except Exception as exc:
|
|
52
|
-
raise RuntimeError(
|
|
58
|
+
raise RuntimeError(
|
|
59
|
+
f"Could not import base_package '{base_package}': {exc}"
|
|
60
|
+
) from exc
|
|
53
61
|
|
|
54
62
|
if not hasattr(package_module, "__path__"):
|
|
55
63
|
raise RuntimeError(
|
|
@@ -64,7 +72,11 @@ def _normalize_environment(environment: Optional[Environment | str]) -> Environm
|
|
|
64
72
|
return (
|
|
65
73
|
CURRENT_ENVIRONMENT
|
|
66
74
|
if environment is None
|
|
67
|
-
else (
|
|
75
|
+
else (
|
|
76
|
+
Environment(environment)
|
|
77
|
+
if not isinstance(environment, Environment)
|
|
78
|
+
else environment
|
|
79
|
+
)
|
|
68
80
|
)
|
|
69
81
|
|
|
70
82
|
|
|
@@ -87,9 +99,12 @@ def _is_router_excluded_by_environment(
|
|
|
87
99
|
|
|
88
100
|
# Support ALL_ENVIRONMENTS as a special value
|
|
89
101
|
if router_excluded_envs is ALL_ENVIRONMENTS or (
|
|
90
|
-
isinstance(router_excluded_envs, set)
|
|
102
|
+
isinstance(router_excluded_envs, set)
|
|
103
|
+
and router_excluded_envs == ALL_ENVIRONMENTS
|
|
91
104
|
):
|
|
92
|
-
logger.debug(
|
|
105
|
+
logger.debug(
|
|
106
|
+
f"Skipping router module {module_name} due to ALL_ENVIRONMENTS exclusion."
|
|
107
|
+
)
|
|
93
108
|
return True
|
|
94
109
|
|
|
95
110
|
# Normalize to set of Environment or str
|
|
@@ -99,14 +114,19 @@ def _is_router_excluded_by_environment(
|
|
|
99
114
|
)
|
|
100
115
|
return False
|
|
101
116
|
|
|
102
|
-
normalized_excluded_envs = set()
|
|
117
|
+
normalized_excluded_envs: set[Environment | str] = set()
|
|
103
118
|
for e in router_excluded_envs:
|
|
104
119
|
try:
|
|
105
|
-
normalized_excluded_envs.add(
|
|
120
|
+
normalized_excluded_envs.add(
|
|
121
|
+
Environment(e) if not isinstance(e, Environment) else e
|
|
122
|
+
)
|
|
106
123
|
except Exception:
|
|
107
124
|
normalized_excluded_envs.add(str(e))
|
|
108
125
|
|
|
109
|
-
if
|
|
126
|
+
if (
|
|
127
|
+
environment in normalized_excluded_envs
|
|
128
|
+
or str(environment) in normalized_excluded_envs
|
|
129
|
+
):
|
|
110
130
|
logger.debug(
|
|
111
131
|
f"Skipping router module {module_name} due to ROUTER_EXCLUDED_ENVIRONMENTS restriction: {router_excluded_envs}"
|
|
112
132
|
)
|
|
@@ -126,7 +146,7 @@ def _is_router_included_by_environment(
|
|
|
126
146
|
f"ROUTER_ENVIRONMENTS in {module_name} must be a set/list/tuple, got {type(router_envs)}"
|
|
127
147
|
)
|
|
128
148
|
return True
|
|
129
|
-
normalized = set()
|
|
149
|
+
normalized: set[Environment | str] = set()
|
|
130
150
|
for e in router_envs:
|
|
131
151
|
try:
|
|
132
152
|
normalized.add(Environment(e) if not isinstance(e, Environment) else e)
|
|
@@ -163,7 +183,7 @@ def _build_include_kwargs(module: ModuleType, prefix: str, force_include: bool)
|
|
|
163
183
|
router_tag = getattr(module, "ROUTER_TAG", None)
|
|
164
184
|
include_in_schema = getattr(module, "INCLUDE_ROUTER_IN_SCHEMA", True)
|
|
165
185
|
|
|
166
|
-
include_kwargs = {"prefix": prefix}
|
|
186
|
+
include_kwargs: dict[str, Any] = {"prefix": prefix}
|
|
167
187
|
if router_prefix:
|
|
168
188
|
include_kwargs["prefix"] = prefix.rstrip("/") + router_prefix
|
|
169
189
|
if router_tag:
|
|
@@ -230,18 +250,24 @@ def register_all_routers(
|
|
|
230
250
|
"""
|
|
231
251
|
if base_package is None:
|
|
232
252
|
if __package__ is None:
|
|
233
|
-
raise RuntimeError(
|
|
253
|
+
raise RuntimeError(
|
|
254
|
+
"Cannot derive base_package; please pass base_package explicitly."
|
|
255
|
+
)
|
|
234
256
|
base_package = __package__
|
|
235
257
|
|
|
236
258
|
package_module = _validate_base_package(base_package)
|
|
237
259
|
environment = _normalize_environment(environment)
|
|
238
|
-
force_include = _should_force_include_in_schema(
|
|
260
|
+
force_include = _should_force_include_in_schema(
|
|
261
|
+
environment, force_include_in_schema
|
|
262
|
+
)
|
|
239
263
|
|
|
240
264
|
for _, module_name, _ in pkgutil.walk_packages(
|
|
241
265
|
package_module.__path__, prefix=f"{base_package}."
|
|
242
266
|
):
|
|
243
267
|
if _should_skip_module(module_name):
|
|
244
|
-
logger.debug(
|
|
268
|
+
logger.debug(
|
|
269
|
+
"Skipping router module due to exclusion/private: %s", module_name
|
|
270
|
+
)
|
|
245
271
|
continue
|
|
246
272
|
|
|
247
273
|
try:
|
|
@@ -250,4 +276,6 @@ def register_all_routers(
|
|
|
250
276
|
logger.exception("Failed to import router module %s: %s", module_name, exc)
|
|
251
277
|
continue
|
|
252
278
|
|
|
253
|
-
_process_router_module(
|
|
279
|
+
_process_router_module(
|
|
280
|
+
app, module, module_name, prefix, environment, force_include
|
|
281
|
+
)
|
|
@@ -14,6 +14,7 @@ router = public_router(tags=["Health Check"])
|
|
|
14
14
|
PING_PATH,
|
|
15
15
|
status_code=status.HTTP_200_OK,
|
|
16
16
|
description="Operation to check if the service is up and running",
|
|
17
|
+
operation_id="health_ping_get",
|
|
17
18
|
)
|
|
18
19
|
def ping():
|
|
19
20
|
logging.info("Health check: /ping endpoint accessed. Service is responsive.")
|
svc_infra/api/fastapi/setup.py
CHANGED
|
@@ -9,14 +9,20 @@ from fastapi import FastAPI
|
|
|
9
9
|
from fastapi.middleware.cors import CORSMiddleware
|
|
10
10
|
from fastapi.responses import HTMLResponse
|
|
11
11
|
from fastapi.routing import APIRoute
|
|
12
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
12
13
|
|
|
13
14
|
from svc_infra.api.fastapi.docs.landing import CardSpec, DocTargets, render_index_html
|
|
14
15
|
from svc_infra.api.fastapi.docs.scoped import DOC_SCOPES
|
|
15
16
|
from svc_infra.api.fastapi.middleware.errors.catchall import CatchAllExceptionMiddleware
|
|
16
17
|
from svc_infra.api.fastapi.middleware.errors.handlers import register_error_handlers
|
|
18
|
+
from svc_infra.api.fastapi.middleware.graceful_shutdown import install_graceful_shutdown
|
|
17
19
|
from svc_infra.api.fastapi.middleware.idempotency import IdempotencyMiddleware
|
|
18
20
|
from svc_infra.api.fastapi.middleware.ratelimit import SimpleRateLimitMiddleware
|
|
19
21
|
from svc_infra.api.fastapi.middleware.request_id import RequestIdMiddleware
|
|
22
|
+
from svc_infra.api.fastapi.middleware.timeout import (
|
|
23
|
+
BodyReadTimeoutMiddleware,
|
|
24
|
+
HandlerTimeoutMiddleware,
|
|
25
|
+
)
|
|
20
26
|
from svc_infra.api.fastapi.openapi.models import APIVersionSpec, ServiceInfo
|
|
21
27
|
from svc_infra.api.fastapi.openapi.mutators import setup_mutators
|
|
22
28
|
from svc_infra.api.fastapi.openapi.pipeline import apply_mutators
|
|
@@ -34,8 +40,9 @@ def _gen_operation_id_factory():
|
|
|
34
40
|
|
|
35
41
|
def _gen(route: APIRoute) -> str:
|
|
36
42
|
base = route.name or getattr(route.endpoint, "__name__", "op")
|
|
37
|
-
base = _normalize(base)
|
|
38
|
-
|
|
43
|
+
base = _normalize(str(base)) # Convert Enum to str if needed
|
|
44
|
+
tag_raw = route.tags[0] if route.tags else ""
|
|
45
|
+
tag = _normalize(str(tag_raw)) if tag_raw else ""
|
|
39
46
|
method = next(iter(route.methods or ["GET"])).lower()
|
|
40
47
|
|
|
41
48
|
candidate = base
|
|
@@ -55,35 +62,103 @@ def _gen_operation_id_factory():
|
|
|
55
62
|
return _gen
|
|
56
63
|
|
|
57
64
|
|
|
65
|
+
def _origin_to_regex(origin: str) -> str | None:
|
|
66
|
+
"""Convert a wildcard origin pattern to a regex.
|
|
67
|
+
|
|
68
|
+
Supports patterns like:
|
|
69
|
+
- "https://*.vercel.app" -> matches any subdomain
|
|
70
|
+
- "https://nfrax-*.vercel.app" -> matches nfrax-xxx.vercel.app
|
|
71
|
+
|
|
72
|
+
Returns None if the origin is not a pattern (no wildcards).
|
|
73
|
+
"""
|
|
74
|
+
import re
|
|
75
|
+
|
|
76
|
+
if "*" not in origin:
|
|
77
|
+
return None
|
|
78
|
+
# Escape special regex chars except *, then replace * with regex pattern
|
|
79
|
+
escaped = re.escape(origin).replace(r"\*", "[a-zA-Z0-9_-]+")
|
|
80
|
+
return f"^{escaped}$"
|
|
81
|
+
|
|
82
|
+
|
|
58
83
|
def _setup_cors(app: FastAPI, public_cors_origins: list[str] | str | None = None):
|
|
84
|
+
# Collect origins from parameter
|
|
59
85
|
if isinstance(public_cors_origins, list):
|
|
60
|
-
|
|
86
|
+
param_origins = [o.strip() for o in public_cors_origins if o and o.strip()]
|
|
61
87
|
elif isinstance(public_cors_origins, str):
|
|
62
|
-
|
|
88
|
+
param_origins = [
|
|
89
|
+
o.strip() for o in public_cors_origins.split(",") if o and o.strip()
|
|
90
|
+
]
|
|
63
91
|
else:
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
92
|
+
param_origins = []
|
|
93
|
+
|
|
94
|
+
# Collect origins from environment variable
|
|
95
|
+
env_value = os.getenv("CORS_ALLOW_ORIGINS", "")
|
|
96
|
+
env_origins = [o.strip() for o in env_value.split(",") if o and o.strip()]
|
|
97
|
+
|
|
98
|
+
# Merge both sources, removing duplicates while preserving order
|
|
99
|
+
seen = set()
|
|
100
|
+
origins = []
|
|
101
|
+
for o in param_origins + env_origins:
|
|
102
|
+
if o not in seen:
|
|
103
|
+
seen.add(o)
|
|
104
|
+
origins.append(o)
|
|
67
105
|
|
|
68
106
|
if not origins:
|
|
69
107
|
return
|
|
70
108
|
|
|
71
109
|
cors_kwargs = dict(allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
|
110
|
+
|
|
111
|
+
# Check for "*" (allow all) first
|
|
72
112
|
if "*" in origins:
|
|
73
113
|
cors_kwargs["allow_origin_regex"] = ".*"
|
|
74
114
|
else:
|
|
75
|
-
|
|
115
|
+
# Separate exact origins from wildcard patterns
|
|
116
|
+
exact_origins = []
|
|
117
|
+
patterns = []
|
|
118
|
+
for o in origins:
|
|
119
|
+
regex = _origin_to_regex(o)
|
|
120
|
+
if regex:
|
|
121
|
+
patterns.append(regex)
|
|
122
|
+
else:
|
|
123
|
+
exact_origins.append(o)
|
|
124
|
+
|
|
125
|
+
# If we have patterns, combine into a single regex with exact origins
|
|
126
|
+
if patterns:
|
|
127
|
+
# Convert exact origins to regex patterns too
|
|
128
|
+
import re
|
|
129
|
+
|
|
130
|
+
for exact in exact_origins:
|
|
131
|
+
patterns.append(f"^{re.escape(exact)}$")
|
|
132
|
+
# Combine all patterns with OR
|
|
133
|
+
cors_kwargs["allow_origin_regex"] = "|".join(patterns)
|
|
134
|
+
else:
|
|
135
|
+
# No patterns, just use allow_origins
|
|
136
|
+
cors_kwargs["allow_origins"] = exact_origins
|
|
137
|
+
|
|
138
|
+
app.add_middleware(CORSMiddleware, **cors_kwargs) # type: ignore[arg-type] # CORSMiddleware accepts these kwargs
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _setup_middlewares(app: FastAPI, skip_paths: list[str] | None = None):
|
|
142
|
+
"""Configure middleware stack. All middlewares are pure ASGI for streaming compatibility.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
app: FastAPI application
|
|
146
|
+
skip_paths: Paths to skip for certain middlewares (e.g., long-running or streaming endpoints)
|
|
147
|
+
"""
|
|
148
|
+
paths = skip_paths or []
|
|
76
149
|
|
|
77
|
-
app.add_middleware(CORSMiddleware, **cors_kwargs)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def _setup_middlewares(app: FastAPI):
|
|
81
150
|
app.add_middleware(RequestIdMiddleware)
|
|
151
|
+
# Timeouts: enforce body read timeout first, then total handler timeout
|
|
152
|
+
app.add_middleware(BodyReadTimeoutMiddleware)
|
|
153
|
+
app.add_middleware(HandlerTimeoutMiddleware, skip_paths=paths)
|
|
82
154
|
app.add_middleware(CatchAllExceptionMiddleware)
|
|
83
|
-
|
|
84
|
-
app.add_middleware(
|
|
155
|
+
# Idempotency and rate limiting
|
|
156
|
+
app.add_middleware(IdempotencyMiddleware, skip_paths=paths)
|
|
157
|
+
app.add_middleware(SimpleRateLimitMiddleware, skip_paths=paths)
|
|
85
158
|
register_error_handlers(app)
|
|
86
|
-
_add_route_logger(app)
|
|
159
|
+
_add_route_logger(app, skip_paths=paths)
|
|
160
|
+
# Graceful shutdown: track in-flight and wait on shutdown
|
|
161
|
+
install_graceful_shutdown(app)
|
|
87
162
|
|
|
88
163
|
|
|
89
164
|
def _coerce_list(value: str | Iterable[str] | None) -> list[str]:
|
|
@@ -98,23 +173,30 @@ def _dump_or_none(model):
|
|
|
98
173
|
return model.model_dump(exclude_none=True) if model is not None else None
|
|
99
174
|
|
|
100
175
|
|
|
101
|
-
def _build_child_app(
|
|
102
|
-
|
|
176
|
+
def _build_child_app(
|
|
177
|
+
service: ServiceInfo, spec: APIVersionSpec, skip_paths: list[str] | None = None
|
|
178
|
+
) -> FastAPI:
|
|
179
|
+
title = (
|
|
180
|
+
f"{service.name} • {spec.tag}" if getattr(spec, "tag", None) else service.name
|
|
181
|
+
)
|
|
103
182
|
child = FastAPI(
|
|
104
183
|
title=title,
|
|
105
184
|
version=service.release,
|
|
106
185
|
contact=_dump_or_none(service.contact),
|
|
107
186
|
license_info=_dump_or_none(service.license),
|
|
108
187
|
terms_of_service=service.terms_of_service,
|
|
109
|
-
description=service.description,
|
|
188
|
+
description=service.description or "",
|
|
110
189
|
generate_unique_id_function=_gen_operation_id_factory(),
|
|
111
190
|
)
|
|
112
191
|
|
|
113
|
-
_setup_middlewares(child)
|
|
192
|
+
_setup_middlewares(child, skip_paths=skip_paths)
|
|
114
193
|
|
|
115
194
|
# ---- OpenAPI pipeline (DRY!) ----
|
|
116
|
-
include_api_key =
|
|
117
|
-
|
|
195
|
+
include_api_key = (
|
|
196
|
+
bool(spec.include_api_key) if spec.include_api_key is not None else False
|
|
197
|
+
)
|
|
198
|
+
tag_str = str(spec.tag).strip("/")
|
|
199
|
+
mount_path = f"/{tag_str}"
|
|
118
200
|
server_url = (
|
|
119
201
|
mount_path
|
|
120
202
|
if not spec.public_base_url
|
|
@@ -131,11 +213,17 @@ def _build_child_app(service: ServiceInfo, spec: APIVersionSpec) -> FastAPI:
|
|
|
131
213
|
|
|
132
214
|
if spec.routers_package:
|
|
133
215
|
register_all_routers(
|
|
134
|
-
child,
|
|
216
|
+
child,
|
|
217
|
+
base_package=spec.routers_package,
|
|
218
|
+
prefix="",
|
|
219
|
+
environment=CURRENT_ENVIRONMENT,
|
|
135
220
|
)
|
|
136
221
|
|
|
137
222
|
logger.info(
|
|
138
|
-
"[%s] initialized version %s [env: %s]",
|
|
223
|
+
"[%s] initialized version %s [env: %s]",
|
|
224
|
+
service.name,
|
|
225
|
+
spec.tag,
|
|
226
|
+
CURRENT_ENVIRONMENT,
|
|
139
227
|
)
|
|
140
228
|
return child
|
|
141
229
|
|
|
@@ -147,23 +235,25 @@ def _build_parent_app(
|
|
|
147
235
|
root_routers: list[str] | str | None,
|
|
148
236
|
root_server_url: str | None = None,
|
|
149
237
|
root_include_api_key: bool = False,
|
|
238
|
+
skip_paths: list[str] | None = None,
|
|
239
|
+
**fastapi_kwargs, # Accept FastAPI kwargs
|
|
150
240
|
) -> FastAPI:
|
|
151
|
-
|
|
152
|
-
|
|
241
|
+
# Root docs are now enabled in all environments to match root card visibility
|
|
153
242
|
parent = FastAPI(
|
|
154
243
|
title=service.name,
|
|
155
244
|
version=service.release,
|
|
156
245
|
contact=_dump_or_none(service.contact),
|
|
157
246
|
license_info=_dump_or_none(service.license),
|
|
158
247
|
terms_of_service=service.terms_of_service,
|
|
159
|
-
description=service.description,
|
|
160
|
-
docs_url=
|
|
161
|
-
redoc_url=
|
|
162
|
-
openapi_url=
|
|
248
|
+
description=service.description or "",
|
|
249
|
+
docs_url="/docs",
|
|
250
|
+
redoc_url="/redoc",
|
|
251
|
+
openapi_url="/openapi.json",
|
|
252
|
+
**fastapi_kwargs, # Forward to FastAPI constructor
|
|
163
253
|
)
|
|
164
254
|
|
|
165
255
|
_setup_cors(parent, public_cors_origins)
|
|
166
|
-
_setup_middlewares(parent)
|
|
256
|
+
_setup_middlewares(parent, skip_paths=skip_paths)
|
|
167
257
|
|
|
168
258
|
mutators = setup_mutators(
|
|
169
259
|
service=service,
|
|
@@ -182,23 +272,54 @@ def _build_parent_app(
|
|
|
182
272
|
)
|
|
183
273
|
# app-provided root routers
|
|
184
274
|
for pkg in _coerce_list(root_routers):
|
|
185
|
-
register_all_routers(
|
|
275
|
+
register_all_routers(
|
|
276
|
+
parent, base_package=pkg, prefix="", environment=CURRENT_ENVIRONMENT
|
|
277
|
+
)
|
|
186
278
|
|
|
187
279
|
return parent
|
|
188
280
|
|
|
189
281
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
282
|
+
class RouteLoggerMiddleware:
|
|
283
|
+
"""Pure ASGI middleware to add X-Handled-By header."""
|
|
284
|
+
|
|
285
|
+
def __init__(self, app: ASGIApp, skip_paths: list[str] | None = None):
|
|
286
|
+
self.app = app
|
|
287
|
+
self.skip_paths = skip_paths or []
|
|
288
|
+
|
|
289
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
290
|
+
if scope.get("type") != "http":
|
|
291
|
+
await self.app(scope, receive, send)
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
path = scope.get("path", "")
|
|
295
|
+
method = scope.get("method", "")
|
|
296
|
+
|
|
297
|
+
# Skip specified paths
|
|
298
|
+
if any(skip in path for skip in self.skip_paths):
|
|
299
|
+
await self.app(scope, receive, send)
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
# Wrap send to add header after response starts
|
|
303
|
+
async def send_wrapper(message):
|
|
304
|
+
if message["type"] == "http.response.start":
|
|
305
|
+
route = scope.get("route")
|
|
306
|
+
route_path = getattr(route, "path_format", None) or getattr(
|
|
307
|
+
route, "path", None
|
|
308
|
+
)
|
|
309
|
+
if route_path:
|
|
310
|
+
root_path = scope.get("root_path", "") or ""
|
|
311
|
+
headers = list(message.get("headers", []))
|
|
312
|
+
headers.append(
|
|
313
|
+
(b"x-handled-by", f"{method} {root_path}{route_path}".encode())
|
|
314
|
+
)
|
|
315
|
+
message = {**message, "headers": headers}
|
|
316
|
+
await send(message)
|
|
317
|
+
|
|
318
|
+
await self.app(scope, receive, send_wrapper)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _add_route_logger(app: FastAPI, skip_paths: list[str] | None = None):
|
|
322
|
+
app.add_middleware(RouteLoggerMiddleware, skip_paths=skip_paths)
|
|
202
323
|
|
|
203
324
|
|
|
204
325
|
def setup_service_api(
|
|
@@ -209,6 +330,8 @@ def setup_service_api(
|
|
|
209
330
|
public_cors_origins: list[str] | str | None = None,
|
|
210
331
|
root_public_base_url: str | None = None,
|
|
211
332
|
root_include_api_key: bool | None = None,
|
|
333
|
+
skip_paths: list[str] | None = None,
|
|
334
|
+
**fastapi_kwargs, # Forward all other FastAPI kwargs (lifespan, etc.)
|
|
212
335
|
) -> FastAPI:
|
|
213
336
|
# infer if not explicitly provided
|
|
214
337
|
effective_root_include_api_key = (
|
|
@@ -224,31 +347,35 @@ def setup_service_api(
|
|
|
224
347
|
root_routers=root_routers,
|
|
225
348
|
root_server_url=root_server,
|
|
226
349
|
root_include_api_key=effective_root_include_api_key,
|
|
350
|
+
skip_paths=skip_paths,
|
|
351
|
+
**fastapi_kwargs, # Forward to _build_parent_app
|
|
227
352
|
)
|
|
228
353
|
|
|
229
354
|
# Mount each version
|
|
230
355
|
for spec in versions:
|
|
231
|
-
child = _build_child_app(service, spec)
|
|
232
|
-
|
|
233
|
-
|
|
356
|
+
child = _build_child_app(service, spec, skip_paths=skip_paths)
|
|
357
|
+
tag_str = str(spec.tag).strip("/")
|
|
358
|
+
mount_path = f"/{tag_str}"
|
|
359
|
+
parent.mount(mount_path, child, name=tag_str)
|
|
234
360
|
|
|
235
|
-
@parent.get("/", include_in_schema=False)
|
|
361
|
+
@parent.get("/", include_in_schema=False, response_class=HTMLResponse)
|
|
236
362
|
def index():
|
|
237
363
|
cards: list[CardSpec] = []
|
|
238
364
|
is_local_dev = CURRENT_ENVIRONMENT in (LOCAL_ENV, DEV_ENV)
|
|
239
365
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
)
|
|
366
|
+
# Root card - always show in all environments
|
|
367
|
+
cards.append(
|
|
368
|
+
CardSpec(
|
|
369
|
+
tag="",
|
|
370
|
+
docs=DocTargets(
|
|
371
|
+
swagger="/docs", redoc="/redoc", openapi_json="/openapi.json"
|
|
372
|
+
),
|
|
247
373
|
)
|
|
374
|
+
)
|
|
248
375
|
|
|
249
376
|
# Version cards
|
|
250
377
|
for spec in versions:
|
|
251
|
-
tag = spec.tag.strip("/")
|
|
378
|
+
tag = str(spec.tag).strip("/")
|
|
252
379
|
cards.append(
|
|
253
380
|
CardSpec(
|
|
254
381
|
tag=tag,
|
|
@@ -266,11 +393,15 @@ def setup_service_api(
|
|
|
266
393
|
cards.append(
|
|
267
394
|
CardSpec(
|
|
268
395
|
tag=scope.strip("/"),
|
|
269
|
-
docs=DocTargets(
|
|
396
|
+
docs=DocTargets(
|
|
397
|
+
swagger=swagger, redoc=redoc, openapi_json=openapi_json
|
|
398
|
+
),
|
|
270
399
|
)
|
|
271
400
|
)
|
|
272
401
|
|
|
273
|
-
html = render_index_html(
|
|
402
|
+
html = render_index_html(
|
|
403
|
+
service_name=service.name, release=service.release, cards=cards
|
|
404
|
+
)
|
|
274
405
|
return HTMLResponse(html)
|
|
275
406
|
|
|
276
407
|
return parent
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Optional
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
|
|
7
|
+
from .context import set_tenant_resolver
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def add_tenancy(app: FastAPI, *, resolver: Optional[Callable[..., Any]] = None) -> None:
|
|
11
|
+
"""Wire tenancy resolver for the application.
|
|
12
|
+
|
|
13
|
+
Provide a resolver(request, identity, header) -> Optional[str] to override
|
|
14
|
+
the default resolution. Pass None to clear a previous override.
|
|
15
|
+
"""
|
|
16
|
+
set_tenant_resolver(resolver)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
__all__ = ["add_tenancy"]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Any, Callable, Optional
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends, HTTPException, Request
|
|
6
|
+
|
|
7
|
+
try: # optional import; auth may not be used by all consumers
|
|
8
|
+
from svc_infra.api.fastapi.auth.security import OptionalIdentity
|
|
9
|
+
except Exception: # pragma: no cover - fallback for minimal builds
|
|
10
|
+
OptionalIdentity = None # type: ignore[misc,assignment]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_tenant_resolver: Optional[Callable[..., Any]] = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def set_tenant_resolver(
|
|
17
|
+
fn: Optional[Callable[..., Any]],
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Set or clear a global override hook for tenant resolution.
|
|
20
|
+
|
|
21
|
+
The function receives (request, identity, tenant_header) and should return a tenant id
|
|
22
|
+
string or None to fall back to default logic.
|
|
23
|
+
"""
|
|
24
|
+
global _tenant_resolver
|
|
25
|
+
_tenant_resolver = fn
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _maybe_await(x):
|
|
29
|
+
if callable(getattr(x, "__await__", None)):
|
|
30
|
+
return await x
|
|
31
|
+
return x
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def resolve_tenant_id(
|
|
35
|
+
request: Request,
|
|
36
|
+
tenant_header: Optional[str] = None,
|
|
37
|
+
identity: Any = Depends(OptionalIdentity) if OptionalIdentity else None, # type: ignore[arg-type]
|
|
38
|
+
) -> Optional[str]:
|
|
39
|
+
"""Resolve tenant id from override, identity, header, or request.state.
|
|
40
|
+
|
|
41
|
+
Order:
|
|
42
|
+
1) Global override hook (set_tenant_resolver)
|
|
43
|
+
2) Auth identity: user.tenant_id then api_key.tenant_id (if available)
|
|
44
|
+
3) X-Tenant-Id header
|
|
45
|
+
4) request.state.tenant_id
|
|
46
|
+
"""
|
|
47
|
+
# read header value if not provided directly (supports direct calls without DI)
|
|
48
|
+
if tenant_header is None:
|
|
49
|
+
try:
|
|
50
|
+
tenant_header = request.headers.get("X-Tenant-Id")
|
|
51
|
+
except Exception:
|
|
52
|
+
tenant_header = None
|
|
53
|
+
|
|
54
|
+
# 1) global override
|
|
55
|
+
if _tenant_resolver is not None:
|
|
56
|
+
try:
|
|
57
|
+
v = _tenant_resolver(request, identity, tenant_header)
|
|
58
|
+
v2 = await _maybe_await(v)
|
|
59
|
+
if v2:
|
|
60
|
+
return str(v2)
|
|
61
|
+
except Exception:
|
|
62
|
+
# fall through to defaults
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
# 2) from identity
|
|
66
|
+
try:
|
|
67
|
+
if identity and getattr(identity, "user", None) is not None:
|
|
68
|
+
tid = getattr(identity.user, "tenant_id", None)
|
|
69
|
+
if tid:
|
|
70
|
+
return str(tid)
|
|
71
|
+
if identity and getattr(identity, "api_key", None) is not None:
|
|
72
|
+
tid = getattr(identity.api_key, "tenant_id", None)
|
|
73
|
+
if tid:
|
|
74
|
+
return str(tid)
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
# 3) from header
|
|
79
|
+
if tenant_header and isinstance(tenant_header, str) and tenant_header.strip():
|
|
80
|
+
return tenant_header.strip()
|
|
81
|
+
|
|
82
|
+
# 4) request.state
|
|
83
|
+
try:
|
|
84
|
+
st_tid = getattr(getattr(request, "state", object()), "tenant_id", None)
|
|
85
|
+
if st_tid:
|
|
86
|
+
return str(st_tid)
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def require_tenant_id(
|
|
94
|
+
tenant_id: Optional[str] = Depends(resolve_tenant_id),
|
|
95
|
+
) -> str:
|
|
96
|
+
if not tenant_id:
|
|
97
|
+
raise HTTPException(status_code=400, detail="tenant_context_missing")
|
|
98
|
+
return tenant_id
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# DX aliases
|
|
102
|
+
TenantId = Annotated[str, Depends(require_tenant_id)]
|
|
103
|
+
OptionalTenantId = Annotated[Optional[str], Depends(resolve_tenant_id)]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
__all__ = [
|
|
107
|
+
"TenantId",
|
|
108
|
+
"OptionalTenantId",
|
|
109
|
+
"resolve_tenant_id",
|
|
110
|
+
"require_tenant_id",
|
|
111
|
+
"set_tenant_resolver",
|
|
112
|
+
]
|