svc-infra 0.1.506__py3-none-any.whl → 0.1.654__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.
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/alembic.py +11 -0
- svc_infra/apf_payments/models.py +339 -0
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +797 -0
- svc_infra/apf_payments/provider/base.py +270 -0
- svc_infra/apf_payments/provider/registry.py +31 -0
- svc_infra/apf_payments/provider/stripe.py +873 -0
- svc_infra/apf_payments/schemas.py +333 -0
- svc_infra/apf_payments/service.py +892 -0
- svc_infra/apf_payments/settings.py +67 -0
- svc_infra/api/fastapi/__init__.py +6 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/apf_payments/__init__.py +0 -0
- svc_infra/api/fastapi/apf_payments/router.py +1082 -0
- svc_infra/api/fastapi/apf_payments/setup.py +73 -0
- svc_infra/api/fastapi/auth/add.py +15 -6
- svc_infra/api/fastapi/auth/gaurd.py +67 -5
- svc_infra/api/fastapi/auth/mfa/router.py +18 -9
- svc_infra/api/fastapi/auth/routers/account.py +3 -2
- svc_infra/api/fastapi/auth/routers/apikey_router.py +11 -5
- svc_infra/api/fastapi/auth/routers/oauth_router.py +82 -37
- svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
- svc_infra/api/fastapi/auth/security.py +3 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/auth/state.py +1 -1
- svc_infra/api/fastapi/billing/router.py +64 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
- svc_infra/api/fastapi/db/sql/add.py +40 -18
- svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
- svc_infra/api/fastapi/db/sql/session.py +16 -0
- svc_infra/api/fastapi/db/sql/users.py +14 -2
- svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
- svc_infra/api/fastapi/docs/add.py +160 -0
- svc_infra/api/fastapi/docs/landing.py +1 -1
- svc_infra/api/fastapi/docs/scoped.py +254 -0
- svc_infra/api/fastapi/dual/dualize.py +38 -33
- svc_infra/api/fastapi/dual/router.py +48 -1
- svc_infra/api/fastapi/dx.py +3 -3
- svc_infra/api/fastapi/http/__init__.py +0 -0
- svc_infra/api/fastapi/http/concurrency.py +14 -0
- svc_infra/api/fastapi/http/conditional.py +33 -0
- svc_infra/api/fastapi/http/deprecation.py +21 -0
- svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
- svc_infra/api/fastapi/middleware/idempotency.py +116 -0
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +119 -0
- svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
- svc_infra/api/fastapi/middleware/request_id.py +23 -0
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +148 -0
- svc_infra/api/fastapi/openapi/mutators.py +768 -55
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +363 -0
- svc_infra/api/fastapi/paths/auth.py +14 -14
- svc_infra/api/fastapi/paths/prefix.py +0 -1
- svc_infra/api/fastapi/paths/user.py +1 -1
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +48 -15
- 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/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +230 -0
- svc_infra/billing/models.py +131 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +33 -0
- svc_infra/billing/service.py +115 -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 +4 -0
- svc_infra/cache/add.py +158 -0
- svc_infra/cache/backend.py +5 -2
- svc_infra/cache/decorators.py +19 -1
- svc_infra/cache/keys.py +24 -4
- svc_infra/cli/__init__.py +32 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +10 -0
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +120 -14
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +5 -4
- svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +99 -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 +43 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +53 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +40 -0
- svc_infra/data/retention.py +55 -0
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/outbox.py +104 -0
- svc_infra/db/sql/apikey.py +1 -1
- svc_infra/db/sql/authref.py +61 -0
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/repository.py +52 -12
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +16 -4
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +199 -76
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +231 -79
- svc_infra/db/sql/tenant.py +79 -0
- svc_infra/db/sql/utils.py +18 -4
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/docs/acceptance-matrix.md +71 -0
- svc_infra/docs/acceptance.md +44 -0
- svc_infra/docs/admin.md +425 -0
- svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
- svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
- svc_infra/docs/adr/0004-tenancy-model.md +42 -0
- svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
- svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
- svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
- svc_infra/docs/adr/0008-billing-primitives.md +143 -0
- svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
- svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
- svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
- svc_infra/docs/api.md +59 -0
- svc_infra/docs/auth.md +11 -0
- svc_infra/docs/billing.md +190 -0
- svc_infra/docs/cache.md +76 -0
- svc_infra/docs/cli.md +74 -0
- svc_infra/docs/contributing.md +34 -0
- svc_infra/docs/data-lifecycle.md +52 -0
- svc_infra/docs/database.md +14 -0
- svc_infra/docs/docs-and-sdks.md +62 -0
- svc_infra/docs/environment.md +114 -0
- svc_infra/docs/getting-started.md +63 -0
- svc_infra/docs/idempotency.md +111 -0
- svc_infra/docs/jobs.md +67 -0
- svc_infra/docs/observability.md +16 -0
- svc_infra/docs/ops.md +37 -0
- svc_infra/docs/rate-limiting.md +125 -0
- svc_infra/docs/repo-review.md +48 -0
- svc_infra/docs/security.md +176 -0
- svc_infra/docs/tenancy.md +35 -0
- svc_infra/docs/timeouts-and-resource-limits.md +147 -0
- svc_infra/docs/versioned-integrations.md +146 -0
- svc_infra/docs/webhooks.md +112 -0
- svc_infra/dx/add.py +63 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +67 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +72 -0
- svc_infra/jobs/builtins/outbox_processor.py +38 -0
- svc_infra/jobs/builtins/webhook_delivery.py +90 -0
- svc_infra/jobs/easy.py +32 -0
- svc_infra/jobs/loader.py +45 -0
- svc_infra/jobs/queue.py +81 -0
- svc_infra/jobs/redis_queue.py +191 -0
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/scheduler.py +41 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/mcp/svc_infra_mcp.py +85 -28
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +54 -7
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +53 -0
- svc_infra/obs/metrics.py +52 -0
- svc_infra/security/add.py +201 -0
- svc_infra/security/audit.py +130 -0
- svc_infra/security/audit_service.py +73 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +95 -0
- svc_infra/security/jwt_rotation.py +53 -0
- svc_infra/security/lockout.py +96 -0
- svc_infra/security/models.py +255 -0
- svc_infra/security/org_invites.py +128 -0
- svc_infra/security/passwords.py +77 -0
- svc_infra/security/permissions.py +149 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +80 -0
- svc_infra/webhooks/__init__.py +16 -0
- svc_infra/webhooks/add.py +322 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +67 -0
- svc_infra/webhooks/signing.py +30 -0
- svc_infra-0.1.654.dist-info/METADATA +154 -0
- svc_infra-0.1.654.dist-info/RECORD +352 -0
- svc_infra/api/fastapi/deps.py +0 -3
- svc_infra-0.1.506.dist-info/METADATA +0 -78
- svc_infra-0.1.506.dist-info/RECORD +0 -213
- /svc_infra/{api/fastapi/schemas → apf_payments}/__init__.py +0 -0
- {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, HTTPException
|
|
7
|
+
from sqlalchemy import select
|
|
8
|
+
|
|
9
|
+
from svc_infra.api.fastapi.auth.security import Identity
|
|
10
|
+
from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
|
|
11
|
+
from svc_infra.security.models import AuthSession
|
|
12
|
+
from svc_infra.security.permissions import RequirePermission
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_session_router() -> APIRouter:
|
|
16
|
+
router = APIRouter(prefix="/sessions", tags=["sessions"])
|
|
17
|
+
|
|
18
|
+
@router.get(
|
|
19
|
+
"/me", response_model=list[dict], dependencies=[RequirePermission("security.session.list")]
|
|
20
|
+
)
|
|
21
|
+
async def list_my_sessions(identity: Identity, session: SqlSessionDep) -> List[dict]:
|
|
22
|
+
stmt = select(AuthSession).where(AuthSession.user_id == identity.user.id)
|
|
23
|
+
rows = (await session.execute(stmt)).scalars().all()
|
|
24
|
+
return [
|
|
25
|
+
{
|
|
26
|
+
"id": str(r.id),
|
|
27
|
+
"user_agent": r.user_agent,
|
|
28
|
+
"ip_hash": r.ip_hash,
|
|
29
|
+
"revoked": bool(r.revoked_at),
|
|
30
|
+
"last_seen_at": r.last_seen_at.isoformat() if r.last_seen_at else None,
|
|
31
|
+
"created_at": r.created_at.isoformat() if r.created_at else None,
|
|
32
|
+
}
|
|
33
|
+
for r in rows
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
@router.post(
|
|
37
|
+
"/{session_id}/revoke",
|
|
38
|
+
status_code=204,
|
|
39
|
+
dependencies=[RequirePermission("security.session.revoke")],
|
|
40
|
+
)
|
|
41
|
+
async def revoke_session(session_id: str, identity: Identity, db: SqlSessionDep):
|
|
42
|
+
# Load session and ensure it belongs to the user (non-admin users cannot revoke others)
|
|
43
|
+
s = await db.get(AuthSession, session_id)
|
|
44
|
+
if not s:
|
|
45
|
+
raise HTTPException(404, "session_not_found")
|
|
46
|
+
# Basic ownership check; could extend for admin bypass later
|
|
47
|
+
if s.user_id != identity.user.id:
|
|
48
|
+
raise HTTPException(403, "forbidden")
|
|
49
|
+
if s.revoked_at:
|
|
50
|
+
return # already revoked
|
|
51
|
+
s.revoked_at = datetime.now(timezone.utc)
|
|
52
|
+
s.revoke_reason = "user_revoked"
|
|
53
|
+
# Revoke all refresh tokens for this session
|
|
54
|
+
for rt in s.refresh_tokens:
|
|
55
|
+
if not rt.revoked_at:
|
|
56
|
+
rt.revoked_at = s.revoked_at
|
|
57
|
+
rt.revoke_reason = "session_revoked"
|
|
58
|
+
await db.flush()
|
|
59
|
+
|
|
60
|
+
return router
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__all__ = ["build_session_router"]
|
|
@@ -10,10 +10,12 @@ from sqlalchemy import select
|
|
|
10
10
|
from svc_infra.api.fastapi.auth.settings import get_auth_settings
|
|
11
11
|
from svc_infra.api.fastapi.auth.state import get_auth_state, get_user_scope_resolver
|
|
12
12
|
from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
|
|
13
|
+
from svc_infra.api.fastapi.paths.prefix import USER_PREFIX
|
|
14
|
+
from svc_infra.api.fastapi.paths.user import LOGIN_PATH
|
|
13
15
|
from svc_infra.db.sql.apikey import get_apikey_model
|
|
14
16
|
|
|
15
17
|
# ---------- OpenAPI security schemes (appear in docs) ----------
|
|
16
|
-
auth_login_path =
|
|
18
|
+
auth_login_path = USER_PREFIX + LOGIN_PATH
|
|
17
19
|
oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl=auth_login_path, auto_error=False)
|
|
18
20
|
cookie_auth_optional = APIKeyCookie(name=get_auth_settings().auth_cookie_name, auto_error=False)
|
|
19
21
|
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
|
@@ -18,6 +18,8 @@ class OIDCProvider(BaseModel):
|
|
|
18
18
|
class JWTSettings(BaseModel):
|
|
19
19
|
secret: SecretStr
|
|
20
20
|
lifetime_seconds: int = 60 * 60 * 24 * 7
|
|
21
|
+
# Optional older secrets accepted for verification during rotation window
|
|
22
|
+
old_secrets: List[SecretStr] = Field(default_factory=list)
|
|
21
23
|
|
|
22
24
|
|
|
23
25
|
class PasswordClient(BaseModel):
|
|
@@ -19,7 +19,7 @@ def set_auth_state(
|
|
|
19
19
|
|
|
20
20
|
def get_auth_state() -> tuple[type, Callable[[], Any], str]:
|
|
21
21
|
if _UserModel is None or _GetStrategy is None:
|
|
22
|
-
raise RuntimeError("Auth state not initialized; call set_auth_state() in
|
|
22
|
+
raise RuntimeError("Auth state not initialized; call set_auth_state() in add_auth_users().")
|
|
23
23
|
return _UserModel, _GetStrategy, _AuthPrefix
|
|
24
24
|
|
|
25
25
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Annotated, Optional
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, Response, status
|
|
7
|
+
|
|
8
|
+
from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
|
|
9
|
+
from svc_infra.api.fastapi.middleware.idempotency import require_idempotency_key
|
|
10
|
+
from svc_infra.api.fastapi.tenancy.context import TenantId
|
|
11
|
+
from svc_infra.billing.async_service import AsyncBillingService
|
|
12
|
+
from svc_infra.billing.schemas import UsageAckOut, UsageAggregateRow, UsageAggregatesOut, UsageIn
|
|
13
|
+
|
|
14
|
+
router = APIRouter(prefix="/_billing", tags=["Billing"])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_service(tenant_id: TenantId, session: SqlSessionDep) -> AsyncBillingService:
|
|
18
|
+
return AsyncBillingService(session=session, tenant_id=tenant_id)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@router.post(
|
|
22
|
+
"/usage",
|
|
23
|
+
name="billing_record_usage",
|
|
24
|
+
status_code=status.HTTP_202_ACCEPTED,
|
|
25
|
+
response_model=UsageAckOut,
|
|
26
|
+
dependencies=[Depends(require_idempotency_key)],
|
|
27
|
+
)
|
|
28
|
+
async def record_usage(
|
|
29
|
+
data: UsageIn, svc: Annotated[AsyncBillingService, Depends(get_service)], response: Response
|
|
30
|
+
):
|
|
31
|
+
at = data.at or datetime.now(tz=timezone.utc)
|
|
32
|
+
evt_id = await svc.record_usage(
|
|
33
|
+
metric=data.metric,
|
|
34
|
+
amount=int(data.amount),
|
|
35
|
+
at=at,
|
|
36
|
+
idempotency_key=data.idempotency_key,
|
|
37
|
+
metadata=data.metadata,
|
|
38
|
+
)
|
|
39
|
+
# For 202, no Location header is required, but we can surface the id in the body
|
|
40
|
+
return UsageAckOut(id=evt_id, accepted=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.get(
|
|
44
|
+
"/usage",
|
|
45
|
+
name="billing_list_aggregates",
|
|
46
|
+
response_model=UsageAggregatesOut,
|
|
47
|
+
)
|
|
48
|
+
async def list_aggregates(
|
|
49
|
+
metric: str,
|
|
50
|
+
date_from: Optional[datetime] = None,
|
|
51
|
+
date_to: Optional[datetime] = None,
|
|
52
|
+
svc: Annotated[AsyncBillingService, Depends(get_service)] = None,
|
|
53
|
+
):
|
|
54
|
+
rows = await svc.list_daily_aggregates(metric=metric, date_from=date_from, date_to=date_to)
|
|
55
|
+
items = [
|
|
56
|
+
UsageAggregateRow(
|
|
57
|
+
period_start=r.period_start,
|
|
58
|
+
granularity=r.granularity,
|
|
59
|
+
metric=r.metric,
|
|
60
|
+
total=int(r.total),
|
|
61
|
+
)
|
|
62
|
+
for r in rows
|
|
63
|
+
]
|
|
64
|
+
return UsageAggregatesOut(items=items, next_cursor=None)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI
|
|
4
|
+
|
|
5
|
+
from .router import router as billing_router
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def add_billing(app: FastAPI, *, prefix: str = "/_billing") -> None:
|
|
9
|
+
# Mount under the chosen prefix; default is /_billing
|
|
10
|
+
if prefix and prefix != "/_billing":
|
|
11
|
+
# If a custom prefix is desired, clone router with new prefix
|
|
12
|
+
from fastapi import APIRouter
|
|
13
|
+
|
|
14
|
+
custom = APIRouter(prefix=prefix, tags=["Billing"])
|
|
15
|
+
for route in billing_router.routes:
|
|
16
|
+
custom.routes.append(route)
|
|
17
|
+
app.include_router(custom)
|
|
18
|
+
else:
|
|
19
|
+
app.include_router(billing_router)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from contextlib import asynccontextmanager
|
|
2
|
+
|
|
1
3
|
from fastapi import FastAPI
|
|
2
4
|
|
|
3
5
|
from svc_infra.cache.backend import shutdown_cache
|
|
@@ -5,10 +7,12 @@ from svc_infra.cache.decorators import init_cache
|
|
|
5
7
|
|
|
6
8
|
|
|
7
9
|
def setup_caching(app: FastAPI) -> None:
|
|
8
|
-
@
|
|
9
|
-
async def
|
|
10
|
+
@asynccontextmanager
|
|
11
|
+
async def lifespan(_app: FastAPI):
|
|
10
12
|
init_cache()
|
|
13
|
+
try:
|
|
14
|
+
yield
|
|
15
|
+
finally:
|
|
16
|
+
await shutdown_cache()
|
|
11
17
|
|
|
12
|
-
|
|
13
|
-
async def _shutdown():
|
|
14
|
-
await shutdown_cache()
|
|
18
|
+
app.router.lifespan_context = lifespan
|
|
@@ -38,8 +38,8 @@ def add_mongo_db_with_url(app: FastAPI, url: str, db_name: str) -> None:
|
|
|
38
38
|
|
|
39
39
|
|
|
40
40
|
def add_mongo_db(app: FastAPI, *, dsn_env: str = "MONGO_URL") -> None:
|
|
41
|
-
@
|
|
42
|
-
async def
|
|
41
|
+
@asynccontextmanager
|
|
42
|
+
async def lifespan(_app: FastAPI):
|
|
43
43
|
if not os.getenv(dsn_env):
|
|
44
44
|
raise RuntimeError(f"Missing environment variable {dsn_env} for Mongo URL")
|
|
45
45
|
await init_mongo()
|
|
@@ -47,10 +47,12 @@ def add_mongo_db(app: FastAPI, *, dsn_env: str = "MONGO_URL") -> None:
|
|
|
47
47
|
db = await acquire_db()
|
|
48
48
|
if expected and db.name != expected:
|
|
49
49
|
raise RuntimeError(f"Connected to Mongo DB '{db.name}', expected '{expected}'.")
|
|
50
|
+
try:
|
|
51
|
+
yield
|
|
52
|
+
finally:
|
|
53
|
+
await close_mongo()
|
|
50
54
|
|
|
51
|
-
|
|
52
|
-
async def _shutdown() -> None:
|
|
53
|
-
await close_mongo()
|
|
55
|
+
app.router.lifespan_context = lifespan
|
|
54
56
|
|
|
55
57
|
|
|
56
58
|
def add_mongo_health(
|
|
@@ -62,46 +64,50 @@ def add_mongo_health(
|
|
|
62
64
|
|
|
63
65
|
|
|
64
66
|
def add_mongo_resources(app: FastAPI, resources: Sequence[NoSqlResource]) -> None:
|
|
65
|
-
for
|
|
67
|
+
for resource in resources:
|
|
66
68
|
repo = NoSqlRepository(
|
|
67
|
-
collection_name=
|
|
68
|
-
id_field=
|
|
69
|
-
soft_delete=
|
|
70
|
-
soft_delete_field=
|
|
71
|
-
soft_delete_flag_field=
|
|
69
|
+
collection_name=resource.resolved_collection(),
|
|
70
|
+
id_field=resource.id_field,
|
|
71
|
+
soft_delete=resource.soft_delete,
|
|
72
|
+
soft_delete_field=resource.soft_delete_field,
|
|
73
|
+
soft_delete_flag_field=resource.soft_delete_flag_field,
|
|
72
74
|
)
|
|
73
|
-
svc =
|
|
75
|
+
svc = resource.service_factory(repo) if resource.service_factory else NoSqlService(repo)
|
|
74
76
|
|
|
75
|
-
if
|
|
76
|
-
Read, Create, Update =
|
|
77
|
-
|
|
77
|
+
if resource.read_schema and resource.create_schema and resource.update_schema:
|
|
78
|
+
Read, Create, Update = (
|
|
79
|
+
resource.read_schema,
|
|
80
|
+
resource.create_schema,
|
|
81
|
+
resource.update_schema,
|
|
82
|
+
)
|
|
83
|
+
elif resource.document_model is not None:
|
|
78
84
|
# CRITICAL: teach Pydantic to dump ObjectId/PyObjectId
|
|
79
85
|
Read, Create, Update = make_document_crud_schemas(
|
|
80
|
-
|
|
81
|
-
create_exclude=
|
|
82
|
-
read_name=
|
|
83
|
-
create_name=
|
|
84
|
-
update_name=
|
|
85
|
-
read_exclude=
|
|
86
|
-
update_exclude=
|
|
86
|
+
resource.document_model,
|
|
87
|
+
create_exclude=resource.create_exclude,
|
|
88
|
+
read_name=resource.read_name,
|
|
89
|
+
create_name=resource.create_name,
|
|
90
|
+
update_name=resource.update_name,
|
|
91
|
+
read_exclude=resource.read_exclude,
|
|
92
|
+
update_exclude=resource.update_exclude,
|
|
87
93
|
json_encoders={ObjectId: str, PyObjectId: str},
|
|
88
94
|
)
|
|
89
95
|
else:
|
|
90
96
|
raise RuntimeError(
|
|
91
|
-
f"Resource for collection '{
|
|
97
|
+
f"Resource for collection '{resource.collection}' requires either explicit schemas "
|
|
92
98
|
f"(read/create/update) or a 'document_model' to derive them."
|
|
93
99
|
)
|
|
94
100
|
|
|
95
101
|
router = make_crud_router_plus_mongo(
|
|
96
|
-
collection=
|
|
102
|
+
collection=resource.resolved_collection(),
|
|
97
103
|
repo=repo,
|
|
98
104
|
service=svc,
|
|
99
105
|
read_schema=Read,
|
|
100
106
|
create_schema=Create,
|
|
101
107
|
update_schema=Update,
|
|
102
|
-
prefix=
|
|
103
|
-
tags=
|
|
104
|
-
search_fields=
|
|
108
|
+
prefix=resource.prefix,
|
|
109
|
+
tags=resource.tags,
|
|
110
|
+
search_fields=resource.search_fields,
|
|
105
111
|
default_ordering=None,
|
|
106
112
|
allowed_order_fields=None,
|
|
107
113
|
)
|
|
@@ -10,7 +10,7 @@ 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
|
|
|
@@ -37,18 +37,37 @@ def add_sql_resources(app: FastAPI, resources: Sequence[SqlResource]) -> None:
|
|
|
37
37
|
update_name=r.update_name,
|
|
38
38
|
)
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
40
|
+
if r.tenant_field:
|
|
41
|
+
# wrap service factory/instance through tenant router
|
|
42
|
+
def _factory():
|
|
43
|
+
return svc
|
|
44
|
+
|
|
45
|
+
router = make_tenant_crud_router_plus_sql(
|
|
46
|
+
model=r.model,
|
|
47
|
+
service_factory=_factory,
|
|
48
|
+
read_schema=Read,
|
|
49
|
+
create_schema=Create,
|
|
50
|
+
update_schema=Update,
|
|
51
|
+
prefix=r.prefix,
|
|
52
|
+
tenant_field=r.tenant_field,
|
|
53
|
+
tags=r.tags,
|
|
54
|
+
search_fields=r.search_fields,
|
|
55
|
+
default_ordering=r.ordering_default,
|
|
56
|
+
allowed_order_fields=r.allowed_order_fields,
|
|
57
|
+
)
|
|
58
|
+
else:
|
|
59
|
+
router = make_crud_router_plus_sql(
|
|
60
|
+
model=r.model,
|
|
61
|
+
service=svc,
|
|
62
|
+
read_schema=Read,
|
|
63
|
+
create_schema=Create,
|
|
64
|
+
update_schema=Update,
|
|
65
|
+
prefix=r.prefix,
|
|
66
|
+
tags=r.tags,
|
|
67
|
+
search_fields=r.search_fields,
|
|
68
|
+
default_ordering=r.ordering_default,
|
|
69
|
+
allowed_order_fields=r.allowed_order_fields,
|
|
70
|
+
)
|
|
52
71
|
app.include_router(router)
|
|
53
72
|
|
|
54
73
|
|
|
@@ -67,16 +86,19 @@ def add_sql_db(app: FastAPI, *, url: Optional[str] = None, dsn_env: str = "SQL_U
|
|
|
67
86
|
app.router.lifespan_context = lifespan
|
|
68
87
|
return
|
|
69
88
|
|
|
70
|
-
|
|
71
|
-
|
|
89
|
+
# Use lifespan context manager instead of deprecated on_event
|
|
90
|
+
@asynccontextmanager
|
|
91
|
+
async def lifespan(_app: FastAPI):
|
|
72
92
|
env_url = os.getenv(dsn_env)
|
|
73
93
|
if not env_url:
|
|
74
94
|
raise RuntimeError(f"Missing environment variable {dsn_env} for database URL")
|
|
75
95
|
initialize_session(env_url)
|
|
96
|
+
try:
|
|
97
|
+
yield
|
|
98
|
+
finally:
|
|
99
|
+
await dispose_session()
|
|
76
100
|
|
|
77
|
-
|
|
78
|
-
async def _shutdown() -> None: # noqa: ANN202
|
|
79
|
-
await dispose_session()
|
|
101
|
+
app.router.lifespan_context = lifespan
|
|
80
102
|
|
|
81
103
|
|
|
82
104
|
def add_sql_health(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import Annotated, Any, Optional, Sequence, Type, TypeVar
|
|
1
|
+
from typing import Annotated, Any, Optional, Sequence, Type, TypeVar
|
|
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,7 +73,7 @@ def make_crud_router_plus_sql(
|
|
|
59
73
|
# -------- LIST --------
|
|
60
74
|
@router.get(
|
|
61
75
|
"",
|
|
62
|
-
response_model=
|
|
76
|
+
response_model=Page[read_schema],
|
|
63
77
|
description=f"List items of type {model.__name__}",
|
|
64
78
|
)
|
|
65
79
|
async def list_items(
|
|
@@ -85,18 +99,16 @@ def make_crud_router_plus_sql(
|
|
|
85
99
|
else:
|
|
86
100
|
items = await service.list(session, limit=lp.limit, offset=lp.offset, order_by=order_by)
|
|
87
101
|
total = await service.count(session)
|
|
88
|
-
return Page[
|
|
89
|
-
total=total, items=items, limit=lp.limit, offset=lp.offset
|
|
90
|
-
)
|
|
102
|
+
return Page[Any].from_items(total=total, items=items, limit=lp.limit, offset=lp.offset)
|
|
91
103
|
|
|
92
104
|
# -------- GET by id --------
|
|
93
105
|
@router.get(
|
|
94
106
|
"/{item_id}",
|
|
95
|
-
response_model=
|
|
107
|
+
response_model=read_schema,
|
|
96
108
|
description=f"Get item of type {model.__name__}",
|
|
97
109
|
)
|
|
98
110
|
async def get_item(item_id: Any, session: SqlSessionDep): # type: ignore[name-defined]
|
|
99
|
-
row = await service.get(session, item_id)
|
|
111
|
+
row = await service.get(session, _coerce_id(item_id))
|
|
100
112
|
if not row:
|
|
101
113
|
raise HTTPException(404, "Not found")
|
|
102
114
|
return row
|
|
@@ -104,7 +116,7 @@ def make_crud_router_plus_sql(
|
|
|
104
116
|
# -------- CREATE --------
|
|
105
117
|
@router.post(
|
|
106
118
|
"",
|
|
107
|
-
response_model=
|
|
119
|
+
response_model=read_schema,
|
|
108
120
|
status_code=201,
|
|
109
121
|
description=f"Create item of type {model.__name__}",
|
|
110
122
|
)
|
|
@@ -112,13 +124,18 @@ def make_crud_router_plus_sql(
|
|
|
112
124
|
session: SqlSessionDep, # type: ignore[name-defined]
|
|
113
125
|
payload: create_schema = Body(...),
|
|
114
126
|
):
|
|
115
|
-
|
|
127
|
+
if isinstance(payload, BaseModel):
|
|
128
|
+
data = payload.model_dump(exclude_unset=True)
|
|
129
|
+
elif isinstance(payload, dict):
|
|
130
|
+
data = payload
|
|
131
|
+
else:
|
|
132
|
+
raise HTTPException(422, "invalid_payload")
|
|
116
133
|
return await service.create(session, data)
|
|
117
134
|
|
|
118
135
|
# -------- UPDATE --------
|
|
119
136
|
@router.patch(
|
|
120
137
|
"/{item_id}",
|
|
121
|
-
response_model=
|
|
138
|
+
response_model=read_schema,
|
|
122
139
|
description=f"Update item of type {model.__name__}",
|
|
123
140
|
)
|
|
124
141
|
async def update_item(
|
|
@@ -126,8 +143,13 @@ def make_crud_router_plus_sql(
|
|
|
126
143
|
session: SqlSessionDep, # type: ignore[name-defined]
|
|
127
144
|
payload: update_schema = Body(...),
|
|
128
145
|
):
|
|
129
|
-
|
|
130
|
-
|
|
146
|
+
if isinstance(payload, BaseModel):
|
|
147
|
+
data = payload.model_dump(exclude_unset=True)
|
|
148
|
+
elif isinstance(payload, dict):
|
|
149
|
+
data = payload
|
|
150
|
+
else:
|
|
151
|
+
raise HTTPException(422, "invalid_payload")
|
|
152
|
+
row = await service.update(session, _coerce_id(item_id), data)
|
|
131
153
|
if not row:
|
|
132
154
|
raise HTTPException(404, "Not found")
|
|
133
155
|
return row
|
|
@@ -137,7 +159,147 @@ def make_crud_router_plus_sql(
|
|
|
137
159
|
"/{item_id}", status_code=204, description=f"Delete item of type {model.__name__}"
|
|
138
160
|
)
|
|
139
161
|
async def delete_item(item_id: Any, session: SqlSessionDep): # type: ignore[name-defined]
|
|
140
|
-
ok = await service.delete(session, item_id)
|
|
162
|
+
ok = await service.delete(session, _coerce_id(item_id))
|
|
163
|
+
if not ok:
|
|
164
|
+
raise HTTPException(404, "Not found")
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
return router
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def make_tenant_crud_router_plus_sql(
|
|
171
|
+
*,
|
|
172
|
+
model: type[Any],
|
|
173
|
+
service_factory: callable, # factory that returns a SqlService (will be wrapped)
|
|
174
|
+
read_schema: Type[ReadModel],
|
|
175
|
+
create_schema: Type[CreateModel],
|
|
176
|
+
update_schema: Type[UpdateModel],
|
|
177
|
+
prefix: str,
|
|
178
|
+
tenant_field: str = "tenant_id",
|
|
179
|
+
tags: list[str] | None = None,
|
|
180
|
+
search_fields: Optional[Sequence[str]] = None,
|
|
181
|
+
default_ordering: Optional[str] = None,
|
|
182
|
+
allowed_order_fields: Optional[list[str]] = None,
|
|
183
|
+
mount_under_db_prefix: bool = True,
|
|
184
|
+
) -> APIRouter:
|
|
185
|
+
"""Like make_crud_router_plus_sql, but requires TenantId and scopes all operations."""
|
|
186
|
+
router_prefix = ("/_sql" + prefix) if mount_under_db_prefix else prefix
|
|
187
|
+
router = public_router(
|
|
188
|
+
prefix=router_prefix,
|
|
189
|
+
tags=tags or [prefix.strip("/")],
|
|
190
|
+
redirect_slashes=False,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Evaluate the base service once to preserve in-memory state across requests in tests/local.
|
|
194
|
+
# Consumers may pass either an instance or a zero-arg factory function.
|
|
195
|
+
try:
|
|
196
|
+
_base_instance = service_factory() if callable(service_factory) else service_factory # type: ignore[misc]
|
|
197
|
+
except TypeError:
|
|
198
|
+
# If the callable requires args, assume it's already an instance
|
|
199
|
+
_base_instance = service_factory # type: ignore[assignment]
|
|
200
|
+
|
|
201
|
+
def _coerce_id(v: Any) -> Any:
|
|
202
|
+
"""Best-effort coercion of path ids: cast digit-only strings to int.
|
|
203
|
+
Keeps original type otherwise.
|
|
204
|
+
"""
|
|
205
|
+
if isinstance(v, str) and v.isdigit():
|
|
206
|
+
try:
|
|
207
|
+
return int(v)
|
|
208
|
+
except Exception:
|
|
209
|
+
return v
|
|
210
|
+
return v
|
|
211
|
+
|
|
212
|
+
def _parse_ordering_to_fields(order_spec: Optional[str]) -> list[str]:
|
|
213
|
+
if not order_spec:
|
|
214
|
+
return []
|
|
215
|
+
pieces = [p.strip() for p in order_spec.split(",") if p.strip()]
|
|
216
|
+
fields: list[str] = []
|
|
217
|
+
for p in pieces:
|
|
218
|
+
name = p[1:] if p.startswith("-") else p
|
|
219
|
+
if allowed_order_fields and name not in (allowed_order_fields or []):
|
|
220
|
+
continue
|
|
221
|
+
fields.append(p)
|
|
222
|
+
return fields
|
|
223
|
+
|
|
224
|
+
# create per-request service with tenant scoping
|
|
225
|
+
async def _svc(session: SqlSessionDep, tenant_id: TenantId): # type: ignore[name-defined]
|
|
226
|
+
repo_or_service = getattr(_base_instance, "repo", _base_instance)
|
|
227
|
+
svc: Any = TenantSqlService(repo_or_service, tenant_id=tenant_id, tenant_field=tenant_field) # type: ignore[arg-type]
|
|
228
|
+
return svc # type: ignore[return-value]
|
|
229
|
+
|
|
230
|
+
@router.get("", response_model=Page[read_schema])
|
|
231
|
+
async def list_items(
|
|
232
|
+
lp: Annotated[LimitOffsetParams, Depends(dep_limit_offset)],
|
|
233
|
+
op: Annotated[OrderParams, Depends(dep_order)],
|
|
234
|
+
sp: Annotated[SearchParams, Depends(dep_search)],
|
|
235
|
+
session: SqlSessionDep, # type: ignore[name-defined]
|
|
236
|
+
tenant_id: TenantId,
|
|
237
|
+
):
|
|
238
|
+
svc = await _svc(session, tenant_id)
|
|
239
|
+
order_spec = op.order_by or default_ordering
|
|
240
|
+
order_fields = _parse_ordering_to_fields(order_spec)
|
|
241
|
+
order_by = build_order_by(model, order_fields)
|
|
242
|
+
if sp.q:
|
|
243
|
+
fields = [
|
|
244
|
+
f.strip()
|
|
245
|
+
for f in (sp.fields or (",".join(search_fields or []) or "")).split(",")
|
|
246
|
+
if f.strip()
|
|
247
|
+
]
|
|
248
|
+
items = await svc.search(
|
|
249
|
+
session, q=sp.q, fields=fields, limit=lp.limit, offset=lp.offset, order_by=order_by
|
|
250
|
+
)
|
|
251
|
+
total = await svc.count_filtered(session, q=sp.q, fields=fields)
|
|
252
|
+
else:
|
|
253
|
+
items = await svc.list(session, limit=lp.limit, offset=lp.offset, order_by=order_by)
|
|
254
|
+
total = await svc.count(session)
|
|
255
|
+
return Page[Any].from_items(total=total, items=items, limit=lp.limit, offset=lp.offset)
|
|
256
|
+
|
|
257
|
+
@router.get("/{item_id}", response_model=read_schema)
|
|
258
|
+
async def get_item(item_id: Any, session: SqlSessionDep, tenant_id: TenantId): # type: ignore[name-defined]
|
|
259
|
+
svc = await _svc(session, tenant_id)
|
|
260
|
+
obj = await svc.get(session, item_id)
|
|
261
|
+
if not obj:
|
|
262
|
+
raise HTTPException(404, "not_found")
|
|
263
|
+
return obj
|
|
264
|
+
|
|
265
|
+
@router.post("", response_model=read_schema, status_code=201)
|
|
266
|
+
async def create_item(
|
|
267
|
+
session: SqlSessionDep, # type: ignore[name-defined]
|
|
268
|
+
tenant_id: TenantId,
|
|
269
|
+
payload: create_schema = Body(...),
|
|
270
|
+
):
|
|
271
|
+
svc = await _svc(session, tenant_id)
|
|
272
|
+
if isinstance(payload, BaseModel):
|
|
273
|
+
data = payload.model_dump(exclude_unset=True)
|
|
274
|
+
elif isinstance(payload, dict):
|
|
275
|
+
data = payload
|
|
276
|
+
else:
|
|
277
|
+
raise HTTPException(422, "invalid_payload")
|
|
278
|
+
return await svc.create(session, data)
|
|
279
|
+
|
|
280
|
+
@router.patch("/{item_id}", response_model=read_schema)
|
|
281
|
+
async def update_item(
|
|
282
|
+
item_id: Any,
|
|
283
|
+
session: SqlSessionDep, # type: ignore[name-defined]
|
|
284
|
+
tenant_id: TenantId,
|
|
285
|
+
payload: update_schema = Body(...),
|
|
286
|
+
):
|
|
287
|
+
svc = await _svc(session, tenant_id)
|
|
288
|
+
if isinstance(payload, BaseModel):
|
|
289
|
+
data = payload.model_dump(exclude_unset=True)
|
|
290
|
+
elif isinstance(payload, dict):
|
|
291
|
+
data = payload
|
|
292
|
+
else:
|
|
293
|
+
raise HTTPException(422, "invalid_payload")
|
|
294
|
+
updated = await svc.update(session, item_id, data)
|
|
295
|
+
if not updated:
|
|
296
|
+
raise HTTPException(404, "not_found")
|
|
297
|
+
return updated
|
|
298
|
+
|
|
299
|
+
@router.delete("/{item_id}", status_code=204)
|
|
300
|
+
async def delete_item(item_id: Any, session: SqlSessionDep, tenant_id: TenantId): # type: ignore[name-defined]
|
|
301
|
+
svc = await _svc(session, tenant_id)
|
|
302
|
+
ok = await svc.delete(session, _coerce_id(item_id))
|
|
141
303
|
if not ok:
|
|
142
304
|
raise HTTPException(404, "Not found")
|
|
143
305
|
return
|
|
@@ -145,4 +307,4 @@ def make_crud_router_plus_sql(
|
|
|
145
307
|
return router
|
|
146
308
|
|
|
147
309
|
|
|
148
|
-
__all__ = ["make_crud_router_plus_sql"]
|
|
310
|
+
__all__ = ["make_crud_router_plus_sql", "make_tenant_crud_router_plus_sql"]
|