svc-infra 0.1.600__py3-none-any.whl → 0.1.664__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 (140) hide show
  1. svc_infra/api/fastapi/admin/__init__.py +3 -0
  2. svc_infra/api/fastapi/admin/add.py +231 -0
  3. svc_infra/api/fastapi/apf_payments/setup.py +0 -2
  4. svc_infra/api/fastapi/auth/add.py +0 -4
  5. svc_infra/api/fastapi/auth/routers/oauth_router.py +19 -4
  6. svc_infra/api/fastapi/billing/router.py +64 -0
  7. svc_infra/api/fastapi/billing/setup.py +19 -0
  8. svc_infra/api/fastapi/cache/add.py +9 -5
  9. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  10. svc_infra/api/fastapi/db/sql/add.py +40 -18
  11. svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
  12. svc_infra/api/fastapi/db/sql/session.py +16 -0
  13. svc_infra/api/fastapi/dependencies/ratelimit.py +57 -7
  14. svc_infra/api/fastapi/docs/add.py +160 -0
  15. svc_infra/api/fastapi/docs/landing.py +1 -1
  16. svc_infra/api/fastapi/docs/scoped.py +41 -6
  17. svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
  18. svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
  19. svc_infra/api/fastapi/middleware/ratelimit.py +59 -1
  20. svc_infra/api/fastapi/middleware/ratelimit_store.py +12 -6
  21. svc_infra/api/fastapi/middleware/timeout.py +148 -0
  22. svc_infra/api/fastapi/openapi/mutators.py +114 -0
  23. svc_infra/api/fastapi/ops/add.py +73 -0
  24. svc_infra/api/fastapi/pagination.py +3 -1
  25. svc_infra/api/fastapi/routers/ping.py +1 -0
  26. svc_infra/api/fastapi/setup.py +21 -13
  27. svc_infra/api/fastapi/tenancy/add.py +19 -0
  28. svc_infra/api/fastapi/tenancy/context.py +112 -0
  29. svc_infra/api/fastapi/versioned.py +101 -0
  30. svc_infra/app/README.md +5 -5
  31. svc_infra/billing/__init__.py +23 -0
  32. svc_infra/billing/async_service.py +147 -0
  33. svc_infra/billing/jobs.py +230 -0
  34. svc_infra/billing/models.py +131 -0
  35. svc_infra/billing/quotas.py +101 -0
  36. svc_infra/billing/schemas.py +33 -0
  37. svc_infra/billing/service.py +115 -0
  38. svc_infra/bundled_docs/README.md +5 -0
  39. svc_infra/bundled_docs/__init__.py +1 -0
  40. svc_infra/bundled_docs/getting-started.md +6 -0
  41. svc_infra/cache/__init__.py +4 -0
  42. svc_infra/cache/add.py +158 -0
  43. svc_infra/cache/backend.py +5 -2
  44. svc_infra/cache/decorators.py +19 -1
  45. svc_infra/cache/keys.py +24 -4
  46. svc_infra/cli/__init__.py +28 -8
  47. svc_infra/cli/cmds/__init__.py +8 -0
  48. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
  49. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
  50. svc_infra/cli/cmds/db/sql/alembic_cmds.py +80 -11
  51. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  52. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  53. svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
  54. svc_infra/cli/cmds/dx/__init__.py +12 -0
  55. svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
  56. svc_infra/cli/cmds/help.py +4 -0
  57. svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
  58. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  59. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  60. svc_infra/data/add.py +61 -0
  61. svc_infra/data/backup.py +53 -0
  62. svc_infra/data/erasure.py +45 -0
  63. svc_infra/data/fixtures.py +40 -0
  64. svc_infra/data/retention.py +55 -0
  65. svc_infra/db/nosql/mongo/README.md +13 -13
  66. svc_infra/db/sql/repository.py +51 -11
  67. svc_infra/db/sql/resource.py +5 -0
  68. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  69. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  70. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  71. svc_infra/db/sql/tenant.py +79 -0
  72. svc_infra/db/sql/utils.py +18 -4
  73. svc_infra/docs/acceptance-matrix.md +88 -0
  74. svc_infra/docs/acceptance.md +44 -0
  75. svc_infra/docs/admin.md +425 -0
  76. svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
  77. svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
  78. svc_infra/docs/adr/0004-tenancy-model.md +42 -0
  79. svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
  80. svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
  81. svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
  82. svc_infra/docs/adr/0008-billing-primitives.md +143 -0
  83. svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
  84. svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
  85. svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
  86. svc_infra/docs/adr/0012-generic-file-storage.md +498 -0
  87. svc_infra/docs/api.md +186 -0
  88. svc_infra/docs/auth.md +11 -0
  89. svc_infra/docs/billing.md +190 -0
  90. svc_infra/docs/cache.md +76 -0
  91. svc_infra/docs/cli.md +74 -0
  92. svc_infra/docs/contributing.md +34 -0
  93. svc_infra/docs/data-lifecycle.md +52 -0
  94. svc_infra/docs/database.md +14 -0
  95. svc_infra/docs/docs-and-sdks.md +62 -0
  96. svc_infra/docs/environment.md +114 -0
  97. svc_infra/docs/getting-started.md +63 -0
  98. svc_infra/docs/idempotency.md +111 -0
  99. svc_infra/docs/jobs.md +67 -0
  100. svc_infra/docs/observability.md +16 -0
  101. svc_infra/docs/ops.md +37 -0
  102. svc_infra/docs/rate-limiting.md +125 -0
  103. svc_infra/docs/repo-review.md +48 -0
  104. svc_infra/docs/security.md +176 -0
  105. svc_infra/docs/storage.md +982 -0
  106. svc_infra/docs/tenancy.md +35 -0
  107. svc_infra/docs/timeouts-and-resource-limits.md +147 -0
  108. svc_infra/docs/versioned-integrations.md +146 -0
  109. svc_infra/docs/webhooks.md +112 -0
  110. svc_infra/dx/add.py +63 -0
  111. svc_infra/dx/changelog.py +74 -0
  112. svc_infra/dx/checks.py +67 -0
  113. svc_infra/http/__init__.py +13 -0
  114. svc_infra/http/client.py +72 -0
  115. svc_infra/jobs/builtins/webhook_delivery.py +14 -2
  116. svc_infra/jobs/queue.py +9 -1
  117. svc_infra/jobs/runner.py +75 -0
  118. svc_infra/jobs/worker.py +17 -1
  119. svc_infra/mcp/svc_infra_mcp.py +85 -28
  120. svc_infra/obs/add.py +54 -7
  121. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  122. svc_infra/security/headers.py +15 -2
  123. svc_infra/security/hibp.py +6 -2
  124. svc_infra/security/models.py +27 -7
  125. svc_infra/security/oauth_models.py +59 -0
  126. svc_infra/security/permissions.py +1 -0
  127. svc_infra/storage/__init__.py +93 -0
  128. svc_infra/storage/add.py +250 -0
  129. svc_infra/storage/backends/__init__.py +11 -0
  130. svc_infra/storage/backends/local.py +331 -0
  131. svc_infra/storage/backends/memory.py +214 -0
  132. svc_infra/storage/backends/s3.py +329 -0
  133. svc_infra/storage/base.py +239 -0
  134. svc_infra/storage/easy.py +182 -0
  135. svc_infra/storage/settings.py +192 -0
  136. svc_infra/webhooks/service.py +10 -2
  137. {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/METADATA +45 -14
  138. {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/RECORD +140 -52
  139. {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/WHEEL +0 -0
  140. {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,3 @@
1
+ from .add import add_admin, admin_router
2
+
3
+ __all__ = ["add_admin", "admin_router"]
@@ -0,0 +1,231 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hmac
5
+ import inspect
6
+ import json
7
+ import logging
8
+ import os
9
+ import time
10
+ from hashlib import sha256
11
+ from types import SimpleNamespace
12
+ from typing import Any, Callable, Optional
13
+
14
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response
15
+
16
+ from ....app.env import get_current_environment
17
+ from ....security.permissions import RequirePermission
18
+ from ..auth.security import Identity, Principal, _current_principal
19
+ from ..auth.state import get_auth_state
20
+ from ..db.sql.session import SqlSessionDep
21
+ from ..dual.protected import roles_router
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def _b64u(data: bytes) -> str:
27
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
28
+
29
+
30
+ def _b64u_decode(s: str) -> bytes:
31
+ pad = "=" * ((4 - len(s) % 4) % 4)
32
+ return base64.urlsafe_b64decode(s + pad)
33
+
34
+
35
+ def _sign(payload: dict, *, secret: str) -> str:
36
+ body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
37
+ sig = hmac.new(secret.encode("utf-8"), body, sha256).digest()
38
+ return _b64u(body) + "." + _b64u(sig)
39
+
40
+
41
+ def _verify(token: str, *, secret: str) -> dict:
42
+ try:
43
+ b64_body, b64_sig = token.split(".", 1)
44
+ body = _b64u_decode(b64_body)
45
+ exp_sig = _b64u_decode(b64_sig)
46
+ got_sig = hmac.new(secret.encode("utf-8"), body, sha256).digest()
47
+ if not hmac.compare_digest(exp_sig, got_sig):
48
+ raise ValueError("bad_signature")
49
+ payload = json.loads(body)
50
+ if int(payload.get("exp", 0)) < int(time.time()):
51
+ raise ValueError("expired")
52
+ return payload
53
+ except Exception as e:
54
+ raise ValueError("invalid_token") from e
55
+
56
+
57
+ def admin_router(*, dependencies: Optional[list[Any]] = None, **kwargs) -> APIRouter:
58
+ """Role-gated admin router for coarse access control.
59
+
60
+ Use permission guards inside endpoints for fine-grained control.
61
+ """
62
+
63
+ return roles_router("admin", **kwargs)
64
+
65
+
66
+ def add_admin(
67
+ app,
68
+ *,
69
+ base_path: str = "/admin",
70
+ enable_impersonation: bool = True,
71
+ secret: Optional[str] = None,
72
+ ttl_seconds: int = 15 * 60,
73
+ cookie_name: str = "impersonation",
74
+ impersonation_user_getter: Optional[Callable[[Any, str], Any]] = None,
75
+ ) -> None:
76
+ """Wire admin surfaces with sensible defaults.
77
+
78
+ - Mounts an admin router under base_path.
79
+ - Optionally enables impersonation start/stop endpoints guarded by permissions.
80
+ - Registers a dependency override to honor impersonation cookie globally (idempotent).
81
+
82
+ impersonation_user_getter: optional callable (request, user_id) -> user object.
83
+ If omitted, defaults to loading from SQLAlchemy User model returned by get_auth_state().
84
+ """
85
+
86
+ # Idempotency: only mount once per app instance
87
+ if getattr(app.state, "_admin_added", False):
88
+ return
89
+
90
+ env = get_current_environment()
91
+ _secret = (
92
+ secret or os.getenv("ADMIN_IMPERSONATION_SECRET") or os.getenv("APP_SECRET") or "dev-secret"
93
+ )
94
+ _ttl = int(os.getenv("ADMIN_IMPERSONATION_TTL", str(ttl_seconds)))
95
+ _cookie = os.getenv("ADMIN_IMPERSONATION_COOKIE", cookie_name)
96
+
97
+ r = admin_router(prefix=base_path, tags=["admin"]) # role-gated
98
+
99
+ async def _default_user_getter(request: Request, user_id: str, session: SqlSessionDep):
100
+ try:
101
+ UserModel, _, _ = get_auth_state()
102
+ except Exception:
103
+ # Fallback: simple shim if auth state not configured
104
+ return SimpleNamespace(id=user_id)
105
+ obj = await session.get(UserModel, user_id)
106
+ if not obj:
107
+ raise HTTPException(404, "user_not_found")
108
+ return obj
109
+
110
+ user_getter = impersonation_user_getter
111
+
112
+ @r.post(
113
+ "/impersonate/start", status_code=204, dependencies=[RequirePermission("admin.impersonate")]
114
+ )
115
+ async def start_impersonation(
116
+ body: dict, request: Request, response: Response, session: SqlSessionDep, identity: Identity
117
+ ):
118
+ target_id = (body or {}).get("user_id")
119
+ reason = (body or {}).get("reason", "")
120
+ if not target_id:
121
+ raise HTTPException(422, "user_id_required")
122
+ # Load target for validation (custom getter or default)
123
+ _res = (
124
+ user_getter(request, target_id)
125
+ if user_getter
126
+ else _default_user_getter(request, target_id, session)
127
+ )
128
+ target = await _res if inspect.isawaitable(_res) else _res
129
+ actor: Principal = identity
130
+ payload = {
131
+ "actor_id": getattr(getattr(actor, "user", None), "id", None),
132
+ "target_id": str(getattr(target, "id", target_id)),
133
+ "iat": int(time.time()),
134
+ "exp": int(time.time()) + _ttl,
135
+ "nonce": _b64u(os.urandom(8)),
136
+ }
137
+ token = _sign(payload, secret=_secret)
138
+ response.set_cookie(
139
+ key=_cookie,
140
+ value=token,
141
+ httponly=True,
142
+ samesite="lax",
143
+ secure=(env in ("prod", "production")),
144
+ path="/",
145
+ max_age=_ttl,
146
+ )
147
+ logger.info(
148
+ "admin.impersonation.started",
149
+ extra={
150
+ "actor_id": payload["actor_id"],
151
+ "target_id": payload["target_id"],
152
+ "reason": reason,
153
+ "expires_in": _ttl,
154
+ },
155
+ )
156
+ # Re-compose override now to wrap any late overrides set by tests/harness
157
+ try:
158
+ _compose_override()
159
+ except Exception:
160
+ pass
161
+
162
+ @r.post("/impersonate/stop", status_code=204)
163
+ async def stop_impersonation(response: Response):
164
+ response.delete_cookie(_cookie, path="/")
165
+ logger.info("admin.impersonation.stopped")
166
+
167
+ app.include_router(r)
168
+
169
+ # Dependency override: wrap the base principal to honor impersonation cookie.
170
+ # Compose with any existing override (e.g., acceptance app/test harness) and
171
+ # re-compose at startup to capture late overrides.
172
+ def _compose_override():
173
+ existing = app.dependency_overrides.get(_current_principal)
174
+ if existing and getattr(existing, "_is_admin_impersonation_override", False):
175
+ dep_provider = getattr(existing, "_admin_impersonation_base", _current_principal)
176
+ else:
177
+ dep_provider = existing or _current_principal
178
+
179
+ async def _override_current_principal(
180
+ base: Principal = Depends(dep_provider),
181
+ request: Request = None,
182
+ session: SqlSessionDep = None,
183
+ ) -> Principal:
184
+ token = request.cookies.get(_cookie) if request else None
185
+ if not token:
186
+ return base
187
+ try:
188
+ payload = _verify(token, secret=_secret)
189
+ except Exception:
190
+ return base
191
+ # Load target user
192
+ target_id = payload.get("target_id")
193
+ if not target_id:
194
+ return base
195
+ # Preserve actor roles/claims so permissions remain that of the actor
196
+ actor_user = getattr(base, "user", None)
197
+ actor_roles = getattr(actor_user, "roles", []) or []
198
+ _res = (
199
+ user_getter(request, target_id)
200
+ if user_getter
201
+ else _default_user_getter(request, target_id, session)
202
+ )
203
+ target = await _res if inspect.isawaitable(_res) else _res
204
+ # Swap user but keep actor for audit if needed
205
+ setattr(base, "actor", getattr(base, "user", None))
206
+ # If target lacks roles, inherit actor roles to maintain permission checks
207
+ try:
208
+ if not getattr(target, "roles", None):
209
+ setattr(target, "roles", actor_roles)
210
+ except Exception:
211
+ # Best-effort; if target object is immutable, fallback by wrapping
212
+ target = SimpleNamespace(id=getattr(target, "id", target_id), roles=actor_roles)
213
+ base.user = target
214
+ base.via = "impersonated"
215
+ return base
216
+
217
+ app.dependency_overrides[_current_principal] = _override_current_principal
218
+ _override_current_principal._is_admin_impersonation_override = True # type: ignore[attr-defined]
219
+ _override_current_principal._admin_impersonation_base = dep_provider # type: ignore[attr-defined]
220
+
221
+ # Compose now (best-effort) and again on startup to wrap any later overrides
222
+ _compose_override()
223
+ try:
224
+ app.add_event_handler("startup", _compose_override)
225
+ except Exception:
226
+ # Best-effort; if app doesn't support event handlers, we already composed once
227
+ pass
228
+ app.state._admin_added = True
229
+
230
+
231
+ # no extra helpers
@@ -7,7 +7,6 @@ from fastapi import FastAPI
7
7
 
8
8
  from svc_infra.apf_payments.provider.registry import get_provider_registry
9
9
  from svc_infra.api.fastapi.apf_payments.router import build_payments_routers
10
- from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
11
10
 
12
11
  logger = logging.getLogger(__name__)
13
12
 
@@ -51,7 +50,6 @@ def add_payments(
51
50
  - Reuses your OpenAPI defaults (security + responses) via DualAPIRouter factories.
52
51
  """
53
52
  _maybe_register_default_providers(register_default_providers, adapters)
54
- add_prefixed_docs(app, prefix=prefix, title="Payments")
55
53
 
56
54
  for r in build_payments_routers(prefix=prefix):
57
55
  app.include_router(
@@ -17,7 +17,6 @@ from svc_infra.api.fastapi.paths.prefix import AUTH_PREFIX, USER_PREFIX
17
17
  from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV
18
18
  from svc_infra.db.sql.apikey import bind_apikey_model
19
19
 
20
- from ..docs.scoped import add_prefixed_docs
21
20
  from .policy import AuthPolicy, DefaultAuthPolicy
22
21
  from .providers import providers_from_settings
23
22
  from .settings import get_auth_settings
@@ -293,9 +292,6 @@ def add_auth_users(
293
292
  https_only=bool(getattr(settings_obj, "session_cookie_secure", False)),
294
293
  )
295
294
 
296
- add_prefixed_docs(app, prefix=user_prefix, title="Users")
297
- add_prefixed_docs(app, prefix=auth_prefix, title="Auth")
298
-
299
295
  if enable_password:
300
296
  setup_password_authentication(
301
297
  app,
@@ -373,9 +373,8 @@ async def _update_provider_account(
373
373
  def _determine_final_redirect_url(request: Request, provider: str, post_login_redirect: str) -> str:
374
374
  """Determine the final redirect URL after successful authentication."""
375
375
  st = get_auth_settings()
376
- redirect_url = str(
377
- getattr(st, "post_login_redirect", post_login_redirect) or post_login_redirect
378
- )
376
+ # Prioritize the parameter passed to the router over settings
377
+ redirect_url = str(post_login_redirect or getattr(st, "post_login_redirect", "/"))
379
378
  allow_hosts = parse_redirect_allow_hosts(getattr(st, "redirect_allow_hosts_raw", None))
380
379
  require_https = bool(getattr(st, "session_cookie_secure", False))
381
380
 
@@ -669,7 +668,23 @@ def _create_oauth_router(
669
668
  ip_hash=None,
670
669
  )
671
670
 
672
- # Create response with auth + refresh cookies
671
+ # Generate JWT token for the response
672
+ strategy = auth_backend.get_strategy()
673
+ jwt_token = await strategy.write_token(user)
674
+
675
+ # If redirecting to a different origin, append token as URL fragment for frontend to extract
676
+ # This handles cross-port scenarios like localhost:8000 -> localhost:3000
677
+ parsed_redirect = urlparse(redirect_url)
678
+ request_origin = f"{request.url.scheme}://{request.url.netloc}"
679
+ redirect_origin = f"{parsed_redirect.scheme}://{parsed_redirect.netloc}"
680
+
681
+ if redirect_origin and redirect_origin != request_origin:
682
+ # Cross-origin redirect: append token as URL fragment
683
+ # Fragment is not sent to server, only accessible to client-side JS
684
+ separator = "#" if not parsed_redirect.fragment else "&"
685
+ redirect_url = f"{redirect_url}{separator}access_token={jwt_token}"
686
+
687
+ # Create response with auth + refresh cookies (for same-origin requests)
673
688
  resp = RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)
674
689
  await _set_cookie_on_response(resp, auth_backend, user, refresh_raw=raw_refresh)
675
690
 
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Annotated, Optional
5
+
6
+ from fastapi import APIRouter, Depends, Response, status
7
+
8
+ from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
9
+ from svc_infra.api.fastapi.middleware.idempotency import require_idempotency_key
10
+ from svc_infra.api.fastapi.tenancy.context import TenantId
11
+ from svc_infra.billing.async_service import AsyncBillingService
12
+ from svc_infra.billing.schemas import UsageAckOut, UsageAggregateRow, UsageAggregatesOut, UsageIn
13
+
14
+ router = APIRouter(prefix="/_billing", tags=["Billing"])
15
+
16
+
17
+ def get_service(tenant_id: TenantId, session: SqlSessionDep) -> AsyncBillingService:
18
+ return AsyncBillingService(session=session, tenant_id=tenant_id)
19
+
20
+
21
+ @router.post(
22
+ "/usage",
23
+ name="billing_record_usage",
24
+ status_code=status.HTTP_202_ACCEPTED,
25
+ response_model=UsageAckOut,
26
+ dependencies=[Depends(require_idempotency_key)],
27
+ )
28
+ async def record_usage(
29
+ data: UsageIn, svc: Annotated[AsyncBillingService, Depends(get_service)], response: Response
30
+ ):
31
+ at = data.at or datetime.now(tz=timezone.utc)
32
+ evt_id = await svc.record_usage(
33
+ metric=data.metric,
34
+ amount=int(data.amount),
35
+ at=at,
36
+ idempotency_key=data.idempotency_key,
37
+ metadata=data.metadata,
38
+ )
39
+ # For 202, no Location header is required, but we can surface the id in the body
40
+ return UsageAckOut(id=evt_id, accepted=True)
41
+
42
+
43
+ @router.get(
44
+ "/usage",
45
+ name="billing_list_aggregates",
46
+ response_model=UsageAggregatesOut,
47
+ )
48
+ async def list_aggregates(
49
+ metric: str,
50
+ date_from: Optional[datetime] = None,
51
+ date_to: Optional[datetime] = None,
52
+ svc: Annotated[AsyncBillingService, Depends(get_service)] = None,
53
+ ):
54
+ rows = await svc.list_daily_aggregates(metric=metric, date_from=date_from, date_to=date_to)
55
+ items = [
56
+ UsageAggregateRow(
57
+ period_start=r.period_start,
58
+ granularity=r.granularity,
59
+ metric=r.metric,
60
+ total=int(r.total),
61
+ )
62
+ for r in rows
63
+ ]
64
+ return UsageAggregatesOut(items=items, next_cursor=None)
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import FastAPI
4
+
5
+ from .router import router as billing_router
6
+
7
+
8
+ def add_billing(app: FastAPI, *, prefix: str = "/_billing") -> None:
9
+ # Mount under the chosen prefix; default is /_billing
10
+ if prefix and prefix != "/_billing":
11
+ # If a custom prefix is desired, clone router with new prefix
12
+ from fastapi import APIRouter
13
+
14
+ custom = APIRouter(prefix=prefix, tags=["Billing"])
15
+ for route in billing_router.routes:
16
+ custom.routes.append(route)
17
+ app.include_router(custom)
18
+ else:
19
+ app.include_router(billing_router)
@@ -1,3 +1,5 @@
1
+ from contextlib import asynccontextmanager
2
+
1
3
  from fastapi import FastAPI
2
4
 
3
5
  from svc_infra.cache.backend import shutdown_cache
@@ -5,10 +7,12 @@ from svc_infra.cache.decorators import init_cache
5
7
 
6
8
 
7
9
  def setup_caching(app: FastAPI) -> None:
8
- @app.on_event("startup")
9
- async def _startup():
10
+ @asynccontextmanager
11
+ async def lifespan(_app: FastAPI):
10
12
  init_cache()
13
+ try:
14
+ yield
15
+ finally:
16
+ await shutdown_cache()
11
17
 
12
- @app.on_event("shutdown")
13
- async def _shutdown():
14
- await shutdown_cache()
18
+ app.router.lifespan_context = lifespan
@@ -38,8 +38,8 @@ def add_mongo_db_with_url(app: FastAPI, url: str, db_name: str) -> None:
38
38
 
39
39
 
40
40
  def add_mongo_db(app: FastAPI, *, dsn_env: str = "MONGO_URL") -> None:
41
- @app.on_event("startup")
42
- async def _startup() -> None:
41
+ @asynccontextmanager
42
+ async def lifespan(_app: FastAPI):
43
43
  if not os.getenv(dsn_env):
44
44
  raise RuntimeError(f"Missing environment variable {dsn_env} for Mongo URL")
45
45
  await init_mongo()
@@ -47,10 +47,12 @@ def add_mongo_db(app: FastAPI, *, dsn_env: str = "MONGO_URL") -> None:
47
47
  db = await acquire_db()
48
48
  if expected and db.name != expected:
49
49
  raise RuntimeError(f"Connected to Mongo DB '{db.name}', expected '{expected}'.")
50
+ try:
51
+ yield
52
+ finally:
53
+ await close_mongo()
50
54
 
51
- @app.on_event("shutdown")
52
- async def _shutdown() -> None:
53
- await close_mongo()
55
+ app.router.lifespan_context = lifespan
54
56
 
55
57
 
56
58
  def add_mongo_health(
@@ -62,46 +64,50 @@ def add_mongo_health(
62
64
 
63
65
 
64
66
  def add_mongo_resources(app: FastAPI, resources: Sequence[NoSqlResource]) -> None:
65
- for r in resources:
67
+ for resource in resources:
66
68
  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,
69
+ collection_name=resource.resolved_collection(),
70
+ id_field=resource.id_field,
71
+ soft_delete=resource.soft_delete,
72
+ soft_delete_field=resource.soft_delete_field,
73
+ soft_delete_flag_field=resource.soft_delete_flag_field,
72
74
  )
73
- svc = r.service_factory(repo) if r.service_factory else NoSqlService(repo)
75
+ svc = resource.service_factory(repo) if resource.service_factory else NoSqlService(repo)
74
76
 
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:
77
+ if resource.read_schema and resource.create_schema and resource.update_schema:
78
+ Read, Create, Update = (
79
+ resource.read_schema,
80
+ resource.create_schema,
81
+ resource.update_schema,
82
+ )
83
+ elif resource.document_model is not None:
78
84
  # CRITICAL: teach Pydantic to dump ObjectId/PyObjectId
79
85
  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,
86
+ resource.document_model,
87
+ create_exclude=resource.create_exclude,
88
+ read_name=resource.read_name,
89
+ create_name=resource.create_name,
90
+ update_name=resource.update_name,
91
+ read_exclude=resource.read_exclude,
92
+ update_exclude=resource.update_exclude,
87
93
  json_encoders={ObjectId: str, PyObjectId: str},
88
94
  )
89
95
  else:
90
96
  raise RuntimeError(
91
- f"Resource for collection '{r.collection}' requires either explicit schemas "
97
+ f"Resource for collection '{resource.collection}' requires either explicit schemas "
92
98
  f"(read/create/update) or a 'document_model' to derive them."
93
99
  )
94
100
 
95
101
  router = make_crud_router_plus_mongo(
96
- collection=r.resolved_collection(),
102
+ collection=resource.resolved_collection(),
97
103
  repo=repo,
98
104
  service=svc,
99
105
  read_schema=Read,
100
106
  create_schema=Create,
101
107
  update_schema=Update,
102
- prefix=r.prefix,
103
- tags=r.tags,
104
- search_fields=r.search_fields,
108
+ prefix=resource.prefix,
109
+ tags=resource.tags,
110
+ search_fields=resource.search_fields,
105
111
  default_ordering=None,
106
112
  allowed_order_fields=None,
107
113
  )
@@ -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,18 +37,37 @@ 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
 
@@ -67,16 +86,19 @@ def add_sql_db(app: FastAPI, *, url: Optional[str] = None, dsn_env: str = "SQL_U
67
86
  app.router.lifespan_context = lifespan
68
87
  return
69
88
 
70
- @app.on_event("startup")
71
- async def _startup() -> None: # noqa: ANN202
89
+ # Use lifespan context manager instead of deprecated on_event
90
+ @asynccontextmanager
91
+ async def lifespan(_app: FastAPI):
72
92
  env_url = os.getenv(dsn_env)
73
93
  if not env_url:
74
94
  raise RuntimeError(f"Missing environment variable {dsn_env} for database URL")
75
95
  initialize_session(env_url)
96
+ try:
97
+ yield
98
+ finally:
99
+ await dispose_session()
76
100
 
77
- @app.on_event("shutdown")
78
- async def _shutdown() -> None: # noqa: ANN202
79
- await dispose_session()
101
+ app.router.lifespan_context = lifespan
80
102
 
81
103
 
82
104
  def add_sql_health(