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
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import Callable
|
|
3
|
+
from typing import Callable
|
|
4
4
|
|
|
5
5
|
from fastapi import APIRouter
|
|
6
|
-
from fastapi.routing import APIRoute
|
|
7
6
|
|
|
8
7
|
from .protected import protected_router, service_router, user_router
|
|
9
8
|
from .public import public_router
|
|
10
9
|
from .router import DualAPIRouter
|
|
11
|
-
from .utils import _alt_with_slash, _norm_primary
|
|
12
10
|
|
|
13
11
|
|
|
14
12
|
def dualize_into(
|
|
15
13
|
src: APIRouter, dst_factory: Callable[..., DualAPIRouter], *, show_in_schema=True
|
|
16
14
|
) -> DualAPIRouter:
|
|
17
|
-
"""
|
|
15
|
+
"""
|
|
16
|
+
Clone `src` into a DualAPIRouter without re-parsing the primary endpoints.
|
|
17
|
+
|
|
18
|
+
Strategy:
|
|
19
|
+
1) Create an empty DualAPIRouter (prefix="").
|
|
20
|
+
2) Include the original router `src` so the *original* APIRoute objects
|
|
21
|
+
(and their already-resolved request models) are used and shown in OpenAPI.
|
|
22
|
+
3) Add *hidden* trailing-slash twins that point to the same endpoint callables.
|
|
23
|
+
These don’t show in OpenAPI, so re-parsing them is harmless.
|
|
24
|
+
"""
|
|
25
|
+
# IMPORTANT: make a fresh router with NO prefix; we will include `src` with its own prefix.
|
|
18
26
|
dst = dst_factory(
|
|
19
|
-
prefix=
|
|
27
|
+
prefix="", # prevent double-prefixing on include_router
|
|
20
28
|
tags=list(src.tags or []),
|
|
21
29
|
dependencies=list(src.dependencies or []),
|
|
22
30
|
default_response_class=src.default_response_class, # type: ignore[arg-type]
|
|
@@ -29,17 +37,37 @@ def dualize_into(
|
|
|
29
37
|
on_shutdown=list(src.on_shutdown),
|
|
30
38
|
)
|
|
31
39
|
|
|
40
|
+
# 1) Keep original routes *intact* (OpenAPI stays correct).
|
|
41
|
+
# We pass prefix=src.prefix so paths remain the same.
|
|
42
|
+
dst.include_router(
|
|
43
|
+
src,
|
|
44
|
+
prefix=src.prefix,
|
|
45
|
+
tags=src.tags,
|
|
46
|
+
include_in_schema=show_in_schema,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# 2) Add hidden trailing-slash twins (no OpenAPI).
|
|
50
|
+
from fastapi.routing import APIRoute
|
|
51
|
+
|
|
52
|
+
from .utils import _alt_with_slash, _norm_primary
|
|
53
|
+
|
|
32
54
|
for r in src.routes:
|
|
33
55
|
if not isinstance(r, APIRoute):
|
|
34
56
|
continue
|
|
35
57
|
|
|
36
|
-
methods
|
|
58
|
+
methods = sorted(r.methods or [])
|
|
37
59
|
primary = _norm_primary(r.path)
|
|
38
60
|
alt = _alt_with_slash(r.path)
|
|
39
61
|
|
|
40
|
-
|
|
62
|
+
if alt == primary:
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
# Build full path using the same prefix we used for include_router
|
|
66
|
+
alt_full = f"{src.prefix}{alt}"
|
|
67
|
+
|
|
68
|
+
# Add a hidden twin. Re-parsing here is okay because this route is not in the schema.
|
|
41
69
|
dst.add_api_route(
|
|
42
|
-
|
|
70
|
+
alt_full,
|
|
43
71
|
r.endpoint,
|
|
44
72
|
methods=list(methods),
|
|
45
73
|
response_model=r.response_model,
|
|
@@ -51,37 +79,14 @@ def dualize_into(
|
|
|
51
79
|
responses=r.responses,
|
|
52
80
|
deprecated=r.deprecated,
|
|
53
81
|
name=r.name,
|
|
54
|
-
operation_id=
|
|
82
|
+
operation_id=None,
|
|
55
83
|
response_class=r.response_class,
|
|
56
84
|
response_description=r.response_description,
|
|
57
85
|
callbacks=r.callbacks,
|
|
58
86
|
openapi_extra=r.openapi_extra,
|
|
59
|
-
include_in_schema=
|
|
87
|
+
include_in_schema=False,
|
|
60
88
|
)
|
|
61
89
|
|
|
62
|
-
# hidden twin (with trailing slash)
|
|
63
|
-
if alt != primary:
|
|
64
|
-
dst.add_api_route(
|
|
65
|
-
alt,
|
|
66
|
-
r.endpoint,
|
|
67
|
-
methods=list(methods),
|
|
68
|
-
response_model=r.response_model,
|
|
69
|
-
status_code=r.status_code,
|
|
70
|
-
tags=r.tags,
|
|
71
|
-
dependencies=r.dependencies,
|
|
72
|
-
summary=r.summary,
|
|
73
|
-
description=r.description,
|
|
74
|
-
responses=r.responses,
|
|
75
|
-
deprecated=r.deprecated,
|
|
76
|
-
name=r.name,
|
|
77
|
-
operation_id=None,
|
|
78
|
-
response_class=r.response_class,
|
|
79
|
-
response_description=r.response_description,
|
|
80
|
-
callbacks=r.callbacks,
|
|
81
|
-
openapi_extra=r.openapi_extra,
|
|
82
|
-
include_in_schema=False,
|
|
83
|
-
)
|
|
84
|
-
|
|
85
90
|
return dst
|
|
86
91
|
|
|
87
92
|
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import Any, Callable
|
|
3
|
+
from typing import Any, Callable, Type
|
|
4
4
|
|
|
5
5
|
from fastapi import APIRouter
|
|
6
|
+
from fastapi.params import Depends
|
|
7
|
+
from pydantic import BaseModel
|
|
6
8
|
|
|
9
|
+
from ..pagination import Paginated, make_pagination_injector
|
|
7
10
|
from .utils import _alt_with_slash, _norm_primary
|
|
8
11
|
|
|
9
12
|
|
|
@@ -85,6 +88,50 @@ class DualAPIRouter(APIRouter):
|
|
|
85
88
|
def head(self, path: str, *_, show_in_schema: bool = True, **kwargs: Any):
|
|
86
89
|
return self._dual_decorator(path, ["HEAD"], show_in_schema=show_in_schema, **kwargs)
|
|
87
90
|
|
|
91
|
+
def list(
|
|
92
|
+
self,
|
|
93
|
+
path: str,
|
|
94
|
+
*,
|
|
95
|
+
model: Type[BaseModel],
|
|
96
|
+
envelope: bool = False,
|
|
97
|
+
cursor: bool = True,
|
|
98
|
+
page: bool = True,
|
|
99
|
+
default_limit: int = 50,
|
|
100
|
+
max_limit: int = 200,
|
|
101
|
+
show_in_schema: bool = True,
|
|
102
|
+
**kwargs: Any,
|
|
103
|
+
):
|
|
104
|
+
"""
|
|
105
|
+
Sugar for list endpoints.
|
|
106
|
+
|
|
107
|
+
- Auto-inject pagination/filter context (no Depends in your signature).
|
|
108
|
+
- Auto-picks response_model: list[model] or Paginated[model].
|
|
109
|
+
- Works with your OpenAPI mutators which already attach the shared params.
|
|
110
|
+
- Per-route opt-out of OpenAPI param auto-attach: openapi_extra={"x_no_auto_pagination": True}
|
|
111
|
+
"""
|
|
112
|
+
# pick response model
|
|
113
|
+
if envelope:
|
|
114
|
+
response_model = Paginated[model] # type: ignore[index]
|
|
115
|
+
else:
|
|
116
|
+
response_model = list[model] # type: ignore[index]
|
|
117
|
+
|
|
118
|
+
injector = make_pagination_injector(
|
|
119
|
+
envelope=envelope,
|
|
120
|
+
allow_cursor=cursor,
|
|
121
|
+
allow_page=page,
|
|
122
|
+
default_limit=default_limit,
|
|
123
|
+
max_limit=max_limit,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# ensure our injector runs; don't mutate caller's dependencies
|
|
127
|
+
deps = list(kwargs.get("dependencies") or [])
|
|
128
|
+
deps.append(Depends(injector))
|
|
129
|
+
kwargs["dependencies"] = deps
|
|
130
|
+
kwargs["response_model"] = kwargs.get("response_model") or response_model
|
|
131
|
+
|
|
132
|
+
# we still want the dual-registration behavior
|
|
133
|
+
return self._dual_decorator(path, ["GET"], show_in_schema=show_in_schema, **kwargs)
|
|
134
|
+
|
|
88
135
|
# ---------- WebSocket ----------
|
|
89
136
|
|
|
90
137
|
def websocket(self, path: str, *_, **kwargs: Any):
|
svc_infra/api/fastapi/dx.py
CHANGED
|
@@ -7,7 +7,7 @@ Usage:
|
|
|
7
7
|
easy_service_app, easy_service_api, EasyAppOptions, LoggingOptions, ObservabilityOptions,
|
|
8
8
|
|
|
9
9
|
# Auth bootstrap
|
|
10
|
-
|
|
10
|
+
add_auth_users, get_auth_settings, AuthSettings, AuthPolicy, DefaultAuthPolicy,
|
|
11
11
|
|
|
12
12
|
# Identity (endpoint params + router deps + guards)
|
|
13
13
|
Principal, Identity, OptionalIdentity,
|
|
@@ -31,7 +31,7 @@ Usage:
|
|
|
31
31
|
# ----------------
|
|
32
32
|
# Auth bootstrap / config
|
|
33
33
|
# ----------------
|
|
34
|
-
from svc_infra.api.fastapi.auth.add import
|
|
34
|
+
from svc_infra.api.fastapi.auth.add import add_auth_users
|
|
35
35
|
from svc_infra.api.fastapi.auth.mfa.router import mfa_router
|
|
36
36
|
from svc_infra.api.fastapi.auth.mfa.security import RequireMFAIfEnabled
|
|
37
37
|
from svc_infra.api.fastapi.auth.policy import AuthPolicy, DefaultAuthPolicy
|
|
@@ -102,7 +102,7 @@ __all__ = [
|
|
|
102
102
|
"LoggingOptions",
|
|
103
103
|
"ObservabilityOptions",
|
|
104
104
|
# Auth bootstrap / config
|
|
105
|
-
"
|
|
105
|
+
"add_auth_users",
|
|
106
106
|
"get_auth_settings",
|
|
107
107
|
"AuthSettings",
|
|
108
108
|
"AuthPolicy",
|
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from fastapi import HTTPException, Request, status
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def require_if_match(request: Request, current_etag: str):
|
|
5
|
+
val = request.headers.get("If-Match")
|
|
6
|
+
if not val:
|
|
7
|
+
raise HTTPException(
|
|
8
|
+
status_code=status.HTTP_428_PRECONDITION_REQUIRED,
|
|
9
|
+
detail="If-Match header required for update.",
|
|
10
|
+
)
|
|
11
|
+
if current_etag not in [t.strip() for t in val.split(",")]:
|
|
12
|
+
raise HTTPException(
|
|
13
|
+
status_code=status.HTTP_412_PRECONDITION_FAILED, detail="ETag precondition failed."
|
|
14
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from email.utils import format_datetime, parsedate_to_datetime
|
|
3
|
+
from hashlib import sha256
|
|
4
|
+
|
|
5
|
+
from fastapi import Request, Response
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def compute_etag(payload: bytes) -> str:
|
|
9
|
+
return '"' + sha256(payload).hexdigest() + '"'
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def set_conditional_headers(
|
|
13
|
+
resp: Response, etag: str | None = None, last_modified: datetime | None = None
|
|
14
|
+
):
|
|
15
|
+
if etag:
|
|
16
|
+
resp.headers["ETag"] = etag
|
|
17
|
+
if last_modified:
|
|
18
|
+
if last_modified.tzinfo is None:
|
|
19
|
+
last_modified = last_modified.replace(tzinfo=timezone.utc)
|
|
20
|
+
resp.headers["Last-Modified"] = format_datetime(last_modified)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def maybe_not_modified(request: Request, etag: str | None, last_modified: datetime | None) -> bool:
|
|
24
|
+
inm = request.headers.get("If-None-Match")
|
|
25
|
+
ims = request.headers.get("If-Modified-Since")
|
|
26
|
+
etag_ok = etag and inm and etag in [t.strip() for t in inm.split(",")]
|
|
27
|
+
time_ok = False
|
|
28
|
+
if last_modified and ims:
|
|
29
|
+
try:
|
|
30
|
+
time_ok = parsedate_to_datetime(ims) >= last_modified
|
|
31
|
+
except Exception:
|
|
32
|
+
pass
|
|
33
|
+
return bool(etag_ok or time_ok)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def deprecated(sunset_http_date: str | None = None, link: str | None = None):
|
|
5
|
+
def deco(handler):
|
|
6
|
+
@wraps(handler)
|
|
7
|
+
async def wrapper(*a, **kw):
|
|
8
|
+
resp = await handler(*a, **kw)
|
|
9
|
+
# starlette Response or FastAPI returns both OK
|
|
10
|
+
headers = getattr(resp, "headers", None)
|
|
11
|
+
if headers is not None:
|
|
12
|
+
headers.setdefault("Deprecation", "true")
|
|
13
|
+
if sunset_http_date:
|
|
14
|
+
headers.setdefault("Sunset", sunset_http_date)
|
|
15
|
+
if link:
|
|
16
|
+
headers.setdefault("Link", f'<{link}>; rel="deprecation"')
|
|
17
|
+
return resp
|
|
18
|
+
|
|
19
|
+
return wrapper
|
|
20
|
+
|
|
21
|
+
return deco
|
|
@@ -2,6 +2,7 @@ import logging
|
|
|
2
2
|
import traceback
|
|
3
3
|
from typing import Any, Dict, Optional
|
|
4
4
|
|
|
5
|
+
import httpx
|
|
5
6
|
from fastapi import Request
|
|
6
7
|
from fastapi.exceptions import HTTPException, RequestValidationError
|
|
7
8
|
from fastapi.responses import JSONResponse, Response
|
|
@@ -46,6 +47,7 @@ def problem_response(
|
|
|
46
47
|
code: str | None = None,
|
|
47
48
|
errors: list[dict] | None = None,
|
|
48
49
|
trace_id: str | None = None,
|
|
50
|
+
headers: dict[str, str] | None = None,
|
|
49
51
|
) -> Response:
|
|
50
52
|
body: Dict[str, Any] = {
|
|
51
53
|
"type": type_uri,
|
|
@@ -62,10 +64,24 @@ def problem_response(
|
|
|
62
64
|
body["errors"] = errors
|
|
63
65
|
if trace_id:
|
|
64
66
|
body["trace_id"] = trace_id
|
|
65
|
-
return JSONResponse(status_code=status, content=body, media_type=PROBLEM_MT)
|
|
67
|
+
return JSONResponse(status_code=status, content=body, media_type=PROBLEM_MT, headers=headers)
|
|
66
68
|
|
|
67
69
|
|
|
68
70
|
def register_error_handlers(app):
|
|
71
|
+
@app.exception_handler(httpx.TimeoutException)
|
|
72
|
+
async def handle_httpx_timeout(request: Request, exc: httpx.TimeoutException):
|
|
73
|
+
trace_id = _trace_id_from_request(request)
|
|
74
|
+
# Map outbound HTTP client timeouts to 504 Gateway Timeout
|
|
75
|
+
# Keep details generic in prod
|
|
76
|
+
return problem_response(
|
|
77
|
+
status=504,
|
|
78
|
+
title="Gateway Timeout",
|
|
79
|
+
detail=("Upstream request timed out." if IS_PROD else (str(exc) or "httpx timeout")),
|
|
80
|
+
code="GATEWAY_TIMEOUT",
|
|
81
|
+
instance=str(request.url),
|
|
82
|
+
trace_id=trace_id,
|
|
83
|
+
)
|
|
84
|
+
|
|
69
85
|
@app.exception_handler(FastApiException)
|
|
70
86
|
async def handle_app_exception(request: Request, exc: FastApiException):
|
|
71
87
|
trace_id = _trace_id_from_request(request)
|
|
@@ -104,14 +120,25 @@ def register_error_handlers(app):
|
|
|
104
120
|
@app.exception_handler(HTTPException)
|
|
105
121
|
async def handle_http_exception(request: Request, exc: HTTPException):
|
|
106
122
|
trace_id = _trace_id_from_request(request)
|
|
107
|
-
title = {
|
|
108
|
-
|
|
109
|
-
|
|
123
|
+
title = {
|
|
124
|
+
401: "Unauthorized",
|
|
125
|
+
403: "Forbidden",
|
|
126
|
+
404: "Not Found",
|
|
127
|
+
429: "Too Many Requests",
|
|
128
|
+
}.get(exc.status_code, "Error")
|
|
110
129
|
detail = (
|
|
111
130
|
exc.detail
|
|
112
131
|
if not IS_PROD or exc.status_code < 500
|
|
113
132
|
else "Something went wrong. Please contact support."
|
|
114
133
|
)
|
|
134
|
+
# Preserve headers set on the exception (e.g., Retry-After for rate limits)
|
|
135
|
+
hdrs: dict[str, str] | None = None
|
|
136
|
+
try:
|
|
137
|
+
if getattr(exc, "headers", None):
|
|
138
|
+
# FastAPI/Starlette exceptions store headers as a dict[str, str]
|
|
139
|
+
hdrs = dict(getattr(exc, "headers")) # type: ignore[arg-type]
|
|
140
|
+
except Exception:
|
|
141
|
+
hdrs = None
|
|
115
142
|
return problem_response(
|
|
116
143
|
status=exc.status_code,
|
|
117
144
|
title=title,
|
|
@@ -119,19 +146,29 @@ def register_error_handlers(app):
|
|
|
119
146
|
code=title.replace(" ", "_").upper(),
|
|
120
147
|
instance=str(request.url),
|
|
121
148
|
trace_id=trace_id,
|
|
149
|
+
headers=hdrs,
|
|
122
150
|
)
|
|
123
151
|
|
|
124
152
|
@app.exception_handler(StarletteHTTPException)
|
|
125
153
|
async def handle_starlette_http_exception(request: Request, exc: StarletteHTTPException):
|
|
126
154
|
trace_id = _trace_id_from_request(request)
|
|
127
|
-
title = {
|
|
128
|
-
|
|
129
|
-
|
|
155
|
+
title = {
|
|
156
|
+
401: "Unauthorized",
|
|
157
|
+
403: "Forbidden",
|
|
158
|
+
404: "Not Found",
|
|
159
|
+
429: "Too Many Requests",
|
|
160
|
+
}.get(exc.status_code, "Error")
|
|
130
161
|
detail = (
|
|
131
162
|
exc.detail
|
|
132
163
|
if not IS_PROD or exc.status_code < 500
|
|
133
164
|
else "Something went wrong. Please contact support."
|
|
134
165
|
)
|
|
166
|
+
hdrs: dict[str, str] | None = None
|
|
167
|
+
try:
|
|
168
|
+
if getattr(exc, "headers", None):
|
|
169
|
+
hdrs = dict(getattr(exc, "headers")) # type: ignore[arg-type]
|
|
170
|
+
except Exception:
|
|
171
|
+
hdrs = None
|
|
135
172
|
return problem_response(
|
|
136
173
|
status=exc.status_code,
|
|
137
174
|
title=title,
|
|
@@ -139,6 +176,7 @@ def register_error_handlers(app):
|
|
|
139
176
|
code=title.replace(" ", "_").upper(),
|
|
140
177
|
instance=str(request.url),
|
|
141
178
|
trace_id=trace_id,
|
|
179
|
+
headers=hdrs,
|
|
142
180
|
)
|
|
143
181
|
|
|
144
182
|
@app.exception_handler(IntegrityError)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI
|
|
10
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
11
|
+
|
|
12
|
+
from svc_infra.app.env import pick
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_grace_period_seconds() -> float:
|
|
18
|
+
default = pick(prod=20.0, nonprod=5.0)
|
|
19
|
+
raw = os.getenv("SHUTDOWN_GRACE_PERIOD_SECONDS")
|
|
20
|
+
if raw is None or raw == "":
|
|
21
|
+
return float(default)
|
|
22
|
+
try:
|
|
23
|
+
return float(raw)
|
|
24
|
+
except ValueError:
|
|
25
|
+
return float(default)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InflightTrackerMiddleware:
|
|
29
|
+
"""Tracks number of in-flight requests to support graceful shutdown drains."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, app: ASGIApp):
|
|
32
|
+
self.app = app
|
|
33
|
+
|
|
34
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
|
35
|
+
if scope.get("type") != "http":
|
|
36
|
+
await self.app(scope, receive, send)
|
|
37
|
+
return
|
|
38
|
+
state = scope.get("app").state # type: ignore[attr-defined]
|
|
39
|
+
state._inflight_requests = getattr(state, "_inflight_requests", 0) + 1
|
|
40
|
+
try:
|
|
41
|
+
await self.app(scope, receive, send)
|
|
42
|
+
finally:
|
|
43
|
+
state._inflight_requests = max(0, getattr(state, "_inflight_requests", 1) - 1)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def _wait_for_drain(app: FastAPI, grace: float) -> None:
|
|
47
|
+
interval = 0.1
|
|
48
|
+
waited = 0.0
|
|
49
|
+
while waited < grace:
|
|
50
|
+
inflight = int(getattr(app.state, "_inflight_requests", 0))
|
|
51
|
+
if inflight <= 0:
|
|
52
|
+
return
|
|
53
|
+
await asyncio.sleep(interval)
|
|
54
|
+
waited += interval
|
|
55
|
+
inflight = int(getattr(app.state, "_inflight_requests", 0))
|
|
56
|
+
if inflight > 0:
|
|
57
|
+
logger.warning(
|
|
58
|
+
"Graceful shutdown timeout: %s in-flight request(s) after %.2fs", inflight, waited
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def install_graceful_shutdown(app: FastAPI, *, grace_seconds: Optional[float] = None) -> None:
|
|
63
|
+
"""Install inflight tracking and lifespan hooks to wait for requests to drain.
|
|
64
|
+
|
|
65
|
+
- Adds InflightTrackerMiddleware
|
|
66
|
+
- Registers a lifespan handler that initializes state and waits up to grace_seconds on shutdown
|
|
67
|
+
"""
|
|
68
|
+
app.add_middleware(InflightTrackerMiddleware)
|
|
69
|
+
|
|
70
|
+
g = float(grace_seconds) if grace_seconds is not None else _get_grace_period_seconds()
|
|
71
|
+
|
|
72
|
+
# Preserve any existing lifespan and wrap it so our drain runs on shutdown.
|
|
73
|
+
previous_lifespan = getattr(app.router, "lifespan_context", None)
|
|
74
|
+
|
|
75
|
+
@asynccontextmanager
|
|
76
|
+
async def _lifespan(a: FastAPI): # noqa: ANN202
|
|
77
|
+
# Startup: initialize inflight counter
|
|
78
|
+
a.state._inflight_requests = 0
|
|
79
|
+
if previous_lifespan is not None:
|
|
80
|
+
async with previous_lifespan(a):
|
|
81
|
+
yield
|
|
82
|
+
else:
|
|
83
|
+
yield
|
|
84
|
+
# Shutdown: wait for in-flight requests to drain (up to grace period)
|
|
85
|
+
await _wait_for_drain(a, g)
|
|
86
|
+
|
|
87
|
+
app.router.lifespan_context = _lifespan
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import time
|
|
4
|
+
from typing import Annotated, Dict, Optional
|
|
5
|
+
|
|
6
|
+
from fastapi import Header, HTTPException, Request
|
|
7
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
8
|
+
from starlette.responses import JSONResponse, Response
|
|
9
|
+
|
|
10
|
+
from .idempotency_store import IdempotencyStore, InMemoryIdempotencyStore
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class IdempotencyMiddleware(BaseHTTPMiddleware):
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
app,
|
|
17
|
+
ttl_seconds: int = 24 * 3600,
|
|
18
|
+
store: Optional[IdempotencyStore] = None,
|
|
19
|
+
header_name: str = "Idempotency-Key",
|
|
20
|
+
):
|
|
21
|
+
super().__init__(app)
|
|
22
|
+
self.ttl = ttl_seconds
|
|
23
|
+
self.store: IdempotencyStore = store or InMemoryIdempotencyStore()
|
|
24
|
+
self.header_name = header_name
|
|
25
|
+
|
|
26
|
+
def _cache_key(self, request, idkey: str):
|
|
27
|
+
# The cache key must NOT include the body to allow conflict detection for mismatched payloads.
|
|
28
|
+
sig = hashlib.sha256(
|
|
29
|
+
(request.method + "|" + request.url.path + "|" + idkey).encode()
|
|
30
|
+
).hexdigest()
|
|
31
|
+
return f"idmp:{sig}"
|
|
32
|
+
|
|
33
|
+
async def dispatch(self, request, call_next):
|
|
34
|
+
if request.method in {"POST", "PATCH", "DELETE"}:
|
|
35
|
+
# read & buffer body once
|
|
36
|
+
body = await request.body()
|
|
37
|
+
request._body = body
|
|
38
|
+
idkey = request.headers.get(self.header_name)
|
|
39
|
+
if idkey:
|
|
40
|
+
k = self._cache_key(request, idkey)
|
|
41
|
+
now = time.time()
|
|
42
|
+
# build request hash to detect mismatched replays
|
|
43
|
+
req_hash = hashlib.sha256(body or b"").hexdigest()
|
|
44
|
+
|
|
45
|
+
existing = self.store.get(k)
|
|
46
|
+
if existing and existing.exp > now:
|
|
47
|
+
# If payload mismatches any existing claim, return conflict
|
|
48
|
+
if existing.req_hash and existing.req_hash != req_hash:
|
|
49
|
+
return JSONResponse(
|
|
50
|
+
status_code=409,
|
|
51
|
+
content={
|
|
52
|
+
"type": "about:blank",
|
|
53
|
+
"title": "Conflict",
|
|
54
|
+
"detail": "Idempotency-Key re-used with different request payload.",
|
|
55
|
+
},
|
|
56
|
+
)
|
|
57
|
+
# If response cached and payload matches, replay it
|
|
58
|
+
if existing.status is not None and existing.body_b64 is not None:
|
|
59
|
+
return Response(
|
|
60
|
+
content=base64.b64decode(existing.body_b64),
|
|
61
|
+
status_code=existing.status,
|
|
62
|
+
headers=existing.headers or {},
|
|
63
|
+
media_type=existing.media_type,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Claim the key if not present
|
|
67
|
+
exp = now + self.ttl
|
|
68
|
+
created = self.store.set_initial(k, req_hash, exp)
|
|
69
|
+
if not created:
|
|
70
|
+
# Someone else claimed; re-check for conflict or replay
|
|
71
|
+
existing = self.store.get(k)
|
|
72
|
+
if existing and existing.req_hash and existing.req_hash != req_hash:
|
|
73
|
+
return JSONResponse(
|
|
74
|
+
status_code=409,
|
|
75
|
+
content={
|
|
76
|
+
"type": "about:blank",
|
|
77
|
+
"title": "Conflict",
|
|
78
|
+
"detail": "Idempotency-Key re-used with different request payload.",
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
if existing and existing.status is not None and existing.body_b64 is not None:
|
|
82
|
+
return Response(
|
|
83
|
+
content=base64.b64decode(existing.body_b64),
|
|
84
|
+
status_code=existing.status,
|
|
85
|
+
headers=existing.headers or {},
|
|
86
|
+
media_type=existing.media_type,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Proceed to handler
|
|
90
|
+
resp = await call_next(request)
|
|
91
|
+
if 200 <= resp.status_code < 300:
|
|
92
|
+
body_bytes = b"".join([section async for section in resp.body_iterator])
|
|
93
|
+
headers: Dict[str, str] = dict(resp.headers)
|
|
94
|
+
self.store.set_response(
|
|
95
|
+
k,
|
|
96
|
+
status=resp.status_code,
|
|
97
|
+
body=body_bytes,
|
|
98
|
+
headers=headers,
|
|
99
|
+
media_type=resp.media_type,
|
|
100
|
+
)
|
|
101
|
+
return Response(
|
|
102
|
+
content=body_bytes,
|
|
103
|
+
status_code=resp.status_code,
|
|
104
|
+
headers=headers,
|
|
105
|
+
media_type=resp.media_type,
|
|
106
|
+
)
|
|
107
|
+
return resp
|
|
108
|
+
return await call_next(request)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def require_idempotency_key(
|
|
112
|
+
idempotency_key: Annotated[str, Header(alias="Idempotency-Key")],
|
|
113
|
+
request: Request,
|
|
114
|
+
) -> None:
|
|
115
|
+
if not idempotency_key.strip():
|
|
116
|
+
raise HTTPException(status_code=400, detail="Idempotency-Key must not be empty.")
|