svc-infra 0.1.706__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/apf_payments/models.py +47 -108
- svc_infra/apf_payments/provider/__init__.py +2 -2
- svc_infra/apf_payments/provider/aiydan.py +42 -100
- svc_infra/apf_payments/provider/base.py +10 -26
- svc_infra/apf_payments/provider/registry.py +3 -5
- svc_infra/apf_payments/provider/stripe.py +63 -135
- svc_infra/apf_payments/schemas.py +82 -90
- svc_infra/apf_payments/service.py +40 -86
- svc_infra/apf_payments/settings.py +10 -13
- svc_infra/api/__init__.py +13 -13
- svc_infra/api/fastapi/__init__.py +19 -0
- svc_infra/api/fastapi/admin/add.py +13 -18
- svc_infra/api/fastapi/apf_payments/router.py +47 -84
- svc_infra/api/fastapi/apf_payments/setup.py +7 -13
- svc_infra/api/fastapi/auth/__init__.py +1 -1
- svc_infra/api/fastapi/auth/_cookies.py +3 -9
- svc_infra/api/fastapi/auth/add.py +4 -8
- svc_infra/api/fastapi/auth/gaurd.py +9 -26
- svc_infra/api/fastapi/auth/mfa/models.py +4 -7
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +3 -3
- svc_infra/api/fastapi/auth/mfa/router.py +9 -15
- svc_infra/api/fastapi/auth/mfa/security.py +3 -5
- svc_infra/api/fastapi/auth/mfa/utils.py +3 -2
- svc_infra/api/fastapi/auth/mfa/verify.py +2 -9
- svc_infra/api/fastapi/auth/providers.py +4 -6
- svc_infra/api/fastapi/auth/routers/apikey_router.py +16 -18
- svc_infra/api/fastapi/auth/routers/oauth_router.py +37 -85
- svc_infra/api/fastapi/auth/routers/session_router.py +3 -6
- svc_infra/api/fastapi/auth/security.py +17 -28
- svc_infra/api/fastapi/auth/sender.py +1 -3
- svc_infra/api/fastapi/auth/settings.py +18 -19
- svc_infra/api/fastapi/auth/state.py +6 -7
- svc_infra/api/fastapi/auth/ws_security.py +2 -2
- svc_infra/api/fastapi/billing/router.py +6 -8
- svc_infra/api/fastapi/db/http.py +10 -11
- svc_infra/api/fastapi/db/nosql/mongo/add.py +5 -15
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +14 -15
- svc_infra/api/fastapi/db/sql/add.py +6 -14
- svc_infra/api/fastapi/db/sql/crud_router.py +27 -40
- svc_infra/api/fastapi/db/sql/health.py +1 -3
- svc_infra/api/fastapi/db/sql/session.py +4 -5
- svc_infra/api/fastapi/db/sql/users.py +8 -11
- svc_infra/api/fastapi/dependencies/ratelimit.py +4 -6
- svc_infra/api/fastapi/docs/add.py +13 -23
- svc_infra/api/fastapi/docs/landing.py +6 -8
- svc_infra/api/fastapi/docs/scoped.py +34 -42
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +12 -21
- svc_infra/api/fastapi/dual/router.py +14 -31
- svc_infra/api/fastapi/ease.py +57 -13
- svc_infra/api/fastapi/http/conditional.py +3 -5
- svc_infra/api/fastapi/middleware/errors/catchall.py +2 -6
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -4
- svc_infra/api/fastapi/middleware/errors/handlers.py +12 -18
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +4 -13
- svc_infra/api/fastapi/middleware/idempotency.py +11 -16
- svc_infra/api/fastapi/middleware/idempotency_store.py +14 -14
- svc_infra/api/fastapi/middleware/optimistic_lock.py +5 -8
- svc_infra/api/fastapi/middleware/ratelimit.py +8 -8
- svc_infra/api/fastapi/middleware/ratelimit_store.py +7 -8
- svc_infra/api/fastapi/middleware/request_id.py +1 -3
- svc_infra/api/fastapi/middleware/timeout.py +9 -10
- svc_infra/api/fastapi/object_router.py +1060 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -6
- svc_infra/api/fastapi/openapi/conventions.py +4 -4
- svc_infra/api/fastapi/openapi/mutators.py +13 -31
- 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 -3
- svc_infra/api/fastapi/ops/add.py +7 -9
- svc_infra/api/fastapi/pagination.py +25 -37
- svc_infra/api/fastapi/routers/__init__.py +16 -38
- svc_infra/api/fastapi/setup.py +13 -31
- svc_infra/api/fastapi/tenancy/add.py +3 -2
- svc_infra/api/fastapi/tenancy/context.py +8 -7
- svc_infra/api/fastapi/versioned.py +3 -2
- svc_infra/app/env.py +5 -7
- svc_infra/app/logging/add.py +2 -1
- svc_infra/app/logging/filter.py +1 -1
- svc_infra/app/logging/formats.py +3 -2
- svc_infra/app/root.py +3 -3
- svc_infra/billing/__init__.py +19 -2
- svc_infra/billing/async_service.py +27 -7
- svc_infra/billing/jobs.py +23 -33
- svc_infra/billing/models.py +21 -52
- svc_infra/billing/quotas.py +5 -7
- svc_infra/billing/schemas.py +4 -6
- svc_infra/cache/__init__.py +12 -5
- svc_infra/cache/add.py +6 -9
- svc_infra/cache/backend.py +6 -5
- svc_infra/cache/decorators.py +17 -28
- svc_infra/cache/keys.py +2 -2
- svc_infra/cache/recache.py +22 -35
- svc_infra/cache/resources.py +8 -16
- svc_infra/cache/ttl.py +2 -3
- svc_infra/cache/utils.py +5 -6
- svc_infra/cli/__init__.py +4 -12
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +11 -10
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +6 -9
- svc_infra/cli/cmds/db/ops_cmds.py +3 -6
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +24 -41
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +9 -17
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +10 -10
- svc_infra/cli/cmds/docs/docs_cmds.py +7 -10
- svc_infra/cli/cmds/dx/dx_cmds.py +5 -11
- svc_infra/cli/cmds/jobs/jobs_cmds.py +2 -7
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -7
- svc_infra/cli/cmds/sdk/sdk_cmds.py +5 -15
- svc_infra/cli/foundation/runner.py +6 -11
- svc_infra/cli/foundation/typer_bootstrap.py +1 -2
- svc_infra/data/__init__.py +83 -0
- svc_infra/data/add.py +5 -5
- svc_infra/data/backup.py +8 -10
- svc_infra/data/erasure.py +3 -2
- svc_infra/data/fixtures.py +3 -3
- svc_infra/data/retention.py +8 -13
- svc_infra/db/crud_schema.py +9 -8
- svc_infra/db/nosql/__init__.py +0 -1
- svc_infra/db/nosql/constants.py +1 -1
- svc_infra/db/nosql/core.py +7 -14
- svc_infra/db/nosql/indexes.py +11 -10
- svc_infra/db/nosql/management.py +3 -3
- svc_infra/db/nosql/mongo/client.py +3 -3
- svc_infra/db/nosql/mongo/settings.py +2 -6
- svc_infra/db/nosql/repository.py +27 -28
- svc_infra/db/nosql/resource.py +15 -20
- svc_infra/db/nosql/scaffold.py +13 -17
- svc_infra/db/nosql/service.py +3 -4
- svc_infra/db/nosql/service_with_hooks.py +4 -3
- svc_infra/db/nosql/types.py +2 -6
- svc_infra/db/nosql/utils.py +4 -4
- svc_infra/db/ops.py +14 -18
- svc_infra/db/outbox.py +15 -18
- svc_infra/db/sql/apikey.py +12 -21
- svc_infra/db/sql/authref.py +3 -7
- svc_infra/db/sql/constants.py +9 -9
- svc_infra/db/sql/core.py +11 -11
- svc_infra/db/sql/management.py +2 -6
- svc_infra/db/sql/repository.py +17 -24
- svc_infra/db/sql/resource.py +14 -13
- svc_infra/db/sql/scaffold.py +13 -17
- svc_infra/db/sql/service.py +7 -16
- svc_infra/db/sql/service_with_hooks.py +4 -3
- svc_infra/db/sql/tenant.py +6 -14
- svc_infra/db/sql/uniq.py +8 -7
- svc_infra/db/sql/uniq_hooks.py +14 -19
- svc_infra/db/sql/utils.py +24 -53
- svc_infra/db/utils.py +3 -3
- svc_infra/deploy/__init__.py +8 -15
- svc_infra/documents/add.py +7 -8
- svc_infra/documents/ease.py +8 -8
- svc_infra/documents/models.py +3 -3
- svc_infra/documents/storage.py +11 -13
- svc_infra/dx/__init__.py +58 -0
- svc_infra/dx/add.py +1 -3
- svc_infra/dx/changelog.py +2 -2
- svc_infra/dx/checks.py +1 -1
- svc_infra/health/__init__.py +15 -16
- svc_infra/http/client.py +10 -14
- svc_infra/jobs/__init__.py +79 -0
- svc_infra/jobs/builtins/outbox_processor.py +3 -5
- svc_infra/jobs/builtins/webhook_delivery.py +1 -3
- svc_infra/jobs/loader.py +4 -5
- svc_infra/jobs/queue.py +14 -24
- svc_infra/jobs/redis_queue.py +20 -34
- svc_infra/jobs/runner.py +7 -11
- svc_infra/jobs/scheduler.py +5 -5
- svc_infra/jobs/worker.py +1 -1
- svc_infra/loaders/base.py +5 -4
- svc_infra/loaders/github.py +1 -3
- svc_infra/loaders/url.py +3 -9
- svc_infra/logging/__init__.py +7 -6
- svc_infra/mcp/__init__.py +82 -0
- svc_infra/mcp/svc_infra_mcp.py +2 -2
- svc_infra/obs/add.py +4 -3
- svc_infra/obs/cloud_dash.py +1 -1
- svc_infra/obs/metrics/__init__.py +3 -3
- svc_infra/obs/metrics/asgi.py +9 -14
- svc_infra/obs/metrics/base.py +13 -13
- svc_infra/obs/metrics/http.py +5 -9
- svc_infra/obs/metrics/sqlalchemy.py +9 -12
- svc_infra/obs/metrics.py +3 -3
- svc_infra/obs/settings.py +2 -6
- 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 +5 -9
- svc_infra/security/audit.py +14 -17
- svc_infra/security/audit_service.py +9 -9
- svc_infra/security/hibp.py +3 -6
- svc_infra/security/jwt_rotation.py +7 -10
- svc_infra/security/lockout.py +12 -11
- svc_infra/security/models.py +37 -46
- svc_infra/security/oauth_models.py +8 -8
- svc_infra/security/org_invites.py +11 -13
- svc_infra/security/passwords.py +4 -6
- svc_infra/security/permissions.py +8 -7
- svc_infra/security/session.py +6 -7
- svc_infra/security/signed_cookies.py +9 -9
- svc_infra/storage/add.py +5 -8
- svc_infra/storage/backends/local.py +13 -21
- svc_infra/storage/backends/memory.py +4 -7
- svc_infra/storage/backends/s3.py +17 -36
- svc_infra/storage/base.py +2 -2
- svc_infra/storage/easy.py +4 -8
- svc_infra/storage/settings.py +16 -18
- svc_infra/testing/__init__.py +36 -39
- svc_infra/utils.py +169 -8
- svc_infra/webhooks/__init__.py +1 -1
- svc_infra/webhooks/add.py +17 -29
- svc_infra/webhooks/encryption.py +2 -2
- svc_infra/webhooks/fastapi.py +2 -4
- svc_infra/webhooks/router.py +3 -3
- svc_infra/webhooks/service.py +5 -6
- svc_infra/webhooks/signing.py +5 -5
- svc_infra/websocket/add.py +2 -3
- svc_infra/websocket/client.py +3 -2
- svc_infra/websocket/config.py +6 -18
- svc_infra/websocket/manager.py +9 -10
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/METADATA +11 -5
- svc_infra-1.1.0.dist-info/RECORD +364 -0
- svc_infra/billing/service.py +0 -123
- svc_infra-0.1.706.dist-info/RECORD +0 -357
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/LICENSE +0 -0
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,1060 @@
|
|
|
1
|
+
"""Object Router — Convert any Python object's methods to FastAPI endpoints.
|
|
2
|
+
|
|
3
|
+
This module provides a generic utility to automatically generate FastAPI router
|
|
4
|
+
endpoints from any Python object's methods. It handles:
|
|
5
|
+
|
|
6
|
+
- Method discovery and filtering
|
|
7
|
+
- HTTP verb inference from method names
|
|
8
|
+
- URL path generation
|
|
9
|
+
- Request/response model generation
|
|
10
|
+
- Exception mapping to HTTP status codes
|
|
11
|
+
- Authentication via svc-infra dual routers
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
>>> from fastapi import FastAPI
|
|
15
|
+
>>> from svc_infra.api.fastapi import router_from_object
|
|
16
|
+
>>>
|
|
17
|
+
>>> class Calculator:
|
|
18
|
+
... def add(self, a: float, b: float) -> float:
|
|
19
|
+
... '''Add two numbers together.'''
|
|
20
|
+
... return a + b
|
|
21
|
+
...
|
|
22
|
+
... def get_history(self) -> list[str]:
|
|
23
|
+
... '''Get calculation history.'''
|
|
24
|
+
... return ["1 + 2 = 3"]
|
|
25
|
+
>>>
|
|
26
|
+
>>> app = FastAPI()
|
|
27
|
+
>>> router = router_from_object(Calculator(), prefix="/calc")
|
|
28
|
+
>>> app.include_router(router)
|
|
29
|
+
>>>
|
|
30
|
+
>>> # POST /calc/add -> {"a": 1, "b": 2} -> 3.0
|
|
31
|
+
>>> # GET /calc/history -> [] -> ["1 + 2 = 3"]
|
|
32
|
+
|
|
33
|
+
For authentication-required endpoints:
|
|
34
|
+
>>> router = router_from_object(MyService(), prefix="/api", auth_required=True)
|
|
35
|
+
|
|
36
|
+
For custom HTTP verbs:
|
|
37
|
+
>>> router = router_from_object(service, methods={"process": "GET"})
|
|
38
|
+
|
|
39
|
+
Note:
|
|
40
|
+
This module uses svc-infra dual routers (not generic APIRouter) following
|
|
41
|
+
the mandatory integration standards from svc-infra AGENTS.md.
|
|
42
|
+
|
|
43
|
+
We intentionally do NOT use `from __future__ import annotations` here
|
|
44
|
+
because FastAPI needs actual type objects (not string annotations) for
|
|
45
|
+
Pydantic model parameter resolution in endpoint handlers.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
import functools
|
|
49
|
+
import inspect
|
|
50
|
+
import logging
|
|
51
|
+
import re
|
|
52
|
+
from collections.abc import Callable
|
|
53
|
+
from typing import Any, TypeVar, get_type_hints
|
|
54
|
+
|
|
55
|
+
from pydantic import BaseModel, create_model
|
|
56
|
+
|
|
57
|
+
logger = logging.getLogger(__name__)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
# Main functions
|
|
62
|
+
"router_from_object",
|
|
63
|
+
"router_from_object_with_websocket",
|
|
64
|
+
# Decorators
|
|
65
|
+
"endpoint",
|
|
66
|
+
"endpoint_exclude",
|
|
67
|
+
"websocket_endpoint",
|
|
68
|
+
# Exception handling
|
|
69
|
+
"map_exception_to_http",
|
|
70
|
+
"DEFAULT_EXCEPTION_MAP",
|
|
71
|
+
"STATUS_TITLES",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Marker for endpoint exclusion
|
|
76
|
+
_ENDPOINT_EXCLUDE_ATTR = "_svc_infra_endpoint_exclude"
|
|
77
|
+
_ENDPOINT_CONFIG_ATTR = "_svc_infra_endpoint_config"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# =============================================================================
|
|
84
|
+
# Decorators
|
|
85
|
+
# =============================================================================
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def endpoint(
|
|
89
|
+
*,
|
|
90
|
+
method: str | None = None,
|
|
91
|
+
path: str | None = None,
|
|
92
|
+
summary: str | None = None,
|
|
93
|
+
description: str | None = None,
|
|
94
|
+
response_model: type | None = None,
|
|
95
|
+
status_code: int | None = None,
|
|
96
|
+
) -> Callable[[F], F]:
|
|
97
|
+
"""Mark a method with custom endpoint configuration.
|
|
98
|
+
|
|
99
|
+
Use this decorator to override the automatic inference for HTTP verb,
|
|
100
|
+
path, summary, or response model.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
method: HTTP verb ("GET", "POST", "PUT", "PATCH", "DELETE").
|
|
104
|
+
path: Custom URL path (overrides auto-generation).
|
|
105
|
+
summary: OpenAPI summary (overrides docstring first line).
|
|
106
|
+
description: OpenAPI description (overrides docstring).
|
|
107
|
+
response_model: Override response model.
|
|
108
|
+
status_code: Override success status code.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Decorator function.
|
|
112
|
+
|
|
113
|
+
Example:
|
|
114
|
+
>>> class Service:
|
|
115
|
+
... @endpoint(method="GET", path="/custom", summary="Custom action")
|
|
116
|
+
... def my_action(self, value: int) -> str:
|
|
117
|
+
... return f"value: {value}"
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def decorator(func: F) -> F:
|
|
121
|
+
config = {
|
|
122
|
+
"method": method,
|
|
123
|
+
"path": path,
|
|
124
|
+
"summary": summary,
|
|
125
|
+
"description": description,
|
|
126
|
+
"response_model": response_model,
|
|
127
|
+
"status_code": status_code,
|
|
128
|
+
}
|
|
129
|
+
# Remove None values
|
|
130
|
+
config = {k: v for k, v in config.items() if v is not None}
|
|
131
|
+
setattr(func, _ENDPOINT_CONFIG_ATTR, config)
|
|
132
|
+
return func
|
|
133
|
+
|
|
134
|
+
return decorator
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def endpoint_exclude(func: F) -> F:
|
|
138
|
+
"""Mark a method to be excluded from router generation.
|
|
139
|
+
|
|
140
|
+
Use this decorator to explicitly exclude a method that would otherwise
|
|
141
|
+
be included in the generated router.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
func: The method to exclude.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
The method unchanged, but marked for exclusion.
|
|
148
|
+
|
|
149
|
+
Example:
|
|
150
|
+
>>> class Service:
|
|
151
|
+
... @endpoint_exclude
|
|
152
|
+
... def internal_helper(self) -> str:
|
|
153
|
+
... return "internal"
|
|
154
|
+
"""
|
|
155
|
+
setattr(func, _ENDPOINT_EXCLUDE_ATTR, True)
|
|
156
|
+
return func
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# Marker for WebSocket endpoints
|
|
160
|
+
_WEBSOCKET_ENDPOINT_ATTR = "_svc_infra_websocket_endpoint"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def websocket_endpoint(
|
|
164
|
+
*,
|
|
165
|
+
path: str | None = None,
|
|
166
|
+
) -> Callable[[F], F]:
|
|
167
|
+
"""Mark a method as a WebSocket endpoint.
|
|
168
|
+
|
|
169
|
+
Use this decorator to indicate a method should be exposed as a WebSocket
|
|
170
|
+
endpoint instead of a regular HTTP endpoint. The method should be an
|
|
171
|
+
async generator or return an async iterator.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
path: Custom URL path (overrides auto-generation).
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Decorator function.
|
|
178
|
+
|
|
179
|
+
Example:
|
|
180
|
+
>>> class Service:
|
|
181
|
+
... @websocket_endpoint(path="/stream")
|
|
182
|
+
... async def stream_data(self, interval: float = 1.0):
|
|
183
|
+
... while True:
|
|
184
|
+
... yield {"timestamp": time.time()}
|
|
185
|
+
... await asyncio.sleep(interval)
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
def decorator(func: F) -> F:
|
|
189
|
+
config = {"path": path}
|
|
190
|
+
config = {k: v for k, v in config.items() if v is not None}
|
|
191
|
+
setattr(func, _WEBSOCKET_ENDPOINT_ATTR, config)
|
|
192
|
+
return func
|
|
193
|
+
|
|
194
|
+
return decorator
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# =============================================================================
|
|
198
|
+
# HTTP Verb Inference
|
|
199
|
+
# =============================================================================
|
|
200
|
+
|
|
201
|
+
# Prefix patterns for HTTP verb inference
|
|
202
|
+
_VERB_PATTERNS: list[tuple[list[str], str]] = [
|
|
203
|
+
(["get_", "list_", "read_", "fetch_", "find_", "search_"], "GET"),
|
|
204
|
+
(["create_", "add_", "insert_", "new_"], "POST"),
|
|
205
|
+
(["update_", "modify_", "edit_", "set_"], "PUT"),
|
|
206
|
+
(["patch_"], "PATCH"),
|
|
207
|
+
(["delete_", "remove_", "destroy_", "drop_"], "DELETE"),
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _infer_http_verb(method_name: str) -> str:
|
|
212
|
+
"""Infer HTTP verb from method name prefix.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
method_name: The method name to analyze.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
HTTP verb string ("GET", "POST", "PUT", "PATCH", "DELETE").
|
|
219
|
+
Defaults to "POST" if no pattern matches.
|
|
220
|
+
"""
|
|
221
|
+
lower_name = method_name.lower()
|
|
222
|
+
for prefixes, verb in _VERB_PATTERNS:
|
|
223
|
+
if any(lower_name.startswith(p) for p in prefixes):
|
|
224
|
+
return verb
|
|
225
|
+
return "POST" # Default for actions
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _strip_verb_prefix(method_name: str) -> str:
|
|
229
|
+
"""Remove HTTP verb prefix from method name.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
method_name: The method name to strip.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Method name without verb prefix.
|
|
236
|
+
"""
|
|
237
|
+
lower_name = method_name.lower()
|
|
238
|
+
for prefixes, _ in _VERB_PATTERNS:
|
|
239
|
+
for prefix in prefixes:
|
|
240
|
+
if lower_name.startswith(prefix):
|
|
241
|
+
return method_name[len(prefix) :]
|
|
242
|
+
return method_name
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# =============================================================================
|
|
246
|
+
# Path Generation
|
|
247
|
+
# =============================================================================
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _to_kebab_case(name: str) -> str:
|
|
251
|
+
"""Convert snake_case or camelCase to kebab-case.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
name: The name to convert.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Name in kebab-case.
|
|
258
|
+
|
|
259
|
+
Examples:
|
|
260
|
+
>>> _to_kebab_case("process_payment")
|
|
261
|
+
'process-payment'
|
|
262
|
+
>>> _to_kebab_case("processPayment")
|
|
263
|
+
'process-payment'
|
|
264
|
+
>>> _to_kebab_case("HTTPClient")
|
|
265
|
+
'http-client'
|
|
266
|
+
"""
|
|
267
|
+
# Handle camelCase and PascalCase
|
|
268
|
+
name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1-\2", name)
|
|
269
|
+
name = re.sub(r"([a-z\d])([A-Z])", r"\1-\2", name)
|
|
270
|
+
# Handle snake_case
|
|
271
|
+
name = name.replace("_", "-")
|
|
272
|
+
return name.lower()
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _generate_path(method_name: str) -> str:
|
|
276
|
+
"""Generate URL path from method name.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
method_name: The method name to convert.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
URL path string (without leading slash).
|
|
283
|
+
|
|
284
|
+
Examples:
|
|
285
|
+
>>> _generate_path("get_user")
|
|
286
|
+
'user'
|
|
287
|
+
>>> _generate_path("create_order")
|
|
288
|
+
'order'
|
|
289
|
+
>>> _generate_path("process_payment")
|
|
290
|
+
'process-payment'
|
|
291
|
+
"""
|
|
292
|
+
stripped = _strip_verb_prefix(method_name)
|
|
293
|
+
if not stripped:
|
|
294
|
+
stripped = method_name
|
|
295
|
+
return _to_kebab_case(stripped)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _generate_path_with_params(
|
|
299
|
+
method_name: str,
|
|
300
|
+
method: Callable,
|
|
301
|
+
path_params: list[str] | None = None,
|
|
302
|
+
) -> tuple[str, list[str]]:
|
|
303
|
+
"""Generate URL path with path parameters from method signature.
|
|
304
|
+
|
|
305
|
+
Detects path parameters from method arguments. Arguments ending with
|
|
306
|
+
'_id' or named 'id' are treated as path parameters.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
method_name: The method name to convert.
|
|
310
|
+
method: The method to inspect for parameters.
|
|
311
|
+
path_params: Explicit list of parameter names to include in path.
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
Tuple of (path_string, list_of_path_param_names).
|
|
315
|
+
|
|
316
|
+
Examples:
|
|
317
|
+
>>> def get_user(self, user_id: str) -> User: ...
|
|
318
|
+
>>> _generate_path_with_params("get_user", get_user)
|
|
319
|
+
('user/{user_id}', ['user_id'])
|
|
320
|
+
|
|
321
|
+
>>> def get_order_item(self, order_id: str, item_id: str) -> Item: ...
|
|
322
|
+
>>> _generate_path_with_params("get_order_item", get_order_item)
|
|
323
|
+
('order-item/{order_id}/{item_id}', ['order_id', 'item_id'])
|
|
324
|
+
"""
|
|
325
|
+
base_path = _generate_path(method_name)
|
|
326
|
+
|
|
327
|
+
# Get method parameters
|
|
328
|
+
try:
|
|
329
|
+
sig = inspect.signature(method)
|
|
330
|
+
except (ValueError, TypeError):
|
|
331
|
+
return base_path, []
|
|
332
|
+
|
|
333
|
+
# Detect path parameters
|
|
334
|
+
detected_params: list[str] = []
|
|
335
|
+
for param_name, param in sig.parameters.items():
|
|
336
|
+
if param_name == "self":
|
|
337
|
+
continue
|
|
338
|
+
|
|
339
|
+
# Check if explicitly marked as path param
|
|
340
|
+
if path_params and param_name in path_params:
|
|
341
|
+
detected_params.append(param_name)
|
|
342
|
+
continue
|
|
343
|
+
|
|
344
|
+
# Auto-detect: parameters ending with _id or named 'id'
|
|
345
|
+
if path_params is None: # Only auto-detect if not explicitly provided
|
|
346
|
+
if param_name == "id" or param_name.endswith("_id"):
|
|
347
|
+
detected_params.append(param_name)
|
|
348
|
+
|
|
349
|
+
if not detected_params:
|
|
350
|
+
return base_path, []
|
|
351
|
+
|
|
352
|
+
# Build path with parameters
|
|
353
|
+
path_suffix = "/".join(f"{{{p}}}" for p in detected_params)
|
|
354
|
+
full_path = f"{base_path}/{path_suffix}"
|
|
355
|
+
|
|
356
|
+
return full_path, detected_params
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# =============================================================================
|
|
360
|
+
# Method Discovery
|
|
361
|
+
# =============================================================================
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _get_method_candidates(obj: Any) -> list[tuple[str, Callable]]:
|
|
365
|
+
"""Get all callable methods from an object.
|
|
366
|
+
|
|
367
|
+
Excludes dunder methods (__*__).
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
obj: The object to inspect.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
List of (name, method) tuples.
|
|
374
|
+
"""
|
|
375
|
+
candidates = []
|
|
376
|
+
for name in dir(obj):
|
|
377
|
+
# Skip dunder methods
|
|
378
|
+
if name.startswith("__") and name.endswith("__"):
|
|
379
|
+
continue
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
attr = getattr(obj, name)
|
|
383
|
+
except AttributeError:
|
|
384
|
+
continue
|
|
385
|
+
|
|
386
|
+
if callable(attr) and not isinstance(attr, type):
|
|
387
|
+
candidates.append((name, attr))
|
|
388
|
+
|
|
389
|
+
return candidates
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _filter_methods(
|
|
393
|
+
candidates: list[tuple[str, Callable]],
|
|
394
|
+
*,
|
|
395
|
+
methods: dict[str, str] | None = None,
|
|
396
|
+
exclude: list[str] | None = None,
|
|
397
|
+
include_private: bool = False,
|
|
398
|
+
) -> list[tuple[str, Callable]]:
|
|
399
|
+
"""Filter method candidates.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
candidates: List of (name, method) tuples.
|
|
403
|
+
methods: If provided, only include methods with keys in this dict.
|
|
404
|
+
exclude: Methods to exclude.
|
|
405
|
+
include_private: Include _underscore methods.
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
Filtered list of (name, method) tuples.
|
|
409
|
+
"""
|
|
410
|
+
exclude = exclude or []
|
|
411
|
+
result = []
|
|
412
|
+
|
|
413
|
+
for name, method in candidates:
|
|
414
|
+
# Check @endpoint_exclude decorator
|
|
415
|
+
if getattr(method, _ENDPOINT_EXCLUDE_ATTR, False):
|
|
416
|
+
continue
|
|
417
|
+
|
|
418
|
+
# Skip private methods (unless include_private)
|
|
419
|
+
if name.startswith("_") and not include_private:
|
|
420
|
+
continue
|
|
421
|
+
|
|
422
|
+
# If methods dict provided, only include those
|
|
423
|
+
if methods is not None and name not in methods:
|
|
424
|
+
continue
|
|
425
|
+
|
|
426
|
+
# Check exclude list
|
|
427
|
+
if name in exclude:
|
|
428
|
+
continue
|
|
429
|
+
|
|
430
|
+
result.append((name, method))
|
|
431
|
+
|
|
432
|
+
return result
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
# =============================================================================
|
|
436
|
+
# Request/Response Model Generation
|
|
437
|
+
# =============================================================================
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _create_request_model(
|
|
441
|
+
method: Callable,
|
|
442
|
+
method_name: str,
|
|
443
|
+
class_name: str,
|
|
444
|
+
) -> type[BaseModel] | None:
|
|
445
|
+
"""Create a Pydantic request model from method signature.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
method: The method to analyze.
|
|
449
|
+
method_name: Name of the method.
|
|
450
|
+
class_name: Name of the class (for model naming).
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
A Pydantic model class, or None if no parameters.
|
|
454
|
+
"""
|
|
455
|
+
try:
|
|
456
|
+
sig = inspect.signature(method)
|
|
457
|
+
hints = get_type_hints(method)
|
|
458
|
+
except (ValueError, TypeError):
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
fields: dict[str, Any] = {}
|
|
462
|
+
for param_name, param in sig.parameters.items():
|
|
463
|
+
# Skip 'self'
|
|
464
|
+
if param_name == "self":
|
|
465
|
+
continue
|
|
466
|
+
|
|
467
|
+
# Get type hint or default to Any
|
|
468
|
+
param_type = hints.get(param_name, Any)
|
|
469
|
+
|
|
470
|
+
# Handle default value
|
|
471
|
+
if param.default is not inspect.Parameter.empty:
|
|
472
|
+
fields[param_name] = (param_type, param.default)
|
|
473
|
+
else:
|
|
474
|
+
fields[param_name] = (param_type, ...)
|
|
475
|
+
|
|
476
|
+
if not fields:
|
|
477
|
+
return None
|
|
478
|
+
|
|
479
|
+
# Create a descriptive model name
|
|
480
|
+
model_name = f"{class_name}_{method_name.title().replace('_', '')}Request"
|
|
481
|
+
|
|
482
|
+
return create_model(model_name, **fields) # type: ignore[call-overload]
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _get_response_type(method: Callable) -> type | None:
|
|
486
|
+
"""Get the return type of a method.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
method: The method to analyze.
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
The return type, or None if not annotated.
|
|
493
|
+
"""
|
|
494
|
+
try:
|
|
495
|
+
hints = get_type_hints(method)
|
|
496
|
+
return hints.get("return")
|
|
497
|
+
except (ValueError, TypeError):
|
|
498
|
+
return None
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
# =============================================================================
|
|
502
|
+
# Exception Handling
|
|
503
|
+
# =============================================================================
|
|
504
|
+
|
|
505
|
+
# Default exception to HTTP status mapping (public for customization)
|
|
506
|
+
DEFAULT_EXCEPTION_MAP: dict[type[Exception], int] = {
|
|
507
|
+
ValueError: 400,
|
|
508
|
+
TypeError: 400,
|
|
509
|
+
KeyError: 404,
|
|
510
|
+
LookupError: 404,
|
|
511
|
+
PermissionError: 403,
|
|
512
|
+
TimeoutError: 504,
|
|
513
|
+
NotImplementedError: 501,
|
|
514
|
+
ConnectionError: 503,
|
|
515
|
+
OSError: 500,
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
# Keep private alias for backwards compatibility
|
|
519
|
+
_DEFAULT_EXCEPTION_MAP = DEFAULT_EXCEPTION_MAP
|
|
520
|
+
|
|
521
|
+
# Status code to title mapping (public for customization)
|
|
522
|
+
STATUS_TITLES: dict[int, str] = {
|
|
523
|
+
400: "Validation Error",
|
|
524
|
+
401: "Unauthorized",
|
|
525
|
+
403: "Forbidden",
|
|
526
|
+
404: "Not Found",
|
|
527
|
+
405: "Method Not Allowed",
|
|
528
|
+
409: "Conflict",
|
|
529
|
+
422: "Unprocessable Entity",
|
|
530
|
+
429: "Too Many Requests",
|
|
531
|
+
500: "Internal Error",
|
|
532
|
+
501: "Not Implemented",
|
|
533
|
+
502: "Bad Gateway",
|
|
534
|
+
503: "Service Unavailable",
|
|
535
|
+
504: "Gateway Timeout",
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def map_exception_to_http(
|
|
540
|
+
exc: Exception,
|
|
541
|
+
custom_handlers: dict[type[Exception], int] | None = None,
|
|
542
|
+
) -> tuple[int, str, str]:
|
|
543
|
+
"""Map an exception to HTTP status code, title, and detail.
|
|
544
|
+
|
|
545
|
+
This is a public utility for mapping exceptions to HTTP responses.
|
|
546
|
+
Can be used standalone outside of router_from_object.
|
|
547
|
+
|
|
548
|
+
Args:
|
|
549
|
+
exc: The exception to map.
|
|
550
|
+
custom_handlers: Custom exception to HTTP status mapping.
|
|
551
|
+
|
|
552
|
+
Returns:
|
|
553
|
+
Tuple of (status_code, title, detail).
|
|
554
|
+
|
|
555
|
+
Example:
|
|
556
|
+
>>> status, title, detail = map_exception_to_http(ValueError("bad input"))
|
|
557
|
+
>>> print(status, title)
|
|
558
|
+
400 Validation Error
|
|
559
|
+
"""
|
|
560
|
+
handlers = {**DEFAULT_EXCEPTION_MAP, **(custom_handlers or {})}
|
|
561
|
+
|
|
562
|
+
# Check for exact type match
|
|
563
|
+
exc_type = type(exc)
|
|
564
|
+
if exc_type in handlers:
|
|
565
|
+
status = handlers[exc_type]
|
|
566
|
+
else:
|
|
567
|
+
# Check for subclass match
|
|
568
|
+
status = 500
|
|
569
|
+
for exc_class, exc_status in handlers.items():
|
|
570
|
+
if isinstance(exc, exc_class):
|
|
571
|
+
status = exc_status
|
|
572
|
+
break
|
|
573
|
+
|
|
574
|
+
title = STATUS_TITLES.get(status, "Error")
|
|
575
|
+
detail = str(exc)
|
|
576
|
+
|
|
577
|
+
return status, title, detail
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _create_exception_handler(
|
|
581
|
+
custom_handlers: dict[type[Exception], int] | None = None,
|
|
582
|
+
) -> Callable[[Exception], None]:
|
|
583
|
+
"""Create an exception handler function.
|
|
584
|
+
|
|
585
|
+
Args:
|
|
586
|
+
custom_handlers: Custom exception to HTTP status mapping.
|
|
587
|
+
|
|
588
|
+
Returns:
|
|
589
|
+
A function that raises FastApiException for exceptions.
|
|
590
|
+
"""
|
|
591
|
+
|
|
592
|
+
def handle_exception(exc: Exception) -> None:
|
|
593
|
+
"""Map exception to FastApiException and raise it."""
|
|
594
|
+
status, title, detail = map_exception_to_http(exc, custom_handlers)
|
|
595
|
+
|
|
596
|
+
try:
|
|
597
|
+
from svc_infra.exceptions import FastApiException
|
|
598
|
+
|
|
599
|
+
raise FastApiException(
|
|
600
|
+
title=title,
|
|
601
|
+
detail=detail,
|
|
602
|
+
status_code=status,
|
|
603
|
+
code=type(exc).__name__.upper(),
|
|
604
|
+
) from exc
|
|
605
|
+
except ImportError:
|
|
606
|
+
from fastapi import HTTPException
|
|
607
|
+
|
|
608
|
+
raise HTTPException(status_code=status, detail=detail) from exc
|
|
609
|
+
|
|
610
|
+
return handle_exception
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
# =============================================================================
|
|
614
|
+
# Endpoint Creation
|
|
615
|
+
# =============================================================================
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _create_endpoint(
|
|
619
|
+
method: Callable,
|
|
620
|
+
method_name: str,
|
|
621
|
+
class_name: str,
|
|
622
|
+
http_verb: str,
|
|
623
|
+
path: str,
|
|
624
|
+
request_model: type[BaseModel] | None,
|
|
625
|
+
response_type: type | None,
|
|
626
|
+
exception_handler: Callable[[Exception], None],
|
|
627
|
+
summary: str | None = None,
|
|
628
|
+
description: str | None = None,
|
|
629
|
+
) -> tuple[Callable, dict[str, Any]]:
|
|
630
|
+
"""Create a FastAPI endpoint function.
|
|
631
|
+
|
|
632
|
+
Args:
|
|
633
|
+
method: The original method.
|
|
634
|
+
method_name: Name of the method.
|
|
635
|
+
class_name: Name of the class.
|
|
636
|
+
http_verb: HTTP verb for the endpoint.
|
|
637
|
+
path: URL path for the endpoint.
|
|
638
|
+
request_model: Pydantic model for request body.
|
|
639
|
+
response_type: Return type for response.
|
|
640
|
+
exception_handler: Function to handle exceptions.
|
|
641
|
+
summary: OpenAPI summary.
|
|
642
|
+
description: OpenAPI description.
|
|
643
|
+
|
|
644
|
+
Returns:
|
|
645
|
+
Tuple of (endpoint_function, route_kwargs).
|
|
646
|
+
"""
|
|
647
|
+
# Determine if method is async
|
|
648
|
+
is_async = inspect.iscoroutinefunction(method)
|
|
649
|
+
|
|
650
|
+
# Get docstring for OpenAPI
|
|
651
|
+
docstring = method.__doc__ or ""
|
|
652
|
+
if not summary:
|
|
653
|
+
summary = docstring.split("\n")[0].strip() if docstring else method_name
|
|
654
|
+
if not description:
|
|
655
|
+
description = docstring
|
|
656
|
+
|
|
657
|
+
# Build route kwargs
|
|
658
|
+
route_kwargs: dict[str, Any] = {
|
|
659
|
+
"summary": summary,
|
|
660
|
+
"description": description,
|
|
661
|
+
}
|
|
662
|
+
if response_type:
|
|
663
|
+
route_kwargs["response_model"] = response_type
|
|
664
|
+
|
|
665
|
+
if http_verb == "GET":
|
|
666
|
+
# For GET, parameters become query params
|
|
667
|
+
if is_async:
|
|
668
|
+
|
|
669
|
+
@functools.wraps(method)
|
|
670
|
+
async def get_endpoint(**kwargs: Any) -> Any:
|
|
671
|
+
try:
|
|
672
|
+
return await method(**kwargs)
|
|
673
|
+
except Exception as e:
|
|
674
|
+
exception_handler(e)
|
|
675
|
+
return None # Never reached
|
|
676
|
+
|
|
677
|
+
else:
|
|
678
|
+
|
|
679
|
+
@functools.wraps(method)
|
|
680
|
+
async def get_endpoint(**kwargs: Any) -> Any:
|
|
681
|
+
try:
|
|
682
|
+
return method(**kwargs)
|
|
683
|
+
except Exception as e:
|
|
684
|
+
exception_handler(e)
|
|
685
|
+
return None
|
|
686
|
+
|
|
687
|
+
# Preserve signature for FastAPI
|
|
688
|
+
sig = inspect.signature(method)
|
|
689
|
+
params = [p for p in sig.parameters.values() if p.name != "self"]
|
|
690
|
+
get_endpoint.__signature__ = sig.replace(parameters=params) # type: ignore[attr-defined]
|
|
691
|
+
|
|
692
|
+
return get_endpoint, route_kwargs
|
|
693
|
+
|
|
694
|
+
else:
|
|
695
|
+
# For POST/PUT/PATCH/DELETE, use request body
|
|
696
|
+
if request_model:
|
|
697
|
+
if is_async:
|
|
698
|
+
|
|
699
|
+
@functools.wraps(method)
|
|
700
|
+
async def body_endpoint(request: request_model) -> Any: # type: ignore[valid-type]
|
|
701
|
+
try:
|
|
702
|
+
return await method(**request.model_dump()) # type: ignore[attr-defined]
|
|
703
|
+
except Exception as e:
|
|
704
|
+
exception_handler(e)
|
|
705
|
+
return None
|
|
706
|
+
|
|
707
|
+
else:
|
|
708
|
+
|
|
709
|
+
@functools.wraps(method)
|
|
710
|
+
async def body_endpoint(request: request_model) -> Any: # type: ignore[valid-type]
|
|
711
|
+
try:
|
|
712
|
+
return method(**request.model_dump()) # type: ignore[attr-defined]
|
|
713
|
+
except Exception as e:
|
|
714
|
+
exception_handler(e)
|
|
715
|
+
return None
|
|
716
|
+
|
|
717
|
+
return body_endpoint, route_kwargs
|
|
718
|
+
|
|
719
|
+
else:
|
|
720
|
+
# No parameters
|
|
721
|
+
if is_async:
|
|
722
|
+
|
|
723
|
+
@functools.wraps(method)
|
|
724
|
+
async def no_param_endpoint() -> Any:
|
|
725
|
+
try:
|
|
726
|
+
return await method()
|
|
727
|
+
except Exception as e:
|
|
728
|
+
exception_handler(e)
|
|
729
|
+
return None
|
|
730
|
+
|
|
731
|
+
else:
|
|
732
|
+
|
|
733
|
+
@functools.wraps(method)
|
|
734
|
+
async def no_param_endpoint() -> Any:
|
|
735
|
+
try:
|
|
736
|
+
return method()
|
|
737
|
+
except Exception as e:
|
|
738
|
+
exception_handler(e)
|
|
739
|
+
return None
|
|
740
|
+
|
|
741
|
+
return no_param_endpoint, route_kwargs
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
# =============================================================================
|
|
745
|
+
# Main Function
|
|
746
|
+
# =============================================================================
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def router_from_object(
|
|
750
|
+
obj: Any,
|
|
751
|
+
*,
|
|
752
|
+
methods: dict[str, str] | None = None,
|
|
753
|
+
exclude: list[str] | None = None,
|
|
754
|
+
prefix: str = "",
|
|
755
|
+
tags: list[str] | None = None,
|
|
756
|
+
auth_required: bool = False,
|
|
757
|
+
include_private: bool = False,
|
|
758
|
+
exception_handlers: dict[type[Exception], int] | None = None,
|
|
759
|
+
) -> Any:
|
|
760
|
+
"""Convert a Python object's methods into a FastAPI router.
|
|
761
|
+
|
|
762
|
+
Discovers callable methods on the object and creates corresponding
|
|
763
|
+
FastAPI endpoints with automatic HTTP verb inference, path generation,
|
|
764
|
+
and request/response model creation.
|
|
765
|
+
|
|
766
|
+
Args:
|
|
767
|
+
obj: The object whose methods become endpoints.
|
|
768
|
+
methods: Override HTTP verb for specific methods. Keys are method
|
|
769
|
+
names, values are HTTP verbs ("GET", "POST", etc.). If provided,
|
|
770
|
+
only methods in this dict are included.
|
|
771
|
+
exclude: Methods to exclude from the router.
|
|
772
|
+
prefix: URL prefix for all endpoints.
|
|
773
|
+
tags: OpenAPI tags (defaults to class name).
|
|
774
|
+
auth_required: If True, uses user_router (JWT auth required).
|
|
775
|
+
If False, uses public_router (no auth).
|
|
776
|
+
include_private: Include methods starting with _ (excluded by default).
|
|
777
|
+
exception_handlers: Custom exception to HTTP status mapping.
|
|
778
|
+
|
|
779
|
+
Returns:
|
|
780
|
+
A DualAPIRouter instance from svc-infra.
|
|
781
|
+
|
|
782
|
+
Raises:
|
|
783
|
+
ImportError: If FastAPI or svc-infra is not installed.
|
|
784
|
+
|
|
785
|
+
Example:
|
|
786
|
+
>>> class Calculator:
|
|
787
|
+
... def add(self, a: float, b: float) -> float:
|
|
788
|
+
... return a + b
|
|
789
|
+
...
|
|
790
|
+
... def get_history(self) -> list[str]:
|
|
791
|
+
... return []
|
|
792
|
+
>>>
|
|
793
|
+
>>> router = router_from_object(Calculator(), prefix="/calc")
|
|
794
|
+
>>> # Creates:
|
|
795
|
+
>>> # POST /calc/add -> {"a": 1, "b": 2} -> 3.0
|
|
796
|
+
>>> # GET /calc/history -> [] -> []
|
|
797
|
+
"""
|
|
798
|
+
class_name = type(obj).__name__
|
|
799
|
+
default_tags = tags or [class_name]
|
|
800
|
+
|
|
801
|
+
# Create router using svc-infra dual routers (MANDATORY per AGENTS.md)
|
|
802
|
+
router: Any # Can be DualAPIRouter or APIRouter depending on availability
|
|
803
|
+
try:
|
|
804
|
+
if auth_required:
|
|
805
|
+
from svc_infra.api.fastapi.dual import user_router
|
|
806
|
+
|
|
807
|
+
router = user_router(prefix=prefix, tags=default_tags)
|
|
808
|
+
else:
|
|
809
|
+
from svc_infra.api.fastapi.dual import public_router
|
|
810
|
+
|
|
811
|
+
router = public_router(prefix=prefix, tags=default_tags)
|
|
812
|
+
except ImportError:
|
|
813
|
+
logger.warning(
|
|
814
|
+
"svc-infra dual routers not available, using generic APIRouter. "
|
|
815
|
+
"Install svc-infra for proper dual router support."
|
|
816
|
+
)
|
|
817
|
+
from fastapi import APIRouter
|
|
818
|
+
|
|
819
|
+
router = APIRouter(prefix=prefix, tags=default_tags) # type: ignore[arg-type]
|
|
820
|
+
|
|
821
|
+
# Create exception handler
|
|
822
|
+
exception_handler = _create_exception_handler(exception_handlers)
|
|
823
|
+
|
|
824
|
+
# Discover and filter methods
|
|
825
|
+
candidates = _get_method_candidates(obj)
|
|
826
|
+
filtered = _filter_methods(
|
|
827
|
+
candidates,
|
|
828
|
+
methods=methods,
|
|
829
|
+
exclude=exclude,
|
|
830
|
+
include_private=include_private,
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
# Create endpoints for each method
|
|
834
|
+
for method_name, method in filtered:
|
|
835
|
+
# Check for @endpoint decorator config
|
|
836
|
+
config = getattr(method, _ENDPOINT_CONFIG_ATTR, {})
|
|
837
|
+
|
|
838
|
+
# Determine HTTP verb
|
|
839
|
+
if methods and method_name in methods:
|
|
840
|
+
http_verb = methods[method_name].upper()
|
|
841
|
+
elif "method" in config:
|
|
842
|
+
http_verb = config["method"].upper()
|
|
843
|
+
else:
|
|
844
|
+
http_verb = _infer_http_verb(method_name)
|
|
845
|
+
|
|
846
|
+
# Determine path
|
|
847
|
+
if "path" in config:
|
|
848
|
+
path = config["path"]
|
|
849
|
+
else:
|
|
850
|
+
path = "/" + _generate_path(method_name)
|
|
851
|
+
|
|
852
|
+
# Create request/response models
|
|
853
|
+
request_model = _create_request_model(method, method_name, class_name)
|
|
854
|
+
response_type = config.get("response_model") or _get_response_type(method)
|
|
855
|
+
|
|
856
|
+
# Get summary/description from config
|
|
857
|
+
summary = config.get("summary")
|
|
858
|
+
description = config.get("description")
|
|
859
|
+
|
|
860
|
+
# Create endpoint
|
|
861
|
+
endpoint_func, route_kwargs = _create_endpoint(
|
|
862
|
+
method=method,
|
|
863
|
+
method_name=method_name,
|
|
864
|
+
class_name=class_name,
|
|
865
|
+
http_verb=http_verb,
|
|
866
|
+
path=path,
|
|
867
|
+
request_model=request_model,
|
|
868
|
+
response_type=response_type,
|
|
869
|
+
exception_handler=exception_handler,
|
|
870
|
+
summary=summary,
|
|
871
|
+
description=description,
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
# Add status code from config
|
|
875
|
+
if "status_code" in config:
|
|
876
|
+
route_kwargs["status_code"] = config["status_code"]
|
|
877
|
+
|
|
878
|
+
# Register the route
|
|
879
|
+
route_decorator = getattr(router, http_verb.lower())
|
|
880
|
+
route_decorator(path, **route_kwargs)(endpoint_func)
|
|
881
|
+
|
|
882
|
+
logger.debug(
|
|
883
|
+
"Created %s %s%s for %s.%s",
|
|
884
|
+
http_verb,
|
|
885
|
+
prefix,
|
|
886
|
+
path,
|
|
887
|
+
class_name,
|
|
888
|
+
method_name,
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
logger.info(
|
|
892
|
+
"Created router for %s with %d endpoints (prefix='%s', auth=%s)",
|
|
893
|
+
class_name,
|
|
894
|
+
len(filtered),
|
|
895
|
+
prefix,
|
|
896
|
+
auth_required,
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
return router
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
# =============================================================================
|
|
903
|
+
# WebSocket Router Generation
|
|
904
|
+
# =============================================================================
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def _get_websocket_methods(obj: Any) -> list[tuple[str, Callable, dict]]:
|
|
908
|
+
"""Get methods marked as WebSocket endpoints.
|
|
909
|
+
|
|
910
|
+
Args:
|
|
911
|
+
obj: The object to inspect.
|
|
912
|
+
|
|
913
|
+
Returns:
|
|
914
|
+
List of (name, method, config) tuples for WebSocket methods.
|
|
915
|
+
"""
|
|
916
|
+
websocket_methods = []
|
|
917
|
+
for name in dir(obj):
|
|
918
|
+
if name.startswith("__") and name.endswith("__"):
|
|
919
|
+
continue
|
|
920
|
+
|
|
921
|
+
try:
|
|
922
|
+
attr = getattr(obj, name)
|
|
923
|
+
except AttributeError:
|
|
924
|
+
continue
|
|
925
|
+
|
|
926
|
+
if callable(attr) and hasattr(attr, _WEBSOCKET_ENDPOINT_ATTR):
|
|
927
|
+
config = getattr(attr, _WEBSOCKET_ENDPOINT_ATTR, {})
|
|
928
|
+
websocket_methods.append((name, attr, config))
|
|
929
|
+
|
|
930
|
+
return websocket_methods
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def router_from_object_with_websocket(
|
|
934
|
+
obj: Any,
|
|
935
|
+
*,
|
|
936
|
+
methods: dict[str, str] | None = None,
|
|
937
|
+
exclude: list[str] | None = None,
|
|
938
|
+
prefix: str = "",
|
|
939
|
+
tags: list[str] | None = None,
|
|
940
|
+
auth_required: bool = False,
|
|
941
|
+
include_private: bool = False,
|
|
942
|
+
exception_handlers: dict[type[Exception], int] | None = None,
|
|
943
|
+
) -> tuple[Any, Any]:
|
|
944
|
+
"""Convert object methods to FastAPI router including WebSocket endpoints.
|
|
945
|
+
|
|
946
|
+
This is an extended version of router_from_object that also creates
|
|
947
|
+
a separate WebSocket router for methods marked with @websocket_endpoint.
|
|
948
|
+
|
|
949
|
+
Args:
|
|
950
|
+
obj: The object whose methods become endpoints.
|
|
951
|
+
methods: Override HTTP verb for specific methods.
|
|
952
|
+
exclude: Methods to exclude from the router.
|
|
953
|
+
prefix: URL prefix for all endpoints.
|
|
954
|
+
tags: OpenAPI tags (defaults to class name).
|
|
955
|
+
auth_required: If True, uses authenticated routers.
|
|
956
|
+
include_private: Include methods starting with _.
|
|
957
|
+
exception_handlers: Custom exception to HTTP status mapping.
|
|
958
|
+
|
|
959
|
+
Returns:
|
|
960
|
+
Tuple of (http_router, websocket_router).
|
|
961
|
+
|
|
962
|
+
Example:
|
|
963
|
+
>>> class StreamService:
|
|
964
|
+
... def get_status(self) -> dict:
|
|
965
|
+
... return {"status": "ok"}
|
|
966
|
+
...
|
|
967
|
+
... @websocket_endpoint(path="/stream")
|
|
968
|
+
... async def stream_data(self):
|
|
969
|
+
... while True:
|
|
970
|
+
... yield {"data": "..."}
|
|
971
|
+
>>>
|
|
972
|
+
>>> http_router, ws_router = router_from_object_with_websocket(
|
|
973
|
+
... StreamService(), prefix="/api"
|
|
974
|
+
... )
|
|
975
|
+
>>> app.include_router(http_router)
|
|
976
|
+
>>> app.include_router(ws_router)
|
|
977
|
+
"""
|
|
978
|
+
# Create HTTP router
|
|
979
|
+
http_router = router_from_object(
|
|
980
|
+
obj,
|
|
981
|
+
methods=methods,
|
|
982
|
+
exclude=exclude,
|
|
983
|
+
prefix=prefix,
|
|
984
|
+
tags=tags,
|
|
985
|
+
auth_required=auth_required,
|
|
986
|
+
include_private=include_private,
|
|
987
|
+
exception_handlers=exception_handlers,
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
class_name = type(obj).__name__
|
|
991
|
+
default_tags = tags or [class_name]
|
|
992
|
+
|
|
993
|
+
# Create WebSocket router using svc-infra dual routers
|
|
994
|
+
ws_router: Any # Can be DualAPIRouter or APIRouter depending on availability
|
|
995
|
+
try:
|
|
996
|
+
if auth_required:
|
|
997
|
+
from svc_infra.api.fastapi.dual import ws_protected_router
|
|
998
|
+
|
|
999
|
+
ws_router = ws_protected_router(prefix=prefix, tags=default_tags)
|
|
1000
|
+
else:
|
|
1001
|
+
from svc_infra.api.fastapi.dual import ws_public_router
|
|
1002
|
+
|
|
1003
|
+
ws_router = ws_public_router(prefix=prefix, tags=default_tags)
|
|
1004
|
+
except ImportError:
|
|
1005
|
+
logger.warning("svc-infra WebSocket routers not available, using generic APIRouter.")
|
|
1006
|
+
from fastapi import APIRouter
|
|
1007
|
+
|
|
1008
|
+
ws_router = APIRouter(prefix=prefix, tags=default_tags) # type: ignore[arg-type]
|
|
1009
|
+
|
|
1010
|
+
# Get WebSocket methods
|
|
1011
|
+
ws_methods = _get_websocket_methods(obj)
|
|
1012
|
+
|
|
1013
|
+
for method_name, method, config in ws_methods:
|
|
1014
|
+
# Determine path
|
|
1015
|
+
if "path" in config:
|
|
1016
|
+
path = config["path"]
|
|
1017
|
+
else:
|
|
1018
|
+
path = "/" + _generate_path(method_name)
|
|
1019
|
+
|
|
1020
|
+
# Create WebSocket endpoint
|
|
1021
|
+
@ws_router.websocket(path)
|
|
1022
|
+
async def websocket_handler(websocket: Any, _method: Callable = method) -> None:
|
|
1023
|
+
"""WebSocket handler that streams data from the method."""
|
|
1024
|
+
await websocket.accept()
|
|
1025
|
+
try:
|
|
1026
|
+
# Check if method is async generator
|
|
1027
|
+
result = _method()
|
|
1028
|
+
if hasattr(result, "__anext__"):
|
|
1029
|
+
# Async generator - stream data
|
|
1030
|
+
async for data in result:
|
|
1031
|
+
await websocket.send_json(data)
|
|
1032
|
+
elif hasattr(result, "__next__"):
|
|
1033
|
+
# Sync generator - stream data
|
|
1034
|
+
for data in result:
|
|
1035
|
+
await websocket.send_json(data)
|
|
1036
|
+
else:
|
|
1037
|
+
# Regular return - send once
|
|
1038
|
+
if inspect.iscoroutine(result):
|
|
1039
|
+
result = await result
|
|
1040
|
+
await websocket.send_json(result)
|
|
1041
|
+
except Exception as e:
|
|
1042
|
+
await websocket.send_json({"error": str(e)})
|
|
1043
|
+
finally:
|
|
1044
|
+
await websocket.close()
|
|
1045
|
+
|
|
1046
|
+
logger.debug(
|
|
1047
|
+
"Created WebSocket %s%s for %s.%s",
|
|
1048
|
+
prefix,
|
|
1049
|
+
path,
|
|
1050
|
+
class_name,
|
|
1051
|
+
method_name,
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
logger.info(
|
|
1055
|
+
"Created WebSocket router for %s with %d endpoints",
|
|
1056
|
+
class_name,
|
|
1057
|
+
len(ws_methods),
|
|
1058
|
+
)
|
|
1059
|
+
|
|
1060
|
+
return http_router, ws_router
|