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,35 @@
1
+ # Tenancy model and integration
2
+
3
+ This framework uses a soft-tenant isolation model by default: tenant_id is a column on tenant-scoped tables, and all queries are filtered by this value. Consumers can later adopt schema-per-tenant or DB-per-tenant strategies; the API surfaces remain compatible.
4
+
5
+ ## How tenant is resolved
6
+ - `resolve_tenant_id(request)` looks up tenant id in this order:
7
+ 1) Global override hook (set via `add_tenancy(app, resolver=...)`)
8
+ 2) Auth identity (user.tenant_id or api_key.tenant_id) when auth is enabled
9
+ 3) `X-Tenant-Id` request header
10
+ 4) `request.state.tenant_id`
11
+
12
+ Use `TenantId` dependency to require it in routes, and `OptionalTenantId` to access it if present.
13
+
14
+ ## Enforcement in data layer
15
+ - Wrap services with `TenantSqlService` to automatically:
16
+ - Apply `WHERE model.tenant_id == <tenant>` on list/get/update/delete/search/count.
17
+ - Inject `tenant_id` upon create when the model has the tenant field.
18
+
19
+ ## Tenant-aware CRUD router
20
+ - When defining a `SqlResource`, set `tenant_field="tenant_id"` to mount a tenant-aware CRUD router. All endpoints will require `TenantId` and enforce scoping.
21
+
22
+ ## Per-tenant rate limits / quotas
23
+ - Global middleware and per-route dependency support tenant-aware policies:
24
+ - `scope_by_tenant=True` puts requests in independent buckets per tenant.
25
+ - `limit_resolver(request, tenant_id)` lets you return dynamic limits (e.g., plan-based quotas).
26
+
27
+ ## Export a tenant’s data (SQL)
28
+ - CLI command: `sql export-tenant`
29
+ - Example:
30
+ - `python -m svc_infra.cli sql export-tenant items --tenant-id t1 --output out.json`
31
+ - Flags:
32
+ - `--tenant-id` (required), `--tenant-field` (default `tenant_id`), `--limit` (optional), `--database-url` (or set `SQL_URL`).
33
+
34
+ ## Migration to other isolation strategies
35
+ - Schema-per-tenant or DB-per-tenant can be layered by adapting the session factory or repository to select the schema/DB based on `tenant_id`. Your application code that relies on `TenantId` and tenant-aware services/routers remains the same.
@@ -0,0 +1,147 @@
1
+ # Timeouts & Resource Limits
2
+
3
+ This guide covers request/handler timeouts, outbound HTTP client timeouts, database statement timeouts, job/webhook delivery timeouts, and graceful shutdown. It explains defaults, configuration, wiring, and recommended tuning by environment.
4
+
5
+ ## Why timeouts?
6
+
7
+ - Protects your service from slowloris uploads and hanging requests
8
+ - Limits blast radius of slow downstreams (HTTP, DB, webhooks)
9
+ - Enables predictable backpressure and faster recovery during incidents
10
+
11
+ ## Configuration overview
12
+
13
+ The library exposes simple environment variables with sensible defaults. Use floats for second values unless noted.
14
+
15
+ - REQUEST_BODY_TIMEOUT_SECONDS (int)
16
+ - Default: prod=15, nonprod=30
17
+ - Purpose: Abort slow request body reads (slowloris defense)
18
+ - REQUEST_TIMEOUT_SECONDS (int)
19
+ - Default: prod=30, nonprod=15
20
+ - Purpose: Cap overall handler execution time
21
+ - HTTP_CLIENT_TIMEOUT_SECONDS (float)
22
+ - Default: 10.0
23
+ - Purpose: Default timeout for outbound httpx clients created via helpers
24
+ - DB_STATEMENT_TIMEOUT_MS (int)
25
+ - Default: unset (disabled)
26
+ - Purpose: Per-transaction statement timeout (Postgres via SET LOCAL)
27
+ - JOB_DEFAULT_TIMEOUT_SECONDS (float)
28
+ - Default: unset (disabled)
29
+ - Purpose: Caps per-job handler runtime in the in-process jobs runner
30
+ - WEBHOOK_DELIVERY_TIMEOUT_SECONDS (float)
31
+ - Default: falls back to HTTP client default (10.0)
32
+ - Purpose: Timeout for webhook delivery HTTP calls
33
+ - SHUTDOWN_GRACE_PERIOD_SECONDS (float)
34
+ - Default: prod=20.0, nonprod=5.0
35
+ - Purpose: Wait time for in-flight requests to drain on shutdown
36
+
37
+ See ADR-0010 for design rationale: `src/svc_infra/docs/adr/0010-timeouts-and-resource-limits.md`.
38
+
39
+ ## Request/handler timeouts (FastAPI)
40
+
41
+ Two middlewares enforce timeouts inside your ASGI app:
42
+
43
+ - BodyReadTimeoutMiddleware
44
+ - Enforces a per-chunk timeout while reading the incoming request body.
45
+ - If reads stall beyond the timeout, responds with 408 application/problem+json.
46
+ - Module: `svc_infra.api.fastapi.middleware.timeout.BodyReadTimeoutMiddleware`
47
+ - HandlerTimeoutMiddleware
48
+ - Caps overall request handler execution time using asyncio.wait_for.
49
+ - If exceeded, responds with 504 application/problem+json.
50
+ - Module: `svc_infra.api.fastapi.middleware.timeout.HandlerTimeoutMiddleware`
51
+
52
+ Example wiring:
53
+
54
+ ```python
55
+ from fastapi import FastAPI
56
+ from svc_infra.api.fastapi.middleware.timeout import (
57
+ BodyReadTimeoutMiddleware,
58
+ HandlerTimeoutMiddleware,
59
+ )
60
+
61
+ app = FastAPI()
62
+
63
+ # Abort slow uploads (slowloris) after 15s in prod / 30s nonprod by default
64
+ app.add_middleware(BodyReadTimeoutMiddleware) # or timeout_seconds=20
65
+
66
+ # Cap total handler time (e.g., 30s in prod by default)
67
+ app.add_middleware(HandlerTimeoutMiddleware) # or timeout_seconds=25
68
+ ```
69
+
70
+ HTTP semantics:
71
+
72
+ - Body timeout → 408 Request Timeout (Problem+JSON) with fields: type, title, status, detail, instance, trace_id
73
+ - Handler timeout → 504 Gateway Timeout (Problem+JSON) with fields: type, title, status, detail, instance, trace_id
74
+
75
+ ## Outbound HTTP client timeouts (httpx)
76
+
77
+ Use the provided helpers to create httpx clients with the default timeout (driven by HTTP_CLIENT_TIMEOUT_SECONDS).
78
+
79
+ - Module: `svc_infra.http.client`
80
+ - `get_default_timeout_seconds()` → float
81
+ - `make_timeout(seconds=None) -> httpx.Timeout`
82
+ - `new_httpx_client(timeout_seconds=None, ...) -> httpx.Client`
83
+ - `new_async_httpx_client(timeout_seconds=None, ...) -> httpx.AsyncClient`
84
+
85
+ Error mapping:
86
+
87
+ - `httpx.TimeoutException` is mapped to 504 Gateway Timeout with Problem+JSON by default when `register_error_handlers(app)` is used.
88
+ - Module: `svc_infra.api.fastapi.middleware.errors.handlers.register_error_handlers`
89
+
90
+ ## Database statement timeouts (SQLAlchemy / Postgres)
91
+
92
+ If `DB_STATEMENT_TIMEOUT_MS` is set and Postgres is used, a per-transaction `SET LOCAL statement_timeout = :ms` is executed for sessions yielded by the built-in dependency.
93
+
94
+ - Module: `svc_infra.api.fastapi.db.sql.session.get_session`
95
+ - Non-Postgres dialects (e.g., SQLite) ignore this gracefully.
96
+
97
+ ## Jobs and webhooks
98
+
99
+ - Jobs runner
100
+ - Env: `JOB_DEFAULT_TIMEOUT_SECONDS`
101
+ - Module: `svc_infra.jobs.worker.process_one` — wraps job handler with `asyncio.wait_for()` when configured.
102
+ - Webhook delivery
103
+ - Env: `WEBHOOK_DELIVERY_TIMEOUT_SECONDS` (falls back to HTTP client default when unset)
104
+ - Module: `svc_infra.jobs.builtins.webhook_delivery.make_webhook_handler` — uses `new_async_httpx_client` with derived timeout.
105
+
106
+ ## Graceful shutdown
107
+
108
+ Install graceful shutdown to wait for in-flight requests (up to a grace period) during application shutdown.
109
+
110
+ - Module: `svc_infra.api.fastapi.middleware.graceful_shutdown.install_graceful_shutdown`
111
+ - Env: `SHUTDOWN_GRACE_PERIOD_SECONDS` (prod=20.0, nonprod=5.0 by default)
112
+
113
+ ```python
114
+ from svc_infra.api.fastapi.middleware.graceful_shutdown import install_graceful_shutdown
115
+
116
+ install_graceful_shutdown(app) # or grace_seconds=30.0
117
+ ```
118
+
119
+ ## Tuning recommendations
120
+
121
+ - Production
122
+ - REQUEST_BODY_TIMEOUT_SECONDS: 10–20s (shorter for public APIs)
123
+ - REQUEST_TIMEOUT_SECONDS: 20–30s (align with upstream proxy/gateway timeouts)
124
+ - HTTP_CLIENT_TIMEOUT_SECONDS: 3–10s (favor quick failover with retries)
125
+ - DB_STATEMENT_TIMEOUT_MS: set per-route/transaction if queries are constrained
126
+ - SHUTDOWN_GRACE_PERIOD_SECONDS: 20–60s depending on peak latencies
127
+ - Staging/Dev
128
+ - Relax timeouts slightly to reduce test flakiness (defaults already reflect this)
129
+ - Gateways/Proxies
130
+ - Ensure upstream (e.g., NGINX, ALB) timeouts exceed app’s body timeout and are aligned with handler timeout to avoid double timeouts.
131
+
132
+ ## Testing and acceptance
133
+
134
+ - Unit tests cover body read timeout, handler timeout, outbound timeout mapping, and a smoke check for DB statement timeout.
135
+ - Acceptance tests:
136
+ - A2-04: slow handler → 504 Problem
137
+ - A2-05: slow body → 408 Problem or 413 (size) as applicable
138
+ - A2-06: outbound httpx timeout → 504 Problem
139
+
140
+ ## Troubleshooting
141
+
142
+ - Seeing 200 instead of 408 for slow uploads under some servers?
143
+ - Some servers buffer the entire body before invoking the app. The BodyReadTimeoutMiddleware greedily drains with per-chunk timeouts and replays to reliably detect slowloris. Ensure HTTP/1.1 parsing with a streaming-capable server implementation (e.g., uvicorn+httptools) in acceptance tests.
144
+ - Outbound timeouts not mapped to Problem?
145
+ - Ensure `register_error_handlers(app)` is installed so `httpx.TimeoutException` returns a 504 Problem.
146
+ - Statement timeout ignored on SQLite?
147
+ - Expected. Non-Postgres dialects skip `SET LOCAL` safely.
@@ -0,0 +1,146 @@
1
+ # Using add_* Functions Under Versioned Routing
2
+
3
+ ## Problem
4
+
5
+ By default, `add_*` functions from svc-infra and fin-infra mount routes at root level (e.g., `/banking/*`, `/_sql/*`). However, you may want all features consolidated under a single versioned API prefix (e.g., `/v0/banking`) to keep your API organized under version namespaces.
6
+
7
+ ## Simple Solution (Recommended)
8
+
9
+ Use the `extract_router()` helper:
10
+
11
+ ```python
12
+ # src/your_api/routers/v0/banking.py
13
+ from svc_infra.api.fastapi.versioned import extract_router
14
+ from fin_infra.banking import add_banking
15
+
16
+ # Extract router and provider from add_banking()
17
+ router, banking_provider = extract_router(
18
+ add_banking,
19
+ prefix="/banking",
20
+ provider="plaid",
21
+ cache_ttl=60,
22
+ )
23
+
24
+ # That's it! svc-infra auto-discovers 'router' and mounts at /v0/banking
25
+ ```
26
+
27
+ ### Result
28
+
29
+ - ✅ All banking endpoints under `/v0/banking/*`
30
+ - ✅ Banking docs included in `/v0/docs` (not separate card)
31
+ - ✅ Full `add_banking()` functionality preserved
32
+ - ✅ Returns provider instance for additional use
33
+
34
+ ## Complete Example
35
+
36
+ ```python
37
+ # Directory structure
38
+ your_api/
39
+ routers/
40
+ v0/
41
+ __init__.py
42
+ status.py
43
+ banking.py # <- Integration using helper
44
+ payments.py # <- Another integration
45
+
46
+ # banking.py - Clean and simple
47
+ """Banking integration under v0 routing."""
48
+ from svc_infra.api.fastapi.versioned import extract_router
49
+ from fin_infra.banking import add_banking
50
+
51
+ router, banking_provider = extract_router(
52
+ add_banking,
53
+ prefix="/banking",
54
+ provider="plaid", # or "teller"
55
+ cache_ttl=60,
56
+ )
57
+
58
+ # Optional: Store provider on app state for later use
59
+ # This happens in app.py after router discovery:
60
+ # app.state.banking = banking_provider
61
+ ```
62
+
63
+ ## Works With
64
+
65
+ Any svc-infra or fin-infra function that calls `app.include_router()`:
66
+
67
+ ```python
68
+ # Banking integration
69
+ from fin_infra.banking import add_banking
70
+ router, provider = extract_router(add_banking, prefix="/banking", provider="plaid")
71
+
72
+ # Market data
73
+ from fin_infra.markets import add_market_data
74
+ router, provider = extract_router(add_market_data, prefix="/markets")
75
+
76
+ # Analytics
77
+ from fin_infra.analytics import add_analytics
78
+ router, provider = extract_router(add_analytics, prefix="/analytics")
79
+
80
+ # Budgets
81
+ from fin_infra.budgets import add_budgets
82
+ router, provider = extract_router(add_budgets, prefix="/budgets")
83
+
84
+ # Documents
85
+ from fin_infra.documents import add_documents
86
+ router, provider = extract_router(add_documents, prefix="/documents")
87
+
88
+ # Any custom add_* function following the pattern
89
+ ```
90
+
91
+ ## When to Use
92
+
93
+ **Use when:**
94
+ - Building a monolithic versioned API where all features belong under `/v0`, `/v1`, etc.
95
+ - You want unified documentation at `/v0/docs` showing all features together
96
+ - You're consolidating multiple integrations under one version
97
+ - You need version-specific behavior for third-party integrations
98
+
99
+ **Don't use when:**
100
+ - Feature should have its own root-level endpoint (e.g., public webhooks at `/webhooks`)
101
+ - Integration is shared across multiple versions (mount at root instead)
102
+ - You only need a subset of endpoints (define manually)
103
+
104
+ ## Alternative: Manual Definition
105
+
106
+ For simple integrations, define routes manually:
107
+
108
+ ```python
109
+ # routers/v0/banking.py
110
+ from svc_infra.api.fastapi.dual.public import public_router
111
+ from fin_infra.banking import easy_banking
112
+
113
+ router = public_router(prefix="/banking", tags=["Banking"])
114
+ banking = easy_banking(provider="plaid")
115
+
116
+ @router.post("/link")
117
+ async def create_link(request: CreateLinkRequest):
118
+ return banking.create_link_token(user_id=request.user_id)
119
+
120
+ # ... define other endpoints
121
+ ```
122
+
123
+ Use manual definition when:
124
+ - Only need a subset of integration endpoints
125
+ - Want custom validation/transforms per endpoint
126
+ - Integration is very simple (2-3 endpoints)
127
+ - Need version-specific behavior per endpoint
128
+
129
+ ## How It Works
130
+
131
+ The `extract_router()` helper:
132
+
133
+ 1. **Creates Mock App**: Temporary FastAPI instance to capture router
134
+ 2. **Intercepts Router**: Monkey-patches `include_router()` to capture instead of mount
135
+ 3. **Calls Integration**: Runs `add_*()` function which creates all routes normally
136
+ 4. **Returns Router**: Exports captured router for svc-infra auto-discovery
137
+ 5. **Auto-Mounts**: svc-infra finds `router` in `v0.banking` and mounts at `/v0/banking`
138
+
139
+ The provider/integration instance is also returned for additional use if needed.
140
+
141
+ ## See Also
142
+
143
+ - [API Versioning](./api.md#versioning) - How svc-infra version routing works
144
+ - [Router Auto-Discovery](./api.md#router-discovery) - How routers are found and mounted
145
+ - [Dual Routers](./api.md#dual-routers) - Similar pattern for public/protected routers
146
+ - `svc_infra.api.fastapi.versioned` - Source code for helper function
@@ -0,0 +1,112 @@
1
+ # Webhooks Framework
2
+
3
+ This module provides primitives to publish events to external consumers via webhooks, verify inbound signatures, and handle robust retries using the shared JobQueue and Outbox patterns.
4
+
5
+ > ℹ️ Webhook helper environment expectations live in [Environment Reference](environment.md).
6
+
7
+ ## Quickstart
8
+
9
+ - Subscriptions and publishing:
10
+
11
+ ```python
12
+ from svc_infra.webhooks.service import InMemoryWebhookSubscriptions, WebhookService
13
+ from svc_infra.db.outbox import InMemoryOutboxStore
14
+
15
+ subs = InMemoryWebhookSubscriptions()
16
+ subs.add("invoice.created", "https://example.com/webhook", "sekrit")
17
+ svc = WebhookService(outbox=InMemoryOutboxStore(), subs=subs)
18
+ svc.publish("invoice.created", {"id": "inv_1", "version": 1})
19
+ ```
20
+
21
+ - Delivery worker and headers:
22
+
23
+ ```python
24
+ from svc_infra.jobs.builtins.webhook_delivery import make_webhook_handler
25
+ from svc_infra.jobs.worker import process_one
26
+
27
+ handler = make_webhook_handler(
28
+ outbox=..., inbox=..., get_webhook_url_for_topic=lambda t: url, get_secret_for_topic=lambda t: secret,
29
+ )
30
+ # process_one(queue, handler) will POST JSON with headers:
31
+ # X-Event-Id, X-Topic, X-Attempt, X-Signature (HMAC-SHA256), X-Signature-Alg, X-Signature-Version, X-Payload-Version
32
+ ```
33
+
34
+ - Verification (FastAPI):
35
+
36
+ ```python
37
+ from fastapi import Depends, FastAPI
38
+ from svc_infra.webhooks.fastapi import require_signature
39
+ from svc_infra.webhooks.signing import sign
40
+
41
+ app = FastAPI()
42
+ app.post("/webhook")(lambda body=Depends(require_signature(lambda: ["old","new"])): {"ok": True})
43
+ ```
44
+
45
+ ## FastAPI wiring
46
+
47
+ - Attach the router with shared in-memory stores (great for tests / local runs):
48
+
49
+ ```python
50
+ from fastapi import FastAPI
51
+
52
+ from svc_infra.webhooks import add_webhooks
53
+
54
+ app = FastAPI()
55
+ add_webhooks(app)
56
+ ```
57
+
58
+ - Respect environment overrides for Redis-backed stores by exporting `REDIS_URL`
59
+ and selecting the backend via `WEBHOOKS_OUTBOX=redis` (optional
60
+ `WEBHOOKS_INBOX=redis` for the dedupe store). The helper records the chosen
61
+ instances on `app.state` for further customisation:
62
+
63
+ ```python
64
+ import os
65
+
66
+ os.environ["WEBHOOKS_OUTBOX"] = "redis"
67
+ os.environ["WEBHOOKS_INBOX"] = "redis"
68
+
69
+ app = FastAPI()
70
+ add_webhooks(app) # creates RedisOutboxStore / RedisInboxStore when redis-py is available
71
+
72
+ # Later you can inspect or extend behaviour:
73
+ app.state.webhooks_subscriptions.add("invoice.created", "https://example.com/webhook", "sekrit")
74
+ ```
75
+
76
+ - Provide explicit overrides (e.g. dependency-injected SQL stores) or reuse your
77
+ existing job queue / scheduler. Passing a queue automatically registers the
78
+ outbox tick and delivery handler so your worker loop can process jobs:
79
+
80
+ ```python
81
+ from svc_infra.jobs.easy import easy_jobs
82
+
83
+ queue, scheduler = easy_jobs()
84
+
85
+ add_webhooks(
86
+ app,
87
+ outbox=my_outbox_store,
88
+ inbox=lambda: my_inbox_store, # factories are supported
89
+ queue=queue,
90
+ scheduler=scheduler,
91
+ )
92
+
93
+ # scheduler.add_task(...) is handled internally when both queue and scheduler are supplied
94
+ ```
95
+
96
+ ## Runner wiring
97
+
98
+ If you prefer explicit wiring, you can still register the tick manually:
99
+
100
+ ```python
101
+ from svc_infra.jobs.easy import easy_jobs
102
+ from svc_infra.jobs.builtins.outbox_processor import make_outbox_tick
103
+
104
+ queue, scheduler = easy_jobs() # uses JOBS_DRIVER and REDIS_URL
105
+ scheduler.add_task("outbox", 1, make_outbox_tick(outbox_store, queue))
106
+ # Start runner: `svc-infra jobs run`
107
+ ```
108
+
109
+ ## Notes
110
+ - Retries/backoff are handled by the JobQueue; delivery marks Inbox after success to prevent duplicates.
111
+ - For production subscriptions and inbox/outbox, provide persistent implementations and override DI in your app.
112
+ - Signature rotation supported via `verify_any` and FastAPI dependency accepting multiple secrets.
svc_infra/dx/add.py ADDED
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def write_ci_workflow(
7
+ *,
8
+ target_dir: str | Path,
9
+ name: str = "ci.yml",
10
+ python_version: str = "3.12",
11
+ ) -> Path:
12
+ """Write a minimal CI workflow file (GitHub Actions) with tests/lint/type steps."""
13
+ p = Path(target_dir) / ".github" / "workflows" / name
14
+ p.parent.mkdir(parents=True, exist_ok=True)
15
+ content = f"""
16
+ name: CI
17
+
18
+ on:
19
+ push:
20
+ branches: [ main ]
21
+ pull_request:
22
+
23
+ jobs:
24
+ build:
25
+ runs-on: ubuntu-latest
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+ - uses: actions/setup-python@v5
29
+ with:
30
+ python-version: '{python_version}'
31
+ - name: Install Poetry
32
+ run: pipx install poetry
33
+ - name: Install deps
34
+ run: poetry install
35
+ - name: Lint
36
+ run: poetry run flake8 --select=E,F
37
+ - name: Typecheck
38
+ run: poetry run mypy src
39
+ - name: Tests
40
+ run: poetry run pytest -q -W error
41
+ """
42
+ p.write_text(content.strip() + "\n")
43
+ return p
44
+
45
+
46
+ def write_openapi_lint_config(*, target_dir: str | Path, name: str = ".redocly.yaml") -> Path:
47
+ """Write a minimal OpenAPI lint config placeholder (Redocly)."""
48
+ p = Path(target_dir) / name
49
+ content = """
50
+ apis:
51
+ main:
52
+ root: openapi.json
53
+
54
+ rules:
55
+ operation-operationId: warn
56
+ no-unused-components: warn
57
+ security-defined: off
58
+ """
59
+ p.write_text(content.strip() + "\n")
60
+ return p
61
+
62
+
63
+ __all__ = ["write_ci_workflow", "write_openapi_lint_config"]
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import date as _date
5
+ from typing import Sequence
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Commit:
10
+ sha: str
11
+ subject: str
12
+
13
+
14
+ _SECTION_ORDER = [
15
+ ("feat", "Features"),
16
+ ("fix", "Bug Fixes"),
17
+ ("perf", "Performance"),
18
+ ("refactor", "Refactors"),
19
+ ]
20
+
21
+
22
+ def _classify(subject: str) -> tuple[str, str]:
23
+ """Return (type, title) where title is display name of the section."""
24
+ lower = subject.strip().lower()
25
+ for t, title in _SECTION_ORDER:
26
+ if lower.startswith(t + ":") or lower.startswith(t + "("):
27
+ return (t, title)
28
+ return ("other", "Other")
29
+
30
+
31
+ def _format_item(commit: Commit) -> str:
32
+ subj = commit.subject.strip()
33
+ # Strip leading type(scope): if present
34
+ i = subj.find(": ")
35
+ if i != -1 and i < 20: # conventional commit prefix
36
+ pretty = subj[i + 2 :].strip()
37
+ else:
38
+ pretty = subj
39
+ return f"- {pretty} ({commit.sha})"
40
+
41
+
42
+ def generate_release_section(
43
+ *,
44
+ version: str,
45
+ commits: Sequence[Commit],
46
+ release_date: str | None = None,
47
+ ) -> str:
48
+ """Generate a markdown release section from commits.
49
+
50
+ Group by type: feat, fix, perf, refactor; everything else under Other.
51
+ """
52
+ if release_date is None:
53
+ release_date = _date.today().isoformat()
54
+
55
+ buckets: dict[str, list[str]] = {k: [] for k, _ in _SECTION_ORDER}
56
+ buckets["other"] = []
57
+
58
+ for c in commits:
59
+ typ, _ = _classify(c.subject)
60
+ buckets.setdefault(typ, []).append(_format_item(c))
61
+
62
+ lines: list[str] = [f"## v{version} - {release_date}", ""]
63
+ for key, title in _SECTION_ORDER + [("other", "Other")]:
64
+ items = buckets.get(key) or []
65
+ if not items:
66
+ continue
67
+ lines.append(f"### {title}")
68
+ lines.extend(items)
69
+ lines.append("")
70
+
71
+ return "\n".join(lines).rstrip() + "\n"
72
+
73
+
74
+ __all__ = ["Commit", "generate_release_section"]
svc_infra/dx/checks.py ADDED
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def _load_json(path: str | Path) -> dict:
7
+ import json
8
+
9
+ p = Path(path)
10
+ return json.loads(p.read_text())
11
+
12
+
13
+ def check_openapi_problem_schema(
14
+ schema: dict | None = None, *, path: str | Path | None = None
15
+ ) -> None:
16
+ """Validate OpenAPI has a Problem schema with required fields and formats.
17
+
18
+ Raises ValueError with a descriptive message on failure.
19
+ """
20
+
21
+ if schema is None:
22
+ if path is None:
23
+ raise ValueError("either schema or path must be provided")
24
+ schema = _load_json(path)
25
+
26
+ comps = (schema or {}).get("components") or {}
27
+ prob = (comps.get("schemas") or {}).get("Problem")
28
+ if not isinstance(prob, dict):
29
+ raise ValueError("Problem schema missing under components.schemas.Problem")
30
+
31
+ props = prob.get("properties") or {}
32
+ # Required keys presence
33
+ for key in ("type", "title", "status", "detail", "instance", "code"):
34
+ if key not in props:
35
+ raise ValueError(f"Problem.{key} missing in properties")
36
+
37
+ # instance must be uri-reference per our convention
38
+ inst = props.get("instance") or {}
39
+ if inst.get("format") != "uri-reference":
40
+ raise ValueError("Problem.instance must have format 'uri-reference'")
41
+
42
+
43
+ def check_migrations_up_to_date(*, project_root: str | Path = ".") -> None:
44
+ """Best-effort migrations check: passes if alembic env present and head is reachable.
45
+
46
+ This is a lightweight stub that can be extended per-project. For now, it checks
47
+ that an Alembic env exists when 'alembic.ini' is present; it does not execute DB calls.
48
+ """
49
+
50
+ root = Path(project_root)
51
+ # If alembic.ini is absent, there's nothing to check here
52
+ if not (root / "alembic.ini").exists():
53
+ return
54
+ # Ensure versions/ dir exists under migrations path if configured, default to 'migrations'
55
+ mig_dir = root / "migrations"
56
+ if not mig_dir.exists():
57
+ # tolerate alternative layout via env; keep stub permissive
58
+ return
59
+ versions = mig_dir / "versions"
60
+ if not versions.exists():
61
+ raise ValueError("Alembic migrations directory missing versions/ subfolder")
62
+
63
+
64
+ __all__ = [
65
+ "check_openapi_problem_schema",
66
+ "check_migrations_up_to_date",
67
+ ]
@@ -0,0 +1,13 @@
1
+ from .client import (
2
+ get_default_timeout_seconds,
3
+ make_timeout,
4
+ new_async_httpx_client,
5
+ new_httpx_client,
6
+ )
7
+
8
+ __all__ = [
9
+ "get_default_timeout_seconds",
10
+ "new_httpx_client",
11
+ "new_async_httpx_client",
12
+ "make_timeout",
13
+ ]