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
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
from
|
|
1
|
+
from collections.abc import Callable, Sequence
|
|
2
|
+
from typing import Annotated, Any, TypeVar, cast
|
|
2
3
|
|
|
3
4
|
from fastapi import APIRouter, Body, Depends, HTTPException
|
|
4
5
|
from pydantic import BaseModel
|
|
@@ -15,7 +16,9 @@ from svc_infra.api.fastapi.db.http import (
|
|
|
15
16
|
)
|
|
16
17
|
from svc_infra.api.fastapi.dual.public import public_router
|
|
17
18
|
from svc_infra.db.sql.service import SqlService
|
|
19
|
+
from svc_infra.db.sql.tenant import TenantSqlService
|
|
18
20
|
|
|
21
|
+
from ...tenancy.context import TenantId
|
|
19
22
|
from .session import SqlSessionDep
|
|
20
23
|
|
|
21
24
|
CreateModel = TypeVar("CreateModel", bound=BaseModel)
|
|
@@ -27,14 +30,14 @@ def make_crud_router_plus_sql(
|
|
|
27
30
|
*,
|
|
28
31
|
model: type[Any],
|
|
29
32
|
service: SqlService,
|
|
30
|
-
read_schema:
|
|
31
|
-
create_schema:
|
|
32
|
-
update_schema:
|
|
33
|
+
read_schema: type[ReadModel],
|
|
34
|
+
create_schema: type[CreateModel],
|
|
35
|
+
update_schema: type[UpdateModel],
|
|
33
36
|
prefix: str,
|
|
34
37
|
tags: list[str] | None = None,
|
|
35
|
-
search_fields:
|
|
36
|
-
default_ordering:
|
|
37
|
-
allowed_order_fields:
|
|
38
|
+
search_fields: Sequence[str] | None = None,
|
|
39
|
+
default_ordering: str | None = None,
|
|
40
|
+
allowed_order_fields: list[str] | None = None,
|
|
38
41
|
mount_under_db_prefix: bool = True,
|
|
39
42
|
) -> APIRouter:
|
|
40
43
|
router_prefix = ("/_sql" + prefix) if mount_under_db_prefix else prefix
|
|
@@ -44,7 +47,19 @@ def make_crud_router_plus_sql(
|
|
|
44
47
|
redirect_slashes=False,
|
|
45
48
|
)
|
|
46
49
|
|
|
47
|
-
def
|
|
50
|
+
def _coerce_id(v: Any) -> Any:
|
|
51
|
+
"""Best-effort coercion of path ids: cast digit-only strings to int.
|
|
52
|
+
|
|
53
|
+
Keeps original type otherwise to avoid breaking non-integer IDs.
|
|
54
|
+
"""
|
|
55
|
+
if isinstance(v, str) and v.isdigit():
|
|
56
|
+
try:
|
|
57
|
+
return int(v)
|
|
58
|
+
except Exception:
|
|
59
|
+
return v
|
|
60
|
+
return v
|
|
61
|
+
|
|
62
|
+
def _parse_ordering_to_fields(order_spec: str | None) -> list[str]:
|
|
48
63
|
if not order_spec:
|
|
49
64
|
return []
|
|
50
65
|
pieces = [p.strip() for p in order_spec.split(",") if p.strip()]
|
|
@@ -59,14 +74,14 @@ def make_crud_router_plus_sql(
|
|
|
59
74
|
# -------- LIST --------
|
|
60
75
|
@router.get(
|
|
61
76
|
"",
|
|
62
|
-
response_model=
|
|
77
|
+
response_model=Page[read_schema], # type: ignore[valid-type]
|
|
63
78
|
description=f"List items of type {model.__name__}",
|
|
64
79
|
)
|
|
65
80
|
async def list_items(
|
|
66
81
|
lp: Annotated[LimitOffsetParams, Depends(dep_limit_offset)],
|
|
67
82
|
op: Annotated[OrderParams, Depends(dep_order)],
|
|
68
83
|
sp: Annotated[SearchParams, Depends(dep_search)],
|
|
69
|
-
session: SqlSessionDep,
|
|
84
|
+
session: SqlSessionDep,
|
|
70
85
|
):
|
|
71
86
|
order_spec = op.order_by or default_ordering
|
|
72
87
|
order_fields = _parse_ordering_to_fields(order_spec)
|
|
@@ -79,24 +94,27 @@ def make_crud_router_plus_sql(
|
|
|
79
94
|
if f.strip()
|
|
80
95
|
]
|
|
81
96
|
items = await service.search(
|
|
82
|
-
session,
|
|
97
|
+
session,
|
|
98
|
+
q=sp.q,
|
|
99
|
+
fields=fields,
|
|
100
|
+
limit=lp.limit,
|
|
101
|
+
offset=lp.offset,
|
|
102
|
+
order_by=order_by,
|
|
83
103
|
)
|
|
84
104
|
total = await service.count_filtered(session, q=sp.q, fields=fields)
|
|
85
105
|
else:
|
|
86
106
|
items = await service.list(session, limit=lp.limit, offset=lp.offset, order_by=order_by)
|
|
87
107
|
total = await service.count(session)
|
|
88
|
-
return Page[
|
|
89
|
-
total=total, items=items, limit=lp.limit, offset=lp.offset
|
|
90
|
-
)
|
|
108
|
+
return Page[Any].from_items(total=total, items=items, limit=lp.limit, offset=lp.offset)
|
|
91
109
|
|
|
92
110
|
# -------- GET by id --------
|
|
93
111
|
@router.get(
|
|
94
112
|
"/{item_id}",
|
|
95
|
-
response_model=
|
|
113
|
+
response_model=read_schema,
|
|
96
114
|
description=f"Get item of type {model.__name__}",
|
|
97
115
|
)
|
|
98
|
-
async def get_item(item_id: Any, session: SqlSessionDep):
|
|
99
|
-
row = await service.get(session, item_id)
|
|
116
|
+
async def get_item(item_id: Any, session: SqlSessionDep):
|
|
117
|
+
row = await service.get(session, _coerce_id(item_id))
|
|
100
118
|
if not row:
|
|
101
119
|
raise HTTPException(404, "Not found")
|
|
102
120
|
return row
|
|
@@ -104,40 +122,197 @@ def make_crud_router_plus_sql(
|
|
|
104
122
|
# -------- CREATE --------
|
|
105
123
|
@router.post(
|
|
106
124
|
"",
|
|
107
|
-
response_model=
|
|
125
|
+
response_model=read_schema,
|
|
108
126
|
status_code=201,
|
|
109
127
|
description=f"Create item of type {model.__name__}",
|
|
110
128
|
)
|
|
111
129
|
async def create_item(
|
|
112
|
-
session: SqlSessionDep,
|
|
113
|
-
payload: create_schema = Body(...),
|
|
130
|
+
session: SqlSessionDep,
|
|
131
|
+
payload: create_schema = Body(...), # type: ignore[valid-type]
|
|
114
132
|
):
|
|
115
|
-
|
|
133
|
+
if isinstance(payload, BaseModel):
|
|
134
|
+
data = cast("BaseModel", payload).model_dump(exclude_unset=True)
|
|
135
|
+
elif isinstance(payload, dict):
|
|
136
|
+
data = payload
|
|
137
|
+
else:
|
|
138
|
+
raise HTTPException(422, "invalid_payload")
|
|
116
139
|
return await service.create(session, data)
|
|
117
140
|
|
|
118
141
|
# -------- UPDATE --------
|
|
119
142
|
@router.patch(
|
|
120
143
|
"/{item_id}",
|
|
121
|
-
response_model=
|
|
144
|
+
response_model=read_schema,
|
|
122
145
|
description=f"Update item of type {model.__name__}",
|
|
123
146
|
)
|
|
124
147
|
async def update_item(
|
|
125
148
|
item_id: Any,
|
|
126
|
-
session: SqlSessionDep,
|
|
127
|
-
payload: update_schema = Body(...),
|
|
149
|
+
session: SqlSessionDep,
|
|
150
|
+
payload: update_schema = Body(...), # type: ignore[valid-type]
|
|
128
151
|
):
|
|
129
|
-
|
|
130
|
-
|
|
152
|
+
if isinstance(payload, BaseModel):
|
|
153
|
+
data = cast("BaseModel", payload).model_dump(exclude_unset=True)
|
|
154
|
+
elif isinstance(payload, dict):
|
|
155
|
+
data = payload
|
|
156
|
+
else:
|
|
157
|
+
raise HTTPException(422, "invalid_payload")
|
|
158
|
+
row = await service.update(session, _coerce_id(item_id), data)
|
|
131
159
|
if not row:
|
|
132
160
|
raise HTTPException(404, "Not found")
|
|
133
161
|
return row
|
|
134
162
|
|
|
135
163
|
# -------- DELETE --------
|
|
136
164
|
@router.delete(
|
|
137
|
-
"/{item_id}",
|
|
165
|
+
"/{item_id}",
|
|
166
|
+
status_code=204,
|
|
167
|
+
description=f"Delete item of type {model.__name__}",
|
|
138
168
|
)
|
|
139
|
-
async def delete_item(item_id: Any, session: SqlSessionDep):
|
|
140
|
-
ok = await service.delete(session, item_id)
|
|
169
|
+
async def delete_item(item_id: Any, session: SqlSessionDep):
|
|
170
|
+
ok = await service.delete(session, _coerce_id(item_id))
|
|
171
|
+
if not ok:
|
|
172
|
+
raise HTTPException(404, "Not found")
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
return router
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def make_tenant_crud_router_plus_sql(
|
|
179
|
+
*,
|
|
180
|
+
model: type[Any],
|
|
181
|
+
service_factory: Callable[[], Any], # factory that returns a SqlService (will be wrapped)
|
|
182
|
+
read_schema: type[ReadModel],
|
|
183
|
+
create_schema: type[CreateModel],
|
|
184
|
+
update_schema: type[UpdateModel],
|
|
185
|
+
prefix: str,
|
|
186
|
+
tenant_field: str = "tenant_id",
|
|
187
|
+
tags: list[str] | None = None,
|
|
188
|
+
search_fields: Sequence[str] | None = None,
|
|
189
|
+
default_ordering: str | None = None,
|
|
190
|
+
allowed_order_fields: list[str] | None = None,
|
|
191
|
+
mount_under_db_prefix: bool = True,
|
|
192
|
+
) -> APIRouter:
|
|
193
|
+
"""Like make_crud_router_plus_sql, but requires TenantId and scopes all operations."""
|
|
194
|
+
router_prefix = ("/_sql" + prefix) if mount_under_db_prefix else prefix
|
|
195
|
+
router = public_router(
|
|
196
|
+
prefix=router_prefix,
|
|
197
|
+
tags=tags or [prefix.strip("/")],
|
|
198
|
+
redirect_slashes=False,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Evaluate the base service once to preserve in-memory state across requests in tests/local.
|
|
202
|
+
# Consumers may pass either an instance or a zero-arg factory function.
|
|
203
|
+
try:
|
|
204
|
+
_base_instance = service_factory() if callable(service_factory) else service_factory
|
|
205
|
+
except TypeError:
|
|
206
|
+
# If the callable requires args, assume it's already an instance
|
|
207
|
+
_base_instance = service_factory
|
|
208
|
+
|
|
209
|
+
def _coerce_id(v: Any) -> Any:
|
|
210
|
+
"""Best-effort coercion of path ids: cast digit-only strings to int.
|
|
211
|
+
Keeps original type otherwise.
|
|
212
|
+
"""
|
|
213
|
+
if isinstance(v, str) and v.isdigit():
|
|
214
|
+
try:
|
|
215
|
+
return int(v)
|
|
216
|
+
except Exception:
|
|
217
|
+
return v
|
|
218
|
+
return v
|
|
219
|
+
|
|
220
|
+
def _parse_ordering_to_fields(order_spec: str | None) -> list[str]:
|
|
221
|
+
if not order_spec:
|
|
222
|
+
return []
|
|
223
|
+
pieces = [p.strip() for p in order_spec.split(",") if p.strip()]
|
|
224
|
+
fields: list[str] = []
|
|
225
|
+
for p in pieces:
|
|
226
|
+
name = p[1:] if p.startswith("-") else p
|
|
227
|
+
if allowed_order_fields and name not in (allowed_order_fields or []):
|
|
228
|
+
continue
|
|
229
|
+
fields.append(p)
|
|
230
|
+
return fields
|
|
231
|
+
|
|
232
|
+
# create per-request service with tenant scoping
|
|
233
|
+
async def _svc(session: SqlSessionDep, tenant_id: TenantId):
|
|
234
|
+
repo_or_service = getattr(_base_instance, "repo", _base_instance)
|
|
235
|
+
svc: Any = TenantSqlService(repo_or_service, tenant_id=tenant_id, tenant_field=tenant_field)
|
|
236
|
+
return svc
|
|
237
|
+
|
|
238
|
+
@router.get("", response_model=Page[read_schema]) # type: ignore[valid-type]
|
|
239
|
+
async def list_items(
|
|
240
|
+
lp: Annotated[LimitOffsetParams, Depends(dep_limit_offset)],
|
|
241
|
+
op: Annotated[OrderParams, Depends(dep_order)],
|
|
242
|
+
sp: Annotated[SearchParams, Depends(dep_search)],
|
|
243
|
+
session: SqlSessionDep,
|
|
244
|
+
tenant_id: TenantId,
|
|
245
|
+
):
|
|
246
|
+
svc = await _svc(session, tenant_id)
|
|
247
|
+
order_spec = op.order_by or default_ordering
|
|
248
|
+
order_fields = _parse_ordering_to_fields(order_spec)
|
|
249
|
+
order_by = build_order_by(model, order_fields)
|
|
250
|
+
if sp.q:
|
|
251
|
+
fields = [
|
|
252
|
+
f.strip()
|
|
253
|
+
for f in (sp.fields or (",".join(search_fields or []) or "")).split(",")
|
|
254
|
+
if f.strip()
|
|
255
|
+
]
|
|
256
|
+
items = await svc.search(
|
|
257
|
+
session,
|
|
258
|
+
q=sp.q,
|
|
259
|
+
fields=fields,
|
|
260
|
+
limit=lp.limit,
|
|
261
|
+
offset=lp.offset,
|
|
262
|
+
order_by=order_by,
|
|
263
|
+
)
|
|
264
|
+
total = await svc.count_filtered(session, q=sp.q, fields=fields)
|
|
265
|
+
else:
|
|
266
|
+
items = await svc.list(session, limit=lp.limit, offset=lp.offset, order_by=order_by)
|
|
267
|
+
total = await svc.count(session)
|
|
268
|
+
return Page[Any].from_items(total=total, items=items, limit=lp.limit, offset=lp.offset)
|
|
269
|
+
|
|
270
|
+
@router.get("/{item_id}", response_model=read_schema)
|
|
271
|
+
async def get_item(item_id: Any, session: SqlSessionDep, tenant_id: TenantId):
|
|
272
|
+
svc = await _svc(session, tenant_id)
|
|
273
|
+
obj = await svc.get(session, item_id)
|
|
274
|
+
if not obj:
|
|
275
|
+
raise HTTPException(404, "not_found")
|
|
276
|
+
return obj
|
|
277
|
+
|
|
278
|
+
@router.post("", response_model=read_schema, status_code=201)
|
|
279
|
+
async def create_item(
|
|
280
|
+
session: SqlSessionDep,
|
|
281
|
+
tenant_id: TenantId,
|
|
282
|
+
payload: create_schema = Body(...), # type: ignore[valid-type]
|
|
283
|
+
):
|
|
284
|
+
svc = await _svc(session, tenant_id)
|
|
285
|
+
if isinstance(payload, BaseModel):
|
|
286
|
+
data = cast("BaseModel", payload).model_dump(exclude_unset=True)
|
|
287
|
+
elif isinstance(payload, dict):
|
|
288
|
+
data = payload
|
|
289
|
+
else:
|
|
290
|
+
raise HTTPException(422, "invalid_payload")
|
|
291
|
+
return await svc.create(session, data)
|
|
292
|
+
|
|
293
|
+
@router.patch("/{item_id}", response_model=read_schema)
|
|
294
|
+
async def update_item(
|
|
295
|
+
item_id: Any,
|
|
296
|
+
session: SqlSessionDep,
|
|
297
|
+
tenant_id: TenantId,
|
|
298
|
+
payload: update_schema = Body(...), # type: ignore[valid-type]
|
|
299
|
+
):
|
|
300
|
+
svc = await _svc(session, tenant_id)
|
|
301
|
+
if isinstance(payload, BaseModel):
|
|
302
|
+
data = cast("BaseModel", payload).model_dump(exclude_unset=True)
|
|
303
|
+
elif isinstance(payload, dict):
|
|
304
|
+
data = payload
|
|
305
|
+
else:
|
|
306
|
+
raise HTTPException(422, "invalid_payload")
|
|
307
|
+
updated = await svc.update(session, item_id, data)
|
|
308
|
+
if not updated:
|
|
309
|
+
raise HTTPException(404, "not_found")
|
|
310
|
+
return updated
|
|
311
|
+
|
|
312
|
+
@router.delete("/{item_id}", status_code=204)
|
|
313
|
+
async def delete_item(item_id: Any, session: SqlSessionDep, tenant_id: TenantId):
|
|
314
|
+
svc = await _svc(session, tenant_id)
|
|
315
|
+
ok = await svc.delete(session, _coerce_id(item_id))
|
|
141
316
|
if not ok:
|
|
142
317
|
raise HTTPException(404, "Not found")
|
|
143
318
|
return
|
|
@@ -145,4 +320,4 @@ def make_crud_router_plus_sql(
|
|
|
145
320
|
return router
|
|
146
321
|
|
|
147
322
|
|
|
148
|
-
__all__ = ["make_crud_router_plus_sql"]
|
|
323
|
+
__all__ = ["make_crud_router_plus_sql", "make_tenant_crud_router_plus_sql"]
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
-
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from typing import Annotated
|
|
5
7
|
|
|
6
8
|
from fastapi import Depends
|
|
9
|
+
from sqlalchemy import text
|
|
7
10
|
from sqlalchemy.ext.asyncio import (
|
|
8
11
|
AsyncEngine,
|
|
9
12
|
AsyncSession,
|
|
@@ -21,7 +24,7 @@ _SessionLocal: async_sessionmaker[AsyncSession] | None = None
|
|
|
21
24
|
|
|
22
25
|
def _init_engine_and_session(
|
|
23
26
|
url: str,
|
|
24
|
-
) ->
|
|
27
|
+
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
|
|
25
28
|
async_url = _coerce_to_async_url(url)
|
|
26
29
|
if async_url != url:
|
|
27
30
|
logger.info(
|
|
@@ -53,6 +56,20 @@ async def get_session() -> AsyncIterator[AsyncSession]:
|
|
|
53
56
|
if _SessionLocal is None:
|
|
54
57
|
raise RuntimeError("Database not initialized. Call add_sql_db(app, ...) first.")
|
|
55
58
|
async with _SessionLocal() as session:
|
|
59
|
+
# Optional: set a per-transaction statement timeout for Postgres if configured
|
|
60
|
+
raw_ms = os.getenv("DB_STATEMENT_TIMEOUT_MS")
|
|
61
|
+
if raw_ms:
|
|
62
|
+
try:
|
|
63
|
+
ms = int(raw_ms)
|
|
64
|
+
if ms > 0:
|
|
65
|
+
try:
|
|
66
|
+
# SET LOCAL applies for the duration of the current transaction only
|
|
67
|
+
await session.execute(text("SET LOCAL statement_timeout = :ms"), {"ms": ms})
|
|
68
|
+
except Exception:
|
|
69
|
+
# Non-PG dialects (e.g., SQLite) will error; ignore silently
|
|
70
|
+
pass
|
|
71
|
+
except ValueError:
|
|
72
|
+
pass
|
|
56
73
|
try:
|
|
57
74
|
yield session
|
|
58
75
|
await session.commit()
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from collections.abc import AsyncIterator, Callable
|
|
4
|
+
from typing import Any
|
|
4
5
|
from uuid import UUID
|
|
5
6
|
|
|
6
7
|
from fastapi import Depends
|
|
7
8
|
from fastapi_users import FastAPIUsers
|
|
8
|
-
from fastapi_users.authentication import
|
|
9
|
+
from fastapi_users.authentication import (
|
|
10
|
+
AuthenticationBackend,
|
|
11
|
+
BearerTransport,
|
|
12
|
+
JWTStrategy,
|
|
13
|
+
)
|
|
9
14
|
from fastapi_users.manager import BaseUserManager, UUIDIDMixin
|
|
10
15
|
|
|
11
16
|
from svc_infra.api.fastapi.auth.settings import get_auth_settings
|
|
12
17
|
from svc_infra.api.fastapi.dual.dualize import dualize_public, dualize_user
|
|
13
18
|
from svc_infra.api.fastapi.dual.router import DualAPIRouter
|
|
14
|
-
from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV
|
|
19
|
+
from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV, require_secret
|
|
15
20
|
from svc_infra.security.jwt_rotation import RotatingJWTStrategy
|
|
16
21
|
|
|
17
22
|
from ...auth.security import auth_login_path
|
|
@@ -26,7 +31,7 @@ def get_fastapi_users(
|
|
|
26
31
|
user_schema_update: Any,
|
|
27
32
|
*,
|
|
28
33
|
public_auth_prefix: str = "/auth",
|
|
29
|
-
) ->
|
|
34
|
+
) -> tuple[
|
|
30
35
|
FastAPIUsers,
|
|
31
36
|
AuthenticationBackend,
|
|
32
37
|
DualAPIRouter,
|
|
@@ -56,10 +61,10 @@ def get_fastapi_users(
|
|
|
56
61
|
verify_url = f"{public_auth_prefix}/verify?token={token}"
|
|
57
62
|
sender = get_sender()
|
|
58
63
|
sender.send(
|
|
59
|
-
to=
|
|
64
|
+
to=user.email,
|
|
60
65
|
subject="Verify your account",
|
|
61
66
|
html_body=f"""
|
|
62
|
-
<p>Hi {getattr(user,
|
|
67
|
+
<p>Hi {getattr(user, "full_name", "") or "there"},</p>
|
|
63
68
|
<p>Click to verify your account:</p>
|
|
64
69
|
<p><a href="{verify_url}">{verify_url}</a></p>
|
|
65
70
|
""",
|
|
@@ -69,7 +74,7 @@ def get_fastapi_users(
|
|
|
69
74
|
reset_url = f"{public_auth_prefix}/reset-password?token={token}"
|
|
70
75
|
sender = get_sender()
|
|
71
76
|
sender.send(
|
|
72
|
-
to=
|
|
77
|
+
to=user.email,
|
|
73
78
|
subject="Reset your password",
|
|
74
79
|
html_body=f"""
|
|
75
80
|
<p>We received a request to reset your password.</p>
|
|
@@ -91,14 +96,18 @@ def get_fastapi_users(
|
|
|
91
96
|
if jwt_block and getattr(jwt_block, "secret", None):
|
|
92
97
|
secret = jwt_block.secret.get_secret_value()
|
|
93
98
|
else:
|
|
94
|
-
secret =
|
|
99
|
+
secret = require_secret(
|
|
100
|
+
None,
|
|
101
|
+
"JWT_SECRET (via auth settings jwt.secret)",
|
|
102
|
+
dev_default="dev-only-jwt-secret-not-for-production",
|
|
103
|
+
)
|
|
95
104
|
lifetime = getattr(jwt_block, "lifetime_seconds", None) if jwt_block else None
|
|
96
105
|
if not isinstance(lifetime, int) or lifetime <= 0:
|
|
97
106
|
lifetime = 3600
|
|
98
107
|
old = []
|
|
99
108
|
if jwt_block and getattr(jwt_block, "old_secrets", None):
|
|
100
109
|
old = [s.get_secret_value() for s in jwt_block.old_secrets or []]
|
|
101
|
-
audience = "fastapi-users:auth"
|
|
110
|
+
audience = ["fastapi-users:auth"]
|
|
102
111
|
if old:
|
|
103
112
|
return RotatingJWTStrategy(
|
|
104
113
|
secret=secret,
|
|
@@ -1,14 +1,27 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import logging
|
|
3
4
|
import time
|
|
4
|
-
from
|
|
5
|
+
from collections.abc import Callable
|
|
5
6
|
|
|
6
7
|
from fastapi import HTTPException
|
|
7
8
|
from starlette.requests import Request
|
|
8
9
|
|
|
9
|
-
from svc_infra.api.fastapi.middleware.ratelimit_store import
|
|
10
|
+
from svc_infra.api.fastapi.middleware.ratelimit_store import (
|
|
11
|
+
InMemoryRateLimitStore,
|
|
12
|
+
RateLimitStore,
|
|
13
|
+
)
|
|
10
14
|
from svc_infra.obs.metrics import emit_rate_limited
|
|
11
15
|
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from svc_infra.api.fastapi.tenancy.context import (
|
|
20
|
+
resolve_tenant_id as _resolve_tenant_id,
|
|
21
|
+
)
|
|
22
|
+
except Exception: # pragma: no cover - minimal builds
|
|
23
|
+
_resolve_tenant_id = None # type: ignore[assignment]
|
|
24
|
+
|
|
12
25
|
|
|
13
26
|
class RateLimiter:
|
|
14
27
|
def __init__(
|
|
@@ -17,24 +30,50 @@ class RateLimiter:
|
|
|
17
30
|
limit: int,
|
|
18
31
|
window: int = 60,
|
|
19
32
|
key_fn: Callable = lambda r: "global",
|
|
33
|
+
limit_resolver: Callable[[Request, str | None], int | None] | None = None,
|
|
34
|
+
scope_by_tenant: bool = False,
|
|
20
35
|
store: RateLimitStore | None = None,
|
|
21
36
|
):
|
|
22
37
|
self.limit = limit
|
|
23
38
|
self.window = window
|
|
24
39
|
self.key_fn = key_fn
|
|
40
|
+
self._limit_resolver = limit_resolver
|
|
41
|
+
self.scope_by_tenant = scope_by_tenant
|
|
25
42
|
self.store = store or InMemoryRateLimitStore(limit=limit)
|
|
26
43
|
|
|
27
44
|
async def __call__(self, request: Request):
|
|
45
|
+
# Try resolving tenant when asked
|
|
46
|
+
tenant_id = None
|
|
47
|
+
if self.scope_by_tenant or self._limit_resolver:
|
|
48
|
+
try:
|
|
49
|
+
if _resolve_tenant_id is not None:
|
|
50
|
+
tenant_id = await _resolve_tenant_id(request)
|
|
51
|
+
except Exception:
|
|
52
|
+
tenant_id = None
|
|
53
|
+
|
|
28
54
|
key = self.key_fn(request)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
55
|
+
if self.scope_by_tenant and tenant_id:
|
|
56
|
+
key = f"{key}:tenant:{tenant_id}"
|
|
57
|
+
|
|
58
|
+
eff_limit = self.limit
|
|
59
|
+
if self._limit_resolver:
|
|
32
60
|
try:
|
|
33
|
-
|
|
61
|
+
v = self._limit_resolver(request, tenant_id)
|
|
62
|
+
eff_limit = int(v) if v is not None else self.limit
|
|
34
63
|
except Exception:
|
|
35
|
-
|
|
64
|
+
eff_limit = self.limit
|
|
65
|
+
|
|
66
|
+
count, _store_limit, reset = self.store.incr(str(key), self.window)
|
|
67
|
+
if count > eff_limit:
|
|
68
|
+
retry = max(0, reset - int(time.time()))
|
|
69
|
+
try:
|
|
70
|
+
emit_rate_limited(str(key), eff_limit, retry)
|
|
71
|
+
except Exception as e:
|
|
72
|
+
logger.warning("Failed to emit rate limit metric: %s", e)
|
|
36
73
|
raise HTTPException(
|
|
37
|
-
status_code=429,
|
|
74
|
+
status_code=429,
|
|
75
|
+
detail="Rate limit exceeded",
|
|
76
|
+
headers={"Retry-After": str(retry)},
|
|
38
77
|
)
|
|
39
78
|
|
|
40
79
|
|
|
@@ -46,21 +85,44 @@ def rate_limiter(
|
|
|
46
85
|
limit: int,
|
|
47
86
|
window: int = 60,
|
|
48
87
|
key_fn: Callable = lambda r: "global",
|
|
88
|
+
limit_resolver: Callable[[Request, str | None], int | None] | None = None,
|
|
89
|
+
scope_by_tenant: bool = False,
|
|
49
90
|
store: RateLimitStore | None = None,
|
|
50
91
|
):
|
|
51
92
|
store_ = store or InMemoryRateLimitStore(limit=limit)
|
|
52
93
|
|
|
53
94
|
async def dep(request: Request):
|
|
95
|
+
tenant_id = None
|
|
96
|
+
if scope_by_tenant or limit_resolver:
|
|
97
|
+
try:
|
|
98
|
+
if _resolve_tenant_id is not None:
|
|
99
|
+
tenant_id = await _resolve_tenant_id(request)
|
|
100
|
+
except Exception:
|
|
101
|
+
tenant_id = None
|
|
102
|
+
|
|
54
103
|
key = key_fn(request)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
104
|
+
if scope_by_tenant and tenant_id:
|
|
105
|
+
key = f"{key}:tenant:{tenant_id}"
|
|
106
|
+
|
|
107
|
+
eff_limit = limit
|
|
108
|
+
if limit_resolver:
|
|
58
109
|
try:
|
|
59
|
-
|
|
110
|
+
v = limit_resolver(request, tenant_id)
|
|
111
|
+
eff_limit = int(v) if v is not None else limit
|
|
60
112
|
except Exception:
|
|
61
|
-
|
|
113
|
+
eff_limit = limit
|
|
114
|
+
|
|
115
|
+
count, _store_limit, reset = store_.incr(str(key), window)
|
|
116
|
+
if count > eff_limit:
|
|
117
|
+
retry = max(0, reset - int(time.time()))
|
|
118
|
+
try:
|
|
119
|
+
emit_rate_limited(str(key), eff_limit, retry)
|
|
120
|
+
except Exception as e:
|
|
121
|
+
logger.warning("Failed to emit rate limit metric: %s", e)
|
|
62
122
|
raise HTTPException(
|
|
63
|
-
status_code=429,
|
|
123
|
+
status_code=429,
|
|
124
|
+
detail="Rate limit exceeded",
|
|
125
|
+
headers={"Retry-After": str(retry)},
|
|
64
126
|
)
|
|
65
127
|
|
|
66
128
|
return dep
|