svc-infra 0.1.589__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 (260) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/README.md +732 -0
  3. svc_infra/apf_payments/models.py +133 -42
  4. svc_infra/apf_payments/provider/__init__.py +4 -0
  5. svc_infra/apf_payments/provider/aiydan.py +871 -0
  6. svc_infra/apf_payments/provider/base.py +30 -9
  7. svc_infra/apf_payments/provider/stripe.py +156 -62
  8. svc_infra/apf_payments/schemas.py +19 -10
  9. svc_infra/apf_payments/service.py +211 -68
  10. svc_infra/apf_payments/settings.py +27 -3
  11. svc_infra/api/__init__.py +61 -0
  12. svc_infra/api/fastapi/__init__.py +15 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +245 -0
  15. svc_infra/api/fastapi/apf_payments/router.py +145 -46
  16. svc_infra/api/fastapi/apf_payments/setup.py +26 -8
  17. svc_infra/api/fastapi/auth/__init__.py +65 -0
  18. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  19. svc_infra/api/fastapi/auth/add.py +27 -14
  20. svc_infra/api/fastapi/auth/gaurd.py +104 -13
  21. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  22. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  23. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  24. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  25. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  26. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  27. svc_infra/api/fastapi/auth/policy.py +0 -1
  28. svc_infra/api/fastapi/auth/providers.py +3 -1
  29. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  30. svc_infra/api/fastapi/auth/routers/oauth_router.py +214 -75
  31. svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
  32. svc_infra/api/fastapi/auth/security.py +31 -10
  33. svc_infra/api/fastapi/auth/sender.py +8 -1
  34. svc_infra/api/fastapi/auth/settings.py +2 -0
  35. svc_infra/api/fastapi/auth/state.py +3 -1
  36. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  37. svc_infra/api/fastapi/billing/router.py +73 -0
  38. svc_infra/api/fastapi/billing/setup.py +19 -0
  39. svc_infra/api/fastapi/cache/add.py +9 -5
  40. svc_infra/api/fastapi/db/__init__.py +5 -1
  41. svc_infra/api/fastapi/db/http.py +3 -1
  42. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  43. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  44. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  45. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  46. svc_infra/api/fastapi/db/sql/add.py +71 -26
  47. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  48. svc_infra/api/fastapi/db/sql/health.py +3 -1
  49. svc_infra/api/fastapi/db/sql/session.py +18 -0
  50. svc_infra/api/fastapi/db/sql/users.py +29 -5
  51. svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
  52. svc_infra/api/fastapi/docs/add.py +173 -0
  53. svc_infra/api/fastapi/docs/landing.py +4 -2
  54. svc_infra/api/fastapi/docs/scoped.py +62 -15
  55. svc_infra/api/fastapi/dual/__init__.py +12 -2
  56. svc_infra/api/fastapi/dual/dualize.py +1 -1
  57. svc_infra/api/fastapi/dual/protected.py +126 -4
  58. svc_infra/api/fastapi/dual/public.py +25 -0
  59. svc_infra/api/fastapi/dual/router.py +40 -13
  60. svc_infra/api/fastapi/dx.py +33 -2
  61. svc_infra/api/fastapi/ease.py +10 -2
  62. svc_infra/api/fastapi/http/concurrency.py +2 -1
  63. svc_infra/api/fastapi/http/conditional.py +3 -1
  64. svc_infra/api/fastapi/middleware/debug.py +4 -1
  65. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  66. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  67. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  68. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  69. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  70. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  71. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  72. svc_infra/api/fastapi/middleware/ratelimit.py +143 -31
  73. svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
  74. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  75. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  76. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  77. svc_infra/api/fastapi/openapi/apply.py +5 -3
  78. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  79. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  80. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  81. svc_infra/api/fastapi/openapi/security.py +3 -1
  82. svc_infra/api/fastapi/ops/add.py +75 -0
  83. svc_infra/api/fastapi/pagination.py +47 -20
  84. svc_infra/api/fastapi/routers/__init__.py +43 -15
  85. svc_infra/api/fastapi/routers/ping.py +1 -0
  86. svc_infra/api/fastapi/setup.py +188 -56
  87. svc_infra/api/fastapi/tenancy/add.py +19 -0
  88. svc_infra/api/fastapi/tenancy/context.py +112 -0
  89. svc_infra/api/fastapi/versioned.py +101 -0
  90. svc_infra/app/README.md +5 -5
  91. svc_infra/app/__init__.py +3 -1
  92. svc_infra/app/env.py +69 -1
  93. svc_infra/app/logging/add.py +9 -2
  94. svc_infra/app/logging/formats.py +12 -5
  95. svc_infra/billing/__init__.py +23 -0
  96. svc_infra/billing/async_service.py +147 -0
  97. svc_infra/billing/jobs.py +241 -0
  98. svc_infra/billing/models.py +177 -0
  99. svc_infra/billing/quotas.py +103 -0
  100. svc_infra/billing/schemas.py +36 -0
  101. svc_infra/billing/service.py +123 -0
  102. svc_infra/bundled_docs/README.md +5 -0
  103. svc_infra/bundled_docs/__init__.py +1 -0
  104. svc_infra/bundled_docs/getting-started.md +6 -0
  105. svc_infra/cache/__init__.py +9 -0
  106. svc_infra/cache/add.py +170 -0
  107. svc_infra/cache/backend.py +7 -6
  108. svc_infra/cache/decorators.py +81 -15
  109. svc_infra/cache/demo.py +2 -2
  110. svc_infra/cache/keys.py +24 -4
  111. svc_infra/cache/recache.py +26 -14
  112. svc_infra/cache/resources.py +14 -5
  113. svc_infra/cache/tags.py +19 -44
  114. svc_infra/cache/utils.py +3 -1
  115. svc_infra/cli/__init__.py +52 -8
  116. svc_infra/cli/__main__.py +4 -0
  117. svc_infra/cli/cmds/__init__.py +39 -2
  118. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  119. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  120. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  121. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  122. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  123. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  124. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  125. svc_infra/cli/cmds/dx/__init__.py +12 -0
  126. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  127. svc_infra/cli/cmds/health/__init__.py +179 -0
  128. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  129. svc_infra/cli/cmds/help.py +4 -0
  130. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  131. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  132. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  133. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  134. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  135. svc_infra/cli/foundation/runner.py +6 -2
  136. svc_infra/data/add.py +61 -0
  137. svc_infra/data/backup.py +58 -0
  138. svc_infra/data/erasure.py +45 -0
  139. svc_infra/data/fixtures.py +42 -0
  140. svc_infra/data/retention.py +61 -0
  141. svc_infra/db/__init__.py +15 -0
  142. svc_infra/db/crud_schema.py +9 -9
  143. svc_infra/db/inbox.py +67 -0
  144. svc_infra/db/nosql/__init__.py +3 -0
  145. svc_infra/db/nosql/core.py +30 -9
  146. svc_infra/db/nosql/indexes.py +3 -1
  147. svc_infra/db/nosql/management.py +1 -1
  148. svc_infra/db/nosql/mongo/README.md +13 -13
  149. svc_infra/db/nosql/mongo/client.py +19 -2
  150. svc_infra/db/nosql/mongo/settings.py +6 -2
  151. svc_infra/db/nosql/repository.py +35 -15
  152. svc_infra/db/nosql/resource.py +20 -3
  153. svc_infra/db/nosql/scaffold.py +9 -3
  154. svc_infra/db/nosql/service.py +3 -1
  155. svc_infra/db/nosql/types.py +6 -2
  156. svc_infra/db/ops.py +384 -0
  157. svc_infra/db/outbox.py +108 -0
  158. svc_infra/db/sql/apikey.py +37 -9
  159. svc_infra/db/sql/authref.py +9 -3
  160. svc_infra/db/sql/constants.py +12 -8
  161. svc_infra/db/sql/core.py +2 -2
  162. svc_infra/db/sql/management.py +11 -8
  163. svc_infra/db/sql/repository.py +99 -26
  164. svc_infra/db/sql/resource.py +5 -0
  165. svc_infra/db/sql/scaffold.py +6 -2
  166. svc_infra/db/sql/service.py +15 -5
  167. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  168. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  169. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  170. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  171. svc_infra/db/sql/tenant.py +88 -0
  172. svc_infra/db/sql/uniq_hooks.py +9 -3
  173. svc_infra/db/sql/utils.py +138 -51
  174. svc_infra/db/sql/versioning.py +14 -0
  175. svc_infra/deploy/__init__.py +538 -0
  176. svc_infra/documents/__init__.py +100 -0
  177. svc_infra/documents/add.py +264 -0
  178. svc_infra/documents/ease.py +233 -0
  179. svc_infra/documents/models.py +114 -0
  180. svc_infra/documents/storage.py +264 -0
  181. svc_infra/dx/add.py +65 -0
  182. svc_infra/dx/changelog.py +74 -0
  183. svc_infra/dx/checks.py +68 -0
  184. svc_infra/exceptions.py +141 -0
  185. svc_infra/health/__init__.py +864 -0
  186. svc_infra/http/__init__.py +13 -0
  187. svc_infra/http/client.py +105 -0
  188. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  189. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  190. svc_infra/jobs/easy.py +33 -0
  191. svc_infra/jobs/loader.py +50 -0
  192. svc_infra/jobs/queue.py +116 -0
  193. svc_infra/jobs/redis_queue.py +256 -0
  194. svc_infra/jobs/runner.py +79 -0
  195. svc_infra/jobs/scheduler.py +53 -0
  196. svc_infra/jobs/worker.py +40 -0
  197. svc_infra/loaders/__init__.py +186 -0
  198. svc_infra/loaders/base.py +142 -0
  199. svc_infra/loaders/github.py +311 -0
  200. svc_infra/loaders/models.py +147 -0
  201. svc_infra/loaders/url.py +235 -0
  202. svc_infra/logging/__init__.py +374 -0
  203. svc_infra/mcp/svc_infra_mcp.py +91 -33
  204. svc_infra/obs/README.md +2 -0
  205. svc_infra/obs/add.py +65 -9
  206. svc_infra/obs/cloud_dash.py +2 -1
  207. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  208. svc_infra/obs/metrics/__init__.py +52 -0
  209. svc_infra/obs/metrics/asgi.py +13 -7
  210. svc_infra/obs/metrics/http.py +9 -5
  211. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  212. svc_infra/obs/metrics.py +53 -0
  213. svc_infra/obs/settings.py +6 -2
  214. svc_infra/security/add.py +217 -0
  215. svc_infra/security/audit.py +212 -0
  216. svc_infra/security/audit_service.py +74 -0
  217. svc_infra/security/headers.py +52 -0
  218. svc_infra/security/hibp.py +101 -0
  219. svc_infra/security/jwt_rotation.py +105 -0
  220. svc_infra/security/lockout.py +102 -0
  221. svc_infra/security/models.py +287 -0
  222. svc_infra/security/oauth_models.py +73 -0
  223. svc_infra/security/org_invites.py +130 -0
  224. svc_infra/security/passwords.py +79 -0
  225. svc_infra/security/permissions.py +171 -0
  226. svc_infra/security/session.py +98 -0
  227. svc_infra/security/signed_cookies.py +100 -0
  228. svc_infra/storage/__init__.py +93 -0
  229. svc_infra/storage/add.py +253 -0
  230. svc_infra/storage/backends/__init__.py +11 -0
  231. svc_infra/storage/backends/local.py +339 -0
  232. svc_infra/storage/backends/memory.py +216 -0
  233. svc_infra/storage/backends/s3.py +353 -0
  234. svc_infra/storage/base.py +239 -0
  235. svc_infra/storage/easy.py +185 -0
  236. svc_infra/storage/settings.py +195 -0
  237. svc_infra/testing/__init__.py +685 -0
  238. svc_infra/utils.py +7 -3
  239. svc_infra/webhooks/__init__.py +69 -0
  240. svc_infra/webhooks/add.py +339 -0
  241. svc_infra/webhooks/encryption.py +115 -0
  242. svc_infra/webhooks/fastapi.py +39 -0
  243. svc_infra/webhooks/router.py +55 -0
  244. svc_infra/webhooks/service.py +70 -0
  245. svc_infra/webhooks/signing.py +34 -0
  246. svc_infra/websocket/__init__.py +79 -0
  247. svc_infra/websocket/add.py +140 -0
  248. svc_infra/websocket/client.py +282 -0
  249. svc_infra/websocket/config.py +69 -0
  250. svc_infra/websocket/easy.py +76 -0
  251. svc_infra/websocket/exceptions.py +61 -0
  252. svc_infra/websocket/manager.py +344 -0
  253. svc_infra/websocket/models.py +49 -0
  254. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  255. svc_infra-0.1.706.dist-info/METADATA +356 -0
  256. svc_infra-0.1.706.dist-info/RECORD +357 -0
  257. svc_infra-0.1.589.dist-info/METADATA +0 -79
  258. svc_infra-0.1.589.dist-info/RECORD +0 -234
  259. {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  260. {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
@@ -24,12 +24,15 @@ from .health import make_mongo_health_router
24
24
  def add_mongo_db_with_url(app: FastAPI, url: str, db_name: str) -> None:
25
25
  @asynccontextmanager
26
26
  async def lifespan(_app: FastAPI):
27
- await init_mongo(MongoSettings(url=url, db_name=db_name))
27
+ # MongoSettings expects url as AnyUrl, which can be constructed from str via Pydantic
28
+ await init_mongo(MongoSettings(url=url, db_name=db_name)) # type: ignore[arg-type] # Pydantic coerces str to AnyUrl
28
29
  try:
29
30
  expected = get_mongo_dbname_from_env(required=False)
30
31
  db = await acquire_db()
31
32
  if expected and db.name != expected:
32
- raise RuntimeError(f"Connected to Mongo DB '{db.name}', expected '{expected}'.")
33
+ raise RuntimeError(
34
+ f"Connected to Mongo DB '{db.name}', expected '{expected}'."
35
+ )
33
36
  yield
34
37
  finally:
35
38
  await close_mongo()
@@ -38,19 +41,23 @@ def add_mongo_db_with_url(app: FastAPI, url: str, db_name: str) -> None:
38
41
 
39
42
 
40
43
  def add_mongo_db(app: FastAPI, *, dsn_env: str = "MONGO_URL") -> None:
41
- @app.on_event("startup")
42
- async def _startup() -> None:
44
+ @asynccontextmanager
45
+ async def lifespan(_app: FastAPI):
43
46
  if not os.getenv(dsn_env):
44
47
  raise RuntimeError(f"Missing environment variable {dsn_env} for Mongo URL")
45
48
  await init_mongo()
46
49
  expected = get_mongo_dbname_from_env(required=False)
47
50
  db = await acquire_db()
48
51
  if expected and db.name != expected:
49
- raise RuntimeError(f"Connected to Mongo DB '{db.name}', expected '{expected}'.")
52
+ raise RuntimeError(
53
+ f"Connected to Mongo DB '{db.name}', expected '{expected}'."
54
+ )
55
+ try:
56
+ yield
57
+ finally:
58
+ await close_mongo()
50
59
 
51
- @app.on_event("shutdown")
52
- async def _shutdown() -> None:
53
- await close_mongo()
60
+ app.router.lifespan_context = lifespan
54
61
 
55
62
 
56
63
  def add_mongo_health(
@@ -58,50 +65,58 @@ def add_mongo_health(
58
65
  ) -> None:
59
66
  if include_in_schema is None:
60
67
  include_in_schema = CURRENT_ENVIRONMENT == LOCAL_ENV
61
- app.include_router(make_mongo_health_router(prefix=prefix, include_in_schema=include_in_schema))
68
+ app.include_router(
69
+ make_mongo_health_router(prefix=prefix, include_in_schema=include_in_schema)
70
+ )
62
71
 
63
72
 
64
73
  def add_mongo_resources(app: FastAPI, resources: Sequence[NoSqlResource]) -> None:
65
- for r in resources:
74
+ for resource in resources:
66
75
  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,
76
+ collection_name=resource.resolved_collection(),
77
+ id_field=resource.id_field,
78
+ soft_delete=resource.soft_delete,
79
+ soft_delete_field=resource.soft_delete_field,
80
+ soft_delete_flag_field=resource.soft_delete_flag_field,
81
+ )
82
+ svc = (
83
+ resource.service_factory(repo)
84
+ if resource.service_factory
85
+ else NoSqlService(repo)
72
86
  )
73
- svc = r.service_factory(repo) if r.service_factory else NoSqlService(repo)
74
87
 
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:
88
+ if resource.read_schema and resource.create_schema and resource.update_schema:
89
+ Read, Create, Update = (
90
+ resource.read_schema,
91
+ resource.create_schema,
92
+ resource.update_schema,
93
+ )
94
+ elif resource.document_model is not None:
78
95
  # CRITICAL: teach Pydantic to dump ObjectId/PyObjectId
79
96
  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,
97
+ resource.document_model,
98
+ create_exclude=resource.create_exclude,
99
+ read_name=resource.read_name,
100
+ create_name=resource.create_name,
101
+ update_name=resource.update_name,
102
+ read_exclude=resource.read_exclude,
103
+ update_exclude=resource.update_exclude,
87
104
  json_encoders={ObjectId: str, PyObjectId: str},
88
105
  )
89
106
  else:
90
107
  raise RuntimeError(
91
- f"Resource for collection '{r.collection}' requires either explicit schemas "
108
+ f"Resource for collection '{resource.collection}' requires either explicit schemas "
92
109
  f"(read/create/update) or a 'document_model' to derive them."
93
110
  )
94
111
 
95
112
  router = make_crud_router_plus_mongo(
96
- collection=r.resolved_collection(),
97
- repo=repo,
98
113
  service=svc,
99
114
  read_schema=Read,
100
115
  create_schema=Create,
101
116
  update_schema=Update,
102
- prefix=r.prefix,
103
- tags=r.tags,
104
- search_fields=r.search_fields,
117
+ prefix=resource.prefix,
118
+ tags=resource.tags,
119
+ search_fields=resource.search_fields,
105
120
  default_ordering=None,
106
121
  allowed_order_fields=None,
107
122
  )
@@ -1,7 +1,14 @@
1
1
  from typing import Annotated, Any, Optional, Sequence, Type, cast
2
2
 
3
3
  from fastapi import APIRouter, Body, Depends, HTTPException
4
- from motor.motor_asyncio import AsyncIOMotorDatabase
4
+
5
+ try:
6
+ from motor.motor_asyncio import AsyncIOMotorDatabase
7
+
8
+ HAS_MOTOR = True
9
+ except ImportError: # pragma: no cover
10
+ HAS_MOTOR = False
11
+ AsyncIOMotorDatabase = Any # type: ignore[assignment, misc]
5
12
 
6
13
  from svc_infra.api.fastapi.db.http import (
7
14
  LimitOffsetParams,
@@ -46,6 +53,9 @@ def make_crud_router_plus_mongo(
46
53
  allowed_order_fields: Optional[list[str]] = None,
47
54
  mount_under_db_prefix: bool = True,
48
55
  ) -> APIRouter:
56
+ read_model = cast(Any, read_schema)
57
+ page_model = cast(Any, Page[read_schema]) # type: ignore[valid-type]
58
+
49
59
  router_prefix = ("/_mongo" + prefix) if mount_under_db_prefix else prefix
50
60
  router = public_router(
51
61
  prefix=router_prefix,
@@ -56,7 +66,7 @@ def make_crud_router_plus_mongo(
56
66
  # LIST
57
67
  @router.get(
58
68
  "",
59
- response_model=cast(Any, Page[read_schema]),
69
+ response_model=page_model,
60
70
  description=f"List items in {prefix} collection",
61
71
  )
62
72
  async def list_items(
@@ -68,20 +78,25 @@ def make_crud_router_plus_mongo(
68
78
  sort = _parse_sort(op.order_by or default_ordering, allowed_order_fields)
69
79
  if sp.q and search_fields:
70
80
  items = await service.search(
71
- db, q=sp.q, fields=search_fields, limit=lp.limit, offset=lp.offset, sort=sort
81
+ db,
82
+ q=sp.q,
83
+ fields=search_fields,
84
+ limit=lp.limit,
85
+ offset=lp.offset,
86
+ sort=sort,
72
87
  )
73
88
  total = await service.count_filtered(db, q=sp.q, fields=search_fields)
74
89
  else:
75
90
  items = await service.list(db, limit=lp.limit, offset=lp.offset, sort=sort)
76
91
  total = await service.count(db)
77
- return Page[read_schema].from_items(
92
+ return Page[Any].from_items(
78
93
  total=total, items=items, limit=lp.limit, offset=lp.offset
79
94
  )
80
95
 
81
96
  # GET by id
82
97
  @router.get(
83
98
  "/{item_id}",
84
- response_model=cast(Any, read_schema),
99
+ response_model=read_model,
85
100
  description=f"Get item from {prefix} collection",
86
101
  )
87
102
  async def get_item(db: DBDep, item_id: Any):
@@ -93,22 +108,26 @@ def make_crud_router_plus_mongo(
93
108
  # CREATE
94
109
  @router.post(
95
110
  "",
96
- response_model=cast(Any, read_schema),
111
+ response_model=read_model,
97
112
  status_code=201,
98
113
  description=f"Create item in {prefix} collection",
99
114
  )
100
- async def create_item(db: DBDep, payload: create_schema = Body(...)):
101
- data = payload.model_dump(exclude_unset=True)
115
+ async def create_item(db: DBDep, payload: create_schema = Body(...)): # type: ignore[valid-type]
116
+ data = cast(Any, payload).model_dump(exclude_unset=True)
102
117
  return await service.create(db, data)
103
118
 
104
119
  # UPDATE
105
120
  @router.patch(
106
121
  "/{item_id}",
107
- response_model=cast(Any, read_schema),
122
+ response_model=read_model,
108
123
  description=f"Update item in {prefix} collection",
109
124
  )
110
- async def update_item(db: DBDep, item_id: Any, payload: update_schema = Body(...)):
111
- data = payload.model_dump(exclude_unset=True)
125
+ async def update_item(
126
+ db: DBDep,
127
+ item_id: Any,
128
+ payload: update_schema = Body(...), # type: ignore[valid-type]
129
+ ):
130
+ data = cast(Any, payload).model_dump(exclude_unset=True)
112
131
  row = await service.update(db, item_id, data)
113
132
  if not row:
114
133
  raise HTTPException(404, "Not found")
@@ -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(