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.

Files changed (227) hide show
  1. svc_infra/apf_payments/models.py +47 -108
  2. svc_infra/apf_payments/provider/__init__.py +2 -2
  3. svc_infra/apf_payments/provider/aiydan.py +42 -100
  4. svc_infra/apf_payments/provider/base.py +10 -26
  5. svc_infra/apf_payments/provider/registry.py +3 -5
  6. svc_infra/apf_payments/provider/stripe.py +63 -135
  7. svc_infra/apf_payments/schemas.py +82 -90
  8. svc_infra/apf_payments/service.py +40 -86
  9. svc_infra/apf_payments/settings.py +10 -13
  10. svc_infra/api/__init__.py +13 -13
  11. svc_infra/api/fastapi/__init__.py +19 -0
  12. svc_infra/api/fastapi/admin/add.py +13 -18
  13. svc_infra/api/fastapi/apf_payments/router.py +47 -84
  14. svc_infra/api/fastapi/apf_payments/setup.py +7 -13
  15. svc_infra/api/fastapi/auth/__init__.py +1 -1
  16. svc_infra/api/fastapi/auth/_cookies.py +3 -9
  17. svc_infra/api/fastapi/auth/add.py +4 -8
  18. svc_infra/api/fastapi/auth/gaurd.py +9 -26
  19. svc_infra/api/fastapi/auth/mfa/models.py +4 -7
  20. svc_infra/api/fastapi/auth/mfa/pre_auth.py +3 -3
  21. svc_infra/api/fastapi/auth/mfa/router.py +9 -15
  22. svc_infra/api/fastapi/auth/mfa/security.py +3 -5
  23. svc_infra/api/fastapi/auth/mfa/utils.py +3 -2
  24. svc_infra/api/fastapi/auth/mfa/verify.py +2 -9
  25. svc_infra/api/fastapi/auth/providers.py +4 -6
  26. svc_infra/api/fastapi/auth/routers/apikey_router.py +16 -18
  27. svc_infra/api/fastapi/auth/routers/oauth_router.py +37 -85
  28. svc_infra/api/fastapi/auth/routers/session_router.py +3 -6
  29. svc_infra/api/fastapi/auth/security.py +17 -28
  30. svc_infra/api/fastapi/auth/sender.py +1 -3
  31. svc_infra/api/fastapi/auth/settings.py +18 -19
  32. svc_infra/api/fastapi/auth/state.py +6 -7
  33. svc_infra/api/fastapi/auth/ws_security.py +2 -2
  34. svc_infra/api/fastapi/billing/router.py +6 -8
  35. svc_infra/api/fastapi/db/http.py +10 -11
  36. svc_infra/api/fastapi/db/nosql/mongo/add.py +5 -15
  37. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +14 -15
  38. svc_infra/api/fastapi/db/sql/add.py +6 -14
  39. svc_infra/api/fastapi/db/sql/crud_router.py +27 -40
  40. svc_infra/api/fastapi/db/sql/health.py +1 -3
  41. svc_infra/api/fastapi/db/sql/session.py +4 -5
  42. svc_infra/api/fastapi/db/sql/users.py +8 -11
  43. svc_infra/api/fastapi/dependencies/ratelimit.py +4 -6
  44. svc_infra/api/fastapi/docs/add.py +13 -23
  45. svc_infra/api/fastapi/docs/landing.py +6 -8
  46. svc_infra/api/fastapi/docs/scoped.py +34 -42
  47. svc_infra/api/fastapi/dual/dualize.py +1 -1
  48. svc_infra/api/fastapi/dual/protected.py +12 -21
  49. svc_infra/api/fastapi/dual/router.py +14 -31
  50. svc_infra/api/fastapi/ease.py +57 -13
  51. svc_infra/api/fastapi/http/conditional.py +3 -5
  52. svc_infra/api/fastapi/middleware/errors/catchall.py +2 -6
  53. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -4
  54. svc_infra/api/fastapi/middleware/errors/handlers.py +12 -18
  55. svc_infra/api/fastapi/middleware/graceful_shutdown.py +4 -13
  56. svc_infra/api/fastapi/middleware/idempotency.py +11 -16
  57. svc_infra/api/fastapi/middleware/idempotency_store.py +14 -14
  58. svc_infra/api/fastapi/middleware/optimistic_lock.py +5 -8
  59. svc_infra/api/fastapi/middleware/ratelimit.py +8 -8
  60. svc_infra/api/fastapi/middleware/ratelimit_store.py +7 -8
  61. svc_infra/api/fastapi/middleware/request_id.py +1 -3
  62. svc_infra/api/fastapi/middleware/timeout.py +9 -10
  63. svc_infra/api/fastapi/object_router.py +1060 -0
  64. svc_infra/api/fastapi/openapi/apply.py +5 -6
  65. svc_infra/api/fastapi/openapi/conventions.py +4 -4
  66. svc_infra/api/fastapi/openapi/mutators.py +13 -31
  67. svc_infra/api/fastapi/openapi/pipeline.py +2 -2
  68. svc_infra/api/fastapi/openapi/responses.py +4 -6
  69. svc_infra/api/fastapi/openapi/security.py +1 -3
  70. svc_infra/api/fastapi/ops/add.py +7 -9
  71. svc_infra/api/fastapi/pagination.py +25 -37
  72. svc_infra/api/fastapi/routers/__init__.py +16 -38
  73. svc_infra/api/fastapi/setup.py +13 -31
  74. svc_infra/api/fastapi/tenancy/add.py +3 -2
  75. svc_infra/api/fastapi/tenancy/context.py +8 -7
  76. svc_infra/api/fastapi/versioned.py +3 -2
  77. svc_infra/app/env.py +5 -7
  78. svc_infra/app/logging/add.py +2 -1
  79. svc_infra/app/logging/filter.py +1 -1
  80. svc_infra/app/logging/formats.py +3 -2
  81. svc_infra/app/root.py +3 -3
  82. svc_infra/billing/__init__.py +19 -2
  83. svc_infra/billing/async_service.py +27 -7
  84. svc_infra/billing/jobs.py +23 -33
  85. svc_infra/billing/models.py +21 -52
  86. svc_infra/billing/quotas.py +5 -7
  87. svc_infra/billing/schemas.py +4 -6
  88. svc_infra/cache/__init__.py +12 -5
  89. svc_infra/cache/add.py +6 -9
  90. svc_infra/cache/backend.py +6 -5
  91. svc_infra/cache/decorators.py +17 -28
  92. svc_infra/cache/keys.py +2 -2
  93. svc_infra/cache/recache.py +22 -35
  94. svc_infra/cache/resources.py +8 -16
  95. svc_infra/cache/ttl.py +2 -3
  96. svc_infra/cache/utils.py +5 -6
  97. svc_infra/cli/__init__.py +4 -12
  98. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +11 -10
  99. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +6 -9
  100. svc_infra/cli/cmds/db/ops_cmds.py +3 -6
  101. svc_infra/cli/cmds/db/sql/alembic_cmds.py +24 -41
  102. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +9 -17
  103. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +10 -10
  104. svc_infra/cli/cmds/docs/docs_cmds.py +7 -10
  105. svc_infra/cli/cmds/dx/dx_cmds.py +5 -11
  106. svc_infra/cli/cmds/jobs/jobs_cmds.py +2 -7
  107. svc_infra/cli/cmds/obs/obs_cmds.py +4 -7
  108. svc_infra/cli/cmds/sdk/sdk_cmds.py +5 -15
  109. svc_infra/cli/foundation/runner.py +6 -11
  110. svc_infra/cli/foundation/typer_bootstrap.py +1 -2
  111. svc_infra/data/__init__.py +83 -0
  112. svc_infra/data/add.py +5 -5
  113. svc_infra/data/backup.py +8 -10
  114. svc_infra/data/erasure.py +3 -2
  115. svc_infra/data/fixtures.py +3 -3
  116. svc_infra/data/retention.py +8 -13
  117. svc_infra/db/crud_schema.py +9 -8
  118. svc_infra/db/nosql/__init__.py +0 -1
  119. svc_infra/db/nosql/constants.py +1 -1
  120. svc_infra/db/nosql/core.py +7 -14
  121. svc_infra/db/nosql/indexes.py +11 -10
  122. svc_infra/db/nosql/management.py +3 -3
  123. svc_infra/db/nosql/mongo/client.py +3 -3
  124. svc_infra/db/nosql/mongo/settings.py +2 -6
  125. svc_infra/db/nosql/repository.py +27 -28
  126. svc_infra/db/nosql/resource.py +15 -20
  127. svc_infra/db/nosql/scaffold.py +13 -17
  128. svc_infra/db/nosql/service.py +3 -4
  129. svc_infra/db/nosql/service_with_hooks.py +4 -3
  130. svc_infra/db/nosql/types.py +2 -6
  131. svc_infra/db/nosql/utils.py +4 -4
  132. svc_infra/db/ops.py +14 -18
  133. svc_infra/db/outbox.py +15 -18
  134. svc_infra/db/sql/apikey.py +12 -21
  135. svc_infra/db/sql/authref.py +3 -7
  136. svc_infra/db/sql/constants.py +9 -9
  137. svc_infra/db/sql/core.py +11 -11
  138. svc_infra/db/sql/management.py +2 -6
  139. svc_infra/db/sql/repository.py +17 -24
  140. svc_infra/db/sql/resource.py +14 -13
  141. svc_infra/db/sql/scaffold.py +13 -17
  142. svc_infra/db/sql/service.py +7 -16
  143. svc_infra/db/sql/service_with_hooks.py +4 -3
  144. svc_infra/db/sql/tenant.py +6 -14
  145. svc_infra/db/sql/uniq.py +8 -7
  146. svc_infra/db/sql/uniq_hooks.py +14 -19
  147. svc_infra/db/sql/utils.py +24 -53
  148. svc_infra/db/utils.py +3 -3
  149. svc_infra/deploy/__init__.py +8 -15
  150. svc_infra/documents/add.py +7 -8
  151. svc_infra/documents/ease.py +8 -8
  152. svc_infra/documents/models.py +3 -3
  153. svc_infra/documents/storage.py +11 -13
  154. svc_infra/dx/__init__.py +58 -0
  155. svc_infra/dx/add.py +1 -3
  156. svc_infra/dx/changelog.py +2 -2
  157. svc_infra/dx/checks.py +1 -1
  158. svc_infra/health/__init__.py +15 -16
  159. svc_infra/http/client.py +10 -14
  160. svc_infra/jobs/__init__.py +79 -0
  161. svc_infra/jobs/builtins/outbox_processor.py +3 -5
  162. svc_infra/jobs/builtins/webhook_delivery.py +1 -3
  163. svc_infra/jobs/loader.py +4 -5
  164. svc_infra/jobs/queue.py +14 -24
  165. svc_infra/jobs/redis_queue.py +20 -34
  166. svc_infra/jobs/runner.py +7 -11
  167. svc_infra/jobs/scheduler.py +5 -5
  168. svc_infra/jobs/worker.py +1 -1
  169. svc_infra/loaders/base.py +5 -4
  170. svc_infra/loaders/github.py +1 -3
  171. svc_infra/loaders/url.py +3 -9
  172. svc_infra/logging/__init__.py +7 -6
  173. svc_infra/mcp/__init__.py +82 -0
  174. svc_infra/mcp/svc_infra_mcp.py +2 -2
  175. svc_infra/obs/add.py +4 -3
  176. svc_infra/obs/cloud_dash.py +1 -1
  177. svc_infra/obs/metrics/__init__.py +3 -3
  178. svc_infra/obs/metrics/asgi.py +9 -14
  179. svc_infra/obs/metrics/base.py +13 -13
  180. svc_infra/obs/metrics/http.py +5 -9
  181. svc_infra/obs/metrics/sqlalchemy.py +9 -12
  182. svc_infra/obs/metrics.py +3 -3
  183. svc_infra/obs/settings.py +2 -6
  184. svc_infra/resilience/__init__.py +44 -0
  185. svc_infra/resilience/circuit_breaker.py +328 -0
  186. svc_infra/resilience/retry.py +289 -0
  187. svc_infra/security/__init__.py +167 -0
  188. svc_infra/security/add.py +5 -9
  189. svc_infra/security/audit.py +14 -17
  190. svc_infra/security/audit_service.py +9 -9
  191. svc_infra/security/hibp.py +3 -6
  192. svc_infra/security/jwt_rotation.py +7 -10
  193. svc_infra/security/lockout.py +12 -11
  194. svc_infra/security/models.py +37 -46
  195. svc_infra/security/oauth_models.py +8 -8
  196. svc_infra/security/org_invites.py +11 -13
  197. svc_infra/security/passwords.py +4 -6
  198. svc_infra/security/permissions.py +8 -7
  199. svc_infra/security/session.py +6 -7
  200. svc_infra/security/signed_cookies.py +9 -9
  201. svc_infra/storage/add.py +5 -8
  202. svc_infra/storage/backends/local.py +13 -21
  203. svc_infra/storage/backends/memory.py +4 -7
  204. svc_infra/storage/backends/s3.py +17 -36
  205. svc_infra/storage/base.py +2 -2
  206. svc_infra/storage/easy.py +4 -8
  207. svc_infra/storage/settings.py +16 -18
  208. svc_infra/testing/__init__.py +36 -39
  209. svc_infra/utils.py +169 -8
  210. svc_infra/webhooks/__init__.py +1 -1
  211. svc_infra/webhooks/add.py +17 -29
  212. svc_infra/webhooks/encryption.py +2 -2
  213. svc_infra/webhooks/fastapi.py +2 -4
  214. svc_infra/webhooks/router.py +3 -3
  215. svc_infra/webhooks/service.py +5 -6
  216. svc_infra/webhooks/signing.py +5 -5
  217. svc_infra/websocket/add.py +2 -3
  218. svc_infra/websocket/client.py +3 -2
  219. svc_infra/websocket/config.py +6 -18
  220. svc_infra/websocket/manager.py +9 -10
  221. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/METADATA +11 -5
  222. svc_infra-1.1.0.dist-info/RECORD +364 -0
  223. svc_infra/billing/service.py +0 -123
  224. svc_infra-0.1.706.dist-info/RECORD +0 -357
  225. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/LICENSE +0 -0
  226. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
  227. {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