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,4 +1,5 @@
1
- from typing import Annotated, Any, Optional, Sequence, Type, TypeVar, cast
1
+ from collections.abc import Callable, Sequence
2
+ from typing import Annotated, Any, TypeVar, cast
2
3
 
3
4
  from fastapi import APIRouter, Body, Depends, HTTPException
4
5
  from pydantic import BaseModel
@@ -15,7 +16,9 @@ from svc_infra.api.fastapi.db.http import (
15
16
  )
16
17
  from svc_infra.api.fastapi.dual.public import public_router
17
18
  from svc_infra.db.sql.service import SqlService
19
+ from svc_infra.db.sql.tenant import TenantSqlService
18
20
 
21
+ from ...tenancy.context import TenantId
19
22
  from .session import SqlSessionDep
20
23
 
21
24
  CreateModel = TypeVar("CreateModel", bound=BaseModel)
@@ -27,14 +30,14 @@ def make_crud_router_plus_sql(
27
30
  *,
28
31
  model: type[Any],
29
32
  service: SqlService,
30
- read_schema: Type[ReadModel],
31
- create_schema: Type[CreateModel],
32
- update_schema: Type[UpdateModel],
33
+ read_schema: type[ReadModel],
34
+ create_schema: type[CreateModel],
35
+ update_schema: type[UpdateModel],
33
36
  prefix: str,
34
37
  tags: list[str] | None = None,
35
- search_fields: Optional[Sequence[str]] = None,
36
- default_ordering: Optional[str] = None,
37
- allowed_order_fields: Optional[list[str]] = None,
38
+ search_fields: Sequence[str] | None = None,
39
+ default_ordering: str | None = None,
40
+ allowed_order_fields: list[str] | None = None,
38
41
  mount_under_db_prefix: bool = True,
39
42
  ) -> APIRouter:
40
43
  router_prefix = ("/_sql" + prefix) if mount_under_db_prefix else prefix
@@ -44,7 +47,19 @@ def make_crud_router_plus_sql(
44
47
  redirect_slashes=False,
45
48
  )
46
49
 
47
- def _parse_ordering_to_fields(order_spec: Optional[str]) -> list[str]:
50
+ def _coerce_id(v: Any) -> Any:
51
+ """Best-effort coercion of path ids: cast digit-only strings to int.
52
+
53
+ Keeps original type otherwise to avoid breaking non-integer IDs.
54
+ """
55
+ if isinstance(v, str) and v.isdigit():
56
+ try:
57
+ return int(v)
58
+ except Exception:
59
+ return v
60
+ return v
61
+
62
+ def _parse_ordering_to_fields(order_spec: str | None) -> list[str]:
48
63
  if not order_spec:
49
64
  return []
50
65
  pieces = [p.strip() for p in order_spec.split(",") if p.strip()]
@@ -59,14 +74,14 @@ def make_crud_router_plus_sql(
59
74
  # -------- LIST --------
60
75
  @router.get(
61
76
  "",
62
- response_model=cast(Any, Page[read_schema]),
77
+ response_model=Page[read_schema], # type: ignore[valid-type]
63
78
  description=f"List items of type {model.__name__}",
64
79
  )
65
80
  async def list_items(
66
81
  lp: Annotated[LimitOffsetParams, Depends(dep_limit_offset)],
67
82
  op: Annotated[OrderParams, Depends(dep_order)],
68
83
  sp: Annotated[SearchParams, Depends(dep_search)],
69
- session: SqlSessionDep, # type: ignore[name-defined]
84
+ session: SqlSessionDep,
70
85
  ):
71
86
  order_spec = op.order_by or default_ordering
72
87
  order_fields = _parse_ordering_to_fields(order_spec)
@@ -79,24 +94,27 @@ def make_crud_router_plus_sql(
79
94
  if f.strip()
80
95
  ]
81
96
  items = await service.search(
82
- session, q=sp.q, fields=fields, limit=lp.limit, offset=lp.offset, order_by=order_by
97
+ session,
98
+ q=sp.q,
99
+ fields=fields,
100
+ limit=lp.limit,
101
+ offset=lp.offset,
102
+ order_by=order_by,
83
103
  )
84
104
  total = await service.count_filtered(session, q=sp.q, fields=fields)
85
105
  else:
86
106
  items = await service.list(session, limit=lp.limit, offset=lp.offset, order_by=order_by)
87
107
  total = await service.count(session)
88
- return Page[read_schema].from_items(
89
- total=total, items=items, limit=lp.limit, offset=lp.offset
90
- )
108
+ return Page[Any].from_items(total=total, items=items, limit=lp.limit, offset=lp.offset)
91
109
 
92
110
  # -------- GET by id --------
93
111
  @router.get(
94
112
  "/{item_id}",
95
- response_model=cast(Any, read_schema),
113
+ response_model=read_schema,
96
114
  description=f"Get item of type {model.__name__}",
97
115
  )
98
- async def get_item(item_id: Any, session: SqlSessionDep): # type: ignore[name-defined]
99
- row = await service.get(session, item_id)
116
+ async def get_item(item_id: Any, session: SqlSessionDep):
117
+ row = await service.get(session, _coerce_id(item_id))
100
118
  if not row:
101
119
  raise HTTPException(404, "Not found")
102
120
  return row
@@ -104,40 +122,197 @@ def make_crud_router_plus_sql(
104
122
  # -------- CREATE --------
105
123
  @router.post(
106
124
  "",
107
- response_model=cast(Any, read_schema),
125
+ response_model=read_schema,
108
126
  status_code=201,
109
127
  description=f"Create item of type {model.__name__}",
110
128
  )
111
129
  async def create_item(
112
- session: SqlSessionDep, # type: ignore[name-defined]
113
- payload: create_schema = Body(...),
130
+ session: SqlSessionDep,
131
+ payload: create_schema = Body(...), # type: ignore[valid-type]
114
132
  ):
115
- data = cast(BaseModel, payload).model_dump(exclude_unset=True)
133
+ if isinstance(payload, BaseModel):
134
+ data = cast("BaseModel", payload).model_dump(exclude_unset=True)
135
+ elif isinstance(payload, dict):
136
+ data = payload
137
+ else:
138
+ raise HTTPException(422, "invalid_payload")
116
139
  return await service.create(session, data)
117
140
 
118
141
  # -------- UPDATE --------
119
142
  @router.patch(
120
143
  "/{item_id}",
121
- response_model=cast(Any, read_schema),
144
+ response_model=read_schema,
122
145
  description=f"Update item of type {model.__name__}",
123
146
  )
124
147
  async def update_item(
125
148
  item_id: Any,
126
- session: SqlSessionDep, # type: ignore[name-defined]
127
- payload: update_schema = Body(...),
149
+ session: SqlSessionDep,
150
+ payload: update_schema = Body(...), # type: ignore[valid-type]
128
151
  ):
129
- data = cast(BaseModel, payload).model_dump(exclude_unset=True)
130
- row = await service.update(session, item_id, data)
152
+ if isinstance(payload, BaseModel):
153
+ data = cast("BaseModel", payload).model_dump(exclude_unset=True)
154
+ elif isinstance(payload, dict):
155
+ data = payload
156
+ else:
157
+ raise HTTPException(422, "invalid_payload")
158
+ row = await service.update(session, _coerce_id(item_id), data)
131
159
  if not row:
132
160
  raise HTTPException(404, "Not found")
133
161
  return row
134
162
 
135
163
  # -------- DELETE --------
136
164
  @router.delete(
137
- "/{item_id}", status_code=204, description=f"Delete item of type {model.__name__}"
165
+ "/{item_id}",
166
+ status_code=204,
167
+ description=f"Delete item of type {model.__name__}",
138
168
  )
139
- async def delete_item(item_id: Any, session: SqlSessionDep): # type: ignore[name-defined]
140
- ok = await service.delete(session, item_id)
169
+ async def delete_item(item_id: Any, session: SqlSessionDep):
170
+ ok = await service.delete(session, _coerce_id(item_id))
171
+ if not ok:
172
+ raise HTTPException(404, "Not found")
173
+ return
174
+
175
+ return router
176
+
177
+
178
+ def make_tenant_crud_router_plus_sql(
179
+ *,
180
+ model: type[Any],
181
+ service_factory: Callable[[], Any], # factory that returns a SqlService (will be wrapped)
182
+ read_schema: type[ReadModel],
183
+ create_schema: type[CreateModel],
184
+ update_schema: type[UpdateModel],
185
+ prefix: str,
186
+ tenant_field: str = "tenant_id",
187
+ tags: list[str] | None = None,
188
+ search_fields: Sequence[str] | None = None,
189
+ default_ordering: str | None = None,
190
+ allowed_order_fields: list[str] | None = None,
191
+ mount_under_db_prefix: bool = True,
192
+ ) -> APIRouter:
193
+ """Like make_crud_router_plus_sql, but requires TenantId and scopes all operations."""
194
+ router_prefix = ("/_sql" + prefix) if mount_under_db_prefix else prefix
195
+ router = public_router(
196
+ prefix=router_prefix,
197
+ tags=tags or [prefix.strip("/")],
198
+ redirect_slashes=False,
199
+ )
200
+
201
+ # Evaluate the base service once to preserve in-memory state across requests in tests/local.
202
+ # Consumers may pass either an instance or a zero-arg factory function.
203
+ try:
204
+ _base_instance = service_factory() if callable(service_factory) else service_factory
205
+ except TypeError:
206
+ # If the callable requires args, assume it's already an instance
207
+ _base_instance = service_factory
208
+
209
+ def _coerce_id(v: Any) -> Any:
210
+ """Best-effort coercion of path ids: cast digit-only strings to int.
211
+ Keeps original type otherwise.
212
+ """
213
+ if isinstance(v, str) and v.isdigit():
214
+ try:
215
+ return int(v)
216
+ except Exception:
217
+ return v
218
+ return v
219
+
220
+ def _parse_ordering_to_fields(order_spec: str | None) -> list[str]:
221
+ if not order_spec:
222
+ return []
223
+ pieces = [p.strip() for p in order_spec.split(",") if p.strip()]
224
+ fields: list[str] = []
225
+ for p in pieces:
226
+ name = p[1:] if p.startswith("-") else p
227
+ if allowed_order_fields and name not in (allowed_order_fields or []):
228
+ continue
229
+ fields.append(p)
230
+ return fields
231
+
232
+ # create per-request service with tenant scoping
233
+ async def _svc(session: SqlSessionDep, tenant_id: TenantId):
234
+ repo_or_service = getattr(_base_instance, "repo", _base_instance)
235
+ svc: Any = TenantSqlService(repo_or_service, tenant_id=tenant_id, tenant_field=tenant_field)
236
+ return svc
237
+
238
+ @router.get("", response_model=Page[read_schema]) # type: ignore[valid-type]
239
+ async def list_items(
240
+ lp: Annotated[LimitOffsetParams, Depends(dep_limit_offset)],
241
+ op: Annotated[OrderParams, Depends(dep_order)],
242
+ sp: Annotated[SearchParams, Depends(dep_search)],
243
+ session: SqlSessionDep,
244
+ tenant_id: TenantId,
245
+ ):
246
+ svc = await _svc(session, tenant_id)
247
+ order_spec = op.order_by or default_ordering
248
+ order_fields = _parse_ordering_to_fields(order_spec)
249
+ order_by = build_order_by(model, order_fields)
250
+ if sp.q:
251
+ fields = [
252
+ f.strip()
253
+ for f in (sp.fields or (",".join(search_fields or []) or "")).split(",")
254
+ if f.strip()
255
+ ]
256
+ items = await svc.search(
257
+ session,
258
+ q=sp.q,
259
+ fields=fields,
260
+ limit=lp.limit,
261
+ offset=lp.offset,
262
+ order_by=order_by,
263
+ )
264
+ total = await svc.count_filtered(session, q=sp.q, fields=fields)
265
+ else:
266
+ items = await svc.list(session, limit=lp.limit, offset=lp.offset, order_by=order_by)
267
+ total = await svc.count(session)
268
+ return Page[Any].from_items(total=total, items=items, limit=lp.limit, offset=lp.offset)
269
+
270
+ @router.get("/{item_id}", response_model=read_schema)
271
+ async def get_item(item_id: Any, session: SqlSessionDep, tenant_id: TenantId):
272
+ svc = await _svc(session, tenant_id)
273
+ obj = await svc.get(session, item_id)
274
+ if not obj:
275
+ raise HTTPException(404, "not_found")
276
+ return obj
277
+
278
+ @router.post("", response_model=read_schema, status_code=201)
279
+ async def create_item(
280
+ session: SqlSessionDep,
281
+ tenant_id: TenantId,
282
+ payload: create_schema = Body(...), # type: ignore[valid-type]
283
+ ):
284
+ svc = await _svc(session, tenant_id)
285
+ if isinstance(payload, BaseModel):
286
+ data = cast("BaseModel", payload).model_dump(exclude_unset=True)
287
+ elif isinstance(payload, dict):
288
+ data = payload
289
+ else:
290
+ raise HTTPException(422, "invalid_payload")
291
+ return await svc.create(session, data)
292
+
293
+ @router.patch("/{item_id}", response_model=read_schema)
294
+ async def update_item(
295
+ item_id: Any,
296
+ session: SqlSessionDep,
297
+ tenant_id: TenantId,
298
+ payload: update_schema = Body(...), # type: ignore[valid-type]
299
+ ):
300
+ svc = await _svc(session, tenant_id)
301
+ if isinstance(payload, BaseModel):
302
+ data = cast("BaseModel", payload).model_dump(exclude_unset=True)
303
+ elif isinstance(payload, dict):
304
+ data = payload
305
+ else:
306
+ raise HTTPException(422, "invalid_payload")
307
+ updated = await svc.update(session, item_id, data)
308
+ if not updated:
309
+ raise HTTPException(404, "not_found")
310
+ return updated
311
+
312
+ @router.delete("/{item_id}", status_code=204)
313
+ async def delete_item(item_id: Any, session: SqlSessionDep, tenant_id: TenantId):
314
+ svc = await _svc(session, tenant_id)
315
+ ok = await svc.delete(session, _coerce_id(item_id))
141
316
  if not ok:
142
317
  raise HTTPException(404, "Not found")
143
318
  return
@@ -145,4 +320,4 @@ def make_crud_router_plus_sql(
145
320
  return router
146
321
 
147
322
 
148
- __all__ = ["make_crud_router_plus_sql"]
323
+ __all__ = ["make_crud_router_plus_sql", "make_tenant_crud_router_plus_sql"]
@@ -1,9 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
- from typing import Annotated, AsyncIterator, Tuple
4
+ import os
5
+ from collections.abc import AsyncIterator
6
+ from typing import Annotated
5
7
 
6
8
  from fastapi import Depends
9
+ from sqlalchemy import text
7
10
  from sqlalchemy.ext.asyncio import (
8
11
  AsyncEngine,
9
12
  AsyncSession,
@@ -21,7 +24,7 @@ _SessionLocal: async_sessionmaker[AsyncSession] | None = None
21
24
 
22
25
  def _init_engine_and_session(
23
26
  url: str,
24
- ) -> Tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
27
+ ) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
25
28
  async_url = _coerce_to_async_url(url)
26
29
  if async_url != url:
27
30
  logger.info(
@@ -53,6 +56,20 @@ async def get_session() -> AsyncIterator[AsyncSession]:
53
56
  if _SessionLocal is None:
54
57
  raise RuntimeError("Database not initialized. Call add_sql_db(app, ...) first.")
55
58
  async with _SessionLocal() as session:
59
+ # Optional: set a per-transaction statement timeout for Postgres if configured
60
+ raw_ms = os.getenv("DB_STATEMENT_TIMEOUT_MS")
61
+ if raw_ms:
62
+ try:
63
+ ms = int(raw_ms)
64
+ if ms > 0:
65
+ try:
66
+ # SET LOCAL applies for the duration of the current transaction only
67
+ await session.execute(text("SET LOCAL statement_timeout = :ms"), {"ms": ms})
68
+ except Exception:
69
+ # Non-PG dialects (e.g., SQLite) will error; ignore silently
70
+ pass
71
+ except ValueError:
72
+ pass
56
73
  try:
57
74
  yield session
58
75
  await session.commit()
@@ -1,17 +1,22 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, AsyncIterator, Callable, Tuple
3
+ from collections.abc import AsyncIterator, Callable
4
+ from typing import Any
4
5
  from uuid import UUID
5
6
 
6
7
  from fastapi import Depends
7
8
  from fastapi_users import FastAPIUsers
8
- from fastapi_users.authentication import AuthenticationBackend, BearerTransport, JWTStrategy
9
+ from fastapi_users.authentication import (
10
+ AuthenticationBackend,
11
+ BearerTransport,
12
+ JWTStrategy,
13
+ )
9
14
  from fastapi_users.manager import BaseUserManager, UUIDIDMixin
10
15
 
11
16
  from svc_infra.api.fastapi.auth.settings import get_auth_settings
12
17
  from svc_infra.api.fastapi.dual.dualize import dualize_public, dualize_user
13
18
  from svc_infra.api.fastapi.dual.router import DualAPIRouter
14
- from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV
19
+ from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV, require_secret
15
20
  from svc_infra.security.jwt_rotation import RotatingJWTStrategy
16
21
 
17
22
  from ...auth.security import auth_login_path
@@ -26,7 +31,7 @@ def get_fastapi_users(
26
31
  user_schema_update: Any,
27
32
  *,
28
33
  public_auth_prefix: str = "/auth",
29
- ) -> Tuple[
34
+ ) -> tuple[
30
35
  FastAPIUsers,
31
36
  AuthenticationBackend,
32
37
  DualAPIRouter,
@@ -56,10 +61,10 @@ def get_fastapi_users(
56
61
  verify_url = f"{public_auth_prefix}/verify?token={token}"
57
62
  sender = get_sender()
58
63
  sender.send(
59
- to=getattr(user, "email"),
64
+ to=user.email,
60
65
  subject="Verify your account",
61
66
  html_body=f"""
62
- <p>Hi {getattr(user, 'full_name', '') or 'there'},</p>
67
+ <p>Hi {getattr(user, "full_name", "") or "there"},</p>
63
68
  <p>Click to verify your account:</p>
64
69
  <p><a href="{verify_url}">{verify_url}</a></p>
65
70
  """,
@@ -69,7 +74,7 @@ def get_fastapi_users(
69
74
  reset_url = f"{public_auth_prefix}/reset-password?token={token}"
70
75
  sender = get_sender()
71
76
  sender.send(
72
- to=getattr(user, "email"),
77
+ to=user.email,
73
78
  subject="Reset your password",
74
79
  html_body=f"""
75
80
  <p>We received a request to reset your password.</p>
@@ -91,14 +96,18 @@ def get_fastapi_users(
91
96
  if jwt_block and getattr(jwt_block, "secret", None):
92
97
  secret = jwt_block.secret.get_secret_value()
93
98
  else:
94
- secret = "svc-dev-secret-change-me"
99
+ secret = require_secret(
100
+ None,
101
+ "JWT_SECRET (via auth settings jwt.secret)",
102
+ dev_default="dev-only-jwt-secret-not-for-production",
103
+ )
95
104
  lifetime = getattr(jwt_block, "lifetime_seconds", None) if jwt_block else None
96
105
  if not isinstance(lifetime, int) or lifetime <= 0:
97
106
  lifetime = 3600
98
107
  old = []
99
108
  if jwt_block and getattr(jwt_block, "old_secrets", None):
100
109
  old = [s.get_secret_value() for s in jwt_block.old_secrets or []]
101
- audience = "fastapi-users:auth"
110
+ audience = ["fastapi-users:auth"]
102
111
  if old:
103
112
  return RotatingJWTStrategy(
104
113
  secret=secret,
@@ -1,14 +1,27 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
3
4
  import time
4
- from typing import Callable
5
+ from collections.abc import Callable
5
6
 
6
7
  from fastapi import HTTPException
7
8
  from starlette.requests import Request
8
9
 
9
- from svc_infra.api.fastapi.middleware.ratelimit_store import InMemoryRateLimitStore, RateLimitStore
10
+ from svc_infra.api.fastapi.middleware.ratelimit_store import (
11
+ InMemoryRateLimitStore,
12
+ RateLimitStore,
13
+ )
10
14
  from svc_infra.obs.metrics import emit_rate_limited
11
15
 
16
+ logger = logging.getLogger(__name__)
17
+
18
+ try:
19
+ from svc_infra.api.fastapi.tenancy.context import (
20
+ resolve_tenant_id as _resolve_tenant_id,
21
+ )
22
+ except Exception: # pragma: no cover - minimal builds
23
+ _resolve_tenant_id = None # type: ignore[assignment]
24
+
12
25
 
13
26
  class RateLimiter:
14
27
  def __init__(
@@ -17,24 +30,50 @@ class RateLimiter:
17
30
  limit: int,
18
31
  window: int = 60,
19
32
  key_fn: Callable = lambda r: "global",
33
+ limit_resolver: Callable[[Request, str | None], int | None] | None = None,
34
+ scope_by_tenant: bool = False,
20
35
  store: RateLimitStore | None = None,
21
36
  ):
22
37
  self.limit = limit
23
38
  self.window = window
24
39
  self.key_fn = key_fn
40
+ self._limit_resolver = limit_resolver
41
+ self.scope_by_tenant = scope_by_tenant
25
42
  self.store = store or InMemoryRateLimitStore(limit=limit)
26
43
 
27
44
  async def __call__(self, request: Request):
45
+ # Try resolving tenant when asked
46
+ tenant_id = None
47
+ if self.scope_by_tenant or self._limit_resolver:
48
+ try:
49
+ if _resolve_tenant_id is not None:
50
+ tenant_id = await _resolve_tenant_id(request)
51
+ except Exception:
52
+ tenant_id = None
53
+
28
54
  key = self.key_fn(request)
29
- count, limit, reset = self.store.incr(str(key), self.window)
30
- if count > limit:
31
- retry = max(0, reset - int(time.time()))
55
+ if self.scope_by_tenant and tenant_id:
56
+ key = f"{key}:tenant:{tenant_id}"
57
+
58
+ eff_limit = self.limit
59
+ if self._limit_resolver:
32
60
  try:
33
- emit_rate_limited(str(key), limit, retry)
61
+ v = self._limit_resolver(request, tenant_id)
62
+ eff_limit = int(v) if v is not None else self.limit
34
63
  except Exception:
35
- pass
64
+ eff_limit = self.limit
65
+
66
+ count, _store_limit, reset = self.store.incr(str(key), self.window)
67
+ if count > eff_limit:
68
+ retry = max(0, reset - int(time.time()))
69
+ try:
70
+ emit_rate_limited(str(key), eff_limit, retry)
71
+ except Exception as e:
72
+ logger.warning("Failed to emit rate limit metric: %s", e)
36
73
  raise HTTPException(
37
- status_code=429, detail="Rate limit exceeded", headers={"Retry-After": str(retry)}
74
+ status_code=429,
75
+ detail="Rate limit exceeded",
76
+ headers={"Retry-After": str(retry)},
38
77
  )
39
78
 
40
79
 
@@ -46,21 +85,44 @@ def rate_limiter(
46
85
  limit: int,
47
86
  window: int = 60,
48
87
  key_fn: Callable = lambda r: "global",
88
+ limit_resolver: Callable[[Request, str | None], int | None] | None = None,
89
+ scope_by_tenant: bool = False,
49
90
  store: RateLimitStore | None = None,
50
91
  ):
51
92
  store_ = store or InMemoryRateLimitStore(limit=limit)
52
93
 
53
94
  async def dep(request: Request):
95
+ tenant_id = None
96
+ if scope_by_tenant or limit_resolver:
97
+ try:
98
+ if _resolve_tenant_id is not None:
99
+ tenant_id = await _resolve_tenant_id(request)
100
+ except Exception:
101
+ tenant_id = None
102
+
54
103
  key = key_fn(request)
55
- count, lim, reset = store_.incr(str(key), window)
56
- if count > lim:
57
- retry = max(0, reset - int(time.time()))
104
+ if scope_by_tenant and tenant_id:
105
+ key = f"{key}:tenant:{tenant_id}"
106
+
107
+ eff_limit = limit
108
+ if limit_resolver:
58
109
  try:
59
- emit_rate_limited(str(key), lim, retry)
110
+ v = limit_resolver(request, tenant_id)
111
+ eff_limit = int(v) if v is not None else limit
60
112
  except Exception:
61
- pass
113
+ eff_limit = limit
114
+
115
+ count, _store_limit, reset = store_.incr(str(key), window)
116
+ if count > eff_limit:
117
+ retry = max(0, reset - int(time.time()))
118
+ try:
119
+ emit_rate_limited(str(key), eff_limit, retry)
120
+ except Exception as e:
121
+ logger.warning("Failed to emit rate limit metric: %s", e)
62
122
  raise HTTPException(
63
- status_code=429, detail="Rate limit exceeded", headers={"Retry-After": str(retry)}
123
+ status_code=429,
124
+ detail="Rate limit exceeded",
125
+ headers={"Retry-After": str(retry)},
64
126
  )
65
127
 
66
128
  return dep