svc-infra 0.1.595__py3-none-any.whl → 1.1.0__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.
- svc_infra/__init__.py +58 -2
- svc_infra/apf_payments/models.py +68 -38
- svc_infra/apf_payments/provider/__init__.py +2 -2
- svc_infra/apf_payments/provider/aiydan.py +39 -23
- svc_infra/apf_payments/provider/base.py +8 -3
- svc_infra/apf_payments/provider/registry.py +3 -5
- svc_infra/apf_payments/provider/stripe.py +74 -52
- svc_infra/apf_payments/schemas.py +84 -83
- svc_infra/apf_payments/service.py +27 -16
- svc_infra/apf_payments/settings.py +12 -11
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +34 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +240 -0
- svc_infra/api/fastapi/apf_payments/router.py +94 -73
- svc_infra/api/fastapi/apf_payments/setup.py +10 -9
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +1 -3
- svc_infra/api/fastapi/auth/add.py +14 -15
- svc_infra/api/fastapi/auth/gaurd.py +32 -20
- svc_infra/api/fastapi/auth/mfa/models.py +3 -4
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +13 -9
- svc_infra/api/fastapi/auth/mfa/router.py +9 -8
- svc_infra/api/fastapi/auth/mfa/security.py +4 -7
- svc_infra/api/fastapi/auth/mfa/utils.py +5 -3
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -3
- svc_infra/api/fastapi/auth/routers/apikey_router.py +19 -21
- svc_infra/api/fastapi/auth/routers/oauth_router.py +98 -52
- svc_infra/api/fastapi/auth/routers/session_router.py +6 -5
- svc_infra/api/fastapi/auth/security.py +25 -15
- svc_infra/api/fastapi/auth/sender.py +5 -0
- svc_infra/api/fastapi/auth/settings.py +18 -19
- svc_infra/api/fastapi/auth/state.py +5 -4
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +71 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/__init__.py +5 -1
- svc_infra/api/fastapi/db/http.py +10 -9
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +35 -30
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +39 -21
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +62 -25
- svc_infra/api/fastapi/db/sql/crud_router.py +205 -30
- svc_infra/api/fastapi/db/sql/session.py +19 -2
- svc_infra/api/fastapi/db/sql/users.py +18 -9
- svc_infra/api/fastapi/dependencies/ratelimit.py +76 -14
- svc_infra/api/fastapi/docs/add.py +163 -0
- svc_infra/api/fastapi/docs/landing.py +6 -6
- svc_infra/api/fastapi/docs/scoped.py +75 -36
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +2 -2
- svc_infra/api/fastapi/dual/protected.py +123 -10
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +18 -8
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +59 -7
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +2 -2
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/exceptions.py +2 -5
- svc_infra/api/fastapi/middleware/errors/handlers.py +50 -10
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +95 -0
- svc_infra/api/fastapi/middleware/idempotency.py +190 -68
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +39 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
- svc_infra/api/fastapi/middleware/ratelimit_store.py +45 -13
- svc_infra/api/fastapi/middleware/request_id.py +24 -10
- svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
- svc_infra/api/fastapi/middleware/timeout.py +176 -0
- svc_infra/api/fastapi/object_router.py +1060 -0
- svc_infra/api/fastapi/openapi/apply.py +4 -3
- svc_infra/api/fastapi/openapi/conventions.py +13 -6
- svc_infra/api/fastapi/openapi/mutators.py +144 -17
- svc_infra/api/fastapi/openapi/pipeline.py +2 -2
- svc_infra/api/fastapi/openapi/responses.py +4 -6
- svc_infra/api/fastapi/openapi/security.py +1 -1
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +47 -32
- svc_infra/api/fastapi/routers/__init__.py +16 -10
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +167 -54
- svc_infra/api/fastapi/tenancy/add.py +20 -0
- svc_infra/api/fastapi/tenancy/context.py +113 -0
- svc_infra/api/fastapi/versioned.py +102 -0
- svc_infra/app/README.md +5 -5
- svc_infra/app/__init__.py +3 -1
- svc_infra/app/env.py +70 -4
- svc_infra/app/logging/add.py +10 -2
- svc_infra/app/logging/filter.py +1 -1
- svc_infra/app/logging/formats.py +13 -5
- svc_infra/app/root.py +3 -3
- svc_infra/billing/__init__.py +40 -0
- svc_infra/billing/async_service.py +167 -0
- svc_infra/billing/jobs.py +231 -0
- svc_infra/billing/models.py +146 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +34 -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 +21 -5
- svc_infra/cache/add.py +167 -0
- svc_infra/cache/backend.py +9 -7
- svc_infra/cache/decorators.py +75 -20
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +26 -6
- svc_infra/cache/recache.py +26 -27
- svc_infra/cache/resources.py +6 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/ttl.py +2 -3
- svc_infra/cache/utils.py +4 -3
- svc_infra/cli/__init__.py +44 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +39 -2
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +18 -14
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +9 -10
- svc_infra/cli/cmds/db/ops_cmds.py +267 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +97 -29
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +13 -13
- svc_infra/cli/cmds/docs/docs_cmds.py +139 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +110 -0
- svc_infra/cli/cmds/health/__init__.py +179 -0
- svc_infra/cli/cmds/health/health_cmds.py +8 -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 +42 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +31 -13
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/cli/foundation/runner.py +4 -5
- svc_infra/cli/foundation/typer_bootstrap.py +1 -2
- svc_infra/data/__init__.py +83 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +56 -0
- svc_infra/data/erasure.py +46 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +56 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +14 -13
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +2 -0
- svc_infra/db/nosql/constants.py +1 -1
- svc_infra/db/nosql/core.py +19 -5
- svc_infra/db/nosql/indexes.py +12 -9
- svc_infra/db/nosql/management.py +4 -4
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +21 -4
- svc_infra/db/nosql/mongo/settings.py +1 -1
- svc_infra/db/nosql/repository.py +46 -27
- svc_infra/db/nosql/resource.py +28 -16
- svc_infra/db/nosql/scaffold.py +14 -12
- svc_infra/db/nosql/service.py +2 -1
- svc_infra/db/nosql/service_with_hooks.py +4 -3
- svc_infra/db/nosql/utils.py +4 -4
- svc_infra/db/ops.py +380 -0
- svc_infra/db/outbox.py +105 -0
- svc_infra/db/sql/apikey.py +34 -15
- svc_infra/db/sql/authref.py +8 -6
- svc_infra/db/sql/constants.py +5 -1
- svc_infra/db/sql/core.py +13 -13
- svc_infra/db/sql/management.py +5 -6
- svc_infra/db/sql/repository.py +92 -26
- svc_infra/db/sql/resource.py +18 -12
- svc_infra/db/sql/scaffold.py +11 -11
- svc_infra/db/sql/service.py +2 -1
- svc_infra/db/sql/service_with_hooks.py +4 -3
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
- svc_infra/db/sql/tenant.py +80 -0
- svc_infra/db/sql/uniq.py +8 -7
- svc_infra/db/sql/uniq_hooks.py +12 -11
- svc_infra/db/sql/utils.py +105 -47
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/db/utils.py +3 -3
- svc_infra/deploy/__init__.py +531 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +263 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +262 -0
- svc_infra/dx/__init__.py +58 -0
- svc_infra/dx/add.py +63 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +68 -0
- svc_infra/exceptions.py +141 -0
- svc_infra/health/__init__.py +863 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +101 -0
- svc_infra/jobs/__init__.py +79 -0
- svc_infra/jobs/builtins/outbox_processor.py +38 -0
- svc_infra/jobs/builtins/webhook_delivery.py +93 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +49 -0
- svc_infra/jobs/queue.py +106 -0
- svc_infra/jobs/redis_queue.py +242 -0
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/scheduler.py +53 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/loaders/__init__.py +186 -0
- svc_infra/loaders/base.py +143 -0
- svc_infra/loaders/github.py +309 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +229 -0
- svc_infra/logging/__init__.py +375 -0
- svc_infra/mcp/__init__.py +82 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +68 -11
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +6 -7
- svc_infra/obs/metrics/asgi.py +8 -7
- svc_infra/obs/metrics/base.py +13 -13
- svc_infra/obs/metrics/http.py +3 -3
- svc_infra/obs/metrics/sqlalchemy.py +14 -13
- svc_infra/obs/metrics.py +9 -8
- svc_infra/resilience/__init__.py +44 -0
- svc_infra/resilience/circuit_breaker.py +328 -0
- svc_infra/resilience/retry.py +289 -0
- svc_infra/security/__init__.py +167 -0
- svc_infra/security/add.py +213 -0
- svc_infra/security/audit.py +97 -18
- svc_infra/security/audit_service.py +10 -9
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +14 -7
- svc_infra/security/jwt_rotation.py +78 -29
- svc_infra/security/lockout.py +23 -16
- svc_infra/security/models.py +77 -44
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +12 -12
- svc_infra/security/passwords.py +3 -3
- svc_infra/security/permissions.py +31 -7
- svc_infra/security/session.py +7 -8
- svc_infra/security/signed_cookies.py +26 -6
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +250 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +331 -0
- svc_infra/storage/backends/memory.py +213 -0
- svc_infra/storage/backends/s3.py +334 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +181 -0
- svc_infra/storage/settings.py +193 -0
- svc_infra/testing/__init__.py +682 -0
- svc_infra/utils.py +170 -5
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +327 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +69 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +139 -0
- svc_infra/websocket/client.py +283 -0
- svc_infra/websocket/config.py +57 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +343 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-1.1.0.dist-info/LICENSE +21 -0
- svc_infra-1.1.0.dist-info/METADATA +362 -0
- svc_infra-1.1.0.dist-info/RECORD +364 -0
- svc_infra-0.1.595.dist-info/METADATA +0 -80
- svc_infra-0.1.595.dist-info/RECORD +0 -253
- {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
4
5
|
|
|
5
6
|
from fastapi import APIRouter
|
|
6
7
|
|
|
@@ -17,7 +18,7 @@ def apply_default_security(router: APIRouter, *, default_security: list[dict] |
|
|
|
17
18
|
kwargs["openapi_extra"] = ox
|
|
18
19
|
return original_add(path, endpoint, **kwargs)
|
|
19
20
|
|
|
20
|
-
router.add_api_route = _wrapped_add_api_route # type: ignore[
|
|
21
|
+
router.add_api_route = _wrapped_add_api_route # type: ignore[method-assign]
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
def apply_default_responses(router: APIRouter, defaults: dict[int, dict]) -> None:
|
|
@@ -38,4 +39,4 @@ def apply_default_responses(router: APIRouter, defaults: dict[int, dict]) -> Non
|
|
|
38
39
|
kwargs["responses"] = responses
|
|
39
40
|
return original_add(path, endpoint, **kwargs)
|
|
40
41
|
|
|
41
|
-
router.add_api_route = _wrapped_add_api_route # type: ignore[
|
|
42
|
+
router.add_api_route = _wrapped_add_api_route # type: ignore[method-assign]
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import Any
|
|
3
|
+
from typing import Any
|
|
4
4
|
|
|
5
5
|
from fastapi import FastAPI
|
|
6
6
|
|
|
7
7
|
from .mutators import conventions_mutator
|
|
8
8
|
from .pipeline import apply_mutators
|
|
9
9
|
|
|
10
|
-
PROBLEM_SCHEMA:
|
|
10
|
+
PROBLEM_SCHEMA: dict[str, Any] = {
|
|
11
11
|
"type": "object",
|
|
12
12
|
"properties": {
|
|
13
13
|
"type": {
|
|
@@ -16,7 +16,11 @@ PROBLEM_SCHEMA: Dict[str, Any] = {
|
|
|
16
16
|
"description": "URI identifying the error type",
|
|
17
17
|
},
|
|
18
18
|
"title": {"type": "string", "description": "Short, human-readable summary"},
|
|
19
|
-
"status": {
|
|
19
|
+
"status": {
|
|
20
|
+
"type": "integer",
|
|
21
|
+
"format": "int32",
|
|
22
|
+
"description": "HTTP status code",
|
|
23
|
+
},
|
|
20
24
|
"detail": {"type": "string", "description": "Human-readable explanation"},
|
|
21
25
|
"instance": {
|
|
22
26
|
"type": "string",
|
|
@@ -36,13 +40,16 @@ PROBLEM_SCHEMA: Dict[str, Any] = {
|
|
|
36
40
|
},
|
|
37
41
|
},
|
|
38
42
|
},
|
|
39
|
-
"trace_id": {
|
|
43
|
+
"trace_id": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"description": "Correlation/trace id (if available)",
|
|
46
|
+
},
|
|
40
47
|
},
|
|
41
48
|
"required": ["title", "status"],
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
|
|
45
|
-
def _problem_example(**kw: Any) ->
|
|
52
|
+
def _problem_example(**kw: Any) -> dict[str, Any]:
|
|
46
53
|
base = {
|
|
47
54
|
"type": "about:blank",
|
|
48
55
|
"title": "Internal Server Error",
|
|
@@ -56,7 +63,7 @@ def _problem_example(**kw: Any) -> Dict[str, Any]:
|
|
|
56
63
|
return base
|
|
57
64
|
|
|
58
65
|
|
|
59
|
-
STANDARD_RESPONSES:
|
|
66
|
+
STANDARD_RESPONSES: dict[str, dict[str, Any]] = {
|
|
60
67
|
"BadRequest": {
|
|
61
68
|
"description": "The request is malformed or missing required fields",
|
|
62
69
|
"content": {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
|
-
from
|
|
4
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
5
5
|
|
|
6
6
|
from ..auth.security import auth_login_path
|
|
7
7
|
from .models import APIVersionSpec, ServiceInfo, VersionInfo
|
|
@@ -9,7 +9,7 @@ from .models import APIVersionSpec, ServiceInfo, VersionInfo
|
|
|
9
9
|
_HTTP_METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace")
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
def _iter_ops(schema: dict) -> Iterator[
|
|
12
|
+
def _iter_ops(schema: dict) -> Iterator[tuple[str, str, dict]]:
|
|
13
13
|
"""Yield (path, method, op) for each operation object."""
|
|
14
14
|
paths = schema.get("paths") or {}
|
|
15
15
|
for path, methods in paths.items():
|
|
@@ -51,7 +51,7 @@ def pagination_components_mutator(
|
|
|
51
51
|
*,
|
|
52
52
|
default_limit: int = 50,
|
|
53
53
|
max_limit: int = 200,
|
|
54
|
-
) ->
|
|
54
|
+
) -> Callable[[dict], dict]:
|
|
55
55
|
"""
|
|
56
56
|
Adds reusable pagination/filtering parameters & paginated envelope schemas.
|
|
57
57
|
- Cursor: cursor/limit
|
|
@@ -196,7 +196,7 @@ def auto_attach_pagination_params_mutator(
|
|
|
196
196
|
attach_filters: bool = True,
|
|
197
197
|
apply_when: str = "array_200",
|
|
198
198
|
flag_disable: str = "x_no_auto_pagination",
|
|
199
|
-
) ->
|
|
199
|
+
) -> Callable[[dict], dict]:
|
|
200
200
|
"""
|
|
201
201
|
Attaches reusable pagination/filter parameters to GET "listy" operations.
|
|
202
202
|
|
|
@@ -479,7 +479,7 @@ def ensure_global_tags_mutator(default_desc: str = "Operations related to {tag}.
|
|
|
479
479
|
|
|
480
480
|
# map existing tags by name and preserve their fields
|
|
481
481
|
existing_list = schema.get("tags") or []
|
|
482
|
-
existing_map:
|
|
482
|
+
existing_map: dict[str, dict] = {}
|
|
483
483
|
for item in existing_list:
|
|
484
484
|
if isinstance(item, dict) and "name" in item:
|
|
485
485
|
existing_map[item["name"]] = dict(item)
|
|
@@ -487,7 +487,10 @@ def ensure_global_tags_mutator(default_desc: str = "Operations related to {tag}.
|
|
|
487
487
|
# add missing tags; do NOT override existing descriptions
|
|
488
488
|
for name in sorted(used):
|
|
489
489
|
if name not in existing_map:
|
|
490
|
-
existing_map[name] = {
|
|
490
|
+
existing_map[name] = {
|
|
491
|
+
"name": name,
|
|
492
|
+
"description": default_desc.format(tag=name),
|
|
493
|
+
}
|
|
491
494
|
else:
|
|
492
495
|
if not existing_map[name].get("description"):
|
|
493
496
|
existing_map[name]["description"] = default_desc.format(tag=name)
|
|
@@ -501,8 +504,8 @@ def ensure_global_tags_mutator(default_desc: str = "Operations related to {tag}.
|
|
|
501
504
|
|
|
502
505
|
|
|
503
506
|
def attach_standard_responses_mutator(
|
|
504
|
-
codes:
|
|
505
|
-
per_method:
|
|
507
|
+
codes: dict[int, str] | None = None,
|
|
508
|
+
per_method: dict[str, Iterable[int]] | None = None,
|
|
506
509
|
exclude_tags: set[str] | None = None,
|
|
507
510
|
op_flag_disable: str = "x_disable_standard_responses",
|
|
508
511
|
):
|
|
@@ -541,7 +544,7 @@ def attach_standard_responses_mutator(
|
|
|
541
544
|
|
|
542
545
|
|
|
543
546
|
def drop_unused_components_mutator(
|
|
544
|
-
drop_responses: list[str] = None, drop_schemas: list[str] = None
|
|
547
|
+
drop_responses: list[str] | None = None, drop_schemas: list[str] | None = None
|
|
545
548
|
):
|
|
546
549
|
drop_responses = drop_responses or []
|
|
547
550
|
drop_schemas = drop_schemas or []
|
|
@@ -640,7 +643,9 @@ def ensure_media_type_schemas_mutator():
|
|
|
640
643
|
|
|
641
644
|
|
|
642
645
|
# ---------- 3) Request body descriptions ----------
|
|
643
|
-
def ensure_request_body_descriptions_mutator(
|
|
646
|
+
def ensure_request_body_descriptions_mutator(
|
|
647
|
+
default_template="Request body for {method} {path}.",
|
|
648
|
+
):
|
|
644
649
|
def m(schema: dict) -> dict:
|
|
645
650
|
schema = dict(schema)
|
|
646
651
|
for path, method, op in _iter_ops(schema):
|
|
@@ -1102,6 +1107,117 @@ def ensure_success_examples_mutator():
|
|
|
1102
1107
|
return m
|
|
1103
1108
|
|
|
1104
1109
|
|
|
1110
|
+
# --- NEW: attach minimal x-codeSamples for common operations ---
|
|
1111
|
+
def attach_code_samples_mutator():
|
|
1112
|
+
"""Attach minimal curl/httpie x-codeSamples for each operation if missing.
|
|
1113
|
+
|
|
1114
|
+
We avoid templating parameters; samples illustrate method and path only.
|
|
1115
|
+
"""
|
|
1116
|
+
|
|
1117
|
+
def m(schema: dict) -> dict:
|
|
1118
|
+
schema = dict(schema)
|
|
1119
|
+
servers = schema.get("servers") or [{"url": ""}]
|
|
1120
|
+
base = servers[0].get("url") or ""
|
|
1121
|
+
|
|
1122
|
+
for path, method, op in _iter_ops(schema):
|
|
1123
|
+
# Don't override existing samples
|
|
1124
|
+
if isinstance(op.get("x-codeSamples"), list) and op["x-codeSamples"]:
|
|
1125
|
+
continue
|
|
1126
|
+
url = f"{base}{path}"
|
|
1127
|
+
method_up = method.upper()
|
|
1128
|
+
samples = [
|
|
1129
|
+
{
|
|
1130
|
+
"lang": "bash",
|
|
1131
|
+
"label": "curl",
|
|
1132
|
+
"source": f"curl -X {method_up} '{url}'",
|
|
1133
|
+
},
|
|
1134
|
+
{
|
|
1135
|
+
"lang": "bash",
|
|
1136
|
+
"label": "httpie",
|
|
1137
|
+
"source": f"http {method_up} '{url}'",
|
|
1138
|
+
},
|
|
1139
|
+
]
|
|
1140
|
+
op["x-codeSamples"] = samples
|
|
1141
|
+
return schema
|
|
1142
|
+
|
|
1143
|
+
return m
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
# --- NEW: ensure Problem+JSON examples exist for standard error responses ---
|
|
1147
|
+
def ensure_problem_examples_mutator():
|
|
1148
|
+
"""Add example objects for 4xx/5xx responses using Problem schema if absent."""
|
|
1149
|
+
|
|
1150
|
+
try:
|
|
1151
|
+
# Internal helper with sensible defaults
|
|
1152
|
+
from .conventions import _problem_example
|
|
1153
|
+
except Exception: # pragma: no cover - fallback
|
|
1154
|
+
|
|
1155
|
+
def _problem_example(**kw): # type: ignore
|
|
1156
|
+
base = {
|
|
1157
|
+
"type": "about:blank",
|
|
1158
|
+
"title": "Error",
|
|
1159
|
+
"status": 500,
|
|
1160
|
+
"detail": "An error occurred.",
|
|
1161
|
+
"instance": "/request/trace",
|
|
1162
|
+
"code": "INTERNAL_ERROR",
|
|
1163
|
+
}
|
|
1164
|
+
base.update(kw)
|
|
1165
|
+
return base
|
|
1166
|
+
|
|
1167
|
+
def m(schema: dict) -> dict:
|
|
1168
|
+
schema = dict(schema)
|
|
1169
|
+
for _, _, op in _iter_ops(schema):
|
|
1170
|
+
resps = op.get("responses") or {}
|
|
1171
|
+
for code, resp in resps.items():
|
|
1172
|
+
if not isinstance(resp, dict):
|
|
1173
|
+
continue
|
|
1174
|
+
try:
|
|
1175
|
+
ic = int(code)
|
|
1176
|
+
except Exception:
|
|
1177
|
+
continue
|
|
1178
|
+
if ic < 400:
|
|
1179
|
+
continue
|
|
1180
|
+
# Do not add content if response is a $ref; avoid creating siblings
|
|
1181
|
+
if "$ref" in resp:
|
|
1182
|
+
continue
|
|
1183
|
+
content = resp.setdefault("content", {})
|
|
1184
|
+
# prefer problem+json but also set application/json if present
|
|
1185
|
+
for mt in ("application/problem+json", "application/json"):
|
|
1186
|
+
mt_obj = content.get(mt)
|
|
1187
|
+
if mt_obj is None:
|
|
1188
|
+
# Create a basic media type referencing Problem schema when appropriate
|
|
1189
|
+
if mt == "application/problem+json":
|
|
1190
|
+
mt_obj = {"schema": {"$ref": "#/components/schemas/Problem"}}
|
|
1191
|
+
content[mt] = mt_obj
|
|
1192
|
+
else:
|
|
1193
|
+
continue
|
|
1194
|
+
if not isinstance(mt_obj, dict):
|
|
1195
|
+
continue
|
|
1196
|
+
if "example" in mt_obj or "examples" in mt_obj:
|
|
1197
|
+
continue
|
|
1198
|
+
mt_obj["example"] = _problem_example(status=ic)
|
|
1199
|
+
return schema
|
|
1200
|
+
|
|
1201
|
+
return m
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
# --- NEW: attach default tags from first path segment when missing ---
|
|
1205
|
+
def attach_default_tags_mutator():
|
|
1206
|
+
"""If an operation has no tags, tag it by its first path segment."""
|
|
1207
|
+
|
|
1208
|
+
def m(schema: dict) -> dict:
|
|
1209
|
+
schema = dict(schema)
|
|
1210
|
+
for path, _method, op in _iter_ops(schema):
|
|
1211
|
+
tags = op.get("tags")
|
|
1212
|
+
if tags:
|
|
1213
|
+
continue
|
|
1214
|
+
seg = path.strip("/").split("/", 1)[0] or "root"
|
|
1215
|
+
op["tags"] = [seg]
|
|
1216
|
+
return schema
|
|
1217
|
+
|
|
1218
|
+
return m
|
|
1219
|
+
|
|
1220
|
+
|
|
1105
1221
|
def dedupe_tags_mutator():
|
|
1106
1222
|
def m(schema: dict) -> dict:
|
|
1107
1223
|
schema = dict(schema)
|
|
@@ -1140,7 +1256,7 @@ def scrub_invalid_object_examples_mutator():
|
|
|
1140
1256
|
sch = mt_obj.get("schema")
|
|
1141
1257
|
ex = mt_obj.get("example")
|
|
1142
1258
|
if "example" in mt_obj and _invalid_object_example(
|
|
1143
|
-
sch if isinstance(sch, dict) else {}, ex
|
|
1259
|
+
sch if isinstance(sch, dict) else {}, ex if isinstance(ex, dict) else {}
|
|
1144
1260
|
):
|
|
1145
1261
|
mt_obj.pop("example", None)
|
|
1146
1262
|
|
|
@@ -1269,17 +1385,20 @@ def hardening_components_mutator():
|
|
|
1269
1385
|
},
|
|
1270
1386
|
)
|
|
1271
1387
|
headers.setdefault(
|
|
1272
|
-
"XRateLimitLimit",
|
|
1388
|
+
"XRateLimitLimit",
|
|
1389
|
+
{"schema": {"type": "integer"}, "description": "Tokens in window."},
|
|
1273
1390
|
)
|
|
1274
1391
|
headers.setdefault(
|
|
1275
1392
|
"XRateLimitRemaining",
|
|
1276
1393
|
{"schema": {"type": "integer"}, "description": "Remaining tokens."},
|
|
1277
1394
|
)
|
|
1278
1395
|
headers.setdefault(
|
|
1279
|
-
"XRateLimitReset",
|
|
1396
|
+
"XRateLimitReset",
|
|
1397
|
+
{"schema": {"type": "integer"}, "description": "Unix reset time."},
|
|
1280
1398
|
)
|
|
1281
1399
|
headers.setdefault(
|
|
1282
|
-
"XRequestId",
|
|
1400
|
+
"XRequestId",
|
|
1401
|
+
{"schema": {"type": "string"}, "description": "Correlation id."},
|
|
1283
1402
|
)
|
|
1284
1403
|
headers.setdefault(
|
|
1285
1404
|
"Deprecation",
|
|
@@ -1290,7 +1409,10 @@ def hardening_components_mutator():
|
|
|
1290
1409
|
)
|
|
1291
1410
|
headers.setdefault(
|
|
1292
1411
|
"Sunset",
|
|
1293
|
-
{
|
|
1412
|
+
{
|
|
1413
|
+
"schema": {"type": "string"},
|
|
1414
|
+
"description": "HTTP-date for deprecation sunset.",
|
|
1415
|
+
},
|
|
1294
1416
|
)
|
|
1295
1417
|
return schema
|
|
1296
1418
|
|
|
@@ -1362,14 +1484,16 @@ def attach_header_params_mutator():
|
|
|
1362
1484
|
hdrs.setdefault("Last-Modified", {"$ref": "#/components/headers/LastModified"})
|
|
1363
1485
|
hdrs.setdefault("X-Request-Id", {"$ref": "#/components/headers/XRequestId"})
|
|
1364
1486
|
hdrs.setdefault(
|
|
1365
|
-
"X-RateLimit-Limit",
|
|
1487
|
+
"X-RateLimit-Limit",
|
|
1488
|
+
{"$ref": "#/components/headers/XRateLimitLimit"},
|
|
1366
1489
|
)
|
|
1367
1490
|
hdrs.setdefault(
|
|
1368
1491
|
"X-RateLimit-Remaining",
|
|
1369
1492
|
{"$ref": "#/components/headers/XRateLimitRemaining"},
|
|
1370
1493
|
)
|
|
1371
1494
|
hdrs.setdefault(
|
|
1372
|
-
"X-RateLimit-Reset",
|
|
1495
|
+
"X-RateLimit-Reset",
|
|
1496
|
+
{"$ref": "#/components/headers/XRateLimitReset"},
|
|
1373
1497
|
)
|
|
1374
1498
|
if code == "429":
|
|
1375
1499
|
resp.setdefault("headers", {})["Retry-After"] = {
|
|
@@ -1429,6 +1553,9 @@ def setup_mutators(
|
|
|
1429
1553
|
ensure_media_type_schemas_mutator(),
|
|
1430
1554
|
ensure_examples_for_json_mutator(),
|
|
1431
1555
|
ensure_success_examples_mutator(),
|
|
1556
|
+
attach_default_tags_mutator(),
|
|
1557
|
+
attach_code_samples_mutator(),
|
|
1558
|
+
ensure_problem_examples_mutator(),
|
|
1432
1559
|
ensure_media_examples_mutator(),
|
|
1433
1560
|
scrub_invalid_object_examples_mutator(),
|
|
1434
1561
|
normalize_no_content_204_mutator(),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from collections.abc import Callable
|
|
4
4
|
|
|
5
5
|
from fastapi import FastAPI
|
|
6
6
|
from fastapi.openapi.utils import get_openapi
|
|
@@ -23,4 +23,4 @@ def apply_mutators(app: FastAPI, *mutators):
|
|
|
23
23
|
app.openapi_schema = schema
|
|
24
24
|
return schema
|
|
25
25
|
|
|
26
|
-
app.openapi = patched
|
|
26
|
+
app.openapi = patched # type: ignore[method-assign]
|
|
@@ -1,32 +1,30 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import Dict
|
|
4
|
-
|
|
5
3
|
|
|
6
4
|
def ref(name: str) -> dict:
|
|
7
5
|
return {"$ref": f"#/components/responses/{name}"}
|
|
8
6
|
|
|
9
7
|
|
|
10
|
-
DEFAULT_PUBLIC:
|
|
8
|
+
DEFAULT_PUBLIC: dict[int, dict] = {
|
|
11
9
|
400: ref("BadRequest"),
|
|
12
10
|
422: ref("ValidationError"),
|
|
13
11
|
500: ref("ServerError"),
|
|
14
12
|
}
|
|
15
|
-
DEFAULT_USER:
|
|
13
|
+
DEFAULT_USER: dict[int, dict] = {
|
|
16
14
|
400: ref("BadRequest"),
|
|
17
15
|
401: ref("Unauthorized"),
|
|
18
16
|
403: ref("Forbidden"),
|
|
19
17
|
422: ref("ValidationError"),
|
|
20
18
|
500: ref("ServerError"),
|
|
21
19
|
}
|
|
22
|
-
DEFAULT_SERVICE:
|
|
20
|
+
DEFAULT_SERVICE: dict[int, dict] = {
|
|
23
21
|
400: ref("BadRequest"),
|
|
24
22
|
401: ref("Unauthorized"),
|
|
25
23
|
403: ref("Forbidden"),
|
|
26
24
|
429: ref("TooManyRequests"),
|
|
27
25
|
500: ref("ServerError"),
|
|
28
26
|
}
|
|
29
|
-
DEFAULT_PROTECTED:
|
|
27
|
+
DEFAULT_PROTECTED: dict[int, dict] = {
|
|
30
28
|
400: ref("BadRequest"),
|
|
31
29
|
401: ref("Unauthorized"),
|
|
32
30
|
403: ref("Forbidden"),
|
|
@@ -6,7 +6,7 @@ from .mutators import auth_mutator
|
|
|
6
6
|
from .pipeline import apply_mutators
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
def _normalize_security_list(sec: list | None, *, drop_schemes: set[str] = None) -> list:
|
|
9
|
+
def _normalize_security_list(sec: list | None, *, drop_schemes: set[str] | None = None) -> list:
|
|
10
10
|
if not sec:
|
|
11
11
|
return []
|
|
12
12
|
drop_schemes = drop_schemes or set()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc 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:
|
|
23
|
+
return JSONResponse({"status": "ok"})
|
|
24
|
+
|
|
25
|
+
@router.get("/ready")
|
|
26
|
+
async def ready() -> JSONResponse:
|
|
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:
|
|
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):
|
|
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:
|
|
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"]
|