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
@@ -3,17 +3,26 @@ from __future__ import annotations
3
3
  import base64
4
4
  import contextvars
5
5
  import json
6
- from typing import Any, Callable, Generic, Iterable, List, Optional, Sequence, TypeVar
6
+ import logging
7
+ from collections.abc import Callable, Iterable, Sequence
8
+ from typing import (
9
+ Any,
10
+ Generic,
11
+ TypeVar,
12
+ cast,
13
+ )
7
14
 
8
15
  from fastapi import Query, Request
9
16
  from pydantic import BaseModel, Field
10
17
 
18
+ logger = logging.getLogger(__name__)
19
+
11
20
  T = TypeVar("T")
12
21
 
13
22
 
14
23
  # ---------- Core query models ----------
15
24
  class CursorParams(BaseModel):
16
- cursor: Optional[str] = None
25
+ cursor: str | None = None
17
26
  limit: int = 50
18
27
 
19
28
 
@@ -23,19 +32,19 @@ class PageParams(BaseModel):
23
32
 
24
33
 
25
34
  class FilterParams(BaseModel):
26
- q: Optional[str] = None
27
- sort: Optional[str] = None
28
- created_after: Optional[str] = None
29
- created_before: Optional[str] = None
30
- updated_after: Optional[str] = None
31
- updated_before: Optional[str] = None
35
+ q: str | None = None
36
+ sort: str | None = None
37
+ created_after: str | None = None
38
+ created_before: str | None = None
39
+ updated_after: str | None = None
40
+ updated_before: str | None = None
32
41
 
33
42
 
34
43
  # ---------- Envelope model ----------
35
44
  class Paginated(BaseModel, Generic[T]):
36
- items: List[T]
37
- next_cursor: Optional[str] = Field(None, description="Opaque cursor for next page")
38
- total: Optional[int] = Field(None, description="Total items (optional)")
45
+ items: list[T]
46
+ next_cursor: str | None = Field(None, description="Opaque cursor for next page")
47
+ total: int | None = Field(None, description="Total items (optional)")
39
48
 
40
49
 
41
50
  # ---------- Cursor helpers ----------
@@ -44,13 +53,13 @@ def _encode_cursor(payload: dict) -> str:
44
53
  return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
45
54
 
46
55
 
47
- def decode_cursor(token: Optional[str]) -> dict:
56
+ def decode_cursor(token: str | None) -> dict[Any, Any]:
48
57
  """Public: decode an incoming cursor token for debugging/ops."""
49
58
  if not token:
50
59
  return {}
51
60
  s = token + "=" * (-len(token) % 4)
52
61
  raw = base64.urlsafe_b64decode(s.encode("ascii")).decode("utf-8")
53
- return json.loads(raw)
62
+ return cast("dict[Any, Any]", json.loads(raw))
54
63
 
55
64
 
56
65
  # ---------- Context ----------
@@ -84,23 +93,25 @@ class PaginationContext(Generic[T]):
84
93
  self.limit_override = limit_override
85
94
 
86
95
  @property
87
- def cursor(self) -> Optional[str]:
96
+ def cursor(self) -> str | None:
88
97
  return (self.cursor_params or CursorParams()).cursor if self.allow_cursor else None
89
98
 
90
99
  @property
91
100
  def limit(self) -> int:
92
- if self.allow_cursor and self.cursor_params and self.cursor_params.cursor is not None:
101
+ # For cursor-based pagination, always honor the requested limit, even on the first page
102
+ # (cursor may be None for the first page).
103
+ if self.allow_cursor and self.cursor_params:
93
104
  return self.cursor_params.limit
94
105
  if self.allow_page and self.page_params:
95
106
  return self.limit_override or self.page_params.page_size
96
107
  return 50
97
108
 
98
109
  @property
99
- def page(self) -> Optional[int]:
110
+ def page(self) -> int | None:
100
111
  return self.page_params.page if (self.allow_page and self.page_params) else None
101
112
 
102
113
  @property
103
- def page_size(self) -> Optional[int]:
114
+ def page_size(self) -> int | None:
104
115
  return self.page_params.page_size if (self.allow_page and self.page_params) else None
105
116
 
106
117
  @property
@@ -110,7 +121,11 @@ class PaginationContext(Generic[T]):
110
121
  return 0
111
122
 
112
123
  def wrap(
113
- self, items: list[T], *, next_cursor: Optional[str] = None, total: Optional[int] = None
124
+ self,
125
+ items: list[T],
126
+ *,
127
+ next_cursor: str | None = None,
128
+ total: int | None = None,
114
129
  ):
115
130
  if self.envelope:
116
131
  return Paginated[T](items=items, next_cursor=next_cursor, total=total)
@@ -118,14 +133,14 @@ class PaginationContext(Generic[T]):
118
133
 
119
134
  def next_cursor_from_last(
120
135
  self, items: Sequence[T], *, key: Callable[[T], str | int]
121
- ) -> Optional[str]:
136
+ ) -> str | None:
122
137
  if not items:
123
138
  return None
124
139
  last_key = key(items[-1])
125
140
  return _encode_cursor({"after": last_key})
126
141
 
127
142
 
128
- _pagination_ctx: contextvars.ContextVar[PaginationContext] = contextvars.ContextVar(
143
+ _pagination_ctx: contextvars.ContextVar[PaginationContext | None] = contextvars.ContextVar(
129
144
  "pagination_ctx", default=None
130
145
  )
131
146
 
@@ -146,7 +161,7 @@ def use_pagination() -> PaginationContext:
146
161
 
147
162
 
148
163
  # ---------- Utilities ----------
149
- def text_filter(items: Iterable[T], q: Optional[str], *getters: Callable[[T], str]) -> list[T]:
164
+ def text_filter(items: Iterable[T], q: str | None, *getters: Callable[[T], str]) -> list[T]:
150
165
  if not q:
151
166
  return list(items)
152
167
  ql = q.lower()
@@ -157,8 +172,8 @@ def text_filter(items: Iterable[T], q: Optional[str], *getters: Callable[[T], st
157
172
  if ql in (g(it) or "").lower():
158
173
  out.append(it)
159
174
  break
160
- except Exception:
161
- pass
175
+ except Exception as e:
176
+ logger.debug("text_filter getter failed for item: %s", e)
162
177
  return out
163
178
 
164
179
 
@@ -168,7 +183,7 @@ def sort_by(
168
183
  key: Callable[[T], Any],
169
184
  desc: bool = False,
170
185
  ) -> list[T]:
171
- return sorted(list(items), key=key, reverse=desc)
186
+ return sorted(items, key=key, reverse=desc)
172
187
 
173
188
 
174
189
  def cursor_window(items, *, cursor, limit, key, descending: bool, offset: int = 0):
@@ -215,7 +230,7 @@ def make_pagination_injector(
215
230
  # Cursor-only (common case)
216
231
  if allow_cursor and not allow_page and not include_filters:
217
232
 
218
- async def _inject(
233
+ async def _inject_cursor(
219
234
  request: Request,
220
235
  cursor: str | None = Query(None),
221
236
  limit: int = Query(default_limit, ge=1, le=max_limit),
@@ -233,12 +248,12 @@ def make_pagination_injector(
233
248
  )
234
249
  return None
235
250
 
236
- return _inject
251
+ return _inject_cursor
237
252
 
238
253
  # Cursor + filters
239
254
  if allow_cursor and not allow_page and include_filters:
240
255
 
241
- async def _inject(
256
+ async def _inject_cursor_with_filters(
242
257
  request: Request,
243
258
  cursor: str | None = Query(None),
244
259
  limit: int = Query(default_limit, ge=1, le=max_limit),
@@ -270,12 +285,12 @@ def make_pagination_injector(
270
285
  )
271
286
  return None
272
287
 
273
- return _inject
288
+ return _inject_cursor_with_filters
274
289
 
275
290
  # Page-only
276
291
  if not allow_cursor and allow_page:
277
292
 
278
- async def _inject(
293
+ async def _inject_page(
279
294
  request: Request,
280
295
  page: int = Query(1, ge=1),
281
296
  page_size: int = Query(default_limit, ge=1, le=max_limit),
@@ -293,10 +308,10 @@ def make_pagination_injector(
293
308
  )
294
309
  return None
295
310
 
296
- return _inject
311
+ return _inject_page
297
312
 
298
313
  # Both cursor + page (rare; exposes all)
299
- async def _inject(
314
+ async def _inject_all(
300
315
  request: Request,
301
316
  cursor: str | None = Query(None),
302
317
  limit: int = Query(default_limit, ge=1, le=max_limit),
@@ -336,7 +351,7 @@ def make_pagination_injector(
336
351
  )
337
352
  return None
338
353
 
339
- return _inject
354
+ return _inject_all
340
355
 
341
356
 
342
357
  # ----- Convenience helpers for routers -----
@@ -4,12 +4,18 @@ import importlib
4
4
  import logging
5
5
  import pkgutil
6
6
  from types import ModuleType
7
- from typing import Optional
7
+ from typing import Any
8
8
 
9
9
  from fastapi import FastAPI
10
10
  from fastapi.routing import APIRoute
11
11
 
12
- from svc_infra.app.env import ALL_ENVIRONMENTS, CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV, Environment
12
+ from svc_infra.app.env import (
13
+ ALL_ENVIRONMENTS,
14
+ CURRENT_ENVIRONMENT,
15
+ DEV_ENV,
16
+ LOCAL_ENV,
17
+ Environment,
18
+ )
13
19
 
14
20
  logger = logging.getLogger(__name__)
15
21
 
@@ -59,7 +65,7 @@ def _validate_base_package(base_package: str) -> ModuleType:
59
65
  return package_module
60
66
 
61
67
 
62
- def _normalize_environment(environment: Optional[Environment | str]) -> Environment:
68
+ def _normalize_environment(environment: Environment | str | None) -> Environment:
63
69
  """Normalize the environment parameter."""
64
70
  return (
65
71
  CURRENT_ENVIRONMENT
@@ -69,7 +75,7 @@ def _normalize_environment(environment: Optional[Environment | str]) -> Environm
69
75
 
70
76
 
71
77
  def _should_force_include_in_schema(
72
- environment: Environment, force_include_in_schema: Optional[bool]
78
+ environment: Environment, force_include_in_schema: bool | None
73
79
  ) -> bool:
74
80
  """Determine if routers should be forced to include in schema."""
75
81
  if force_include_in_schema is None:
@@ -99,7 +105,7 @@ def _is_router_excluded_by_environment(
99
105
  )
100
106
  return False
101
107
 
102
- normalized_excluded_envs = set()
108
+ normalized_excluded_envs: set[Environment | str] = set()
103
109
  for e in router_excluded_envs:
104
110
  try:
105
111
  normalized_excluded_envs.add(Environment(e) if not isinstance(e, Environment) else e)
@@ -126,7 +132,7 @@ def _is_router_included_by_environment(
126
132
  f"ROUTER_ENVIRONMENTS in {module_name} must be a set/list/tuple, got {type(router_envs)}"
127
133
  )
128
134
  return True
129
- normalized = set()
135
+ normalized: set[Environment | str] = set()
130
136
  for e in router_envs:
131
137
  try:
132
138
  normalized.add(Environment(e) if not isinstance(e, Environment) else e)
@@ -163,7 +169,7 @@ def _build_include_kwargs(module: ModuleType, prefix: str, force_include: bool)
163
169
  router_tag = getattr(module, "ROUTER_TAG", None)
164
170
  include_in_schema = getattr(module, "INCLUDE_ROUTER_IN_SCHEMA", True)
165
171
 
166
- include_kwargs = {"prefix": prefix}
172
+ include_kwargs: dict[str, Any] = {"prefix": prefix}
167
173
  if router_prefix:
168
174
  include_kwargs["prefix"] = prefix.rstrip("/") + router_prefix
169
175
  if router_tag:
@@ -205,10 +211,10 @@ def _process_router_module(
205
211
  def register_all_routers(
206
212
  app: FastAPI,
207
213
  *,
208
- base_package: Optional[str] = None,
214
+ base_package: str | None = None,
209
215
  prefix: str = "",
210
- environment: Optional[Environment | str] = None,
211
- force_include_in_schema: Optional[bool] = None,
216
+ environment: Environment | str | None = None,
217
+ force_include_in_schema: bool | None = None,
212
218
  ) -> None:
213
219
  """
214
220
  Recursively discover and register all FastAPI routers under a routers package.
@@ -14,6 +14,7 @@ router = public_router(tags=["Health Check"])
14
14
  PING_PATH,
15
15
  status_code=status.HTTP_200_OK,
16
16
  description="Operation to check if the service is up and running",
17
+ operation_id="health_ping_get",
17
18
  )
18
19
  def ping():
19
20
  logging.info("Health check: /ping endpoint accessed. Service is responsive.")
@@ -3,20 +3,26 @@ from __future__ import annotations
3
3
  import logging
4
4
  import os
5
5
  from collections import defaultdict
6
- from typing import Iterable, Sequence
6
+ from collections.abc import Iterable, Sequence
7
7
 
8
8
  from fastapi import FastAPI
9
9
  from fastapi.middleware.cors import CORSMiddleware
10
10
  from fastapi.responses import HTMLResponse
11
11
  from fastapi.routing import APIRoute
12
+ from starlette.types import ASGIApp, Receive, Scope, Send
12
13
 
13
14
  from svc_infra.api.fastapi.docs.landing import CardSpec, DocTargets, render_index_html
14
15
  from svc_infra.api.fastapi.docs.scoped import DOC_SCOPES
15
16
  from svc_infra.api.fastapi.middleware.errors.catchall import CatchAllExceptionMiddleware
16
17
  from svc_infra.api.fastapi.middleware.errors.handlers import register_error_handlers
18
+ from svc_infra.api.fastapi.middleware.graceful_shutdown import install_graceful_shutdown
17
19
  from svc_infra.api.fastapi.middleware.idempotency import IdempotencyMiddleware
18
20
  from svc_infra.api.fastapi.middleware.ratelimit import SimpleRateLimitMiddleware
19
21
  from svc_infra.api.fastapi.middleware.request_id import RequestIdMiddleware
22
+ from svc_infra.api.fastapi.middleware.timeout import (
23
+ BodyReadTimeoutMiddleware,
24
+ HandlerTimeoutMiddleware,
25
+ )
20
26
  from svc_infra.api.fastapi.openapi.models import APIVersionSpec, ServiceInfo
21
27
  from svc_infra.api.fastapi.openapi.mutators import setup_mutators
22
28
  from svc_infra.api.fastapi.openapi.pipeline import apply_mutators
@@ -34,8 +40,9 @@ def _gen_operation_id_factory():
34
40
 
35
41
  def _gen(route: APIRoute) -> str:
36
42
  base = route.name or getattr(route.endpoint, "__name__", "op")
37
- base = _normalize(base)
38
- tag = _normalize(route.tags[0]) if route.tags else ""
43
+ base = _normalize(str(base)) # Convert Enum to str if needed
44
+ tag_raw = route.tags[0] if route.tags else ""
45
+ tag = _normalize(str(tag_raw)) if tag_raw else ""
39
46
  method = next(iter(route.methods or ["GET"])).lower()
40
47
 
41
48
  candidate = base
@@ -55,35 +62,101 @@ def _gen_operation_id_factory():
55
62
  return _gen
56
63
 
57
64
 
65
+ def _origin_to_regex(origin: str) -> str | None:
66
+ """Convert a wildcard origin pattern to a regex.
67
+
68
+ Supports patterns like:
69
+ - "https://*.vercel.app" -> matches any subdomain
70
+ - "https://nfrax-*.vercel.app" -> matches nfrax-xxx.vercel.app
71
+
72
+ Returns None if the origin is not a pattern (no wildcards).
73
+ """
74
+ import re
75
+
76
+ if "*" not in origin:
77
+ return None
78
+ # Escape special regex chars except *, then replace * with regex pattern
79
+ escaped = re.escape(origin).replace(r"\*", "[a-zA-Z0-9_-]+")
80
+ return f"^{escaped}$"
81
+
82
+
58
83
  def _setup_cors(app: FastAPI, public_cors_origins: list[str] | str | None = None):
84
+ # Collect origins from parameter
59
85
  if isinstance(public_cors_origins, list):
60
- origins = [o.strip() for o in public_cors_origins if o and o.strip()]
86
+ param_origins = [o.strip() for o in public_cors_origins if o and o.strip()]
61
87
  elif isinstance(public_cors_origins, str):
62
- origins = [o.strip() for o in public_cors_origins.split(",") if o and o.strip()]
88
+ param_origins = [o.strip() for o in public_cors_origins.split(",") if o and o.strip()]
63
89
  else:
64
- # Strict by default: no CORS unless explicitly configured via env or parameter.
65
- fallback = os.getenv("CORS_ALLOW_ORIGINS", "")
66
- origins = [o.strip() for o in fallback.split(",") if o and o.strip()]
90
+ param_origins = []
91
+
92
+ # Collect origins from environment variable
93
+ env_value = os.getenv("CORS_ALLOW_ORIGINS", "")
94
+ env_origins = [o.strip() for o in env_value.split(",") if o and o.strip()]
95
+
96
+ # Merge both sources, removing duplicates while preserving order
97
+ seen = set()
98
+ origins = []
99
+ for o in param_origins + env_origins:
100
+ if o not in seen:
101
+ seen.add(o)
102
+ origins.append(o)
67
103
 
68
104
  if not origins:
69
105
  return
70
106
 
71
- cors_kwargs = dict(allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
107
+ cors_kwargs = {"allow_credentials": True, "allow_methods": ["*"], "allow_headers": ["*"]}
108
+
109
+ # Check for "*" (allow all) first
72
110
  if "*" in origins:
73
111
  cors_kwargs["allow_origin_regex"] = ".*"
74
112
  else:
75
- cors_kwargs["allow_origins"] = origins
76
-
77
- app.add_middleware(CORSMiddleware, **cors_kwargs)
78
-
113
+ # Separate exact origins from wildcard patterns
114
+ exact_origins = []
115
+ patterns = []
116
+ for o in origins:
117
+ regex = _origin_to_regex(o)
118
+ if regex:
119
+ patterns.append(regex)
120
+ else:
121
+ exact_origins.append(o)
122
+
123
+ # If we have patterns, combine into a single regex with exact origins
124
+ if patterns:
125
+ # Convert exact origins to regex patterns too
126
+ import re
127
+
128
+ for exact in exact_origins:
129
+ patterns.append(f"^{re.escape(exact)}$")
130
+ # Combine all patterns with OR
131
+ cors_kwargs["allow_origin_regex"] = "|".join(patterns)
132
+ else:
133
+ # No patterns, just use allow_origins
134
+ cors_kwargs["allow_origins"] = exact_origins
135
+
136
+ app.add_middleware(CORSMiddleware, **cors_kwargs) # type: ignore[arg-type] # CORSMiddleware accepts these kwargs
137
+
138
+
139
+ def _setup_middlewares(app: FastAPI, skip_paths: list[str] | None = None):
140
+ """Configure middleware stack. All middlewares are pure ASGI for streaming compatibility.
141
+
142
+ Args:
143
+ app: FastAPI application
144
+ skip_paths: Paths to skip for certain middlewares (e.g., long-running or streaming endpoints)
145
+ """
146
+ paths = skip_paths or []
79
147
 
80
- def _setup_middlewares(app: FastAPI):
81
148
  app.add_middleware(RequestIdMiddleware)
149
+ # Timeouts: enforce body read timeout first, then total handler timeout
150
+ app.add_middleware(BodyReadTimeoutMiddleware)
151
+ app.add_middleware(HandlerTimeoutMiddleware, skip_paths=paths)
82
152
  app.add_middleware(CatchAllExceptionMiddleware)
83
- app.add_middleware(IdempotencyMiddleware)
84
- app.add_middleware(SimpleRateLimitMiddleware)
153
+ # Idempotency and rate limiting
154
+ app.add_middleware(IdempotencyMiddleware, skip_paths=paths)
155
+ app.add_middleware(SimpleRateLimitMiddleware, skip_paths=paths)
85
156
  register_error_handlers(app)
86
- _add_route_logger(app)
157
+ _add_route_logger(app, skip_paths=paths)
158
+ # Graceful shutdown: track in-flight and wait on shutdown
159
+ install_graceful_shutdown(app)
87
160
 
88
161
 
89
162
  def _coerce_list(value: str | Iterable[str] | None) -> list[str]:
@@ -98,7 +171,9 @@ def _dump_or_none(model):
98
171
  return model.model_dump(exclude_none=True) if model is not None else None
99
172
 
100
173
 
101
- def _build_child_app(service: ServiceInfo, spec: APIVersionSpec) -> FastAPI:
174
+ def _build_child_app(
175
+ service: ServiceInfo, spec: APIVersionSpec, skip_paths: list[str] | None = None
176
+ ) -> FastAPI:
102
177
  title = f"{service.name} • {spec.tag}" if getattr(spec, "tag", None) else service.name
103
178
  child = FastAPI(
104
179
  title=title,
@@ -106,15 +181,16 @@ def _build_child_app(service: ServiceInfo, spec: APIVersionSpec) -> FastAPI:
106
181
  contact=_dump_or_none(service.contact),
107
182
  license_info=_dump_or_none(service.license),
108
183
  terms_of_service=service.terms_of_service,
109
- description=service.description,
184
+ description=service.description or "",
110
185
  generate_unique_id_function=_gen_operation_id_factory(),
111
186
  )
112
187
 
113
- _setup_middlewares(child)
188
+ _setup_middlewares(child, skip_paths=skip_paths)
114
189
 
115
190
  # ---- OpenAPI pipeline (DRY!) ----
116
191
  include_api_key = bool(spec.include_api_key) if spec.include_api_key is not None else False
117
- mount_path = f"/{spec.tag.strip('/')}"
192
+ tag_str = str(spec.tag).strip("/")
193
+ mount_path = f"/{tag_str}"
118
194
  server_url = (
119
195
  mount_path
120
196
  if not spec.public_base_url
@@ -131,11 +207,17 @@ def _build_child_app(service: ServiceInfo, spec: APIVersionSpec) -> FastAPI:
131
207
 
132
208
  if spec.routers_package:
133
209
  register_all_routers(
134
- child, base_package=spec.routers_package, prefix="", environment=CURRENT_ENVIRONMENT
210
+ child,
211
+ base_package=spec.routers_package,
212
+ prefix="",
213
+ environment=CURRENT_ENVIRONMENT,
135
214
  )
136
215
 
137
216
  logger.info(
138
- "[%s] initialized version %s [env: %s]", service.name, spec.tag, CURRENT_ENVIRONMENT
217
+ "[%s] initialized version %s [env: %s]",
218
+ service.name,
219
+ spec.tag,
220
+ CURRENT_ENVIRONMENT,
139
221
  )
140
222
  return child
141
223
 
@@ -147,23 +229,25 @@ def _build_parent_app(
147
229
  root_routers: list[str] | str | None,
148
230
  root_server_url: str | None = None,
149
231
  root_include_api_key: bool = False,
232
+ skip_paths: list[str] | None = None,
233
+ **fastapi_kwargs, # Accept FastAPI kwargs
150
234
  ) -> FastAPI:
151
- show_root_docs = CURRENT_ENVIRONMENT in (LOCAL_ENV, DEV_ENV)
152
-
235
+ # Root docs are now enabled in all environments to match root card visibility
153
236
  parent = FastAPI(
154
237
  title=service.name,
155
238
  version=service.release,
156
239
  contact=_dump_or_none(service.contact),
157
240
  license_info=_dump_or_none(service.license),
158
241
  terms_of_service=service.terms_of_service,
159
- description=service.description,
160
- docs_url=("/docs" if show_root_docs else None),
161
- redoc_url=("/redoc" if show_root_docs else None),
162
- openapi_url=("/openapi.json" if show_root_docs else None),
242
+ description=service.description or "",
243
+ docs_url="/docs",
244
+ redoc_url="/redoc",
245
+ openapi_url="/openapi.json",
246
+ **fastapi_kwargs, # Forward to FastAPI constructor
163
247
  )
164
248
 
165
249
  _setup_cors(parent, public_cors_origins)
166
- _setup_middlewares(parent)
250
+ _setup_middlewares(parent, skip_paths=skip_paths)
167
251
 
168
252
  mutators = setup_mutators(
169
253
  service=service,
@@ -187,18 +271,43 @@ def _build_parent_app(
187
271
  return parent
188
272
 
189
273
 
190
- def _add_route_logger(app: FastAPI):
191
- @app.middleware("http")
192
- async def _log_route(request, call_next):
193
- resp = await call_next(request)
194
- route = request.scope.get("route")
195
- # Prefer FastAPI's path_format (shows param patterns), fall back to path
196
- path = getattr(route, "path_format", None) or getattr(route, "path", None)
197
- if path:
198
- # Include mount root_path so mounted children show their full path
199
- root_path = request.scope.get("root_path", "") or ""
200
- resp.headers["X-Handled-By"] = f"{request.method} {root_path}{path}"
201
- return resp
274
+ class RouteLoggerMiddleware:
275
+ """Pure ASGI middleware to add X-Handled-By header."""
276
+
277
+ def __init__(self, app: ASGIApp, skip_paths: list[str] | None = None):
278
+ self.app = app
279
+ self.skip_paths = skip_paths or []
280
+
281
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
282
+ if scope.get("type") != "http":
283
+ await self.app(scope, receive, send)
284
+ return
285
+
286
+ path = scope.get("path", "")
287
+ method = scope.get("method", "")
288
+
289
+ # Skip specified paths using prefix matching
290
+ if any(path.startswith(skip) for skip in self.skip_paths):
291
+ await self.app(scope, receive, send)
292
+ return
293
+
294
+ # Wrap send to add header after response starts
295
+ async def send_wrapper(message):
296
+ if message["type"] == "http.response.start":
297
+ route = scope.get("route")
298
+ route_path = getattr(route, "path_format", None) or getattr(route, "path", None)
299
+ if route_path:
300
+ root_path = scope.get("root_path", "") or ""
301
+ headers = list(message.get("headers", []))
302
+ headers.append((b"x-handled-by", f"{method} {root_path}{route_path}".encode()))
303
+ message = {**message, "headers": headers}
304
+ await send(message)
305
+
306
+ await self.app(scope, receive, send_wrapper)
307
+
308
+
309
+ def _add_route_logger(app: FastAPI, skip_paths: list[str] | None = None):
310
+ app.add_middleware(RouteLoggerMiddleware, skip_paths=skip_paths)
202
311
 
203
312
 
204
313
  def setup_service_api(
@@ -209,6 +318,8 @@ def setup_service_api(
209
318
  public_cors_origins: list[str] | str | None = None,
210
319
  root_public_base_url: str | None = None,
211
320
  root_include_api_key: bool | None = None,
321
+ skip_paths: list[str] | None = None,
322
+ **fastapi_kwargs, # Forward all other FastAPI kwargs (lifespan, etc.)
212
323
  ) -> FastAPI:
213
324
  # infer if not explicitly provided
214
325
  effective_root_include_api_key = (
@@ -224,31 +335,33 @@ def setup_service_api(
224
335
  root_routers=root_routers,
225
336
  root_server_url=root_server,
226
337
  root_include_api_key=effective_root_include_api_key,
338
+ skip_paths=skip_paths,
339
+ **fastapi_kwargs, # Forward to _build_parent_app
227
340
  )
228
341
 
229
342
  # Mount each version
230
343
  for spec in versions:
231
- child = _build_child_app(service, spec)
232
- mount_path = f"/{spec.tag.strip('/')}"
233
- parent.mount(mount_path, child, name=spec.tag.strip("/"))
344
+ child = _build_child_app(service, spec, skip_paths=skip_paths)
345
+ tag_str = str(spec.tag).strip("/")
346
+ mount_path = f"/{tag_str}"
347
+ parent.mount(mount_path, child, name=tag_str)
234
348
 
235
- @parent.get("/", include_in_schema=False)
349
+ @parent.get("/", include_in_schema=False, response_class=HTMLResponse)
236
350
  def index():
237
351
  cards: list[CardSpec] = []
238
352
  is_local_dev = CURRENT_ENVIRONMENT in (LOCAL_ENV, DEV_ENV)
239
353
 
240
- if is_local_dev:
241
- # Root card
242
- cards.append(
243
- CardSpec(
244
- tag="",
245
- docs=DocTargets(swagger="/docs", redoc="/redoc", openapi_json="/openapi.json"),
246
- )
354
+ # Root card - always show in all environments
355
+ cards.append(
356
+ CardSpec(
357
+ tag="",
358
+ docs=DocTargets(swagger="/docs", redoc="/redoc", openapi_json="/openapi.json"),
247
359
  )
360
+ )
248
361
 
249
362
  # Version cards
250
363
  for spec in versions:
251
- tag = spec.tag.strip("/")
364
+ tag = str(spec.tag).strip("/")
252
365
  cards.append(
253
366
  CardSpec(
254
367
  tag=tag,
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ from fastapi import FastAPI
7
+
8
+ from .context import set_tenant_resolver
9
+
10
+
11
+ def add_tenancy(app: FastAPI, *, resolver: Callable[..., Any] | None = None) -> None:
12
+ """Wire tenancy resolver for the application.
13
+
14
+ Provide a resolver(request, identity, header) -> Optional[str] to override
15
+ the default resolution. Pass None to clear a previous override.
16
+ """
17
+ set_tenant_resolver(resolver)
18
+
19
+
20
+ __all__ = ["add_tenancy"]