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.
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/alembic.py +11 -0
- svc_infra/apf_payments/models.py +339 -0
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +797 -0
- svc_infra/apf_payments/provider/base.py +270 -0
- svc_infra/apf_payments/provider/registry.py +31 -0
- svc_infra/apf_payments/provider/stripe.py +873 -0
- svc_infra/apf_payments/schemas.py +333 -0
- svc_infra/apf_payments/service.py +892 -0
- svc_infra/apf_payments/settings.py +67 -0
- svc_infra/api/fastapi/__init__.py +6 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/apf_payments/__init__.py +0 -0
- svc_infra/api/fastapi/apf_payments/router.py +1082 -0
- svc_infra/api/fastapi/apf_payments/setup.py +73 -0
- svc_infra/api/fastapi/auth/add.py +15 -6
- svc_infra/api/fastapi/auth/gaurd.py +67 -5
- svc_infra/api/fastapi/auth/mfa/router.py +18 -9
- svc_infra/api/fastapi/auth/routers/account.py +3 -2
- svc_infra/api/fastapi/auth/routers/apikey_router.py +11 -5
- svc_infra/api/fastapi/auth/routers/oauth_router.py +82 -37
- svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
- svc_infra/api/fastapi/auth/security.py +3 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/auth/state.py +1 -1
- svc_infra/api/fastapi/billing/router.py +64 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
- svc_infra/api/fastapi/db/sql/add.py +40 -18
- svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
- svc_infra/api/fastapi/db/sql/session.py +16 -0
- svc_infra/api/fastapi/db/sql/users.py +14 -2
- svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
- svc_infra/api/fastapi/docs/add.py +160 -0
- svc_infra/api/fastapi/docs/landing.py +1 -1
- svc_infra/api/fastapi/docs/scoped.py +254 -0
- svc_infra/api/fastapi/dual/dualize.py +38 -33
- svc_infra/api/fastapi/dual/router.py +48 -1
- svc_infra/api/fastapi/dx.py +3 -3
- svc_infra/api/fastapi/http/__init__.py +0 -0
- svc_infra/api/fastapi/http/concurrency.py +14 -0
- svc_infra/api/fastapi/http/conditional.py +33 -0
- svc_infra/api/fastapi/http/deprecation.py +21 -0
- svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
- svc_infra/api/fastapi/middleware/idempotency.py +116 -0
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +119 -0
- svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
- svc_infra/api/fastapi/middleware/request_id.py +23 -0
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +148 -0
- svc_infra/api/fastapi/openapi/mutators.py +768 -55
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +363 -0
- svc_infra/api/fastapi/paths/auth.py +14 -14
- svc_infra/api/fastapi/paths/prefix.py +0 -1
- svc_infra/api/fastapi/paths/user.py +1 -1
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +48 -15
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +230 -0
- svc_infra/billing/models.py +131 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +33 -0
- svc_infra/billing/service.py +115 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +4 -0
- svc_infra/cache/add.py +158 -0
- svc_infra/cache/backend.py +5 -2
- svc_infra/cache/decorators.py +19 -1
- svc_infra/cache/keys.py +24 -4
- svc_infra/cli/__init__.py +32 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +10 -0
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +120 -14
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +5 -4
- svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +53 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +40 -0
- svc_infra/data/retention.py +55 -0
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/outbox.py +104 -0
- svc_infra/db/sql/apikey.py +1 -1
- svc_infra/db/sql/authref.py +61 -0
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/repository.py +52 -12
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +16 -4
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +199 -76
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +231 -79
- svc_infra/db/sql/tenant.py +79 -0
- svc_infra/db/sql/utils.py +18 -4
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/docs/acceptance-matrix.md +71 -0
- svc_infra/docs/acceptance.md +44 -0
- svc_infra/docs/admin.md +425 -0
- svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
- svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
- svc_infra/docs/adr/0004-tenancy-model.md +42 -0
- svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
- svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
- svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
- svc_infra/docs/adr/0008-billing-primitives.md +143 -0
- svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
- svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
- svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
- svc_infra/docs/api.md +59 -0
- svc_infra/docs/auth.md +11 -0
- svc_infra/docs/billing.md +190 -0
- svc_infra/docs/cache.md +76 -0
- svc_infra/docs/cli.md +74 -0
- svc_infra/docs/contributing.md +34 -0
- svc_infra/docs/data-lifecycle.md +52 -0
- svc_infra/docs/database.md +14 -0
- svc_infra/docs/docs-and-sdks.md +62 -0
- svc_infra/docs/environment.md +114 -0
- svc_infra/docs/getting-started.md +63 -0
- svc_infra/docs/idempotency.md +111 -0
- svc_infra/docs/jobs.md +67 -0
- svc_infra/docs/observability.md +16 -0
- svc_infra/docs/ops.md +37 -0
- svc_infra/docs/rate-limiting.md +125 -0
- svc_infra/docs/repo-review.md +48 -0
- svc_infra/docs/security.md +176 -0
- svc_infra/docs/tenancy.md +35 -0
- svc_infra/docs/timeouts-and-resource-limits.md +147 -0
- svc_infra/docs/versioned-integrations.md +146 -0
- svc_infra/docs/webhooks.md +112 -0
- svc_infra/dx/add.py +63 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +67 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +72 -0
- svc_infra/jobs/builtins/outbox_processor.py +38 -0
- svc_infra/jobs/builtins/webhook_delivery.py +90 -0
- svc_infra/jobs/easy.py +32 -0
- svc_infra/jobs/loader.py +45 -0
- svc_infra/jobs/queue.py +81 -0
- svc_infra/jobs/redis_queue.py +191 -0
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/scheduler.py +41 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/mcp/svc_infra_mcp.py +85 -28
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +54 -7
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +53 -0
- svc_infra/obs/metrics.py +52 -0
- svc_infra/security/add.py +201 -0
- svc_infra/security/audit.py +130 -0
- svc_infra/security/audit_service.py +73 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +95 -0
- svc_infra/security/jwt_rotation.py +53 -0
- svc_infra/security/lockout.py +96 -0
- svc_infra/security/models.py +255 -0
- svc_infra/security/org_invites.py +128 -0
- svc_infra/security/passwords.py +77 -0
- svc_infra/security/permissions.py +149 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +80 -0
- svc_infra/webhooks/__init__.py +16 -0
- svc_infra/webhooks/add.py +322 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +67 -0
- svc_infra/webhooks/signing.py +30 -0
- svc_infra-0.1.654.dist-info/METADATA +154 -0
- svc_infra-0.1.654.dist-info/RECORD +352 -0
- svc_infra/api/fastapi/deps.py +0 -3
- svc_infra-0.1.506.dist-info/METADATA +0 -78
- svc_infra-0.1.506.dist-info/RECORD +0 -213
- /svc_infra/{api/fastapi/schemas → apf_payments}/__init__.py +0 -0
- {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
7
|
+
from starlette.responses import JSONResponse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def add_probes(
|
|
11
|
+
app: FastAPI,
|
|
12
|
+
*,
|
|
13
|
+
prefix: str = "/_ops",
|
|
14
|
+
include_in_schema: bool = False,
|
|
15
|
+
) -> None:
|
|
16
|
+
"""Mount basic liveness/readiness/startup probes under prefix."""
|
|
17
|
+
from svc_infra.api.fastapi.dual.public import public_router
|
|
18
|
+
|
|
19
|
+
router = public_router(prefix=prefix, tags=["ops"], include_in_schema=include_in_schema)
|
|
20
|
+
|
|
21
|
+
@router.get("/live")
|
|
22
|
+
async def live() -> JSONResponse: # noqa: D401, ANN201
|
|
23
|
+
return JSONResponse({"status": "ok"})
|
|
24
|
+
|
|
25
|
+
@router.get("/ready")
|
|
26
|
+
async def ready() -> JSONResponse: # noqa: D401, ANN201
|
|
27
|
+
# In the future, add checks (DB ping, cache ping) via DI hooks.
|
|
28
|
+
return JSONResponse({"status": "ok"})
|
|
29
|
+
|
|
30
|
+
@router.get("/startup")
|
|
31
|
+
async def startup_probe() -> JSONResponse: # noqa: D401, ANN201
|
|
32
|
+
return JSONResponse({"status": "ok"})
|
|
33
|
+
|
|
34
|
+
app.include_router(router)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def add_maintenance_mode(
|
|
38
|
+
app: FastAPI,
|
|
39
|
+
*,
|
|
40
|
+
env_var: str = "MAINTENANCE_MODE",
|
|
41
|
+
exempt_prefixes: tuple[str, ...] | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Enable a simple maintenance gate controlled by an env var.
|
|
44
|
+
|
|
45
|
+
When MAINTENANCE_MODE is truthy, all non-GET requests return 503.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
@app.middleware("http")
|
|
49
|
+
async def _maintenance_gate(request: Request, call_next): # noqa: ANN001, ANN202
|
|
50
|
+
flag = str(os.getenv(env_var, "")).lower() in {"1", "true", "yes", "on"}
|
|
51
|
+
if flag and request.method not in {"GET", "HEAD", "OPTIONS"}:
|
|
52
|
+
path = request.scope.get("path", "")
|
|
53
|
+
if exempt_prefixes and any(path.startswith(p) for p in exempt_prefixes):
|
|
54
|
+
return await call_next(request)
|
|
55
|
+
return JSONResponse({"detail": "maintenance"}, status_code=503)
|
|
56
|
+
return await call_next(request)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def circuit_breaker_dependency(limit: int = 100, window_seconds: int = 60) -> Callable:
|
|
60
|
+
"""Return a dependency that can trip rejective errors based on external metrics.
|
|
61
|
+
|
|
62
|
+
This is a placeholder; callers can swap with a provider that tracks failures and opens the
|
|
63
|
+
breaker. Here, we read an env var to simulate an open breaker.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
async def _dep(_: Request) -> None: # noqa: D401, ANN202
|
|
67
|
+
if str(os.getenv("CIRCUIT_OPEN", "")).lower() in {"1", "true", "yes", "on"}:
|
|
68
|
+
raise HTTPException(status_code=503, detail="circuit open")
|
|
69
|
+
|
|
70
|
+
return _dep
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
__all__ = ["add_probes", "add_maintenance_mode", "circuit_breaker_dependency"]
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import contextvars
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Callable, Generic, Iterable, List, Optional, Sequence, TypeVar
|
|
7
|
+
|
|
8
|
+
from fastapi import Query, Request
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ---------- Core query models ----------
|
|
15
|
+
class CursorParams(BaseModel):
|
|
16
|
+
cursor: Optional[str] = None
|
|
17
|
+
limit: int = 50
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PageParams(BaseModel):
|
|
21
|
+
page: int = 1
|
|
22
|
+
page_size: int = 50
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FilterParams(BaseModel):
|
|
26
|
+
q: Optional[str] = None
|
|
27
|
+
sort: Optional[str] = None
|
|
28
|
+
created_after: Optional[str] = None
|
|
29
|
+
created_before: Optional[str] = None
|
|
30
|
+
updated_after: Optional[str] = None
|
|
31
|
+
updated_before: Optional[str] = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------- Envelope model ----------
|
|
35
|
+
class Paginated(BaseModel, Generic[T]):
|
|
36
|
+
items: List[T]
|
|
37
|
+
next_cursor: Optional[str] = Field(None, description="Opaque cursor for next page")
|
|
38
|
+
total: Optional[int] = Field(None, description="Total items (optional)")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------- Cursor helpers ----------
|
|
42
|
+
def _encode_cursor(payload: dict) -> str:
|
|
43
|
+
raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
|
44
|
+
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def decode_cursor(token: Optional[str]) -> dict:
|
|
48
|
+
"""Public: decode an incoming cursor token for debugging/ops."""
|
|
49
|
+
if not token:
|
|
50
|
+
return {}
|
|
51
|
+
s = token + "=" * (-len(token) % 4)
|
|
52
|
+
raw = base64.urlsafe_b64decode(s.encode("ascii")).decode("utf-8")
|
|
53
|
+
return json.loads(raw)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------- Context ----------
|
|
57
|
+
class PaginationContext(Generic[T]):
|
|
58
|
+
envelope: bool
|
|
59
|
+
allow_cursor: bool
|
|
60
|
+
allow_page: bool
|
|
61
|
+
|
|
62
|
+
cursor_params: CursorParams | None
|
|
63
|
+
page_params: PageParams | None
|
|
64
|
+
filters: FilterParams | None
|
|
65
|
+
limit_override: int | None
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
*,
|
|
70
|
+
envelope: bool,
|
|
71
|
+
allow_cursor: bool,
|
|
72
|
+
allow_page: bool,
|
|
73
|
+
cursor_params: CursorParams | None,
|
|
74
|
+
page_params: PageParams | None,
|
|
75
|
+
filters: FilterParams | None,
|
|
76
|
+
limit_override: int | None = None,
|
|
77
|
+
):
|
|
78
|
+
self.envelope = envelope
|
|
79
|
+
self.allow_cursor = allow_cursor
|
|
80
|
+
self.allow_page = allow_page
|
|
81
|
+
self.cursor_params = cursor_params
|
|
82
|
+
self.page_params = page_params
|
|
83
|
+
self.filters = filters
|
|
84
|
+
self.limit_override = limit_override
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def cursor(self) -> Optional[str]:
|
|
88
|
+
return (self.cursor_params or CursorParams()).cursor if self.allow_cursor else None
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def limit(self) -> int:
|
|
92
|
+
# For cursor-based pagination, always honor the requested limit, even on the first page
|
|
93
|
+
# (cursor may be None for the first page).
|
|
94
|
+
if self.allow_cursor and self.cursor_params:
|
|
95
|
+
return self.cursor_params.limit
|
|
96
|
+
if self.allow_page and self.page_params:
|
|
97
|
+
return self.limit_override or self.page_params.page_size
|
|
98
|
+
return 50
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def page(self) -> Optional[int]:
|
|
102
|
+
return self.page_params.page if (self.allow_page and self.page_params) else None
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def page_size(self) -> Optional[int]:
|
|
106
|
+
return self.page_params.page_size if (self.allow_page and self.page_params) else None
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def offset(self) -> int:
|
|
110
|
+
if self.cursor is None and self.allow_page and self.page and self.page_size:
|
|
111
|
+
return (self.page - 1) * self.page_size
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
def wrap(
|
|
115
|
+
self, items: list[T], *, next_cursor: Optional[str] = None, total: Optional[int] = None
|
|
116
|
+
):
|
|
117
|
+
if self.envelope:
|
|
118
|
+
return Paginated[T](items=items, next_cursor=next_cursor, total=total)
|
|
119
|
+
return items
|
|
120
|
+
|
|
121
|
+
def next_cursor_from_last(
|
|
122
|
+
self, items: Sequence[T], *, key: Callable[[T], str | int]
|
|
123
|
+
) -> Optional[str]:
|
|
124
|
+
if not items:
|
|
125
|
+
return None
|
|
126
|
+
last_key = key(items[-1])
|
|
127
|
+
return _encode_cursor({"after": last_key})
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
_pagination_ctx: contextvars.ContextVar[PaginationContext] = contextvars.ContextVar(
|
|
131
|
+
"pagination_ctx", default=None
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def use_pagination() -> PaginationContext:
|
|
136
|
+
ctx = _pagination_ctx.get()
|
|
137
|
+
if ctx is None:
|
|
138
|
+
# Safe defaults; if a route forgot to install the injector
|
|
139
|
+
ctx = PaginationContext(
|
|
140
|
+
envelope=False,
|
|
141
|
+
allow_cursor=True,
|
|
142
|
+
allow_page=False,
|
|
143
|
+
cursor_params=CursorParams(),
|
|
144
|
+
page_params=None,
|
|
145
|
+
filters=None,
|
|
146
|
+
)
|
|
147
|
+
return ctx
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------- Utilities ----------
|
|
151
|
+
def text_filter(items: Iterable[T], q: Optional[str], *getters: Callable[[T], str]) -> list[T]:
|
|
152
|
+
if not q:
|
|
153
|
+
return list(items)
|
|
154
|
+
ql = q.lower()
|
|
155
|
+
out: list[T] = []
|
|
156
|
+
for it in items:
|
|
157
|
+
for g in getters:
|
|
158
|
+
try:
|
|
159
|
+
if ql in (g(it) or "").lower():
|
|
160
|
+
out.append(it)
|
|
161
|
+
break
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def sort_by(
|
|
168
|
+
items: Iterable[T],
|
|
169
|
+
*,
|
|
170
|
+
key: Callable[[T], Any],
|
|
171
|
+
desc: bool = False,
|
|
172
|
+
) -> list[T]:
|
|
173
|
+
return sorted(list(items), key=key, reverse=desc)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def cursor_window(items, *, cursor, limit, key, descending: bool, offset: int = 0):
|
|
177
|
+
# compute start_index
|
|
178
|
+
if cursor:
|
|
179
|
+
payload = decode_cursor(cursor)
|
|
180
|
+
after = payload.get("after")
|
|
181
|
+
ids = [key(x) for x in items]
|
|
182
|
+
if descending:
|
|
183
|
+
start_index = next((i for i, v in enumerate(ids) if v < after), len(items))
|
|
184
|
+
else:
|
|
185
|
+
start_index = next((i for i, v in enumerate(ids) if v > after), len(items))
|
|
186
|
+
else:
|
|
187
|
+
start_index = offset
|
|
188
|
+
|
|
189
|
+
# take limit+1 to see if there’s another page
|
|
190
|
+
slice_ = items[start_index : start_index + limit + 1]
|
|
191
|
+
has_more = len(slice_) > limit
|
|
192
|
+
window = slice_[:limit]
|
|
193
|
+
|
|
194
|
+
next_cur = None
|
|
195
|
+
if has_more and window:
|
|
196
|
+
last_key = key(window[-1])
|
|
197
|
+
next_cur = _encode_cursor({"after": last_key})
|
|
198
|
+
|
|
199
|
+
return window, next_cur
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------- Dependency factories ----------
|
|
203
|
+
def make_pagination_injector(
|
|
204
|
+
*,
|
|
205
|
+
envelope: bool,
|
|
206
|
+
allow_cursor: bool,
|
|
207
|
+
allow_page: bool,
|
|
208
|
+
default_limit: int = 50,
|
|
209
|
+
max_limit: int = 200,
|
|
210
|
+
include_filters: bool = False,
|
|
211
|
+
):
|
|
212
|
+
"""
|
|
213
|
+
Returns a dependency with a signature that only includes the relevant query params.
|
|
214
|
+
This keeps the generated OpenAPI in sync with actual behavior.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
# Cursor-only (common case)
|
|
218
|
+
if allow_cursor and not allow_page and not include_filters:
|
|
219
|
+
|
|
220
|
+
async def _inject(
|
|
221
|
+
request: Request,
|
|
222
|
+
cursor: str | None = Query(None),
|
|
223
|
+
limit: int = Query(default_limit, ge=1, le=max_limit),
|
|
224
|
+
):
|
|
225
|
+
cur = CursorParams(cursor=cursor, limit=limit)
|
|
226
|
+
_pagination_ctx.set(
|
|
227
|
+
PaginationContext(
|
|
228
|
+
envelope=envelope,
|
|
229
|
+
allow_cursor=True,
|
|
230
|
+
allow_page=False,
|
|
231
|
+
cursor_params=cur,
|
|
232
|
+
page_params=None,
|
|
233
|
+
filters=None,
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
return _inject
|
|
239
|
+
|
|
240
|
+
# Cursor + filters
|
|
241
|
+
if allow_cursor and not allow_page and include_filters:
|
|
242
|
+
|
|
243
|
+
async def _inject(
|
|
244
|
+
request: Request,
|
|
245
|
+
cursor: str | None = Query(None),
|
|
246
|
+
limit: int = Query(default_limit, ge=1, le=max_limit),
|
|
247
|
+
q: str | None = Query(None),
|
|
248
|
+
sort: str | None = Query(None),
|
|
249
|
+
created_after: str | None = Query(None),
|
|
250
|
+
created_before: str | None = Query(None),
|
|
251
|
+
updated_after: str | None = Query(None),
|
|
252
|
+
updated_before: str | None = Query(None),
|
|
253
|
+
):
|
|
254
|
+
cur = CursorParams(cursor=cursor, limit=limit)
|
|
255
|
+
flt = FilterParams(
|
|
256
|
+
q=q,
|
|
257
|
+
sort=sort,
|
|
258
|
+
created_after=created_after,
|
|
259
|
+
created_before=created_before,
|
|
260
|
+
updated_after=updated_after,
|
|
261
|
+
updated_before=updated_before,
|
|
262
|
+
)
|
|
263
|
+
_pagination_ctx.set(
|
|
264
|
+
PaginationContext(
|
|
265
|
+
envelope=envelope,
|
|
266
|
+
allow_cursor=True,
|
|
267
|
+
allow_page=False,
|
|
268
|
+
cursor_params=cur,
|
|
269
|
+
page_params=None,
|
|
270
|
+
filters=flt,
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
return _inject
|
|
276
|
+
|
|
277
|
+
# Page-only
|
|
278
|
+
if not allow_cursor and allow_page:
|
|
279
|
+
|
|
280
|
+
async def _inject(
|
|
281
|
+
request: Request,
|
|
282
|
+
page: int = Query(1, ge=1),
|
|
283
|
+
page_size: int = Query(default_limit, ge=1, le=max_limit),
|
|
284
|
+
):
|
|
285
|
+
pag = PageParams(page=page, page_size=page_size)
|
|
286
|
+
_pagination_ctx.set(
|
|
287
|
+
PaginationContext(
|
|
288
|
+
envelope=envelope,
|
|
289
|
+
allow_cursor=False,
|
|
290
|
+
allow_page=True,
|
|
291
|
+
cursor_params=None,
|
|
292
|
+
page_params=pag,
|
|
293
|
+
filters=None,
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
return _inject
|
|
299
|
+
|
|
300
|
+
# Both cursor + page (rare; exposes all)
|
|
301
|
+
async def _inject(
|
|
302
|
+
request: Request,
|
|
303
|
+
cursor: str | None = Query(None),
|
|
304
|
+
limit: int = Query(default_limit, ge=1, le=max_limit),
|
|
305
|
+
page: int = Query(1, ge=1),
|
|
306
|
+
page_size: int = Query(default_limit, ge=1, le=max_limit),
|
|
307
|
+
q: str | None = Query(None),
|
|
308
|
+
sort: str | None = Query(None),
|
|
309
|
+
created_after: str | None = Query(None),
|
|
310
|
+
created_before: str | None = Query(None),
|
|
311
|
+
updated_after: str | None = Query(None),
|
|
312
|
+
updated_before: str | None = Query(None),
|
|
313
|
+
):
|
|
314
|
+
cur = CursorParams(cursor=cursor, limit=limit) if allow_cursor else None
|
|
315
|
+
pag = PageParams(page=page, page_size=page_size) if allow_page else None
|
|
316
|
+
flt = (
|
|
317
|
+
FilterParams(
|
|
318
|
+
q=q,
|
|
319
|
+
sort=sort,
|
|
320
|
+
created_after=created_after,
|
|
321
|
+
created_before=created_before,
|
|
322
|
+
updated_after=updated_after,
|
|
323
|
+
updated_before=updated_before,
|
|
324
|
+
)
|
|
325
|
+
if include_filters
|
|
326
|
+
else None
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
_pagination_ctx.set(
|
|
330
|
+
PaginationContext(
|
|
331
|
+
envelope=envelope,
|
|
332
|
+
allow_cursor=allow_cursor,
|
|
333
|
+
allow_page=allow_page,
|
|
334
|
+
cursor_params=cur,
|
|
335
|
+
page_params=pag,
|
|
336
|
+
filters=flt,
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
return _inject
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# ----- Convenience helpers for routers -----
|
|
345
|
+
def cursor_pager(
|
|
346
|
+
default_limit: int = 50,
|
|
347
|
+
max_limit: int = 200,
|
|
348
|
+
*,
|
|
349
|
+
envelope: bool = True,
|
|
350
|
+
include_filters: bool = False,
|
|
351
|
+
):
|
|
352
|
+
"""
|
|
353
|
+
The one-liner most routes should use.
|
|
354
|
+
Produces OpenAPI with only: `cursor` and `limit` (plus filters if requested).
|
|
355
|
+
"""
|
|
356
|
+
return make_pagination_injector(
|
|
357
|
+
envelope=envelope,
|
|
358
|
+
allow_cursor=True,
|
|
359
|
+
allow_page=False,
|
|
360
|
+
default_limit=default_limit,
|
|
361
|
+
max_limit=max_limit,
|
|
362
|
+
include_filters=include_filters,
|
|
363
|
+
)
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
# --- API KEYS ---
|
|
2
|
-
LIST_KEYS_PATH = "/
|
|
3
|
-
CREATE_KEY_PATH = "/
|
|
4
|
-
REVOKE_KEY_PATH = "/
|
|
5
|
-
DELETE_KEY_PATH = "/
|
|
2
|
+
LIST_KEYS_PATH = "/keys"
|
|
3
|
+
CREATE_KEY_PATH = "/keys"
|
|
4
|
+
REVOKE_KEY_PATH = "/keys/{key_id}/revoke"
|
|
5
|
+
DELETE_KEY_PATH = "/keys/{key_id}"
|
|
6
6
|
|
|
7
7
|
# --- MFA ---
|
|
8
|
-
MFA_START_PATH = "/
|
|
9
|
-
MFA_CONFIRM_PATH = "/
|
|
10
|
-
MFA_DISABLE_PATH = "/
|
|
11
|
-
MFA_STATUS_PATH = "/
|
|
12
|
-
MFA_REGENERATE_RECOVERY_PATH = "/
|
|
13
|
-
MFA_VERIFY_PATH = "/
|
|
14
|
-
MFA_SEND_CODE_PATH = "/
|
|
8
|
+
MFA_START_PATH = "/mfa/start"
|
|
9
|
+
MFA_CONFIRM_PATH = "/mfa/confirm"
|
|
10
|
+
MFA_DISABLE_PATH = "/mfa/disable"
|
|
11
|
+
MFA_STATUS_PATH = "/mfa/status"
|
|
12
|
+
MFA_REGENERATE_RECOVERY_PATH = "/mfa/recovery/regenerate"
|
|
13
|
+
MFA_VERIFY_PATH = "/mfa/verify"
|
|
14
|
+
MFA_SEND_CODE_PATH = "/mfa/send_code"
|
|
15
15
|
|
|
16
16
|
# --- OAUTH ---
|
|
17
|
-
OAUTH_LOGIN_PATH = "/
|
|
18
|
-
OAUTH_CALLBACK_PATH = "/
|
|
19
|
-
OAUTH_REFRESH_PATH = "/
|
|
17
|
+
OAUTH_LOGIN_PATH = "/{provider}/login"
|
|
18
|
+
OAUTH_CALLBACK_PATH = "/{provider}/callback"
|
|
19
|
+
OAUTH_REFRESH_PATH = "/refresh"
|
|
@@ -14,6 +14,7 @@ router = public_router(tags=["Health Check"])
|
|
|
14
14
|
PING_PATH,
|
|
15
15
|
status_code=status.HTTP_200_OK,
|
|
16
16
|
description="Operation to check if the service is up and running",
|
|
17
|
+
operation_id="health_ping_get",
|
|
17
18
|
)
|
|
18
19
|
def ping():
|
|
19
20
|
logging.info("Health check: /ping endpoint accessed. Service is responsive.")
|
svc_infra/api/fastapi/setup.py
CHANGED
|
@@ -11,13 +11,22 @@ from fastapi.responses import HTMLResponse
|
|
|
11
11
|
from fastapi.routing import APIRoute
|
|
12
12
|
|
|
13
13
|
from svc_infra.api.fastapi.docs.landing import CardSpec, DocTargets, render_index_html
|
|
14
|
+
from svc_infra.api.fastapi.docs.scoped import DOC_SCOPES
|
|
14
15
|
from svc_infra.api.fastapi.middleware.errors.catchall import CatchAllExceptionMiddleware
|
|
15
16
|
from svc_infra.api.fastapi.middleware.errors.handlers import register_error_handlers
|
|
17
|
+
from svc_infra.api.fastapi.middleware.graceful_shutdown import install_graceful_shutdown
|
|
18
|
+
from svc_infra.api.fastapi.middleware.idempotency import IdempotencyMiddleware
|
|
19
|
+
from svc_infra.api.fastapi.middleware.ratelimit import SimpleRateLimitMiddleware
|
|
20
|
+
from svc_infra.api.fastapi.middleware.request_id import RequestIdMiddleware
|
|
21
|
+
from svc_infra.api.fastapi.middleware.timeout import (
|
|
22
|
+
BodyReadTimeoutMiddleware,
|
|
23
|
+
HandlerTimeoutMiddleware,
|
|
24
|
+
)
|
|
16
25
|
from svc_infra.api.fastapi.openapi.models import APIVersionSpec, ServiceInfo
|
|
17
26
|
from svc_infra.api.fastapi.openapi.mutators import setup_mutators
|
|
18
27
|
from svc_infra.api.fastapi.openapi.pipeline import apply_mutators
|
|
19
28
|
from svc_infra.api.fastapi.routers import register_all_routers
|
|
20
|
-
from svc_infra.app.env import CURRENT_ENVIRONMENT
|
|
29
|
+
from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV
|
|
21
30
|
|
|
22
31
|
logger = logging.getLogger(__name__)
|
|
23
32
|
|
|
@@ -57,7 +66,8 @@ def _setup_cors(app: FastAPI, public_cors_origins: list[str] | str | None = None
|
|
|
57
66
|
elif isinstance(public_cors_origins, str):
|
|
58
67
|
origins = [o.strip() for o in public_cors_origins.split(",") if o and o.strip()]
|
|
59
68
|
else:
|
|
60
|
-
|
|
69
|
+
# Strict by default: no CORS unless explicitly configured via env or parameter.
|
|
70
|
+
fallback = os.getenv("CORS_ALLOW_ORIGINS", "")
|
|
61
71
|
origins = [o.strip() for o in fallback.split(",") if o and o.strip()]
|
|
62
72
|
|
|
63
73
|
if not origins:
|
|
@@ -72,6 +82,20 @@ def _setup_cors(app: FastAPI, public_cors_origins: list[str] | str | None = None
|
|
|
72
82
|
app.add_middleware(CORSMiddleware, **cors_kwargs)
|
|
73
83
|
|
|
74
84
|
|
|
85
|
+
def _setup_middlewares(app: FastAPI):
|
|
86
|
+
app.add_middleware(RequestIdMiddleware)
|
|
87
|
+
# Timeouts: enforce body read timeout first, then total handler timeout
|
|
88
|
+
app.add_middleware(BodyReadTimeoutMiddleware)
|
|
89
|
+
app.add_middleware(HandlerTimeoutMiddleware)
|
|
90
|
+
app.add_middleware(CatchAllExceptionMiddleware)
|
|
91
|
+
app.add_middleware(IdempotencyMiddleware)
|
|
92
|
+
app.add_middleware(SimpleRateLimitMiddleware)
|
|
93
|
+
register_error_handlers(app)
|
|
94
|
+
_add_route_logger(app)
|
|
95
|
+
# Graceful shutdown: track in-flight and wait on shutdown
|
|
96
|
+
install_graceful_shutdown(app)
|
|
97
|
+
|
|
98
|
+
|
|
75
99
|
def _coerce_list(value: str | Iterable[str] | None) -> list[str]:
|
|
76
100
|
if value is None:
|
|
77
101
|
return []
|
|
@@ -85,19 +109,18 @@ def _dump_or_none(model):
|
|
|
85
109
|
|
|
86
110
|
|
|
87
111
|
def _build_child_app(service: ServiceInfo, spec: APIVersionSpec) -> FastAPI:
|
|
112
|
+
title = f"{service.name} • {spec.tag}" if getattr(spec, "tag", None) else service.name
|
|
88
113
|
child = FastAPI(
|
|
89
|
-
title=
|
|
114
|
+
title=title,
|
|
90
115
|
version=service.release,
|
|
91
|
-
contact=_dump_or_none(service.contact),
|
|
116
|
+
contact=_dump_or_none(service.contact),
|
|
92
117
|
license_info=_dump_or_none(service.license),
|
|
93
118
|
terms_of_service=service.terms_of_service,
|
|
94
119
|
description=service.description,
|
|
95
120
|
generate_unique_id_function=_gen_operation_id_factory(),
|
|
96
121
|
)
|
|
97
122
|
|
|
98
|
-
child
|
|
99
|
-
register_error_handlers(child)
|
|
100
|
-
_add_route_logger(child)
|
|
123
|
+
_setup_middlewares(child)
|
|
101
124
|
|
|
102
125
|
# ---- OpenAPI pipeline (DRY!) ----
|
|
103
126
|
include_api_key = bool(spec.include_api_key) if spec.include_api_key is not None else False
|
|
@@ -133,8 +156,9 @@ def _build_parent_app(
|
|
|
133
156
|
public_cors_origins: list[str] | str | None,
|
|
134
157
|
root_routers: list[str] | str | None,
|
|
135
158
|
root_server_url: str | None = None,
|
|
136
|
-
root_include_api_key: bool = False,
|
|
159
|
+
root_include_api_key: bool = False,
|
|
137
160
|
) -> FastAPI:
|
|
161
|
+
# Root docs are now enabled in all environments to match root card visibility
|
|
138
162
|
parent = FastAPI(
|
|
139
163
|
title=service.name,
|
|
140
164
|
version=service.release,
|
|
@@ -148,9 +172,7 @@ def _build_parent_app(
|
|
|
148
172
|
)
|
|
149
173
|
|
|
150
174
|
_setup_cors(parent, public_cors_origins)
|
|
151
|
-
parent
|
|
152
|
-
register_error_handlers(parent)
|
|
153
|
-
_add_route_logger(parent)
|
|
175
|
+
_setup_middlewares(parent)
|
|
154
176
|
|
|
155
177
|
mutators = setup_mutators(
|
|
156
178
|
service=service,
|
|
@@ -219,19 +241,20 @@ def setup_service_api(
|
|
|
219
241
|
mount_path = f"/{spec.tag.strip('/')}"
|
|
220
242
|
parent.mount(mount_path, child, name=spec.tag.strip("/"))
|
|
221
243
|
|
|
222
|
-
@parent.get("/", include_in_schema=False)
|
|
244
|
+
@parent.get("/", include_in_schema=False, response_class=HTMLResponse)
|
|
223
245
|
def index():
|
|
224
246
|
cards: list[CardSpec] = []
|
|
247
|
+
is_local_dev = CURRENT_ENVIRONMENT in (LOCAL_ENV, DEV_ENV)
|
|
225
248
|
|
|
226
|
-
# Root card
|
|
249
|
+
# Root card - always show in all environments
|
|
227
250
|
cards.append(
|
|
228
251
|
CardSpec(
|
|
229
|
-
tag="",
|
|
252
|
+
tag="",
|
|
230
253
|
docs=DocTargets(swagger="/docs", redoc="/redoc", openapi_json="/openapi.json"),
|
|
231
254
|
)
|
|
232
255
|
)
|
|
233
256
|
|
|
234
|
-
#
|
|
257
|
+
# Version cards
|
|
235
258
|
for spec in versions:
|
|
236
259
|
tag = spec.tag.strip("/")
|
|
237
260
|
cards.append(
|
|
@@ -245,6 +268,16 @@ def setup_service_api(
|
|
|
245
268
|
)
|
|
246
269
|
)
|
|
247
270
|
|
|
271
|
+
if is_local_dev:
|
|
272
|
+
# Scoped cards (auth, payments, etc.)
|
|
273
|
+
for scope, swagger, redoc, openapi_json, title in DOC_SCOPES:
|
|
274
|
+
cards.append(
|
|
275
|
+
CardSpec(
|
|
276
|
+
tag=scope.strip("/"),
|
|
277
|
+
docs=DocTargets(swagger=swagger, redoc=redoc, openapi_json=openapi_json),
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
|
|
248
281
|
html = render_index_html(service_name=service.name, release=service.release, cards=cards)
|
|
249
282
|
return HTMLResponse(html)
|
|
250
283
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Optional
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
|
|
7
|
+
from .context import set_tenant_resolver
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def add_tenancy(app: FastAPI, *, resolver: Optional[Callable[..., Any]] = None) -> None:
|
|
11
|
+
"""Wire tenancy resolver for the application.
|
|
12
|
+
|
|
13
|
+
Provide a resolver(request, identity, header) -> Optional[str] to override
|
|
14
|
+
the default resolution. Pass None to clear a previous override.
|
|
15
|
+
"""
|
|
16
|
+
set_tenant_resolver(resolver)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
__all__ = ["add_tenancy"]
|