svc-infra 0.1.595__py3-none-any.whl → 0.1.706__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of svc-infra might be problematic. Click here for more details.
- svc_infra/__init__.py +58 -2
- svc_infra/apf_payments/models.py +133 -42
- svc_infra/apf_payments/provider/aiydan.py +121 -47
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +18 -9
- svc_infra/apf_payments/service.py +98 -41
- svc_infra/apf_payments/settings.py +5 -1
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +15 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +245 -0
- svc_infra/api/fastapi/apf_payments/router.py +128 -70
- svc_infra/api/fastapi/apf_payments/setup.py +13 -6
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +6 -2
- svc_infra/api/fastapi/auth/add.py +17 -14
- svc_infra/api/fastapi/auth/gaurd.py +45 -16
- svc_infra/api/fastapi/auth/mfa/models.py +3 -1
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
- svc_infra/api/fastapi/auth/mfa/router.py +15 -8
- svc_infra/api/fastapi/auth/mfa/security.py +1 -2
- svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
- svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -1
- svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
- svc_infra/api/fastapi/auth/routers/oauth_router.py +146 -52
- svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- svc_infra/api/fastapi/auth/state.py +3 -1
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +73 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/__init__.py +5 -1
- svc_infra/api/fastapi/db/http.py +3 -1
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +71 -26
- svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
- svc_infra/api/fastapi/db/sql/health.py +3 -1
- svc_infra/api/fastapi/db/sql/session.py +18 -0
- svc_infra/api/fastapi/db/sql/users.py +18 -6
- svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
- svc_infra/api/fastapi/docs/add.py +173 -0
- svc_infra/api/fastapi/docs/landing.py +4 -2
- svc_infra/api/fastapi/docs/scoped.py +62 -15
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +126 -4
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +40 -13
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +10 -2
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +3 -1
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
- svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
- svc_infra/api/fastapi/middleware/idempotency.py +197 -70
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
- svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
- svc_infra/api/fastapi/middleware/timeout.py +177 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -3
- svc_infra/api/fastapi/openapi/conventions.py +9 -2
- svc_infra/api/fastapi/openapi/mutators.py +165 -20
- svc_infra/api/fastapi/openapi/pipeline.py +1 -1
- svc_infra/api/fastapi/openapi/security.py +3 -1
- svc_infra/api/fastapi/ops/add.py +75 -0
- svc_infra/api/fastapi/pagination.py +47 -20
- svc_infra/api/fastapi/routers/__init__.py +43 -15
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +188 -57
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/app/__init__.py +3 -1
- svc_infra/app/env.py +69 -1
- svc_infra/app/logging/add.py +9 -2
- svc_infra/app/logging/formats.py +12 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +241 -0
- svc_infra/billing/models.py +177 -0
- svc_infra/billing/quotas.py +103 -0
- svc_infra/billing/schemas.py +36 -0
- svc_infra/billing/service.py +123 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +9 -0
- svc_infra/cache/add.py +170 -0
- svc_infra/cache/backend.py +7 -6
- svc_infra/cache/decorators.py +81 -15
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +24 -4
- svc_infra/cache/recache.py +26 -14
- svc_infra/cache/resources.py +14 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/utils.py +3 -1
- svc_infra/cli/__init__.py +52 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +39 -2
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
- svc_infra/cli/cmds/db/ops_cmds.py +270 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
- svc_infra/cli/cmds/health/__init__.py +179 -0
- svc_infra/cli/cmds/health/health_cmds.py +8 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
- svc_infra/cli/foundation/runner.py +6 -2
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +58 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +61 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +9 -9
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +3 -0
- svc_infra/db/nosql/core.py +30 -9
- svc_infra/db/nosql/indexes.py +3 -1
- svc_infra/db/nosql/management.py +1 -1
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +19 -2
- svc_infra/db/nosql/mongo/settings.py +6 -2
- svc_infra/db/nosql/repository.py +35 -15
- svc_infra/db/nosql/resource.py +20 -3
- svc_infra/db/nosql/scaffold.py +9 -3
- svc_infra/db/nosql/service.py +3 -1
- svc_infra/db/nosql/types.py +6 -2
- svc_infra/db/ops.py +384 -0
- svc_infra/db/outbox.py +108 -0
- svc_infra/db/sql/apikey.py +37 -9
- svc_infra/db/sql/authref.py +9 -3
- svc_infra/db/sql/constants.py +12 -8
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/management.py +11 -8
- svc_infra/db/sql/repository.py +99 -26
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +6 -2
- svc_infra/db/sql/service.py +15 -5
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
- svc_infra/db/sql/tenant.py +88 -0
- svc_infra/db/sql/uniq_hooks.py +9 -3
- svc_infra/db/sql/utils.py +138 -51
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/deploy/__init__.py +538 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +264 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +264 -0
- svc_infra/dx/add.py +65 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +68 -0
- svc_infra/exceptions.py +141 -0
- svc_infra/health/__init__.py +864 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +105 -0
- svc_infra/jobs/builtins/outbox_processor.py +40 -0
- svc_infra/jobs/builtins/webhook_delivery.py +95 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +50 -0
- svc_infra/jobs/queue.py +116 -0
- svc_infra/jobs/redis_queue.py +256 -0
- svc_infra/jobs/runner.py +79 -0
- svc_infra/jobs/scheduler.py +53 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/loaders/__init__.py +186 -0
- svc_infra/loaders/base.py +142 -0
- svc_infra/loaders/github.py +311 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +235 -0
- svc_infra/logging/__init__.py +374 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +65 -9
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +3 -4
- svc_infra/obs/metrics/asgi.py +13 -7
- svc_infra/obs/metrics/http.py +9 -5
- svc_infra/obs/metrics/sqlalchemy.py +13 -9
- svc_infra/obs/metrics.py +6 -5
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +92 -10
- svc_infra/security/audit_service.py +4 -3
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +14 -4
- svc_infra/security/jwt_rotation.py +74 -22
- svc_infra/security/lockout.py +11 -5
- svc_infra/security/models.py +54 -12
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +5 -3
- svc_infra/security/passwords.py +3 -1
- svc_infra/security/permissions.py +25 -2
- svc_infra/security/session.py +1 -1
- svc_infra/security/signed_cookies.py +21 -1
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +253 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +339 -0
- svc_infra/storage/backends/memory.py +216 -0
- svc_infra/storage/backends/s3.py +353 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +185 -0
- svc_infra/storage/settings.py +195 -0
- svc_infra/testing/__init__.py +685 -0
- svc_infra/utils.py +7 -3
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +339 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +39 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +70 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +140 -0
- svc_infra/websocket/client.py +282 -0
- svc_infra/websocket/config.py +69 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +344 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-0.1.706.dist-info/LICENSE +21 -0
- svc_infra-0.1.706.dist-info/METADATA +356 -0
- svc_infra-0.1.706.dist-info/RECORD +357 -0
- svc_infra-0.1.595.dist-info/METADATA +0 -80
- svc_infra-0.1.595.dist-info/RECORD +0 -253
- {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
from svc_infra.api.fastapi.db.sql.add import
|
|
1
|
+
from svc_infra.api.fastapi.db.sql.add import (
|
|
2
|
+
add_sql_db,
|
|
3
|
+
add_sql_health,
|
|
4
|
+
add_sql_resources,
|
|
5
|
+
)
|
|
2
6
|
from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
|
|
3
7
|
|
|
4
8
|
__all__ = [
|
|
@@ -10,14 +10,16 @@ from svc_infra.db.sql.management import make_crud_schemas
|
|
|
10
10
|
from svc_infra.db.sql.repository import SqlRepository
|
|
11
11
|
from svc_infra.db.sql.resource import SqlResource
|
|
12
12
|
|
|
13
|
-
from .crud_router import make_crud_router_plus_sql
|
|
13
|
+
from .crud_router import make_crud_router_plus_sql, make_tenant_crud_router_plus_sql
|
|
14
14
|
from .health import _make_db_health_router
|
|
15
15
|
from .session import dispose_session, initialize_session
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def add_sql_resources(app: FastAPI, resources: Sequence[SqlResource]) -> None:
|
|
19
19
|
for r in resources:
|
|
20
|
-
repo = SqlRepository(
|
|
20
|
+
repo = SqlRepository(
|
|
21
|
+
model=r.model, id_attr=r.id_attr, soft_delete=r.soft_delete
|
|
22
|
+
)
|
|
21
23
|
|
|
22
24
|
if r.service_factory:
|
|
23
25
|
svc = r.service_factory(repo)
|
|
@@ -37,52 +39,95 @@ def add_sql_resources(app: FastAPI, resources: Sequence[SqlResource]) -> None:
|
|
|
37
39
|
update_name=r.update_name,
|
|
38
40
|
)
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
42
|
+
if r.tenant_field:
|
|
43
|
+
# wrap service factory/instance through tenant router
|
|
44
|
+
def _factory():
|
|
45
|
+
return svc
|
|
46
|
+
|
|
47
|
+
router = make_tenant_crud_router_plus_sql(
|
|
48
|
+
model=r.model,
|
|
49
|
+
service_factory=_factory,
|
|
50
|
+
read_schema=Read,
|
|
51
|
+
create_schema=Create,
|
|
52
|
+
update_schema=Update,
|
|
53
|
+
prefix=r.prefix,
|
|
54
|
+
tenant_field=r.tenant_field,
|
|
55
|
+
tags=r.tags,
|
|
56
|
+
search_fields=r.search_fields,
|
|
57
|
+
default_ordering=r.ordering_default,
|
|
58
|
+
allowed_order_fields=r.allowed_order_fields,
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
router = make_crud_router_plus_sql(
|
|
62
|
+
model=r.model,
|
|
63
|
+
service=svc,
|
|
64
|
+
read_schema=Read,
|
|
65
|
+
create_schema=Create,
|
|
66
|
+
update_schema=Update,
|
|
67
|
+
prefix=r.prefix,
|
|
68
|
+
tags=r.tags,
|
|
69
|
+
search_fields=r.search_fields,
|
|
70
|
+
default_ordering=r.ordering_default,
|
|
71
|
+
allowed_order_fields=r.allowed_order_fields,
|
|
72
|
+
)
|
|
52
73
|
app.include_router(router)
|
|
53
74
|
|
|
54
75
|
|
|
55
|
-
def add_sql_db(
|
|
56
|
-
|
|
76
|
+
def add_sql_db(
|
|
77
|
+
app: FastAPI, *, url: Optional[str] = None, dsn_env: str = "SQL_URL"
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Configure DB lifecycle for the app (either explicit URL or from env).
|
|
80
|
+
|
|
81
|
+
This preserves any existing lifespan context (like user-defined lifespans)
|
|
82
|
+
and wraps it with the database session initialization/cleanup.
|
|
83
|
+
"""
|
|
84
|
+
# Preserve existing lifespan to wrap it
|
|
85
|
+
existing_lifespan = getattr(app.router, "lifespan_context", None)
|
|
86
|
+
|
|
57
87
|
if url:
|
|
58
88
|
|
|
59
89
|
@asynccontextmanager
|
|
60
|
-
async def
|
|
90
|
+
async def lifespan_with_url(_app: FastAPI):
|
|
61
91
|
initialize_session(url)
|
|
62
92
|
try:
|
|
63
|
-
|
|
93
|
+
if existing_lifespan is not None:
|
|
94
|
+
async with existing_lifespan(_app):
|
|
95
|
+
yield
|
|
96
|
+
else:
|
|
97
|
+
yield
|
|
64
98
|
finally:
|
|
65
99
|
await dispose_session()
|
|
66
100
|
|
|
67
|
-
app.router.lifespan_context =
|
|
101
|
+
app.router.lifespan_context = lifespan_with_url
|
|
68
102
|
return
|
|
69
103
|
|
|
70
|
-
|
|
71
|
-
|
|
104
|
+
# Use lifespan context manager instead of deprecated on_event
|
|
105
|
+
@asynccontextmanager
|
|
106
|
+
async def lifespan_from_env(_app: FastAPI):
|
|
72
107
|
env_url = os.getenv(dsn_env)
|
|
73
108
|
if not env_url:
|
|
74
|
-
raise RuntimeError(
|
|
109
|
+
raise RuntimeError(
|
|
110
|
+
f"Missing environment variable {dsn_env} for database URL"
|
|
111
|
+
)
|
|
75
112
|
initialize_session(env_url)
|
|
113
|
+
try:
|
|
114
|
+
if existing_lifespan is not None:
|
|
115
|
+
async with existing_lifespan(_app):
|
|
116
|
+
yield
|
|
117
|
+
else:
|
|
118
|
+
yield
|
|
119
|
+
finally:
|
|
120
|
+
await dispose_session()
|
|
76
121
|
|
|
77
|
-
|
|
78
|
-
async def _shutdown() -> None: # noqa: ANN202
|
|
79
|
-
await dispose_session()
|
|
122
|
+
app.router.lifespan_context = lifespan_from_env
|
|
80
123
|
|
|
81
124
|
|
|
82
125
|
def add_sql_health(
|
|
83
126
|
app: FastAPI, *, prefix: str = "/_sql/health", include_in_schema: bool = False
|
|
84
127
|
) -> None:
|
|
85
|
-
app.include_router(
|
|
128
|
+
app.include_router(
|
|
129
|
+
_make_db_health_router(prefix=prefix, include_in_schema=include_in_schema)
|
|
130
|
+
)
|
|
86
131
|
|
|
87
132
|
|
|
88
133
|
def setup_sql(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import Annotated, Any, Optional, Sequence, Type, TypeVar, cast
|
|
1
|
+
from typing import Annotated, Any, Callable, Optional, Sequence, Type, TypeVar, cast
|
|
2
2
|
|
|
3
3
|
from fastapi import APIRouter, Body, Depends, HTTPException
|
|
4
4
|
from pydantic import BaseModel
|
|
@@ -15,7 +15,9 @@ from svc_infra.api.fastapi.db.http import (
|
|
|
15
15
|
)
|
|
16
16
|
from svc_infra.api.fastapi.dual.public import public_router
|
|
17
17
|
from svc_infra.db.sql.service import SqlService
|
|
18
|
+
from svc_infra.db.sql.tenant import TenantSqlService
|
|
18
19
|
|
|
20
|
+
from ...tenancy.context import TenantId
|
|
19
21
|
from .session import SqlSessionDep
|
|
20
22
|
|
|
21
23
|
CreateModel = TypeVar("CreateModel", bound=BaseModel)
|
|
@@ -44,6 +46,18 @@ def make_crud_router_plus_sql(
|
|
|
44
46
|
redirect_slashes=False,
|
|
45
47
|
)
|
|
46
48
|
|
|
49
|
+
def _coerce_id(v: Any) -> Any:
|
|
50
|
+
"""Best-effort coercion of path ids: cast digit-only strings to int.
|
|
51
|
+
|
|
52
|
+
Keeps original type otherwise to avoid breaking non-integer IDs.
|
|
53
|
+
"""
|
|
54
|
+
if isinstance(v, str) and v.isdigit():
|
|
55
|
+
try:
|
|
56
|
+
return int(v)
|
|
57
|
+
except Exception:
|
|
58
|
+
return v
|
|
59
|
+
return v
|
|
60
|
+
|
|
47
61
|
def _parse_ordering_to_fields(order_spec: Optional[str]) -> list[str]:
|
|
48
62
|
if not order_spec:
|
|
49
63
|
return []
|
|
@@ -59,14 +73,14 @@ def make_crud_router_plus_sql(
|
|
|
59
73
|
# -------- LIST --------
|
|
60
74
|
@router.get(
|
|
61
75
|
"",
|
|
62
|
-
response_model=
|
|
76
|
+
response_model=Page[read_schema], # type: ignore[valid-type]
|
|
63
77
|
description=f"List items of type {model.__name__}",
|
|
64
78
|
)
|
|
65
79
|
async def list_items(
|
|
66
80
|
lp: Annotated[LimitOffsetParams, Depends(dep_limit_offset)],
|
|
67
81
|
op: Annotated[OrderParams, Depends(dep_order)],
|
|
68
82
|
sp: Annotated[SearchParams, Depends(dep_search)],
|
|
69
|
-
session: SqlSessionDep,
|
|
83
|
+
session: SqlSessionDep,
|
|
70
84
|
):
|
|
71
85
|
order_spec = op.order_by or default_ordering
|
|
72
86
|
order_fields = _parse_ordering_to_fields(order_spec)
|
|
@@ -79,24 +93,31 @@ def make_crud_router_plus_sql(
|
|
|
79
93
|
if f.strip()
|
|
80
94
|
]
|
|
81
95
|
items = await service.search(
|
|
82
|
-
session,
|
|
96
|
+
session,
|
|
97
|
+
q=sp.q,
|
|
98
|
+
fields=fields,
|
|
99
|
+
limit=lp.limit,
|
|
100
|
+
offset=lp.offset,
|
|
101
|
+
order_by=order_by,
|
|
83
102
|
)
|
|
84
103
|
total = await service.count_filtered(session, q=sp.q, fields=fields)
|
|
85
104
|
else:
|
|
86
|
-
items = await service.list(
|
|
105
|
+
items = await service.list(
|
|
106
|
+
session, limit=lp.limit, offset=lp.offset, order_by=order_by
|
|
107
|
+
)
|
|
87
108
|
total = await service.count(session)
|
|
88
|
-
return Page[
|
|
109
|
+
return Page[Any].from_items(
|
|
89
110
|
total=total, items=items, limit=lp.limit, offset=lp.offset
|
|
90
111
|
)
|
|
91
112
|
|
|
92
113
|
# -------- GET by id --------
|
|
93
114
|
@router.get(
|
|
94
115
|
"/{item_id}",
|
|
95
|
-
response_model=
|
|
116
|
+
response_model=read_schema,
|
|
96
117
|
description=f"Get item of type {model.__name__}",
|
|
97
118
|
)
|
|
98
|
-
async def get_item(item_id: Any, session: SqlSessionDep):
|
|
99
|
-
row = await service.get(session, item_id)
|
|
119
|
+
async def get_item(item_id: Any, session: SqlSessionDep):
|
|
120
|
+
row = await service.get(session, _coerce_id(item_id))
|
|
100
121
|
if not row:
|
|
101
122
|
raise HTTPException(404, "Not found")
|
|
102
123
|
return row
|
|
@@ -104,40 +125,207 @@ def make_crud_router_plus_sql(
|
|
|
104
125
|
# -------- CREATE --------
|
|
105
126
|
@router.post(
|
|
106
127
|
"",
|
|
107
|
-
response_model=
|
|
128
|
+
response_model=read_schema,
|
|
108
129
|
status_code=201,
|
|
109
130
|
description=f"Create item of type {model.__name__}",
|
|
110
131
|
)
|
|
111
132
|
async def create_item(
|
|
112
|
-
session: SqlSessionDep,
|
|
113
|
-
payload: create_schema = Body(...),
|
|
133
|
+
session: SqlSessionDep,
|
|
134
|
+
payload: create_schema = Body(...), # type: ignore[valid-type]
|
|
114
135
|
):
|
|
115
|
-
|
|
136
|
+
if isinstance(payload, BaseModel):
|
|
137
|
+
data = cast(BaseModel, payload).model_dump(exclude_unset=True)
|
|
138
|
+
elif isinstance(payload, dict):
|
|
139
|
+
data = payload
|
|
140
|
+
else:
|
|
141
|
+
raise HTTPException(422, "invalid_payload")
|
|
116
142
|
return await service.create(session, data)
|
|
117
143
|
|
|
118
144
|
# -------- UPDATE --------
|
|
119
145
|
@router.patch(
|
|
120
146
|
"/{item_id}",
|
|
121
|
-
response_model=
|
|
147
|
+
response_model=read_schema,
|
|
122
148
|
description=f"Update item of type {model.__name__}",
|
|
123
149
|
)
|
|
124
150
|
async def update_item(
|
|
125
151
|
item_id: Any,
|
|
126
|
-
session: SqlSessionDep,
|
|
127
|
-
payload: update_schema = Body(...),
|
|
152
|
+
session: SqlSessionDep,
|
|
153
|
+
payload: update_schema = Body(...), # type: ignore[valid-type]
|
|
128
154
|
):
|
|
129
|
-
|
|
130
|
-
|
|
155
|
+
if isinstance(payload, BaseModel):
|
|
156
|
+
data = cast(BaseModel, payload).model_dump(exclude_unset=True)
|
|
157
|
+
elif isinstance(payload, dict):
|
|
158
|
+
data = payload
|
|
159
|
+
else:
|
|
160
|
+
raise HTTPException(422, "invalid_payload")
|
|
161
|
+
row = await service.update(session, _coerce_id(item_id), data)
|
|
131
162
|
if not row:
|
|
132
163
|
raise HTTPException(404, "Not found")
|
|
133
164
|
return row
|
|
134
165
|
|
|
135
166
|
# -------- DELETE --------
|
|
136
167
|
@router.delete(
|
|
137
|
-
"/{item_id}",
|
|
168
|
+
"/{item_id}",
|
|
169
|
+
status_code=204,
|
|
170
|
+
description=f"Delete item of type {model.__name__}",
|
|
171
|
+
)
|
|
172
|
+
async def delete_item(item_id: Any, session: SqlSessionDep):
|
|
173
|
+
ok = await service.delete(session, _coerce_id(item_id))
|
|
174
|
+
if not ok:
|
|
175
|
+
raise HTTPException(404, "Not found")
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
return router
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def make_tenant_crud_router_plus_sql(
|
|
182
|
+
*,
|
|
183
|
+
model: type[Any],
|
|
184
|
+
service_factory: Callable[
|
|
185
|
+
[], Any
|
|
186
|
+
], # factory that returns a SqlService (will be wrapped)
|
|
187
|
+
read_schema: Type[ReadModel],
|
|
188
|
+
create_schema: Type[CreateModel],
|
|
189
|
+
update_schema: Type[UpdateModel],
|
|
190
|
+
prefix: str,
|
|
191
|
+
tenant_field: str = "tenant_id",
|
|
192
|
+
tags: list[str] | None = None,
|
|
193
|
+
search_fields: Optional[Sequence[str]] = None,
|
|
194
|
+
default_ordering: Optional[str] = None,
|
|
195
|
+
allowed_order_fields: Optional[list[str]] = None,
|
|
196
|
+
mount_under_db_prefix: bool = True,
|
|
197
|
+
) -> APIRouter:
|
|
198
|
+
"""Like make_crud_router_plus_sql, but requires TenantId and scopes all operations."""
|
|
199
|
+
router_prefix = ("/_sql" + prefix) if mount_under_db_prefix else prefix
|
|
200
|
+
router = public_router(
|
|
201
|
+
prefix=router_prefix,
|
|
202
|
+
tags=tags or [prefix.strip("/")],
|
|
203
|
+
redirect_slashes=False,
|
|
138
204
|
)
|
|
139
|
-
|
|
140
|
-
|
|
205
|
+
|
|
206
|
+
# Evaluate the base service once to preserve in-memory state across requests in tests/local.
|
|
207
|
+
# Consumers may pass either an instance or a zero-arg factory function.
|
|
208
|
+
try:
|
|
209
|
+
_base_instance = (
|
|
210
|
+
service_factory() if callable(service_factory) else service_factory
|
|
211
|
+
)
|
|
212
|
+
except TypeError:
|
|
213
|
+
# If the callable requires args, assume it's already an instance
|
|
214
|
+
_base_instance = service_factory
|
|
215
|
+
|
|
216
|
+
def _coerce_id(v: Any) -> Any:
|
|
217
|
+
"""Best-effort coercion of path ids: cast digit-only strings to int.
|
|
218
|
+
Keeps original type otherwise.
|
|
219
|
+
"""
|
|
220
|
+
if isinstance(v, str) and v.isdigit():
|
|
221
|
+
try:
|
|
222
|
+
return int(v)
|
|
223
|
+
except Exception:
|
|
224
|
+
return v
|
|
225
|
+
return v
|
|
226
|
+
|
|
227
|
+
def _parse_ordering_to_fields(order_spec: Optional[str]) -> list[str]:
|
|
228
|
+
if not order_spec:
|
|
229
|
+
return []
|
|
230
|
+
pieces = [p.strip() for p in order_spec.split(",") if p.strip()]
|
|
231
|
+
fields: list[str] = []
|
|
232
|
+
for p in pieces:
|
|
233
|
+
name = p[1:] if p.startswith("-") else p
|
|
234
|
+
if allowed_order_fields and name not in (allowed_order_fields or []):
|
|
235
|
+
continue
|
|
236
|
+
fields.append(p)
|
|
237
|
+
return fields
|
|
238
|
+
|
|
239
|
+
# create per-request service with tenant scoping
|
|
240
|
+
async def _svc(session: SqlSessionDep, tenant_id: TenantId):
|
|
241
|
+
repo_or_service = getattr(_base_instance, "repo", _base_instance)
|
|
242
|
+
svc: Any = TenantSqlService(
|
|
243
|
+
repo_or_service, tenant_id=tenant_id, tenant_field=tenant_field
|
|
244
|
+
)
|
|
245
|
+
return svc
|
|
246
|
+
|
|
247
|
+
@router.get("", response_model=Page[read_schema]) # type: ignore[valid-type]
|
|
248
|
+
async def list_items(
|
|
249
|
+
lp: Annotated[LimitOffsetParams, Depends(dep_limit_offset)],
|
|
250
|
+
op: Annotated[OrderParams, Depends(dep_order)],
|
|
251
|
+
sp: Annotated[SearchParams, Depends(dep_search)],
|
|
252
|
+
session: SqlSessionDep,
|
|
253
|
+
tenant_id: TenantId,
|
|
254
|
+
):
|
|
255
|
+
svc = await _svc(session, tenant_id)
|
|
256
|
+
order_spec = op.order_by or default_ordering
|
|
257
|
+
order_fields = _parse_ordering_to_fields(order_spec)
|
|
258
|
+
order_by = build_order_by(model, order_fields)
|
|
259
|
+
if sp.q:
|
|
260
|
+
fields = [
|
|
261
|
+
f.strip()
|
|
262
|
+
for f in (sp.fields or (",".join(search_fields or []) or "")).split(",")
|
|
263
|
+
if f.strip()
|
|
264
|
+
]
|
|
265
|
+
items = await svc.search(
|
|
266
|
+
session,
|
|
267
|
+
q=sp.q,
|
|
268
|
+
fields=fields,
|
|
269
|
+
limit=lp.limit,
|
|
270
|
+
offset=lp.offset,
|
|
271
|
+
order_by=order_by,
|
|
272
|
+
)
|
|
273
|
+
total = await svc.count_filtered(session, q=sp.q, fields=fields)
|
|
274
|
+
else:
|
|
275
|
+
items = await svc.list(
|
|
276
|
+
session, limit=lp.limit, offset=lp.offset, order_by=order_by
|
|
277
|
+
)
|
|
278
|
+
total = await svc.count(session)
|
|
279
|
+
return Page[Any].from_items(
|
|
280
|
+
total=total, items=items, limit=lp.limit, offset=lp.offset
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
@router.get("/{item_id}", response_model=read_schema)
|
|
284
|
+
async def get_item(item_id: Any, session: SqlSessionDep, tenant_id: TenantId):
|
|
285
|
+
svc = await _svc(session, tenant_id)
|
|
286
|
+
obj = await svc.get(session, item_id)
|
|
287
|
+
if not obj:
|
|
288
|
+
raise HTTPException(404, "not_found")
|
|
289
|
+
return obj
|
|
290
|
+
|
|
291
|
+
@router.post("", response_model=read_schema, status_code=201)
|
|
292
|
+
async def create_item(
|
|
293
|
+
session: SqlSessionDep,
|
|
294
|
+
tenant_id: TenantId,
|
|
295
|
+
payload: create_schema = Body(...), # type: ignore[valid-type]
|
|
296
|
+
):
|
|
297
|
+
svc = await _svc(session, tenant_id)
|
|
298
|
+
if isinstance(payload, BaseModel):
|
|
299
|
+
data = cast(BaseModel, payload).model_dump(exclude_unset=True)
|
|
300
|
+
elif isinstance(payload, dict):
|
|
301
|
+
data = payload
|
|
302
|
+
else:
|
|
303
|
+
raise HTTPException(422, "invalid_payload")
|
|
304
|
+
return await svc.create(session, data)
|
|
305
|
+
|
|
306
|
+
@router.patch("/{item_id}", response_model=read_schema)
|
|
307
|
+
async def update_item(
|
|
308
|
+
item_id: Any,
|
|
309
|
+
session: SqlSessionDep,
|
|
310
|
+
tenant_id: TenantId,
|
|
311
|
+
payload: update_schema = Body(...), # type: ignore[valid-type]
|
|
312
|
+
):
|
|
313
|
+
svc = await _svc(session, tenant_id)
|
|
314
|
+
if isinstance(payload, BaseModel):
|
|
315
|
+
data = cast(BaseModel, payload).model_dump(exclude_unset=True)
|
|
316
|
+
elif isinstance(payload, dict):
|
|
317
|
+
data = payload
|
|
318
|
+
else:
|
|
319
|
+
raise HTTPException(422, "invalid_payload")
|
|
320
|
+
updated = await svc.update(session, item_id, data)
|
|
321
|
+
if not updated:
|
|
322
|
+
raise HTTPException(404, "not_found")
|
|
323
|
+
return updated
|
|
324
|
+
|
|
325
|
+
@router.delete("/{item_id}", status_code=204)
|
|
326
|
+
async def delete_item(item_id: Any, session: SqlSessionDep, tenant_id: TenantId):
|
|
327
|
+
svc = await _svc(session, tenant_id)
|
|
328
|
+
ok = await svc.delete(session, _coerce_id(item_id))
|
|
141
329
|
if not ok:
|
|
142
330
|
raise HTTPException(404, "Not found")
|
|
143
331
|
return
|
|
@@ -145,4 +333,4 @@ def make_crud_router_plus_sql(
|
|
|
145
333
|
return router
|
|
146
334
|
|
|
147
335
|
|
|
148
|
-
__all__ = ["make_crud_router_plus_sql"]
|
|
336
|
+
__all__ = ["make_crud_router_plus_sql", "make_tenant_crud_router_plus_sql"]
|
|
@@ -14,7 +14,9 @@ def _make_db_health_router(
|
|
|
14
14
|
include_in_schema: bool = False,
|
|
15
15
|
) -> APIRouter:
|
|
16
16
|
"""Internal factory for the DB health router."""
|
|
17
|
-
router = public_router(
|
|
17
|
+
router = public_router(
|
|
18
|
+
prefix=prefix, tags=["health"], include_in_schema=include_in_schema
|
|
19
|
+
)
|
|
18
20
|
|
|
19
21
|
@router.get("", status_code=status.HTTP_200_OK)
|
|
20
22
|
async def db_health(session: SqlSessionDep) -> Response:
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
+
import os
|
|
4
5
|
from typing import Annotated, AsyncIterator, Tuple
|
|
5
6
|
|
|
6
7
|
from fastapi import Depends
|
|
8
|
+
from sqlalchemy import text
|
|
7
9
|
from sqlalchemy.ext.asyncio import (
|
|
8
10
|
AsyncEngine,
|
|
9
11
|
AsyncSession,
|
|
@@ -53,6 +55,22 @@ async def get_session() -> AsyncIterator[AsyncSession]:
|
|
|
53
55
|
if _SessionLocal is None:
|
|
54
56
|
raise RuntimeError("Database not initialized. Call add_sql_db(app, ...) first.")
|
|
55
57
|
async with _SessionLocal() as session:
|
|
58
|
+
# Optional: set a per-transaction statement timeout for Postgres if configured
|
|
59
|
+
raw_ms = os.getenv("DB_STATEMENT_TIMEOUT_MS")
|
|
60
|
+
if raw_ms:
|
|
61
|
+
try:
|
|
62
|
+
ms = int(raw_ms)
|
|
63
|
+
if ms > 0:
|
|
64
|
+
try:
|
|
65
|
+
# SET LOCAL applies for the duration of the current transaction only
|
|
66
|
+
await session.execute(
|
|
67
|
+
text("SET LOCAL statement_timeout = :ms"), {"ms": ms}
|
|
68
|
+
)
|
|
69
|
+
except Exception:
|
|
70
|
+
# Non-PG dialects (e.g., SQLite) will error; ignore silently
|
|
71
|
+
pass
|
|
72
|
+
except ValueError:
|
|
73
|
+
pass
|
|
56
74
|
try:
|
|
57
75
|
yield session
|
|
58
76
|
await session.commit()
|
|
@@ -5,13 +5,17 @@ from uuid import UUID
|
|
|
5
5
|
|
|
6
6
|
from fastapi import Depends
|
|
7
7
|
from fastapi_users import FastAPIUsers
|
|
8
|
-
from fastapi_users.authentication import
|
|
8
|
+
from fastapi_users.authentication import (
|
|
9
|
+
AuthenticationBackend,
|
|
10
|
+
BearerTransport,
|
|
11
|
+
JWTStrategy,
|
|
12
|
+
)
|
|
9
13
|
from fastapi_users.manager import BaseUserManager, UUIDIDMixin
|
|
10
14
|
|
|
11
15
|
from svc_infra.api.fastapi.auth.settings import get_auth_settings
|
|
12
16
|
from svc_infra.api.fastapi.dual.dualize import dualize_public, dualize_user
|
|
13
17
|
from svc_infra.api.fastapi.dual.router import DualAPIRouter
|
|
14
|
-
from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV
|
|
18
|
+
from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV, require_secret
|
|
15
19
|
from svc_infra.security.jwt_rotation import RotatingJWTStrategy
|
|
16
20
|
|
|
17
21
|
from ...auth.security import auth_login_path
|
|
@@ -47,7 +51,9 @@ def get_fastapi_users(
|
|
|
47
51
|
|
|
48
52
|
async def on_after_register(self, user: Any, request=None):
|
|
49
53
|
st = get_auth_settings()
|
|
50
|
-
if CURRENT_ENVIRONMENT in (DEV_ENV, LOCAL_ENV) and bool(
|
|
54
|
+
if CURRENT_ENVIRONMENT in (DEV_ENV, LOCAL_ENV) and bool(
|
|
55
|
+
st.auto_verify_in_dev
|
|
56
|
+
):
|
|
51
57
|
await self.user_db.update(user, {"is_verified": True})
|
|
52
58
|
return
|
|
53
59
|
await self.request_verify(user, request)
|
|
@@ -91,14 +97,18 @@ def get_fastapi_users(
|
|
|
91
97
|
if jwt_block and getattr(jwt_block, "secret", None):
|
|
92
98
|
secret = jwt_block.secret.get_secret_value()
|
|
93
99
|
else:
|
|
94
|
-
secret =
|
|
100
|
+
secret = require_secret(
|
|
101
|
+
None,
|
|
102
|
+
"JWT_SECRET (via auth settings jwt.secret)",
|
|
103
|
+
dev_default="dev-only-jwt-secret-not-for-production",
|
|
104
|
+
)
|
|
95
105
|
lifetime = getattr(jwt_block, "lifetime_seconds", None) if jwt_block else None
|
|
96
106
|
if not isinstance(lifetime, int) or lifetime <= 0:
|
|
97
107
|
lifetime = 3600
|
|
98
108
|
old = []
|
|
99
109
|
if jwt_block and getattr(jwt_block, "old_secrets", None):
|
|
100
110
|
old = [s.get_secret_value() for s in jwt_block.old_secrets or []]
|
|
101
|
-
audience = "fastapi-users:auth"
|
|
111
|
+
audience = ["fastapi-users:auth"]
|
|
102
112
|
if old:
|
|
103
113
|
return RotatingJWTStrategy(
|
|
104
114
|
secret=secret,
|
|
@@ -106,7 +116,9 @@ def get_fastapi_users(
|
|
|
106
116
|
old_secrets=old,
|
|
107
117
|
token_audience=audience,
|
|
108
118
|
)
|
|
109
|
-
return JWTStrategy(
|
|
119
|
+
return JWTStrategy(
|
|
120
|
+
secret=secret, lifetime_seconds=lifetime, token_audience=audience
|
|
121
|
+
)
|
|
110
122
|
|
|
111
123
|
bearer_transport = BearerTransport(tokenUrl=auth_login_path)
|
|
112
124
|
auth_backend = AuthenticationBackend(
|