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

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

Potentially problematic release.


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

Files changed (274) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +68 -38
  3. svc_infra/apf_payments/provider/__init__.py +2 -2
  4. svc_infra/apf_payments/provider/aiydan.py +39 -23
  5. svc_infra/apf_payments/provider/base.py +8 -3
  6. svc_infra/apf_payments/provider/registry.py +3 -5
  7. svc_infra/apf_payments/provider/stripe.py +74 -52
  8. svc_infra/apf_payments/schemas.py +84 -83
  9. svc_infra/apf_payments/service.py +27 -16
  10. svc_infra/apf_payments/settings.py +12 -11
  11. svc_infra/api/__init__.py +61 -0
  12. svc_infra/api/fastapi/__init__.py +34 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +240 -0
  15. svc_infra/api/fastapi/apf_payments/router.py +94 -73
  16. svc_infra/api/fastapi/apf_payments/setup.py +10 -9
  17. svc_infra/api/fastapi/auth/__init__.py +65 -0
  18. svc_infra/api/fastapi/auth/_cookies.py +1 -3
  19. svc_infra/api/fastapi/auth/add.py +14 -15
  20. svc_infra/api/fastapi/auth/gaurd.py +32 -20
  21. svc_infra/api/fastapi/auth/mfa/models.py +3 -4
  22. svc_infra/api/fastapi/auth/mfa/pre_auth.py +13 -9
  23. svc_infra/api/fastapi/auth/mfa/router.py +9 -8
  24. svc_infra/api/fastapi/auth/mfa/security.py +4 -7
  25. svc_infra/api/fastapi/auth/mfa/utils.py +5 -3
  26. svc_infra/api/fastapi/auth/policy.py +0 -1
  27. svc_infra/api/fastapi/auth/providers.py +3 -3
  28. svc_infra/api/fastapi/auth/routers/apikey_router.py +19 -21
  29. svc_infra/api/fastapi/auth/routers/oauth_router.py +98 -52
  30. svc_infra/api/fastapi/auth/routers/session_router.py +6 -5
  31. svc_infra/api/fastapi/auth/security.py +25 -15
  32. svc_infra/api/fastapi/auth/sender.py +5 -0
  33. svc_infra/api/fastapi/auth/settings.py +18 -19
  34. svc_infra/api/fastapi/auth/state.py +5 -4
  35. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  36. svc_infra/api/fastapi/billing/router.py +71 -0
  37. svc_infra/api/fastapi/billing/setup.py +19 -0
  38. svc_infra/api/fastapi/cache/add.py +9 -5
  39. svc_infra/api/fastapi/db/__init__.py +5 -1
  40. svc_infra/api/fastapi/db/http.py +10 -9
  41. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  42. svc_infra/api/fastapi/db/nosql/mongo/add.py +35 -30
  43. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +39 -21
  44. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  45. svc_infra/api/fastapi/db/sql/add.py +62 -25
  46. svc_infra/api/fastapi/db/sql/crud_router.py +205 -30
  47. svc_infra/api/fastapi/db/sql/session.py +19 -2
  48. svc_infra/api/fastapi/db/sql/users.py +18 -9
  49. svc_infra/api/fastapi/dependencies/ratelimit.py +76 -14
  50. svc_infra/api/fastapi/docs/add.py +163 -0
  51. svc_infra/api/fastapi/docs/landing.py +6 -6
  52. svc_infra/api/fastapi/docs/scoped.py +75 -36
  53. svc_infra/api/fastapi/dual/__init__.py +12 -2
  54. svc_infra/api/fastapi/dual/dualize.py +2 -2
  55. svc_infra/api/fastapi/dual/protected.py +123 -10
  56. svc_infra/api/fastapi/dual/public.py +25 -0
  57. svc_infra/api/fastapi/dual/router.py +18 -8
  58. svc_infra/api/fastapi/dx.py +33 -2
  59. svc_infra/api/fastapi/ease.py +59 -7
  60. svc_infra/api/fastapi/http/concurrency.py +2 -1
  61. svc_infra/api/fastapi/http/conditional.py +2 -2
  62. svc_infra/api/fastapi/middleware/debug.py +4 -1
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +2 -5
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +50 -10
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +95 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +190 -68
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +39 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +45 -13
  71. svc_infra/api/fastapi/middleware/request_id.py +24 -10
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +176 -0
  74. svc_infra/api/fastapi/object_router.py +1060 -0
  75. svc_infra/api/fastapi/openapi/apply.py +4 -3
  76. svc_infra/api/fastapi/openapi/conventions.py +13 -6
  77. svc_infra/api/fastapi/openapi/mutators.py +144 -17
  78. svc_infra/api/fastapi/openapi/pipeline.py +2 -2
  79. svc_infra/api/fastapi/openapi/responses.py +4 -6
  80. svc_infra/api/fastapi/openapi/security.py +1 -1
  81. svc_infra/api/fastapi/ops/add.py +73 -0
  82. svc_infra/api/fastapi/pagination.py +47 -32
  83. svc_infra/api/fastapi/routers/__init__.py +16 -10
  84. svc_infra/api/fastapi/routers/ping.py +1 -0
  85. svc_infra/api/fastapi/setup.py +167 -54
  86. svc_infra/api/fastapi/tenancy/add.py +20 -0
  87. svc_infra/api/fastapi/tenancy/context.py +113 -0
  88. svc_infra/api/fastapi/versioned.py +102 -0
  89. svc_infra/app/README.md +5 -5
  90. svc_infra/app/__init__.py +3 -1
  91. svc_infra/app/env.py +70 -4
  92. svc_infra/app/logging/add.py +10 -2
  93. svc_infra/app/logging/filter.py +1 -1
  94. svc_infra/app/logging/formats.py +13 -5
  95. svc_infra/app/root.py +3 -3
  96. svc_infra/billing/__init__.py +40 -0
  97. svc_infra/billing/async_service.py +167 -0
  98. svc_infra/billing/jobs.py +231 -0
  99. svc_infra/billing/models.py +146 -0
  100. svc_infra/billing/quotas.py +101 -0
  101. svc_infra/billing/schemas.py +34 -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 +21 -5
  106. svc_infra/cache/add.py +167 -0
  107. svc_infra/cache/backend.py +9 -7
  108. svc_infra/cache/decorators.py +75 -20
  109. svc_infra/cache/demo.py +2 -2
  110. svc_infra/cache/keys.py +26 -6
  111. svc_infra/cache/recache.py +26 -27
  112. svc_infra/cache/resources.py +6 -5
  113. svc_infra/cache/tags.py +19 -44
  114. svc_infra/cache/ttl.py +2 -3
  115. svc_infra/cache/utils.py +4 -3
  116. svc_infra/cli/__init__.py +44 -8
  117. svc_infra/cli/__main__.py +4 -0
  118. svc_infra/cli/cmds/__init__.py +39 -2
  119. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +18 -14
  120. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +9 -10
  121. svc_infra/cli/cmds/db/ops_cmds.py +267 -0
  122. svc_infra/cli/cmds/db/sql/alembic_cmds.py +97 -29
  123. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  124. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +13 -13
  125. svc_infra/cli/cmds/docs/docs_cmds.py +139 -0
  126. svc_infra/cli/cmds/dx/__init__.py +12 -0
  127. svc_infra/cli/cmds/dx/dx_cmds.py +110 -0
  128. svc_infra/cli/cmds/health/__init__.py +179 -0
  129. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  130. svc_infra/cli/cmds/help.py +4 -0
  131. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  132. svc_infra/cli/cmds/jobs/jobs_cmds.py +42 -0
  133. svc_infra/cli/cmds/obs/obs_cmds.py +31 -13
  134. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  135. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  136. svc_infra/cli/foundation/runner.py +4 -5
  137. svc_infra/cli/foundation/typer_bootstrap.py +1 -2
  138. svc_infra/data/__init__.py +83 -0
  139. svc_infra/data/add.py +61 -0
  140. svc_infra/data/backup.py +56 -0
  141. svc_infra/data/erasure.py +46 -0
  142. svc_infra/data/fixtures.py +42 -0
  143. svc_infra/data/retention.py +56 -0
  144. svc_infra/db/__init__.py +15 -0
  145. svc_infra/db/crud_schema.py +14 -13
  146. svc_infra/db/inbox.py +67 -0
  147. svc_infra/db/nosql/__init__.py +2 -0
  148. svc_infra/db/nosql/constants.py +1 -1
  149. svc_infra/db/nosql/core.py +19 -5
  150. svc_infra/db/nosql/indexes.py +12 -9
  151. svc_infra/db/nosql/management.py +4 -4
  152. svc_infra/db/nosql/mongo/README.md +13 -13
  153. svc_infra/db/nosql/mongo/client.py +21 -4
  154. svc_infra/db/nosql/mongo/settings.py +1 -1
  155. svc_infra/db/nosql/repository.py +46 -27
  156. svc_infra/db/nosql/resource.py +28 -16
  157. svc_infra/db/nosql/scaffold.py +14 -12
  158. svc_infra/db/nosql/service.py +2 -1
  159. svc_infra/db/nosql/service_with_hooks.py +4 -3
  160. svc_infra/db/nosql/utils.py +4 -4
  161. svc_infra/db/ops.py +380 -0
  162. svc_infra/db/outbox.py +105 -0
  163. svc_infra/db/sql/apikey.py +34 -15
  164. svc_infra/db/sql/authref.py +8 -6
  165. svc_infra/db/sql/constants.py +5 -1
  166. svc_infra/db/sql/core.py +13 -13
  167. svc_infra/db/sql/management.py +5 -6
  168. svc_infra/db/sql/repository.py +92 -26
  169. svc_infra/db/sql/resource.py +18 -12
  170. svc_infra/db/sql/scaffold.py +11 -11
  171. svc_infra/db/sql/service.py +2 -1
  172. svc_infra/db/sql/service_with_hooks.py +4 -3
  173. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  174. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  175. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  176. svc_infra/db/sql/tenant.py +80 -0
  177. svc_infra/db/sql/uniq.py +8 -7
  178. svc_infra/db/sql/uniq_hooks.py +12 -11
  179. svc_infra/db/sql/utils.py +105 -47
  180. svc_infra/db/sql/versioning.py +14 -0
  181. svc_infra/db/utils.py +3 -3
  182. svc_infra/deploy/__init__.py +531 -0
  183. svc_infra/documents/__init__.py +100 -0
  184. svc_infra/documents/add.py +263 -0
  185. svc_infra/documents/ease.py +233 -0
  186. svc_infra/documents/models.py +114 -0
  187. svc_infra/documents/storage.py +262 -0
  188. svc_infra/dx/__init__.py +58 -0
  189. svc_infra/dx/add.py +63 -0
  190. svc_infra/dx/changelog.py +74 -0
  191. svc_infra/dx/checks.py +68 -0
  192. svc_infra/exceptions.py +141 -0
  193. svc_infra/health/__init__.py +863 -0
  194. svc_infra/http/__init__.py +13 -0
  195. svc_infra/http/client.py +101 -0
  196. svc_infra/jobs/__init__.py +79 -0
  197. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  198. svc_infra/jobs/builtins/webhook_delivery.py +93 -0
  199. svc_infra/jobs/easy.py +33 -0
  200. svc_infra/jobs/loader.py +49 -0
  201. svc_infra/jobs/queue.py +106 -0
  202. svc_infra/jobs/redis_queue.py +242 -0
  203. svc_infra/jobs/runner.py +75 -0
  204. svc_infra/jobs/scheduler.py +53 -0
  205. svc_infra/jobs/worker.py +40 -0
  206. svc_infra/loaders/__init__.py +186 -0
  207. svc_infra/loaders/base.py +143 -0
  208. svc_infra/loaders/github.py +309 -0
  209. svc_infra/loaders/models.py +147 -0
  210. svc_infra/loaders/url.py +229 -0
  211. svc_infra/logging/__init__.py +375 -0
  212. svc_infra/mcp/__init__.py +82 -0
  213. svc_infra/mcp/svc_infra_mcp.py +91 -33
  214. svc_infra/obs/README.md +2 -0
  215. svc_infra/obs/add.py +68 -11
  216. svc_infra/obs/cloud_dash.py +2 -1
  217. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  218. svc_infra/obs/metrics/__init__.py +6 -7
  219. svc_infra/obs/metrics/asgi.py +8 -7
  220. svc_infra/obs/metrics/base.py +13 -13
  221. svc_infra/obs/metrics/http.py +3 -3
  222. svc_infra/obs/metrics/sqlalchemy.py +14 -13
  223. svc_infra/obs/metrics.py +9 -8
  224. svc_infra/resilience/__init__.py +44 -0
  225. svc_infra/resilience/circuit_breaker.py +328 -0
  226. svc_infra/resilience/retry.py +289 -0
  227. svc_infra/security/__init__.py +167 -0
  228. svc_infra/security/add.py +213 -0
  229. svc_infra/security/audit.py +97 -18
  230. svc_infra/security/audit_service.py +10 -9
  231. svc_infra/security/headers.py +15 -2
  232. svc_infra/security/hibp.py +14 -7
  233. svc_infra/security/jwt_rotation.py +78 -29
  234. svc_infra/security/lockout.py +23 -16
  235. svc_infra/security/models.py +77 -44
  236. svc_infra/security/oauth_models.py +73 -0
  237. svc_infra/security/org_invites.py +12 -12
  238. svc_infra/security/passwords.py +3 -3
  239. svc_infra/security/permissions.py +31 -7
  240. svc_infra/security/session.py +7 -8
  241. svc_infra/security/signed_cookies.py +26 -6
  242. svc_infra/storage/__init__.py +93 -0
  243. svc_infra/storage/add.py +250 -0
  244. svc_infra/storage/backends/__init__.py +11 -0
  245. svc_infra/storage/backends/local.py +331 -0
  246. svc_infra/storage/backends/memory.py +213 -0
  247. svc_infra/storage/backends/s3.py +334 -0
  248. svc_infra/storage/base.py +239 -0
  249. svc_infra/storage/easy.py +181 -0
  250. svc_infra/storage/settings.py +193 -0
  251. svc_infra/testing/__init__.py +682 -0
  252. svc_infra/utils.py +170 -5
  253. svc_infra/webhooks/__init__.py +69 -0
  254. svc_infra/webhooks/add.py +327 -0
  255. svc_infra/webhooks/encryption.py +115 -0
  256. svc_infra/webhooks/fastapi.py +37 -0
  257. svc_infra/webhooks/router.py +55 -0
  258. svc_infra/webhooks/service.py +69 -0
  259. svc_infra/webhooks/signing.py +34 -0
  260. svc_infra/websocket/__init__.py +79 -0
  261. svc_infra/websocket/add.py +139 -0
  262. svc_infra/websocket/client.py +283 -0
  263. svc_infra/websocket/config.py +57 -0
  264. svc_infra/websocket/easy.py +76 -0
  265. svc_infra/websocket/exceptions.py +61 -0
  266. svc_infra/websocket/manager.py +343 -0
  267. svc_infra/websocket/models.py +49 -0
  268. svc_infra-1.1.0.dist-info/LICENSE +21 -0
  269. svc_infra-1.1.0.dist-info/METADATA +362 -0
  270. svc_infra-1.1.0.dist-info/RECORD +364 -0
  271. svc_infra-0.1.595.dist-info/METADATA +0 -80
  272. svc_infra-0.1.595.dist-info/RECORD +0 -253
  273. {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
  274. {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Generic, Iterable, List, Optional, Sequence, TypeVar
3
+ from collections.abc import Iterable, Sequence
4
+ from typing import Any, Generic, TypeVar
4
5
 
5
6
  from fastapi import Query
6
7
  from pydantic import BaseModel
@@ -21,37 +22,37 @@ def dep_limit_offset(
21
22
 
22
23
 
23
24
  class OrderParams(BaseModel):
24
- order_by: Optional[str] = None
25
+ order_by: str | None = None
25
26
 
26
27
 
27
28
  def dep_order(
28
- order_by: Optional[str] = Query(None, description="Comma-separated fields; '-' for DESC"),
29
+ order_by: str | None = Query(None, description="Comma-separated fields; '-' for DESC"),
29
30
  ) -> OrderParams:
30
31
  return OrderParams(order_by=order_by)
31
32
 
32
33
 
33
34
  class SearchParams(BaseModel):
34
- q: Optional[str] = None
35
- fields: Optional[str] = None
35
+ q: str | None = None
36
+ fields: str | None = None
36
37
 
37
38
 
38
39
  def dep_search(
39
- q: Optional[str] = Query(None, description="Search query"),
40
- fields: Optional[str] = Query(None, description="Comma-separated list of fields"),
40
+ q: str | None = Query(None, description="Search query"),
41
+ fields: str | None = Query(None, description="Comma-separated list of fields"),
41
42
  ) -> SearchParams:
42
43
  return SearchParams(q=q, fields=fields)
43
44
 
44
45
 
45
46
  class Page(BaseModel, Generic[T]):
46
47
  total: int
47
- items: List[T]
48
+ items: list[T]
48
49
  limit: int
49
50
  offset: int
50
51
 
51
52
  @classmethod
52
53
  def from_items(
53
54
  cls, *, total: int, items: Sequence[T] | Iterable[T], limit: int, offset: int
54
- ) -> "Page[T]":
55
+ ) -> Page[T]:
55
56
  return cls(total=total, items=list(items), limit=limit, offset=offset)
56
57
 
57
58
 
@@ -1,4 +1,42 @@
1
- from .mongo.add import add_mongo_db, add_mongo_health, add_mongo_resources
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from fastapi import FastAPI
8
+
9
+ from svc_infra.db.nosql.resource import NoSqlResource
10
+
11
+
12
+ def _missing_mongo_dependency() -> ModuleNotFoundError:
13
+ return ModuleNotFoundError(
14
+ "MongoDB support is an optional dependency. Install pymongo (and motor) to use "
15
+ "Mongo helpers like add_mongo_db/add_mongo_health/add_mongo_resources."
16
+ )
17
+
18
+
19
+ try:
20
+ from .mongo.add import add_mongo_db, add_mongo_health, add_mongo_resources
21
+ except ModuleNotFoundError as exc:
22
+ mongo_import_error = exc
23
+
24
+ # NOTE: pymongo provides `bson`, which can be absent in minimal installs/CI.
25
+ # We keep imports working for non-mongo users/tests by providing stubs.
26
+ def add_mongo_db(app: FastAPI, *, dsn_env: str = "MONGO_URL") -> None:
27
+ raise _missing_mongo_dependency() from mongo_import_error
28
+
29
+ def add_mongo_health(
30
+ app: FastAPI,
31
+ *,
32
+ prefix: str = "/_mongo/health",
33
+ include_in_schema: bool = False,
34
+ ) -> None:
35
+ raise _missing_mongo_dependency() from mongo_import_error
36
+
37
+ def add_mongo_resources(app: FastAPI, resources: Sequence[NoSqlResource]) -> None:
38
+ raise _missing_mongo_dependency() from mongo_import_error
39
+
2
40
 
3
41
  __all__ = [
4
42
  # MongoDB
@@ -1,8 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
+ from collections.abc import Sequence
4
5
  from contextlib import asynccontextmanager
5
- from typing import Sequence
6
6
 
7
7
  from bson import ObjectId
8
8
  from fastapi import FastAPI
@@ -24,7 +24,8 @@ 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()
@@ -38,8 +39,8 @@ def add_mongo_db_with_url(app: FastAPI, url: str, db_name: str) -> None:
38
39
 
39
40
 
40
41
  def add_mongo_db(app: FastAPI, *, dsn_env: str = "MONGO_URL") -> None:
41
- @app.on_event("startup")
42
- async def _startup() -> None:
42
+ @asynccontextmanager
43
+ async def lifespan(_app: FastAPI):
43
44
  if not os.getenv(dsn_env):
44
45
  raise RuntimeError(f"Missing environment variable {dsn_env} for Mongo URL")
45
46
  await init_mongo()
@@ -47,10 +48,12 @@ def add_mongo_db(app: FastAPI, *, dsn_env: str = "MONGO_URL") -> None:
47
48
  db = await acquire_db()
48
49
  if expected and db.name != expected:
49
50
  raise RuntimeError(f"Connected to Mongo DB '{db.name}', expected '{expected}'.")
51
+ try:
52
+ yield
53
+ finally:
54
+ await close_mongo()
50
55
 
51
- @app.on_event("shutdown")
52
- async def _shutdown() -> None:
53
- await close_mongo()
56
+ app.router.lifespan_context = lifespan
54
57
 
55
58
 
56
59
  def add_mongo_health(
@@ -62,46 +65,48 @@ def add_mongo_health(
62
65
 
63
66
 
64
67
  def add_mongo_resources(app: FastAPI, resources: Sequence[NoSqlResource]) -> None:
65
- for r in resources:
68
+ for resource in resources:
66
69
  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,
70
+ collection_name=resource.resolved_collection(),
71
+ id_field=resource.id_field,
72
+ soft_delete=resource.soft_delete,
73
+ soft_delete_field=resource.soft_delete_field,
74
+ soft_delete_flag_field=resource.soft_delete_flag_field,
72
75
  )
73
- svc = r.service_factory(repo) if r.service_factory else NoSqlService(repo)
76
+ svc = resource.service_factory(repo) if resource.service_factory else NoSqlService(repo)
74
77
 
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:
78
+ if resource.read_schema and resource.create_schema and resource.update_schema:
79
+ Read, Create, Update = (
80
+ resource.read_schema,
81
+ resource.create_schema,
82
+ resource.update_schema,
83
+ )
84
+ elif resource.document_model is not None:
78
85
  # CRITICAL: teach Pydantic to dump ObjectId/PyObjectId
79
86
  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,
87
+ resource.document_model,
88
+ create_exclude=resource.create_exclude,
89
+ read_name=resource.read_name,
90
+ create_name=resource.create_name,
91
+ update_name=resource.update_name,
92
+ read_exclude=resource.read_exclude,
93
+ update_exclude=resource.update_exclude,
87
94
  json_encoders={ObjectId: str, PyObjectId: str},
88
95
  )
89
96
  else:
90
97
  raise RuntimeError(
91
- f"Resource for collection '{r.collection}' requires either explicit schemas "
98
+ f"Resource for collection '{resource.collection}' requires either explicit schemas "
92
99
  f"(read/create/update) or a 'document_model' to derive them."
93
100
  )
94
101
 
95
102
  router = make_crud_router_plus_mongo(
96
- collection=r.resolved_collection(),
97
- repo=repo,
98
103
  service=svc,
99
104
  read_schema=Read,
100
105
  create_schema=Create,
101
106
  update_schema=Update,
102
- prefix=r.prefix,
103
- tags=r.tags,
104
- search_fields=r.search_fields,
107
+ prefix=resource.prefix,
108
+ tags=resource.tags,
109
+ search_fields=resource.search_fields,
105
110
  default_ordering=None,
106
111
  allowed_order_fields=None,
107
112
  )
@@ -1,7 +1,15 @@
1
- from typing import Annotated, Any, Optional, Sequence, Type, cast
1
+ from collections.abc import Sequence
2
+ from typing import Annotated, Any, cast
2
3
 
3
4
  from fastapi import APIRouter, Body, Depends, HTTPException
4
- from motor.motor_asyncio import AsyncIOMotorDatabase
5
+
6
+ try:
7
+ from motor.motor_asyncio import AsyncIOMotorDatabase
8
+
9
+ HAS_MOTOR = True
10
+ except ImportError: # pragma: no cover
11
+ HAS_MOTOR = False
12
+ AsyncIOMotorDatabase = Any # type: ignore[assignment, misc]
5
13
 
6
14
  from svc_infra.api.fastapi.db.http import (
7
15
  LimitOffsetParams,
@@ -20,7 +28,7 @@ DBDep = Annotated[AsyncIOMotorDatabase, Depends(acquire_db)]
20
28
 
21
29
 
22
30
  def _parse_sort(
23
- order_spec: Optional[str], allowed_order_fields: Optional[list[str]]
31
+ order_spec: str | None, allowed_order_fields: list[str] | None
24
32
  ) -> list[tuple[str, int]]:
25
33
  if not order_spec:
26
34
  return []
@@ -36,16 +44,19 @@ def _parse_sort(
36
44
  def make_crud_router_plus_mongo(
37
45
  *,
38
46
  service: NoSqlService,
39
- read_schema: Type[Any],
40
- create_schema: Type[Any],
41
- update_schema: Type[Any],
47
+ read_schema: type[Any],
48
+ create_schema: type[Any],
49
+ update_schema: type[Any],
42
50
  prefix: str,
43
51
  tags: list[str] | None = None,
44
- search_fields: Optional[Sequence[str]] = None,
45
- default_ordering: Optional[str] = None,
46
- allowed_order_fields: Optional[list[str]] = None,
52
+ search_fields: Sequence[str] | None = None,
53
+ default_ordering: str | None = None,
54
+ allowed_order_fields: list[str] | None = None,
47
55
  mount_under_db_prefix: bool = True,
48
56
  ) -> APIRouter:
57
+ read_model = cast("Any", read_schema)
58
+ page_model = cast("Any", Page[read_schema]) # type: ignore[valid-type]
59
+
49
60
  router_prefix = ("/_mongo" + prefix) if mount_under_db_prefix else prefix
50
61
  router = public_router(
51
62
  prefix=router_prefix,
@@ -56,7 +67,7 @@ def make_crud_router_plus_mongo(
56
67
  # LIST
57
68
  @router.get(
58
69
  "",
59
- response_model=cast(Any, Page[read_schema]),
70
+ response_model=page_model,
60
71
  description=f"List items in {prefix} collection",
61
72
  )
62
73
  async def list_items(
@@ -68,20 +79,23 @@ def make_crud_router_plus_mongo(
68
79
  sort = _parse_sort(op.order_by or default_ordering, allowed_order_fields)
69
80
  if sp.q and search_fields:
70
81
  items = await service.search(
71
- db, q=sp.q, fields=search_fields, limit=lp.limit, offset=lp.offset, sort=sort
82
+ db,
83
+ q=sp.q,
84
+ fields=search_fields,
85
+ limit=lp.limit,
86
+ offset=lp.offset,
87
+ sort=sort,
72
88
  )
73
89
  total = await service.count_filtered(db, q=sp.q, fields=search_fields)
74
90
  else:
75
91
  items = await service.list(db, limit=lp.limit, offset=lp.offset, sort=sort)
76
92
  total = await service.count(db)
77
- return Page[read_schema].from_items(
78
- total=total, items=items, limit=lp.limit, offset=lp.offset
79
- )
93
+ return Page[Any].from_items(total=total, items=items, limit=lp.limit, offset=lp.offset)
80
94
 
81
95
  # GET by id
82
96
  @router.get(
83
97
  "/{item_id}",
84
- response_model=cast(Any, read_schema),
98
+ response_model=read_model,
85
99
  description=f"Get item from {prefix} collection",
86
100
  )
87
101
  async def get_item(db: DBDep, item_id: Any):
@@ -93,22 +107,26 @@ def make_crud_router_plus_mongo(
93
107
  # CREATE
94
108
  @router.post(
95
109
  "",
96
- response_model=cast(Any, read_schema),
110
+ response_model=read_model,
97
111
  status_code=201,
98
112
  description=f"Create item in {prefix} collection",
99
113
  )
100
- async def create_item(db: DBDep, payload: create_schema = Body(...)):
101
- data = payload.model_dump(exclude_unset=True)
114
+ async def create_item(db: DBDep, payload: create_schema = Body(...)): # type: ignore[valid-type]
115
+ data = cast("Any", payload).model_dump(exclude_unset=True)
102
116
  return await service.create(db, data)
103
117
 
104
118
  # UPDATE
105
119
  @router.patch(
106
120
  "/{item_id}",
107
- response_model=cast(Any, read_schema),
121
+ response_model=read_model,
108
122
  description=f"Update item in {prefix} collection",
109
123
  )
110
- async def update_item(db: DBDep, item_id: Any, payload: update_schema = Body(...)):
111
- data = payload.model_dump(exclude_unset=True)
124
+ async def update_item(
125
+ db: DBDep,
126
+ item_id: Any,
127
+ payload: update_schema = Body(...), # type: ignore[valid-type]
128
+ ):
129
+ data = cast("Any", payload).model_dump(exclude_unset=True)
112
130
  row = await service.update(db, item_id, data)
113
131
  if not row:
114
132
  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__ = [
@@ -1,8 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
+ from collections.abc import Sequence
4
5
  from contextlib import asynccontextmanager
5
- from typing import Optional, Sequence
6
6
 
7
7
  from fastapi import FastAPI
8
8
 
@@ -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,46 +37,83 @@ 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
 
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)."""
74
+ def add_sql_db(app: FastAPI, *, url: str | None = None, dsn_env: str = "SQL_URL") -> None:
75
+ """Configure DB lifecycle for the app (either explicit URL or from env).
76
+
77
+ This preserves any existing lifespan context (like user-defined lifespans)
78
+ and wraps it with the database session initialization/cleanup.
79
+ """
80
+ # Preserve existing lifespan to wrap it
81
+ existing_lifespan = getattr(app.router, "lifespan_context", None)
82
+
57
83
  if url:
58
84
 
59
85
  @asynccontextmanager
60
- async def lifespan(_app: FastAPI):
86
+ async def lifespan_with_url(_app: FastAPI):
61
87
  initialize_session(url)
62
88
  try:
63
- yield
89
+ if existing_lifespan is not None:
90
+ async with existing_lifespan(_app):
91
+ yield
92
+ else:
93
+ yield
64
94
  finally:
65
95
  await dispose_session()
66
96
 
67
- app.router.lifespan_context = lifespan
97
+ app.router.lifespan_context = lifespan_with_url
68
98
  return
69
99
 
70
- @app.on_event("startup")
71
- async def _startup() -> None: # noqa: ANN202
100
+ # Use lifespan context manager instead of deprecated on_event
101
+ @asynccontextmanager
102
+ async def lifespan_from_env(_app: FastAPI):
72
103
  env_url = os.getenv(dsn_env)
73
104
  if not env_url:
74
105
  raise RuntimeError(f"Missing environment variable {dsn_env} for database URL")
75
106
  initialize_session(env_url)
107
+ try:
108
+ if existing_lifespan is not None:
109
+ async with existing_lifespan(_app):
110
+ yield
111
+ else:
112
+ yield
113
+ finally:
114
+ await dispose_session()
76
115
 
77
- @app.on_event("shutdown")
78
- async def _shutdown() -> None: # noqa: ANN202
79
- await dispose_session()
116
+ app.router.lifespan_context = lifespan_from_env
80
117
 
81
118
 
82
119
  def add_sql_health(
@@ -89,7 +126,7 @@ def setup_sql(
89
126
  app: FastAPI,
90
127
  resources: Sequence[SqlResource],
91
128
  *,
92
- url: Optional[str] = None,
129
+ url: str | None = None,
93
130
  dsn_env: str = "SQL_URL",
94
131
  include_health: bool = True,
95
132
  health_prefix: str = "/_sql/health",