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.
Files changed (202) hide show
  1. svc_infra/apf_payments/README.md +732 -0
  2. svc_infra/apf_payments/alembic.py +11 -0
  3. svc_infra/apf_payments/models.py +339 -0
  4. svc_infra/apf_payments/provider/__init__.py +4 -0
  5. svc_infra/apf_payments/provider/aiydan.py +797 -0
  6. svc_infra/apf_payments/provider/base.py +270 -0
  7. svc_infra/apf_payments/provider/registry.py +31 -0
  8. svc_infra/apf_payments/provider/stripe.py +873 -0
  9. svc_infra/apf_payments/schemas.py +333 -0
  10. svc_infra/apf_payments/service.py +892 -0
  11. svc_infra/apf_payments/settings.py +67 -0
  12. svc_infra/api/fastapi/__init__.py +6 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +231 -0
  15. svc_infra/api/fastapi/apf_payments/__init__.py +0 -0
  16. svc_infra/api/fastapi/apf_payments/router.py +1082 -0
  17. svc_infra/api/fastapi/apf_payments/setup.py +73 -0
  18. svc_infra/api/fastapi/auth/add.py +15 -6
  19. svc_infra/api/fastapi/auth/gaurd.py +67 -5
  20. svc_infra/api/fastapi/auth/mfa/router.py +18 -9
  21. svc_infra/api/fastapi/auth/routers/account.py +3 -2
  22. svc_infra/api/fastapi/auth/routers/apikey_router.py +11 -5
  23. svc_infra/api/fastapi/auth/routers/oauth_router.py +82 -37
  24. svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
  25. svc_infra/api/fastapi/auth/security.py +3 -1
  26. svc_infra/api/fastapi/auth/settings.py +2 -0
  27. svc_infra/api/fastapi/auth/state.py +1 -1
  28. svc_infra/api/fastapi/billing/router.py +64 -0
  29. svc_infra/api/fastapi/billing/setup.py +19 -0
  30. svc_infra/api/fastapi/cache/add.py +9 -5
  31. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  32. svc_infra/api/fastapi/db/sql/add.py +40 -18
  33. svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
  34. svc_infra/api/fastapi/db/sql/session.py +16 -0
  35. svc_infra/api/fastapi/db/sql/users.py +14 -2
  36. svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
  37. svc_infra/api/fastapi/docs/add.py +160 -0
  38. svc_infra/api/fastapi/docs/landing.py +1 -1
  39. svc_infra/api/fastapi/docs/scoped.py +254 -0
  40. svc_infra/api/fastapi/dual/dualize.py +38 -33
  41. svc_infra/api/fastapi/dual/router.py +48 -1
  42. svc_infra/api/fastapi/dx.py +3 -3
  43. svc_infra/api/fastapi/http/__init__.py +0 -0
  44. svc_infra/api/fastapi/http/concurrency.py +14 -0
  45. svc_infra/api/fastapi/http/conditional.py +33 -0
  46. svc_infra/api/fastapi/http/deprecation.py +21 -0
  47. svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
  48. svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
  49. svc_infra/api/fastapi/middleware/idempotency.py +116 -0
  50. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  51. svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
  52. svc_infra/api/fastapi/middleware/ratelimit.py +119 -0
  53. svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
  54. svc_infra/api/fastapi/middleware/request_id.py +23 -0
  55. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  56. svc_infra/api/fastapi/middleware/timeout.py +148 -0
  57. svc_infra/api/fastapi/openapi/mutators.py +768 -55
  58. svc_infra/api/fastapi/ops/add.py +73 -0
  59. svc_infra/api/fastapi/pagination.py +363 -0
  60. svc_infra/api/fastapi/paths/auth.py +14 -14
  61. svc_infra/api/fastapi/paths/prefix.py +0 -1
  62. svc_infra/api/fastapi/paths/user.py +1 -1
  63. svc_infra/api/fastapi/routers/ping.py +1 -0
  64. svc_infra/api/fastapi/setup.py +48 -15
  65. svc_infra/api/fastapi/tenancy/add.py +19 -0
  66. svc_infra/api/fastapi/tenancy/context.py +112 -0
  67. svc_infra/api/fastapi/versioned.py +101 -0
  68. svc_infra/app/README.md +5 -5
  69. svc_infra/billing/__init__.py +23 -0
  70. svc_infra/billing/async_service.py +147 -0
  71. svc_infra/billing/jobs.py +230 -0
  72. svc_infra/billing/models.py +131 -0
  73. svc_infra/billing/quotas.py +101 -0
  74. svc_infra/billing/schemas.py +33 -0
  75. svc_infra/billing/service.py +115 -0
  76. svc_infra/bundled_docs/README.md +5 -0
  77. svc_infra/bundled_docs/__init__.py +1 -0
  78. svc_infra/bundled_docs/getting-started.md +6 -0
  79. svc_infra/cache/__init__.py +4 -0
  80. svc_infra/cache/add.py +158 -0
  81. svc_infra/cache/backend.py +5 -2
  82. svc_infra/cache/decorators.py +19 -1
  83. svc_infra/cache/keys.py +24 -4
  84. svc_infra/cli/__init__.py +32 -8
  85. svc_infra/cli/__main__.py +4 -0
  86. svc_infra/cli/cmds/__init__.py +10 -0
  87. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
  88. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
  89. svc_infra/cli/cmds/db/sql/alembic_cmds.py +120 -14
  90. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  91. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +5 -4
  92. svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
  93. svc_infra/cli/cmds/dx/__init__.py +12 -0
  94. svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
  95. svc_infra/cli/cmds/help.py +4 -0
  96. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  97. svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
  98. svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
  99. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  100. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  101. svc_infra/data/add.py +61 -0
  102. svc_infra/data/backup.py +53 -0
  103. svc_infra/data/erasure.py +45 -0
  104. svc_infra/data/fixtures.py +40 -0
  105. svc_infra/data/retention.py +55 -0
  106. svc_infra/db/inbox.py +67 -0
  107. svc_infra/db/nosql/mongo/README.md +13 -13
  108. svc_infra/db/outbox.py +104 -0
  109. svc_infra/db/sql/apikey.py +1 -1
  110. svc_infra/db/sql/authref.py +61 -0
  111. svc_infra/db/sql/core.py +2 -2
  112. svc_infra/db/sql/repository.py +52 -12
  113. svc_infra/db/sql/resource.py +5 -0
  114. svc_infra/db/sql/scaffold.py +16 -4
  115. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  116. svc_infra/db/sql/templates/setup/env_async.py.tmpl +199 -76
  117. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +231 -79
  118. svc_infra/db/sql/tenant.py +79 -0
  119. svc_infra/db/sql/utils.py +18 -4
  120. svc_infra/db/sql/versioning.py +14 -0
  121. svc_infra/docs/acceptance-matrix.md +71 -0
  122. svc_infra/docs/acceptance.md +44 -0
  123. svc_infra/docs/admin.md +425 -0
  124. svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
  125. svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
  126. svc_infra/docs/adr/0004-tenancy-model.md +42 -0
  127. svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
  128. svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
  129. svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
  130. svc_infra/docs/adr/0008-billing-primitives.md +143 -0
  131. svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
  132. svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
  133. svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
  134. svc_infra/docs/api.md +59 -0
  135. svc_infra/docs/auth.md +11 -0
  136. svc_infra/docs/billing.md +190 -0
  137. svc_infra/docs/cache.md +76 -0
  138. svc_infra/docs/cli.md +74 -0
  139. svc_infra/docs/contributing.md +34 -0
  140. svc_infra/docs/data-lifecycle.md +52 -0
  141. svc_infra/docs/database.md +14 -0
  142. svc_infra/docs/docs-and-sdks.md +62 -0
  143. svc_infra/docs/environment.md +114 -0
  144. svc_infra/docs/getting-started.md +63 -0
  145. svc_infra/docs/idempotency.md +111 -0
  146. svc_infra/docs/jobs.md +67 -0
  147. svc_infra/docs/observability.md +16 -0
  148. svc_infra/docs/ops.md +37 -0
  149. svc_infra/docs/rate-limiting.md +125 -0
  150. svc_infra/docs/repo-review.md +48 -0
  151. svc_infra/docs/security.md +176 -0
  152. svc_infra/docs/tenancy.md +35 -0
  153. svc_infra/docs/timeouts-and-resource-limits.md +147 -0
  154. svc_infra/docs/versioned-integrations.md +146 -0
  155. svc_infra/docs/webhooks.md +112 -0
  156. svc_infra/dx/add.py +63 -0
  157. svc_infra/dx/changelog.py +74 -0
  158. svc_infra/dx/checks.py +67 -0
  159. svc_infra/http/__init__.py +13 -0
  160. svc_infra/http/client.py +72 -0
  161. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  162. svc_infra/jobs/builtins/webhook_delivery.py +90 -0
  163. svc_infra/jobs/easy.py +32 -0
  164. svc_infra/jobs/loader.py +45 -0
  165. svc_infra/jobs/queue.py +81 -0
  166. svc_infra/jobs/redis_queue.py +191 -0
  167. svc_infra/jobs/runner.py +75 -0
  168. svc_infra/jobs/scheduler.py +41 -0
  169. svc_infra/jobs/worker.py +40 -0
  170. svc_infra/mcp/svc_infra_mcp.py +85 -28
  171. svc_infra/obs/README.md +2 -0
  172. svc_infra/obs/add.py +54 -7
  173. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  174. svc_infra/obs/metrics/__init__.py +53 -0
  175. svc_infra/obs/metrics.py +52 -0
  176. svc_infra/security/add.py +201 -0
  177. svc_infra/security/audit.py +130 -0
  178. svc_infra/security/audit_service.py +73 -0
  179. svc_infra/security/headers.py +52 -0
  180. svc_infra/security/hibp.py +95 -0
  181. svc_infra/security/jwt_rotation.py +53 -0
  182. svc_infra/security/lockout.py +96 -0
  183. svc_infra/security/models.py +255 -0
  184. svc_infra/security/org_invites.py +128 -0
  185. svc_infra/security/passwords.py +77 -0
  186. svc_infra/security/permissions.py +149 -0
  187. svc_infra/security/session.py +98 -0
  188. svc_infra/security/signed_cookies.py +80 -0
  189. svc_infra/webhooks/__init__.py +16 -0
  190. svc_infra/webhooks/add.py +322 -0
  191. svc_infra/webhooks/fastapi.py +37 -0
  192. svc_infra/webhooks/router.py +55 -0
  193. svc_infra/webhooks/service.py +67 -0
  194. svc_infra/webhooks/signing.py +30 -0
  195. svc_infra-0.1.654.dist-info/METADATA +154 -0
  196. svc_infra-0.1.654.dist-info/RECORD +352 -0
  197. svc_infra/api/fastapi/deps.py +0 -3
  198. svc_infra-0.1.506.dist-info/METADATA +0 -78
  199. svc_infra-0.1.506.dist-info/RECORD +0 -213
  200. /svc_infra/{api/fastapi/schemas → apf_payments}/__init__.py +0 -0
  201. {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
  202. {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 = "/user/login"
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 add_auth().")
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
- @app.on_event("startup")
9
- async def _startup():
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
- @app.on_event("shutdown")
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
- @app.on_event("startup")
42
- async def _startup() -> None:
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
- @app.on_event("shutdown")
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 r in resources:
67
+ for resource in resources:
66
68
  repo = NoSqlRepository(
67
- collection_name=r.resolved_collection(),
68
- id_field=r.id_field,
69
- soft_delete=r.soft_delete,
70
- soft_delete_field=r.soft_delete_field,
71
- soft_delete_flag_field=r.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 = r.service_factory(repo) if r.service_factory else NoSqlService(repo)
75
+ svc = resource.service_factory(repo) if resource.service_factory else NoSqlService(repo)
74
76
 
75
- if r.read_schema and r.create_schema and r.update_schema:
76
- Read, Create, Update = r.read_schema, r.create_schema, r.update_schema
77
- elif r.document_model is not None:
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
- r.document_model,
81
- create_exclude=r.create_exclude,
82
- read_name=r.read_name,
83
- create_name=r.create_name,
84
- update_name=r.update_name,
85
- read_exclude=r.read_exclude,
86
- update_exclude=r.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 '{r.collection}' requires either explicit schemas "
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=r.resolved_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=r.prefix,
103
- tags=r.tags,
104
- search_fields=r.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
- router = make_crud_router_plus_sql(
41
- model=r.model,
42
- service=svc,
43
- read_schema=Read,
44
- create_schema=Create,
45
- update_schema=Update,
46
- prefix=r.prefix,
47
- tags=r.tags,
48
- search_fields=r.search_fields,
49
- default_ordering=r.ordering_default,
50
- allowed_order_fields=r.allowed_order_fields,
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
- @app.on_event("startup")
71
- async def _startup() -> None: # noqa: ANN202
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
- @app.on_event("shutdown")
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, cast
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=cast(Any, Page[read_schema]),
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[read_schema].from_items(
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=cast(Any, read_schema),
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=cast(Any, read_schema),
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
- data = cast(BaseModel, payload).model_dump(exclude_unset=True)
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=cast(Any, read_schema),
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
- data = cast(BaseModel, payload).model_dump(exclude_unset=True)
130
- row = await service.update(session, item_id, data)
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"]