svc-infra 0.1.595__py3-none-any.whl → 0.1.706__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of svc-infra might be problematic. Click here for more details.

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