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
|
@@ -3,17 +3,26 @@ from __future__ import annotations
|
|
|
3
3
|
import base64
|
|
4
4
|
import contextvars
|
|
5
5
|
import json
|
|
6
|
-
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
8
|
+
from typing import (
|
|
9
|
+
Any,
|
|
10
|
+
Generic,
|
|
11
|
+
TypeVar,
|
|
12
|
+
cast,
|
|
13
|
+
)
|
|
7
14
|
|
|
8
15
|
from fastapi import Query, Request
|
|
9
16
|
from pydantic import BaseModel, Field
|
|
10
17
|
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
11
20
|
T = TypeVar("T")
|
|
12
21
|
|
|
13
22
|
|
|
14
23
|
# ---------- Core query models ----------
|
|
15
24
|
class CursorParams(BaseModel):
|
|
16
|
-
cursor:
|
|
25
|
+
cursor: str | None = None
|
|
17
26
|
limit: int = 50
|
|
18
27
|
|
|
19
28
|
|
|
@@ -23,19 +32,19 @@ class PageParams(BaseModel):
|
|
|
23
32
|
|
|
24
33
|
|
|
25
34
|
class FilterParams(BaseModel):
|
|
26
|
-
q:
|
|
27
|
-
sort:
|
|
28
|
-
created_after:
|
|
29
|
-
created_before:
|
|
30
|
-
updated_after:
|
|
31
|
-
updated_before:
|
|
35
|
+
q: str | None = None
|
|
36
|
+
sort: str | None = None
|
|
37
|
+
created_after: str | None = None
|
|
38
|
+
created_before: str | None = None
|
|
39
|
+
updated_after: str | None = None
|
|
40
|
+
updated_before: str | None = None
|
|
32
41
|
|
|
33
42
|
|
|
34
43
|
# ---------- Envelope model ----------
|
|
35
44
|
class Paginated(BaseModel, Generic[T]):
|
|
36
|
-
items:
|
|
37
|
-
next_cursor:
|
|
38
|
-
total:
|
|
45
|
+
items: list[T]
|
|
46
|
+
next_cursor: str | None = Field(None, description="Opaque cursor for next page")
|
|
47
|
+
total: int | None = Field(None, description="Total items (optional)")
|
|
39
48
|
|
|
40
49
|
|
|
41
50
|
# ---------- Cursor helpers ----------
|
|
@@ -44,13 +53,13 @@ def _encode_cursor(payload: dict) -> str:
|
|
|
44
53
|
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
|
45
54
|
|
|
46
55
|
|
|
47
|
-
def decode_cursor(token:
|
|
56
|
+
def decode_cursor(token: str | None) -> dict[Any, Any]:
|
|
48
57
|
"""Public: decode an incoming cursor token for debugging/ops."""
|
|
49
58
|
if not token:
|
|
50
59
|
return {}
|
|
51
60
|
s = token + "=" * (-len(token) % 4)
|
|
52
61
|
raw = base64.urlsafe_b64decode(s.encode("ascii")).decode("utf-8")
|
|
53
|
-
return json.loads(raw)
|
|
62
|
+
return cast("dict[Any, Any]", json.loads(raw))
|
|
54
63
|
|
|
55
64
|
|
|
56
65
|
# ---------- Context ----------
|
|
@@ -84,23 +93,25 @@ class PaginationContext(Generic[T]):
|
|
|
84
93
|
self.limit_override = limit_override
|
|
85
94
|
|
|
86
95
|
@property
|
|
87
|
-
def cursor(self) ->
|
|
96
|
+
def cursor(self) -> str | None:
|
|
88
97
|
return (self.cursor_params or CursorParams()).cursor if self.allow_cursor else None
|
|
89
98
|
|
|
90
99
|
@property
|
|
91
100
|
def limit(self) -> int:
|
|
92
|
-
|
|
101
|
+
# For cursor-based pagination, always honor the requested limit, even on the first page
|
|
102
|
+
# (cursor may be None for the first page).
|
|
103
|
+
if self.allow_cursor and self.cursor_params:
|
|
93
104
|
return self.cursor_params.limit
|
|
94
105
|
if self.allow_page and self.page_params:
|
|
95
106
|
return self.limit_override or self.page_params.page_size
|
|
96
107
|
return 50
|
|
97
108
|
|
|
98
109
|
@property
|
|
99
|
-
def page(self) ->
|
|
110
|
+
def page(self) -> int | None:
|
|
100
111
|
return self.page_params.page if (self.allow_page and self.page_params) else None
|
|
101
112
|
|
|
102
113
|
@property
|
|
103
|
-
def page_size(self) ->
|
|
114
|
+
def page_size(self) -> int | None:
|
|
104
115
|
return self.page_params.page_size if (self.allow_page and self.page_params) else None
|
|
105
116
|
|
|
106
117
|
@property
|
|
@@ -110,7 +121,11 @@ class PaginationContext(Generic[T]):
|
|
|
110
121
|
return 0
|
|
111
122
|
|
|
112
123
|
def wrap(
|
|
113
|
-
self,
|
|
124
|
+
self,
|
|
125
|
+
items: list[T],
|
|
126
|
+
*,
|
|
127
|
+
next_cursor: str | None = None,
|
|
128
|
+
total: int | None = None,
|
|
114
129
|
):
|
|
115
130
|
if self.envelope:
|
|
116
131
|
return Paginated[T](items=items, next_cursor=next_cursor, total=total)
|
|
@@ -118,14 +133,14 @@ class PaginationContext(Generic[T]):
|
|
|
118
133
|
|
|
119
134
|
def next_cursor_from_last(
|
|
120
135
|
self, items: Sequence[T], *, key: Callable[[T], str | int]
|
|
121
|
-
) ->
|
|
136
|
+
) -> str | None:
|
|
122
137
|
if not items:
|
|
123
138
|
return None
|
|
124
139
|
last_key = key(items[-1])
|
|
125
140
|
return _encode_cursor({"after": last_key})
|
|
126
141
|
|
|
127
142
|
|
|
128
|
-
_pagination_ctx: contextvars.ContextVar[PaginationContext] = contextvars.ContextVar(
|
|
143
|
+
_pagination_ctx: contextvars.ContextVar[PaginationContext | None] = contextvars.ContextVar(
|
|
129
144
|
"pagination_ctx", default=None
|
|
130
145
|
)
|
|
131
146
|
|
|
@@ -146,7 +161,7 @@ def use_pagination() -> PaginationContext:
|
|
|
146
161
|
|
|
147
162
|
|
|
148
163
|
# ---------- Utilities ----------
|
|
149
|
-
def text_filter(items: Iterable[T], q:
|
|
164
|
+
def text_filter(items: Iterable[T], q: str | None, *getters: Callable[[T], str]) -> list[T]:
|
|
150
165
|
if not q:
|
|
151
166
|
return list(items)
|
|
152
167
|
ql = q.lower()
|
|
@@ -157,8 +172,8 @@ def text_filter(items: Iterable[T], q: Optional[str], *getters: Callable[[T], st
|
|
|
157
172
|
if ql in (g(it) or "").lower():
|
|
158
173
|
out.append(it)
|
|
159
174
|
break
|
|
160
|
-
except Exception:
|
|
161
|
-
|
|
175
|
+
except Exception as e:
|
|
176
|
+
logger.debug("text_filter getter failed for item: %s", e)
|
|
162
177
|
return out
|
|
163
178
|
|
|
164
179
|
|
|
@@ -168,7 +183,7 @@ def sort_by(
|
|
|
168
183
|
key: Callable[[T], Any],
|
|
169
184
|
desc: bool = False,
|
|
170
185
|
) -> list[T]:
|
|
171
|
-
return sorted(
|
|
186
|
+
return sorted(items, key=key, reverse=desc)
|
|
172
187
|
|
|
173
188
|
|
|
174
189
|
def cursor_window(items, *, cursor, limit, key, descending: bool, offset: int = 0):
|
|
@@ -215,7 +230,7 @@ def make_pagination_injector(
|
|
|
215
230
|
# Cursor-only (common case)
|
|
216
231
|
if allow_cursor and not allow_page and not include_filters:
|
|
217
232
|
|
|
218
|
-
async def
|
|
233
|
+
async def _inject_cursor(
|
|
219
234
|
request: Request,
|
|
220
235
|
cursor: str | None = Query(None),
|
|
221
236
|
limit: int = Query(default_limit, ge=1, le=max_limit),
|
|
@@ -233,12 +248,12 @@ def make_pagination_injector(
|
|
|
233
248
|
)
|
|
234
249
|
return None
|
|
235
250
|
|
|
236
|
-
return
|
|
251
|
+
return _inject_cursor
|
|
237
252
|
|
|
238
253
|
# Cursor + filters
|
|
239
254
|
if allow_cursor and not allow_page and include_filters:
|
|
240
255
|
|
|
241
|
-
async def
|
|
256
|
+
async def _inject_cursor_with_filters(
|
|
242
257
|
request: Request,
|
|
243
258
|
cursor: str | None = Query(None),
|
|
244
259
|
limit: int = Query(default_limit, ge=1, le=max_limit),
|
|
@@ -270,12 +285,12 @@ def make_pagination_injector(
|
|
|
270
285
|
)
|
|
271
286
|
return None
|
|
272
287
|
|
|
273
|
-
return
|
|
288
|
+
return _inject_cursor_with_filters
|
|
274
289
|
|
|
275
290
|
# Page-only
|
|
276
291
|
if not allow_cursor and allow_page:
|
|
277
292
|
|
|
278
|
-
async def
|
|
293
|
+
async def _inject_page(
|
|
279
294
|
request: Request,
|
|
280
295
|
page: int = Query(1, ge=1),
|
|
281
296
|
page_size: int = Query(default_limit, ge=1, le=max_limit),
|
|
@@ -293,10 +308,10 @@ def make_pagination_injector(
|
|
|
293
308
|
)
|
|
294
309
|
return None
|
|
295
310
|
|
|
296
|
-
return
|
|
311
|
+
return _inject_page
|
|
297
312
|
|
|
298
313
|
# Both cursor + page (rare; exposes all)
|
|
299
|
-
async def
|
|
314
|
+
async def _inject_all(
|
|
300
315
|
request: Request,
|
|
301
316
|
cursor: str | None = Query(None),
|
|
302
317
|
limit: int = Query(default_limit, ge=1, le=max_limit),
|
|
@@ -336,7 +351,7 @@ def make_pagination_injector(
|
|
|
336
351
|
)
|
|
337
352
|
return None
|
|
338
353
|
|
|
339
|
-
return
|
|
354
|
+
return _inject_all
|
|
340
355
|
|
|
341
356
|
|
|
342
357
|
# ----- Convenience helpers for routers -----
|
|
@@ -4,12 +4,18 @@ import importlib
|
|
|
4
4
|
import logging
|
|
5
5
|
import pkgutil
|
|
6
6
|
from types import ModuleType
|
|
7
|
-
from typing import
|
|
7
|
+
from typing import Any
|
|
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
|
|
|
@@ -59,7 +65,7 @@ def _validate_base_package(base_package: str) -> ModuleType:
|
|
|
59
65
|
return package_module
|
|
60
66
|
|
|
61
67
|
|
|
62
|
-
def _normalize_environment(environment:
|
|
68
|
+
def _normalize_environment(environment: Environment | str | None) -> Environment:
|
|
63
69
|
"""Normalize the environment parameter."""
|
|
64
70
|
return (
|
|
65
71
|
CURRENT_ENVIRONMENT
|
|
@@ -69,7 +75,7 @@ def _normalize_environment(environment: Optional[Environment | str]) -> Environm
|
|
|
69
75
|
|
|
70
76
|
|
|
71
77
|
def _should_force_include_in_schema(
|
|
72
|
-
environment: Environment, force_include_in_schema:
|
|
78
|
+
environment: Environment, force_include_in_schema: bool | None
|
|
73
79
|
) -> bool:
|
|
74
80
|
"""Determine if routers should be forced to include in schema."""
|
|
75
81
|
if force_include_in_schema is None:
|
|
@@ -99,7 +105,7 @@ def _is_router_excluded_by_environment(
|
|
|
99
105
|
)
|
|
100
106
|
return False
|
|
101
107
|
|
|
102
|
-
normalized_excluded_envs = set()
|
|
108
|
+
normalized_excluded_envs: set[Environment | str] = set()
|
|
103
109
|
for e in router_excluded_envs:
|
|
104
110
|
try:
|
|
105
111
|
normalized_excluded_envs.add(Environment(e) if not isinstance(e, Environment) else e)
|
|
@@ -126,7 +132,7 @@ def _is_router_included_by_environment(
|
|
|
126
132
|
f"ROUTER_ENVIRONMENTS in {module_name} must be a set/list/tuple, got {type(router_envs)}"
|
|
127
133
|
)
|
|
128
134
|
return True
|
|
129
|
-
normalized = set()
|
|
135
|
+
normalized: set[Environment | str] = set()
|
|
130
136
|
for e in router_envs:
|
|
131
137
|
try:
|
|
132
138
|
normalized.add(Environment(e) if not isinstance(e, Environment) else e)
|
|
@@ -163,7 +169,7 @@ def _build_include_kwargs(module: ModuleType, prefix: str, force_include: bool)
|
|
|
163
169
|
router_tag = getattr(module, "ROUTER_TAG", None)
|
|
164
170
|
include_in_schema = getattr(module, "INCLUDE_ROUTER_IN_SCHEMA", True)
|
|
165
171
|
|
|
166
|
-
include_kwargs = {"prefix": prefix}
|
|
172
|
+
include_kwargs: dict[str, Any] = {"prefix": prefix}
|
|
167
173
|
if router_prefix:
|
|
168
174
|
include_kwargs["prefix"] = prefix.rstrip("/") + router_prefix
|
|
169
175
|
if router_tag:
|
|
@@ -205,10 +211,10 @@ def _process_router_module(
|
|
|
205
211
|
def register_all_routers(
|
|
206
212
|
app: FastAPI,
|
|
207
213
|
*,
|
|
208
|
-
base_package:
|
|
214
|
+
base_package: str | None = None,
|
|
209
215
|
prefix: str = "",
|
|
210
|
-
environment:
|
|
211
|
-
force_include_in_schema:
|
|
216
|
+
environment: Environment | str | None = None,
|
|
217
|
+
force_include_in_schema: bool | None = None,
|
|
212
218
|
) -> None:
|
|
213
219
|
"""
|
|
214
220
|
Recursively discover and register all FastAPI routers under a routers package.
|
|
@@ -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
|
@@ -3,20 +3,26 @@ from __future__ import annotations
|
|
|
3
3
|
import logging
|
|
4
4
|
import os
|
|
5
5
|
from collections import defaultdict
|
|
6
|
-
from
|
|
6
|
+
from collections.abc import Iterable, Sequence
|
|
7
7
|
|
|
8
8
|
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,101 @@ 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 = [o.strip() for o in public_cors_origins.split(",") if o and o.strip()]
|
|
63
89
|
else:
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
90
|
+
param_origins = []
|
|
91
|
+
|
|
92
|
+
# Collect origins from environment variable
|
|
93
|
+
env_value = os.getenv("CORS_ALLOW_ORIGINS", "")
|
|
94
|
+
env_origins = [o.strip() for o in env_value.split(",") if o and o.strip()]
|
|
95
|
+
|
|
96
|
+
# Merge both sources, removing duplicates while preserving order
|
|
97
|
+
seen = set()
|
|
98
|
+
origins = []
|
|
99
|
+
for o in param_origins + env_origins:
|
|
100
|
+
if o not in seen:
|
|
101
|
+
seen.add(o)
|
|
102
|
+
origins.append(o)
|
|
67
103
|
|
|
68
104
|
if not origins:
|
|
69
105
|
return
|
|
70
106
|
|
|
71
|
-
cors_kwargs =
|
|
107
|
+
cors_kwargs = {"allow_credentials": True, "allow_methods": ["*"], "allow_headers": ["*"]}
|
|
108
|
+
|
|
109
|
+
# Check for "*" (allow all) first
|
|
72
110
|
if "*" in origins:
|
|
73
111
|
cors_kwargs["allow_origin_regex"] = ".*"
|
|
74
112
|
else:
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
113
|
+
# Separate exact origins from wildcard patterns
|
|
114
|
+
exact_origins = []
|
|
115
|
+
patterns = []
|
|
116
|
+
for o in origins:
|
|
117
|
+
regex = _origin_to_regex(o)
|
|
118
|
+
if regex:
|
|
119
|
+
patterns.append(regex)
|
|
120
|
+
else:
|
|
121
|
+
exact_origins.append(o)
|
|
122
|
+
|
|
123
|
+
# If we have patterns, combine into a single regex with exact origins
|
|
124
|
+
if patterns:
|
|
125
|
+
# Convert exact origins to regex patterns too
|
|
126
|
+
import re
|
|
127
|
+
|
|
128
|
+
for exact in exact_origins:
|
|
129
|
+
patterns.append(f"^{re.escape(exact)}$")
|
|
130
|
+
# Combine all patterns with OR
|
|
131
|
+
cors_kwargs["allow_origin_regex"] = "|".join(patterns)
|
|
132
|
+
else:
|
|
133
|
+
# No patterns, just use allow_origins
|
|
134
|
+
cors_kwargs["allow_origins"] = exact_origins
|
|
135
|
+
|
|
136
|
+
app.add_middleware(CORSMiddleware, **cors_kwargs) # type: ignore[arg-type] # CORSMiddleware accepts these kwargs
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _setup_middlewares(app: FastAPI, skip_paths: list[str] | None = None):
|
|
140
|
+
"""Configure middleware stack. All middlewares are pure ASGI for streaming compatibility.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
app: FastAPI application
|
|
144
|
+
skip_paths: Paths to skip for certain middlewares (e.g., long-running or streaming endpoints)
|
|
145
|
+
"""
|
|
146
|
+
paths = skip_paths or []
|
|
79
147
|
|
|
80
|
-
def _setup_middlewares(app: FastAPI):
|
|
81
148
|
app.add_middleware(RequestIdMiddleware)
|
|
149
|
+
# Timeouts: enforce body read timeout first, then total handler timeout
|
|
150
|
+
app.add_middleware(BodyReadTimeoutMiddleware)
|
|
151
|
+
app.add_middleware(HandlerTimeoutMiddleware, skip_paths=paths)
|
|
82
152
|
app.add_middleware(CatchAllExceptionMiddleware)
|
|
83
|
-
|
|
84
|
-
app.add_middleware(
|
|
153
|
+
# Idempotency and rate limiting
|
|
154
|
+
app.add_middleware(IdempotencyMiddleware, skip_paths=paths)
|
|
155
|
+
app.add_middleware(SimpleRateLimitMiddleware, skip_paths=paths)
|
|
85
156
|
register_error_handlers(app)
|
|
86
|
-
_add_route_logger(app)
|
|
157
|
+
_add_route_logger(app, skip_paths=paths)
|
|
158
|
+
# Graceful shutdown: track in-flight and wait on shutdown
|
|
159
|
+
install_graceful_shutdown(app)
|
|
87
160
|
|
|
88
161
|
|
|
89
162
|
def _coerce_list(value: str | Iterable[str] | None) -> list[str]:
|
|
@@ -98,7 +171,9 @@ def _dump_or_none(model):
|
|
|
98
171
|
return model.model_dump(exclude_none=True) if model is not None else None
|
|
99
172
|
|
|
100
173
|
|
|
101
|
-
def _build_child_app(
|
|
174
|
+
def _build_child_app(
|
|
175
|
+
service: ServiceInfo, spec: APIVersionSpec, skip_paths: list[str] | None = None
|
|
176
|
+
) -> FastAPI:
|
|
102
177
|
title = f"{service.name} • {spec.tag}" if getattr(spec, "tag", None) else service.name
|
|
103
178
|
child = FastAPI(
|
|
104
179
|
title=title,
|
|
@@ -106,15 +181,16 @@ def _build_child_app(service: ServiceInfo, spec: APIVersionSpec) -> FastAPI:
|
|
|
106
181
|
contact=_dump_or_none(service.contact),
|
|
107
182
|
license_info=_dump_or_none(service.license),
|
|
108
183
|
terms_of_service=service.terms_of_service,
|
|
109
|
-
description=service.description,
|
|
184
|
+
description=service.description or "",
|
|
110
185
|
generate_unique_id_function=_gen_operation_id_factory(),
|
|
111
186
|
)
|
|
112
187
|
|
|
113
|
-
_setup_middlewares(child)
|
|
188
|
+
_setup_middlewares(child, skip_paths=skip_paths)
|
|
114
189
|
|
|
115
190
|
# ---- OpenAPI pipeline (DRY!) ----
|
|
116
191
|
include_api_key = bool(spec.include_api_key) if spec.include_api_key is not None else False
|
|
117
|
-
|
|
192
|
+
tag_str = str(spec.tag).strip("/")
|
|
193
|
+
mount_path = f"/{tag_str}"
|
|
118
194
|
server_url = (
|
|
119
195
|
mount_path
|
|
120
196
|
if not spec.public_base_url
|
|
@@ -131,11 +207,17 @@ def _build_child_app(service: ServiceInfo, spec: APIVersionSpec) -> FastAPI:
|
|
|
131
207
|
|
|
132
208
|
if spec.routers_package:
|
|
133
209
|
register_all_routers(
|
|
134
|
-
child,
|
|
210
|
+
child,
|
|
211
|
+
base_package=spec.routers_package,
|
|
212
|
+
prefix="",
|
|
213
|
+
environment=CURRENT_ENVIRONMENT,
|
|
135
214
|
)
|
|
136
215
|
|
|
137
216
|
logger.info(
|
|
138
|
-
"[%s] initialized version %s [env: %s]",
|
|
217
|
+
"[%s] initialized version %s [env: %s]",
|
|
218
|
+
service.name,
|
|
219
|
+
spec.tag,
|
|
220
|
+
CURRENT_ENVIRONMENT,
|
|
139
221
|
)
|
|
140
222
|
return child
|
|
141
223
|
|
|
@@ -147,23 +229,25 @@ def _build_parent_app(
|
|
|
147
229
|
root_routers: list[str] | str | None,
|
|
148
230
|
root_server_url: str | None = None,
|
|
149
231
|
root_include_api_key: bool = False,
|
|
232
|
+
skip_paths: list[str] | None = None,
|
|
233
|
+
**fastapi_kwargs, # Accept FastAPI kwargs
|
|
150
234
|
) -> FastAPI:
|
|
151
|
-
|
|
152
|
-
|
|
235
|
+
# Root docs are now enabled in all environments to match root card visibility
|
|
153
236
|
parent = FastAPI(
|
|
154
237
|
title=service.name,
|
|
155
238
|
version=service.release,
|
|
156
239
|
contact=_dump_or_none(service.contact),
|
|
157
240
|
license_info=_dump_or_none(service.license),
|
|
158
241
|
terms_of_service=service.terms_of_service,
|
|
159
|
-
description=service.description,
|
|
160
|
-
docs_url=
|
|
161
|
-
redoc_url=
|
|
162
|
-
openapi_url=
|
|
242
|
+
description=service.description or "",
|
|
243
|
+
docs_url="/docs",
|
|
244
|
+
redoc_url="/redoc",
|
|
245
|
+
openapi_url="/openapi.json",
|
|
246
|
+
**fastapi_kwargs, # Forward to FastAPI constructor
|
|
163
247
|
)
|
|
164
248
|
|
|
165
249
|
_setup_cors(parent, public_cors_origins)
|
|
166
|
-
_setup_middlewares(parent)
|
|
250
|
+
_setup_middlewares(parent, skip_paths=skip_paths)
|
|
167
251
|
|
|
168
252
|
mutators = setup_mutators(
|
|
169
253
|
service=service,
|
|
@@ -187,18 +271,43 @@ def _build_parent_app(
|
|
|
187
271
|
return parent
|
|
188
272
|
|
|
189
273
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
274
|
+
class RouteLoggerMiddleware:
|
|
275
|
+
"""Pure ASGI middleware to add X-Handled-By header."""
|
|
276
|
+
|
|
277
|
+
def __init__(self, app: ASGIApp, skip_paths: list[str] | None = None):
|
|
278
|
+
self.app = app
|
|
279
|
+
self.skip_paths = skip_paths or []
|
|
280
|
+
|
|
281
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
282
|
+
if scope.get("type") != "http":
|
|
283
|
+
await self.app(scope, receive, send)
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
path = scope.get("path", "")
|
|
287
|
+
method = scope.get("method", "")
|
|
288
|
+
|
|
289
|
+
# Skip specified paths using prefix matching
|
|
290
|
+
if any(path.startswith(skip) for skip in self.skip_paths):
|
|
291
|
+
await self.app(scope, receive, send)
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
# Wrap send to add header after response starts
|
|
295
|
+
async def send_wrapper(message):
|
|
296
|
+
if message["type"] == "http.response.start":
|
|
297
|
+
route = scope.get("route")
|
|
298
|
+
route_path = getattr(route, "path_format", None) or getattr(route, "path", None)
|
|
299
|
+
if route_path:
|
|
300
|
+
root_path = scope.get("root_path", "") or ""
|
|
301
|
+
headers = list(message.get("headers", []))
|
|
302
|
+
headers.append((b"x-handled-by", f"{method} {root_path}{route_path}".encode()))
|
|
303
|
+
message = {**message, "headers": headers}
|
|
304
|
+
await send(message)
|
|
305
|
+
|
|
306
|
+
await self.app(scope, receive, send_wrapper)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _add_route_logger(app: FastAPI, skip_paths: list[str] | None = None):
|
|
310
|
+
app.add_middleware(RouteLoggerMiddleware, skip_paths=skip_paths)
|
|
202
311
|
|
|
203
312
|
|
|
204
313
|
def setup_service_api(
|
|
@@ -209,6 +318,8 @@ def setup_service_api(
|
|
|
209
318
|
public_cors_origins: list[str] | str | None = None,
|
|
210
319
|
root_public_base_url: str | None = None,
|
|
211
320
|
root_include_api_key: bool | None = None,
|
|
321
|
+
skip_paths: list[str] | None = None,
|
|
322
|
+
**fastapi_kwargs, # Forward all other FastAPI kwargs (lifespan, etc.)
|
|
212
323
|
) -> FastAPI:
|
|
213
324
|
# infer if not explicitly provided
|
|
214
325
|
effective_root_include_api_key = (
|
|
@@ -224,31 +335,33 @@ def setup_service_api(
|
|
|
224
335
|
root_routers=root_routers,
|
|
225
336
|
root_server_url=root_server,
|
|
226
337
|
root_include_api_key=effective_root_include_api_key,
|
|
338
|
+
skip_paths=skip_paths,
|
|
339
|
+
**fastapi_kwargs, # Forward to _build_parent_app
|
|
227
340
|
)
|
|
228
341
|
|
|
229
342
|
# Mount each version
|
|
230
343
|
for spec in versions:
|
|
231
|
-
child = _build_child_app(service, spec)
|
|
232
|
-
|
|
233
|
-
|
|
344
|
+
child = _build_child_app(service, spec, skip_paths=skip_paths)
|
|
345
|
+
tag_str = str(spec.tag).strip("/")
|
|
346
|
+
mount_path = f"/{tag_str}"
|
|
347
|
+
parent.mount(mount_path, child, name=tag_str)
|
|
234
348
|
|
|
235
|
-
@parent.get("/", include_in_schema=False)
|
|
349
|
+
@parent.get("/", include_in_schema=False, response_class=HTMLResponse)
|
|
236
350
|
def index():
|
|
237
351
|
cards: list[CardSpec] = []
|
|
238
352
|
is_local_dev = CURRENT_ENVIRONMENT in (LOCAL_ENV, DEV_ENV)
|
|
239
353
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
docs=DocTargets(swagger="/docs", redoc="/redoc", openapi_json="/openapi.json"),
|
|
246
|
-
)
|
|
354
|
+
# Root card - always show in all environments
|
|
355
|
+
cards.append(
|
|
356
|
+
CardSpec(
|
|
357
|
+
tag="",
|
|
358
|
+
docs=DocTargets(swagger="/docs", redoc="/redoc", openapi_json="/openapi.json"),
|
|
247
359
|
)
|
|
360
|
+
)
|
|
248
361
|
|
|
249
362
|
# Version cards
|
|
250
363
|
for spec in versions:
|
|
251
|
-
tag = spec.tag.strip("/")
|
|
364
|
+
tag = str(spec.tag).strip("/")
|
|
252
365
|
cards.append(
|
|
253
366
|
CardSpec(
|
|
254
367
|
tag=tag,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
|
|
8
|
+
from .context import set_tenant_resolver
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def add_tenancy(app: FastAPI, *, resolver: Callable[..., Any] | None = None) -> None:
|
|
12
|
+
"""Wire tenancy resolver for the application.
|
|
13
|
+
|
|
14
|
+
Provide a resolver(request, identity, header) -> Optional[str] to override
|
|
15
|
+
the default resolution. Pass None to clear a previous override.
|
|
16
|
+
"""
|
|
17
|
+
set_tenant_resolver(resolver)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
__all__ = ["add_tenancy"]
|