svc-infra 0.1.506__py3-none-any.whl → 0.1.654__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.
Files changed (202) hide show
  1. svc_infra/apf_payments/README.md +732 -0
  2. svc_infra/apf_payments/alembic.py +11 -0
  3. svc_infra/apf_payments/models.py +339 -0
  4. svc_infra/apf_payments/provider/__init__.py +4 -0
  5. svc_infra/apf_payments/provider/aiydan.py +797 -0
  6. svc_infra/apf_payments/provider/base.py +270 -0
  7. svc_infra/apf_payments/provider/registry.py +31 -0
  8. svc_infra/apf_payments/provider/stripe.py +873 -0
  9. svc_infra/apf_payments/schemas.py +333 -0
  10. svc_infra/apf_payments/service.py +892 -0
  11. svc_infra/apf_payments/settings.py +67 -0
  12. svc_infra/api/fastapi/__init__.py +6 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +231 -0
  15. svc_infra/api/fastapi/apf_payments/__init__.py +0 -0
  16. svc_infra/api/fastapi/apf_payments/router.py +1082 -0
  17. svc_infra/api/fastapi/apf_payments/setup.py +73 -0
  18. svc_infra/api/fastapi/auth/add.py +15 -6
  19. svc_infra/api/fastapi/auth/gaurd.py +67 -5
  20. svc_infra/api/fastapi/auth/mfa/router.py +18 -9
  21. svc_infra/api/fastapi/auth/routers/account.py +3 -2
  22. svc_infra/api/fastapi/auth/routers/apikey_router.py +11 -5
  23. svc_infra/api/fastapi/auth/routers/oauth_router.py +82 -37
  24. svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
  25. svc_infra/api/fastapi/auth/security.py +3 -1
  26. svc_infra/api/fastapi/auth/settings.py +2 -0
  27. svc_infra/api/fastapi/auth/state.py +1 -1
  28. svc_infra/api/fastapi/billing/router.py +64 -0
  29. svc_infra/api/fastapi/billing/setup.py +19 -0
  30. svc_infra/api/fastapi/cache/add.py +9 -5
  31. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  32. svc_infra/api/fastapi/db/sql/add.py +40 -18
  33. svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
  34. svc_infra/api/fastapi/db/sql/session.py +16 -0
  35. svc_infra/api/fastapi/db/sql/users.py +14 -2
  36. svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
  37. svc_infra/api/fastapi/docs/add.py +160 -0
  38. svc_infra/api/fastapi/docs/landing.py +1 -1
  39. svc_infra/api/fastapi/docs/scoped.py +254 -0
  40. svc_infra/api/fastapi/dual/dualize.py +38 -33
  41. svc_infra/api/fastapi/dual/router.py +48 -1
  42. svc_infra/api/fastapi/dx.py +3 -3
  43. svc_infra/api/fastapi/http/__init__.py +0 -0
  44. svc_infra/api/fastapi/http/concurrency.py +14 -0
  45. svc_infra/api/fastapi/http/conditional.py +33 -0
  46. svc_infra/api/fastapi/http/deprecation.py +21 -0
  47. svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
  48. svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
  49. svc_infra/api/fastapi/middleware/idempotency.py +116 -0
  50. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  51. svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
  52. svc_infra/api/fastapi/middleware/ratelimit.py +119 -0
  53. svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
  54. svc_infra/api/fastapi/middleware/request_id.py +23 -0
  55. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  56. svc_infra/api/fastapi/middleware/timeout.py +148 -0
  57. svc_infra/api/fastapi/openapi/mutators.py +768 -55
  58. svc_infra/api/fastapi/ops/add.py +73 -0
  59. svc_infra/api/fastapi/pagination.py +363 -0
  60. svc_infra/api/fastapi/paths/auth.py +14 -14
  61. svc_infra/api/fastapi/paths/prefix.py +0 -1
  62. svc_infra/api/fastapi/paths/user.py +1 -1
  63. svc_infra/api/fastapi/routers/ping.py +1 -0
  64. svc_infra/api/fastapi/setup.py +48 -15
  65. svc_infra/api/fastapi/tenancy/add.py +19 -0
  66. svc_infra/api/fastapi/tenancy/context.py +112 -0
  67. svc_infra/api/fastapi/versioned.py +101 -0
  68. svc_infra/app/README.md +5 -5
  69. svc_infra/billing/__init__.py +23 -0
  70. svc_infra/billing/async_service.py +147 -0
  71. svc_infra/billing/jobs.py +230 -0
  72. svc_infra/billing/models.py +131 -0
  73. svc_infra/billing/quotas.py +101 -0
  74. svc_infra/billing/schemas.py +33 -0
  75. svc_infra/billing/service.py +115 -0
  76. svc_infra/bundled_docs/README.md +5 -0
  77. svc_infra/bundled_docs/__init__.py +1 -0
  78. svc_infra/bundled_docs/getting-started.md +6 -0
  79. svc_infra/cache/__init__.py +4 -0
  80. svc_infra/cache/add.py +158 -0
  81. svc_infra/cache/backend.py +5 -2
  82. svc_infra/cache/decorators.py +19 -1
  83. svc_infra/cache/keys.py +24 -4
  84. svc_infra/cli/__init__.py +32 -8
  85. svc_infra/cli/__main__.py +4 -0
  86. svc_infra/cli/cmds/__init__.py +10 -0
  87. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
  88. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
  89. svc_infra/cli/cmds/db/sql/alembic_cmds.py +120 -14
  90. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  91. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +5 -4
  92. svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
  93. svc_infra/cli/cmds/dx/__init__.py +12 -0
  94. svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
  95. svc_infra/cli/cmds/help.py +4 -0
  96. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  97. svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
  98. svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
  99. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  100. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  101. svc_infra/data/add.py +61 -0
  102. svc_infra/data/backup.py +53 -0
  103. svc_infra/data/erasure.py +45 -0
  104. svc_infra/data/fixtures.py +40 -0
  105. svc_infra/data/retention.py +55 -0
  106. svc_infra/db/inbox.py +67 -0
  107. svc_infra/db/nosql/mongo/README.md +13 -13
  108. svc_infra/db/outbox.py +104 -0
  109. svc_infra/db/sql/apikey.py +1 -1
  110. svc_infra/db/sql/authref.py +61 -0
  111. svc_infra/db/sql/core.py +2 -2
  112. svc_infra/db/sql/repository.py +52 -12
  113. svc_infra/db/sql/resource.py +5 -0
  114. svc_infra/db/sql/scaffold.py +16 -4
  115. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  116. svc_infra/db/sql/templates/setup/env_async.py.tmpl +199 -76
  117. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +231 -79
  118. svc_infra/db/sql/tenant.py +79 -0
  119. svc_infra/db/sql/utils.py +18 -4
  120. svc_infra/db/sql/versioning.py +14 -0
  121. svc_infra/docs/acceptance-matrix.md +71 -0
  122. svc_infra/docs/acceptance.md +44 -0
  123. svc_infra/docs/admin.md +425 -0
  124. svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
  125. svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
  126. svc_infra/docs/adr/0004-tenancy-model.md +42 -0
  127. svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
  128. svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
  129. svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
  130. svc_infra/docs/adr/0008-billing-primitives.md +143 -0
  131. svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
  132. svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
  133. svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
  134. svc_infra/docs/api.md +59 -0
  135. svc_infra/docs/auth.md +11 -0
  136. svc_infra/docs/billing.md +190 -0
  137. svc_infra/docs/cache.md +76 -0
  138. svc_infra/docs/cli.md +74 -0
  139. svc_infra/docs/contributing.md +34 -0
  140. svc_infra/docs/data-lifecycle.md +52 -0
  141. svc_infra/docs/database.md +14 -0
  142. svc_infra/docs/docs-and-sdks.md +62 -0
  143. svc_infra/docs/environment.md +114 -0
  144. svc_infra/docs/getting-started.md +63 -0
  145. svc_infra/docs/idempotency.md +111 -0
  146. svc_infra/docs/jobs.md +67 -0
  147. svc_infra/docs/observability.md +16 -0
  148. svc_infra/docs/ops.md +37 -0
  149. svc_infra/docs/rate-limiting.md +125 -0
  150. svc_infra/docs/repo-review.md +48 -0
  151. svc_infra/docs/security.md +176 -0
  152. svc_infra/docs/tenancy.md +35 -0
  153. svc_infra/docs/timeouts-and-resource-limits.md +147 -0
  154. svc_infra/docs/versioned-integrations.md +146 -0
  155. svc_infra/docs/webhooks.md +112 -0
  156. svc_infra/dx/add.py +63 -0
  157. svc_infra/dx/changelog.py +74 -0
  158. svc_infra/dx/checks.py +67 -0
  159. svc_infra/http/__init__.py +13 -0
  160. svc_infra/http/client.py +72 -0
  161. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  162. svc_infra/jobs/builtins/webhook_delivery.py +90 -0
  163. svc_infra/jobs/easy.py +32 -0
  164. svc_infra/jobs/loader.py +45 -0
  165. svc_infra/jobs/queue.py +81 -0
  166. svc_infra/jobs/redis_queue.py +191 -0
  167. svc_infra/jobs/runner.py +75 -0
  168. svc_infra/jobs/scheduler.py +41 -0
  169. svc_infra/jobs/worker.py +40 -0
  170. svc_infra/mcp/svc_infra_mcp.py +85 -28
  171. svc_infra/obs/README.md +2 -0
  172. svc_infra/obs/add.py +54 -7
  173. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  174. svc_infra/obs/metrics/__init__.py +53 -0
  175. svc_infra/obs/metrics.py +52 -0
  176. svc_infra/security/add.py +201 -0
  177. svc_infra/security/audit.py +130 -0
  178. svc_infra/security/audit_service.py +73 -0
  179. svc_infra/security/headers.py +52 -0
  180. svc_infra/security/hibp.py +95 -0
  181. svc_infra/security/jwt_rotation.py +53 -0
  182. svc_infra/security/lockout.py +96 -0
  183. svc_infra/security/models.py +255 -0
  184. svc_infra/security/org_invites.py +128 -0
  185. svc_infra/security/passwords.py +77 -0
  186. svc_infra/security/permissions.py +149 -0
  187. svc_infra/security/session.py +98 -0
  188. svc_infra/security/signed_cookies.py +80 -0
  189. svc_infra/webhooks/__init__.py +16 -0
  190. svc_infra/webhooks/add.py +322 -0
  191. svc_infra/webhooks/fastapi.py +37 -0
  192. svc_infra/webhooks/router.py +55 -0
  193. svc_infra/webhooks/service.py +67 -0
  194. svc_infra/webhooks/signing.py +30 -0
  195. svc_infra-0.1.654.dist-info/METADATA +154 -0
  196. svc_infra-0.1.654.dist-info/RECORD +352 -0
  197. svc_infra/api/fastapi/deps.py +0 -3
  198. svc_infra-0.1.506.dist-info/METADATA +0 -78
  199. svc_infra-0.1.506.dist-info/RECORD +0 -213
  200. /svc_infra/{api/fastapi/schemas → apf_payments}/__init__.py +0 -0
  201. {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
  202. {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ from typing import Awaitable, Callable
6
+
7
+ from .queue import Job, JobQueue
8
+
9
+ ProcessFunc = Callable[[Job], Awaitable[None]]
10
+
11
+
12
+ def _get_job_timeout_seconds() -> float | None:
13
+ raw = os.getenv("JOB_DEFAULT_TIMEOUT_SECONDS")
14
+ if not raw:
15
+ return None
16
+ try:
17
+ return float(raw)
18
+ except ValueError:
19
+ return None
20
+
21
+
22
+ async def process_one(queue: JobQueue, handler: ProcessFunc) -> bool:
23
+ """Reserve a job, process with handler, ack on success or fail with backoff.
24
+
25
+ Returns True if a job was processed (success or fail), False if no job was available.
26
+ """
27
+ job = queue.reserve_next()
28
+ if not job:
29
+ return False
30
+ try:
31
+ timeout = _get_job_timeout_seconds()
32
+ if timeout and timeout > 0:
33
+ await asyncio.wait_for(handler(job), timeout=timeout)
34
+ else:
35
+ await handler(job)
36
+ except Exception as exc: # pragma: no cover - exercise in tests by raising
37
+ queue.fail(job.id, error=str(exc))
38
+ return True
39
+ queue.ack(job.id)
40
+ return True
@@ -5,6 +5,9 @@ from enum import Enum
5
5
  from ai_infra.llm.tools.custom.cli import cli_cmd_help, cli_subcmd_help
6
6
  from ai_infra.mcp.server.tools import mcp_from_functions
7
7
 
8
+ from svc_infra.app.env import prepare_env
9
+ from svc_infra.cli.foundation.runner import run_from_root
10
+
8
11
  CLI_PROG = "svc-infra"
9
12
 
10
13
 
@@ -17,34 +20,73 @@ async def svc_infra_cmd_help() -> dict:
17
20
  return await cli_cmd_help(CLI_PROG)
18
21
 
19
22
 
23
+ # No dedicated 'docs list' function — users can use 'docs --help' to discover topics.
24
+
25
+
26
+ async def svc_infra_docs_help() -> dict:
27
+ """
28
+ Run 'svc-infra docs --help' and return its output.
29
+ Prepares the project environment and executes from the repo root so
30
+ environment-provided docs directories and local topics are discoverable.
31
+ """
32
+ root = prepare_env()
33
+ text = await run_from_root(root, CLI_PROG, ["docs", "--help"])
34
+ return {
35
+ "ok": True,
36
+ "action": "docs_help",
37
+ "project_root": str(root),
38
+ "help": text,
39
+ }
40
+
41
+
20
42
  class Subcommand(str, Enum):
21
- # SQL commands
22
- sql_init = "sql-init"
23
- sql_revision = "sql-revision"
24
- sql_upgrade = "sql-upgrade"
25
- sql_downgrade = "sql-downgrade"
26
- sql_current = "sql-current"
27
- sql_history = "sql-history"
28
- sql_stamp = "sql-stamp"
29
- sql_merge_heads = "sql-merge-heads"
30
- sql_setup_and_migrate = "sql-setup-and-migrate"
31
- sql_scaffold = "sql-scaffold"
32
- sql_scaffold_models = "sql-scaffold-models"
33
- sql_scaffold_schemas = "sql-scaffold-schemas"
34
-
35
- # Mongo commands
36
- mongo_prepare = "mongo-prepare"
37
- mongo_setup_and_prepare = "mongo-setup-and-prepare"
38
- mongo_ping = "mongo-ping"
39
- mongo_scaffold = "mongo-scaffold"
40
- mongo_scaffold_documents = "mongo-scaffold-documents"
41
- mongo_scaffold_schemas = "mongo-scaffold-schemas"
42
- mongo_scaffold_resources = "mongo-scaffold-resources"
43
-
44
- # Observability commands
45
- obs_up = "obs-up"
46
- obs_down = "obs-down"
47
- obs_scaffold = "obs-scaffold"
43
+ # SQL group commands
44
+ sql_init = "sql init"
45
+ sql_revision = "sql revision"
46
+ sql_upgrade = "sql upgrade"
47
+ sql_downgrade = "sql downgrade"
48
+ sql_current = "sql current"
49
+ sql_history = "sql history"
50
+ sql_stamp = "sql stamp"
51
+ sql_merge_heads = "sql merge-heads"
52
+ sql_setup_and_migrate = "sql setup-and-migrate"
53
+ sql_scaffold = "sql scaffold"
54
+ sql_scaffold_models = "sql scaffold-models"
55
+ sql_scaffold_schemas = "sql scaffold-schemas"
56
+ sql_export_tenant = "sql export-tenant"
57
+ sql_seed = "sql seed"
58
+
59
+ # Mongo group commands
60
+ mongo_prepare = "mongo prepare"
61
+ mongo_setup_and_prepare = "mongo setup-and-prepare"
62
+ mongo_ping = "mongo ping"
63
+ mongo_scaffold = "mongo scaffold"
64
+ mongo_scaffold_documents = "mongo scaffold-documents"
65
+ mongo_scaffold_schemas = "mongo scaffold-schemas"
66
+ mongo_scaffold_resources = "mongo scaffold-resources"
67
+
68
+ # Observability group commands
69
+ obs_up = "obs up"
70
+ obs_down = "obs down"
71
+ obs_scaffold = "obs scaffold"
72
+
73
+ # Docs group
74
+ docs_help = "docs --help"
75
+ docs_show = "docs show"
76
+
77
+ # DX group
78
+ dx_openapi = "dx openapi"
79
+ dx_migrations = "dx migrations"
80
+ dx_changelog = "dx changelog"
81
+ dx_ci = "dx ci"
82
+
83
+ # Jobs group
84
+ jobs_run = "jobs run"
85
+
86
+ # SDK group
87
+ sdk_ts = "sdk ts"
88
+ sdk_py = "sdk py"
89
+ sdk_postman = "sdk postman"
48
90
 
49
91
 
50
92
  async def svc_infra_subcmd_help(subcommand: Subcommand) -> dict:
@@ -52,7 +94,19 @@ async def svc_infra_subcmd_help(subcommand: Subcommand) -> dict:
52
94
  Get help text for a specific subcommand of svc-infra CLI.
53
95
  (Enum keeps a tight schema; function signature remains simple.)
54
96
  """
55
- return await cli_subcmd_help(CLI_PROG, subcommand)
97
+ tokens = subcommand.value.split()
98
+ if len(tokens) == 1:
99
+ return await cli_subcmd_help(CLI_PROG, subcommand)
100
+
101
+ root = prepare_env()
102
+ text = await run_from_root(root, CLI_PROG, [*tokens, "--help"])
103
+ return {
104
+ "ok": True,
105
+ "action": "subcommand_help",
106
+ "subcommand": subcommand.value,
107
+ "project_root": str(root),
108
+ "help": text,
109
+ }
56
110
 
57
111
 
58
112
  mcp = mcp_from_functions(
@@ -60,8 +114,11 @@ mcp = mcp_from_functions(
60
114
  functions=[
61
115
  svc_infra_cmd_help,
62
116
  svc_infra_subcmd_help,
117
+ svc_infra_docs_help,
118
+ # Docs listing is available via 'docs --help'; no separate MCP function needed.
63
119
  ],
64
120
  )
65
121
 
122
+
66
123
  if __name__ == "__main__":
67
124
  mcp.run(transport="stdio")
svc_infra/obs/README.md CHANGED
@@ -8,6 +8,8 @@ This guide shows you how to turn on metrics + dashboards in three easy modes:
8
8
 
9
9
  It's "one button": run `svc-infra obs-up` and you're good. The CLI will read your `.env` automatically and do the right thing.
10
10
 
11
+ > ℹ️ A complete list of observability-related environment variables lives in [Environment Reference](../../../docs/environment.md).
12
+
11
13
  ---
12
14
 
13
15
  ## 0) Install & instrument your app (once)
svc_infra/obs/add.py CHANGED
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Callable, Iterable, Optional
3
+ from typing import Any, Callable, Iterable, Optional, Protocol
4
4
 
5
5
  from svc_infra.obs.settings import ObservabilitySettings
6
6
 
@@ -9,12 +9,20 @@ def _want_metrics(cfg: ObservabilitySettings) -> bool:
9
9
  return bool(cfg.METRICS_ENABLED)
10
10
 
11
11
 
12
+ class RouteClassifier(Protocol):
13
+ def __call__(
14
+ self, route_path: str, method: str
15
+ ) -> str: # e.g., returns "public|internal|admin"
16
+ ...
17
+
18
+
12
19
  def add_observability(
13
20
  app: Any | None = None,
14
21
  *,
15
22
  db_engines: Optional[Iterable[Any]] = None,
16
23
  metrics_path: str | None = None,
17
24
  skip_metric_paths: Optional[Iterable[str]] = None,
25
+ route_classifier: RouteClassifier | None = None,
18
26
  ) -> Callable[[], None]:
19
27
  """
20
28
  Enable Prometheus metrics for the ASGI app and optional SQLAlchemy pool metrics.
@@ -25,14 +33,53 @@ def add_observability(
25
33
  # --- Metrics (Prometheus) — import lazily so CLIs/tests don’t require prometheus_client
26
34
  if app is not None and _want_metrics(cfg):
27
35
  try:
28
- from svc_infra.obs.metrics.asgi import add_prometheus # lazy
36
+ from svc_infra.obs.metrics.asgi import ( # lazy
37
+ PrometheusMiddleware,
38
+ add_prometheus,
39
+ metrics_endpoint,
40
+ )
29
41
 
30
42
  path = metrics_path or cfg.METRICS_PATH
31
- add_prometheus(
32
- app,
33
- path=path,
34
- skip_paths=tuple(skip_metric_paths or (path, "/health", "/healthz")),
35
- )
43
+ skip_paths = tuple(skip_metric_paths or (path, "/health", "/healthz"))
44
+ # If a route_classifier is provided, use a custom route_resolver to append class label
45
+ if route_classifier is None:
46
+ add_prometheus(
47
+ app,
48
+ path=path,
49
+ skip_paths=skip_paths,
50
+ )
51
+ else:
52
+ # Install middleware manually to pass route_resolver
53
+ def _resolver(req):
54
+ # Base template
55
+ from svc_infra.obs.metrics.asgi import _route_template # type: ignore
56
+
57
+ base = _route_template(req)
58
+ method = getattr(req, "method", "GET")
59
+ cls = route_classifier(base, method)
60
+ # Encode as base|class for downstream label splitting in dashboards
61
+ return f"{base}|{cls}"
62
+
63
+ app.add_middleware(
64
+ PrometheusMiddleware,
65
+ skip_paths=skip_paths,
66
+ route_resolver=_resolver,
67
+ )
68
+ # Mount /metrics endpoint without re-adding middleware
69
+ try:
70
+ from svc_infra.api.fastapi.dual.public import public_router
71
+ from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV
72
+
73
+ router = public_router()
74
+ router.add_api_route(
75
+ path,
76
+ endpoint=metrics_endpoint(),
77
+ include_in_schema=CURRENT_ENVIRONMENT in (LOCAL_ENV, DEV_ENV),
78
+ tags=["observability"],
79
+ )
80
+ app.include_router(router)
81
+ except Exception:
82
+ app.add_route(path, metrics_endpoint())
36
83
  except Exception:
37
84
  pass
38
85
 
@@ -0,0 +1,45 @@
1
+ {
2
+ "title": "Service HTTP Overview",
3
+ "tags": ["svc-infra", "http"],
4
+ "timezone": "browser",
5
+ "panels": [
6
+ {
7
+ "type": "timeseries",
8
+ "title": "Success Rate (5m)",
9
+ "targets": [
10
+ {
11
+ "expr": "sum(rate(http_server_requests_total{code!~\"5..\"}[5m])) / sum(rate(http_server_requests_total[5m]))",
12
+ "legendFormat": "success_rate"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "type": "timeseries",
18
+ "title": "Latency p99",
19
+ "targets": [
20
+ {
21
+ "expr": "histogram_quantile(0.99, sum(rate(http_server_request_duration_seconds_bucket[5m])) by (le))",
22
+ "legendFormat": "p99"
23
+ }
24
+ ]
25
+ },
26
+ {
27
+ "type": "table",
28
+ "title": "Top Routes by Error (5m)",
29
+ "targets": [
30
+ {
31
+ "expr": "topk(10, sum(rate(http_server_requests_total{code=~\"5..\"}[5m])) by (route))",
32
+ "legendFormat": "{{route}}"
33
+ }
34
+ ]
35
+ }
36
+ ],
37
+ "templating": {
38
+ "list": []
39
+ },
40
+ "time": {
41
+ "from": "now-6h",
42
+ "to": "now"
43
+ },
44
+ "refresh": "30s"
45
+ }
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Metrics package public API.
5
+
6
+ Provides lightweight, overridable hooks for abuse heuristics so callers can
7
+ plug in logging or a metrics backend without a hard dependency.
8
+ """
9
+
10
+ from typing import Callable, Optional
11
+
12
+ # Function variables so applications/tests can replace them at runtime.
13
+ on_rate_limit_exceeded: Callable[[str, int, int], None] | None = None
14
+ """
15
+ Called when a request is rate-limited.
16
+ Args:
17
+ key: identifier used for rate limiting (e.g., API key or IP)
18
+ limit: configured limit for the window
19
+ retry_after: seconds until next allowed attempt
20
+ """
21
+
22
+ on_suspect_payload: Callable[[Optional[str], int], None] | None = None
23
+ """
24
+ Called when a request exceeds the configured size limit.
25
+ Args:
26
+ path: request path if available
27
+ size: reported content-length
28
+ """
29
+
30
+
31
+ def emit_rate_limited(key: str, limit: int, retry_after: int) -> None:
32
+ if on_rate_limit_exceeded:
33
+ try:
34
+ on_rate_limit_exceeded(key, limit, retry_after)
35
+ except Exception:
36
+ # Never break request flow on metrics exceptions
37
+ pass
38
+
39
+
40
+ def emit_suspect_payload(path: Optional[str], size: int) -> None:
41
+ if on_suspect_payload:
42
+ try:
43
+ on_suspect_payload(path, size)
44
+ except Exception:
45
+ pass
46
+
47
+
48
+ __all__ = [
49
+ "emit_rate_limited",
50
+ "emit_suspect_payload",
51
+ "on_rate_limit_exceeded",
52
+ "on_suspect_payload",
53
+ ]
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Lightweight metrics hooks for abuse heuristics. Intentionally minimal to avoid pulling
5
+ full metrics stacks; these are no-ops by default but can be swapped in tests or wired
6
+ to a metrics backend by overriding the functions.
7
+ """
8
+
9
+ from typing import Callable, Optional
10
+
11
+ # Function variables so applications/tests can replace them at runtime.
12
+ on_rate_limit_exceeded: Callable[[str, int, int], None] | None = None
13
+ """
14
+ Called when a request is rate-limited.
15
+ Args:
16
+ key: identifier used for rate limiting (e.g., API key or IP)
17
+ limit: configured limit for the window
18
+ retry_after: seconds until next allowed attempt
19
+ """
20
+
21
+ on_suspect_payload: Callable[[Optional[str], int], None] | None = None
22
+ """
23
+ Called when a request exceeds the configured size limit.
24
+ Args:
25
+ path: request path if available
26
+ size: reported content-length
27
+ """
28
+
29
+
30
+ def emit_rate_limited(key: str, limit: int, retry_after: int) -> None:
31
+ if on_rate_limit_exceeded:
32
+ try:
33
+ on_rate_limit_exceeded(key, limit, retry_after)
34
+ except Exception:
35
+ # Never break request flow on metrics exceptions
36
+ pass
37
+
38
+
39
+ def emit_suspect_payload(path: Optional[str], size: int) -> None:
40
+ if on_suspect_payload:
41
+ try:
42
+ on_suspect_payload(path, size)
43
+ except Exception:
44
+ pass
45
+
46
+
47
+ __all__ = [
48
+ "emit_rate_limited",
49
+ "emit_suspect_payload",
50
+ "on_rate_limit_exceeded",
51
+ "on_suspect_payload",
52
+ ]
@@ -0,0 +1,201 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections.abc import Mapping
5
+ from typing import Iterable
6
+
7
+ from fastapi import FastAPI
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from starlette.middleware.sessions import SessionMiddleware
10
+
11
+ from svc_infra.security.headers import SECURE_DEFAULTS, SecurityHeadersMiddleware
12
+
13
+ DEFAULT_SESSION_SECRET = "svc-dev-secret-change-me"
14
+
15
+
16
+ def _parse_bool(value: str | None) -> bool | None:
17
+ if value is None:
18
+ return None
19
+ lowered = value.strip().lower()
20
+ if lowered in {"1", "true", "yes", "on"}:
21
+ return True
22
+ if lowered in {"0", "false", "no", "off"}:
23
+ return False
24
+ return None
25
+
26
+
27
+ def _normalize_origins(value: Iterable[str] | str | None) -> list[str]:
28
+ if value is None:
29
+ return []
30
+ if isinstance(value, str):
31
+ parts = [p.strip() for p in value.split(",")]
32
+ else:
33
+ parts = [str(v).strip() for v in value]
34
+ return [p for p in parts if p]
35
+
36
+
37
+ def _resolve_cors_origins(
38
+ provided: Iterable[str] | str | None,
39
+ env: Mapping[str, str],
40
+ ) -> list[str]:
41
+ if provided is not None:
42
+ return _normalize_origins(provided)
43
+ return _normalize_origins(env.get("CORS_ALLOW_ORIGINS"))
44
+
45
+
46
+ def _resolve_allow_credentials(
47
+ allow_credentials: bool,
48
+ env: Mapping[str, str],
49
+ ) -> bool:
50
+ env_value = _parse_bool(env.get("CORS_ALLOW_CREDENTIALS"))
51
+ if env_value is None:
52
+ return allow_credentials
53
+ # Allow explicit overrides via function arguments.
54
+ if allow_credentials is not True:
55
+ return allow_credentials
56
+ return env_value
57
+
58
+
59
+ def _configure_cors(
60
+ app: FastAPI,
61
+ *,
62
+ cors_origins: Iterable[str] | str | None,
63
+ allow_credentials: bool,
64
+ env: Mapping[str, str],
65
+ ) -> None:
66
+ origins = _resolve_cors_origins(cors_origins, env)
67
+ if not origins:
68
+ return
69
+
70
+ allow_methods = _normalize_origins(env.get("CORS_ALLOW_METHODS")) or ["*"]
71
+ allow_headers = _normalize_origins(env.get("CORS_ALLOW_HEADERS")) or ["*"]
72
+
73
+ credentials = _resolve_allow_credentials(allow_credentials, env)
74
+
75
+ wildcard_origins = "*" in origins
76
+
77
+ cors_kwargs: dict[str, object] = {
78
+ "allow_credentials": credentials,
79
+ "allow_methods": allow_methods,
80
+ "allow_headers": allow_headers,
81
+ "allow_origins": ["*"] if wildcard_origins else origins,
82
+ }
83
+ origin_regex = env.get("CORS_ALLOW_ORIGIN_REGEX")
84
+ if wildcard_origins:
85
+ cors_kwargs["allow_origin_regex"] = origin_regex or ".*"
86
+ else:
87
+ if origin_regex:
88
+ cors_kwargs["allow_origin_regex"] = origin_regex
89
+
90
+ app.add_middleware(CORSMiddleware, **cors_kwargs)
91
+
92
+
93
+ def _configure_security_headers(
94
+ app: FastAPI,
95
+ *,
96
+ overrides: dict[str, str] | None,
97
+ enable_hsts_preload: bool | None,
98
+ ) -> None:
99
+ merged_overrides = dict(overrides or {})
100
+ if enable_hsts_preload is not None:
101
+ current = merged_overrides.get(
102
+ "Strict-Transport-Security",
103
+ SECURE_DEFAULTS["Strict-Transport-Security"],
104
+ )
105
+ directives = [p.strip() for p in current.split(";") if p.strip()]
106
+ directives = [d for d in directives if d.lower() != "preload"]
107
+ if enable_hsts_preload:
108
+ directives.append("preload")
109
+ merged_overrides["Strict-Transport-Security"] = "; ".join(directives)
110
+
111
+ app.add_middleware(SecurityHeadersMiddleware, overrides=merged_overrides)
112
+
113
+
114
+ def _should_add_session_middleware(app: FastAPI) -> bool:
115
+ return not any(m.cls is SessionMiddleware for m in app.user_middleware)
116
+
117
+
118
+ def _configure_session_middleware(
119
+ app: FastAPI,
120
+ *,
121
+ env: Mapping[str, str],
122
+ install: bool,
123
+ secret_key: str | None,
124
+ session_cookie: str,
125
+ max_age: int,
126
+ same_site: str,
127
+ https_only: bool | None,
128
+ ) -> None:
129
+ if not install or not _should_add_session_middleware(app):
130
+ return
131
+
132
+ secret = secret_key or env.get("SESSION_SECRET") or DEFAULT_SESSION_SECRET
133
+ https_env = _parse_bool(env.get("SESSION_COOKIE_SECURE"))
134
+ effective_https_only = (
135
+ https_only if https_only is not None else (https_env if https_env is not None else False)
136
+ )
137
+ same_site_env = env.get("SESSION_COOKIE_SAMESITE")
138
+ same_site_value = same_site_env.strip() if same_site_env else same_site
139
+
140
+ max_age_env = env.get("SESSION_COOKIE_MAX_AGE_SECONDS")
141
+ try:
142
+ max_age_value = int(max_age_env) if max_age_env is not None else max_age
143
+ except ValueError:
144
+ max_age_value = max_age
145
+
146
+ session_cookie_env = env.get("SESSION_COOKIE_NAME")
147
+ session_cookie_value = session_cookie_env.strip() if session_cookie_env else session_cookie
148
+
149
+ app.add_middleware(
150
+ SessionMiddleware,
151
+ secret_key=secret,
152
+ session_cookie=session_cookie_value,
153
+ max_age=max_age_value,
154
+ same_site=same_site_value,
155
+ https_only=effective_https_only,
156
+ )
157
+
158
+
159
+ def add_security(
160
+ app: FastAPI,
161
+ *,
162
+ cors_origins: Iterable[str] | str | None = None,
163
+ headers_overrides: dict[str, str] | None = None,
164
+ allow_credentials: bool = True,
165
+ env: Mapping[str, str] = os.environ,
166
+ enable_hsts_preload: bool | None = None,
167
+ install_session_middleware: bool = False,
168
+ session_secret_key: str | None = None,
169
+ session_cookie_name: str = "svc_session",
170
+ session_cookie_max_age_seconds: int = 4 * 3600,
171
+ session_cookie_samesite: str = "lax",
172
+ session_cookie_https_only: bool | None = None,
173
+ ) -> None:
174
+ """Install security middlewares with svc-infra defaults."""
175
+
176
+ _configure_security_headers(
177
+ app,
178
+ overrides=headers_overrides,
179
+ enable_hsts_preload=enable_hsts_preload,
180
+ )
181
+ _configure_cors(
182
+ app,
183
+ cors_origins=cors_origins,
184
+ allow_credentials=allow_credentials,
185
+ env=env,
186
+ )
187
+ _configure_session_middleware(
188
+ app,
189
+ env=env,
190
+ install=install_session_middleware,
191
+ secret_key=session_secret_key,
192
+ session_cookie=session_cookie_name,
193
+ max_age=session_cookie_max_age_seconds,
194
+ same_site=session_cookie_samesite,
195
+ https_only=session_cookie_https_only,
196
+ )
197
+
198
+
199
+ __all__ = [
200
+ "add_security",
201
+ ]