aoa-fastapi-adapter 1.0.0__tar.gz
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.
- aoa_fastapi_adapter-1.0.0/.gitignore +92 -0
- aoa_fastapi_adapter-1.0.0/CHANGELOG.md +16 -0
- aoa_fastapi_adapter-1.0.0/PKG-INFO +12 -0
- aoa_fastapi_adapter-1.0.0/pyproject.toml +24 -0
- aoa_fastapi_adapter-1.0.0/src/aoa/fastapi/__init__.py +28 -0
- aoa_fastapi_adapter-1.0.0/src/aoa/fastapi/adapter.py +889 -0
- aoa_fastapi_adapter-1.0.0/src/aoa/fastapi/query_field_before/__init__.py +17 -0
- aoa_fastapi_adapter-1.0.0/src/aoa/fastapi/query_field_before/query_str_list.py +49 -0
- aoa_fastapi_adapter-1.0.0/src/aoa/fastapi/route_record.py +124 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# ============================================================================
|
|
2
|
+
# Python
|
|
3
|
+
# ============================================================================
|
|
4
|
+
__pycache__/
|
|
5
|
+
*.py[cod]
|
|
6
|
+
*$py.class
|
|
7
|
+
*.so
|
|
8
|
+
.Python
|
|
9
|
+
|
|
10
|
+
# ============================================================================
|
|
11
|
+
# Virtual environments
|
|
12
|
+
# ============================================================================
|
|
13
|
+
.venv/
|
|
14
|
+
venv/
|
|
15
|
+
ENV/
|
|
16
|
+
env/
|
|
17
|
+
|
|
18
|
+
# ============================================================================
|
|
19
|
+
# Distribution / packaging
|
|
20
|
+
# ============================================================================
|
|
21
|
+
build/
|
|
22
|
+
dist/
|
|
23
|
+
*.egg-info/
|
|
24
|
+
*.egg
|
|
25
|
+
.eggs/
|
|
26
|
+
wheels/
|
|
27
|
+
|
|
28
|
+
# ============================================================================
|
|
29
|
+
# Testing
|
|
30
|
+
# ============================================================================
|
|
31
|
+
.pytest_cache/
|
|
32
|
+
.coverage
|
|
33
|
+
.coverage.*
|
|
34
|
+
htmlcov/
|
|
35
|
+
*.cover
|
|
36
|
+
.hypothesis/
|
|
37
|
+
.tox/
|
|
38
|
+
.nox/
|
|
39
|
+
|
|
40
|
+
# ============================================================================
|
|
41
|
+
# Type checking
|
|
42
|
+
# ============================================================================
|
|
43
|
+
.mypy_cache/
|
|
44
|
+
.dmypy.json
|
|
45
|
+
dmypy.json
|
|
46
|
+
.pyre/
|
|
47
|
+
.pytype/
|
|
48
|
+
|
|
49
|
+
# ============================================================================
|
|
50
|
+
# Node (Maxitor web client — source lives in git; node_modules stays local only)
|
|
51
|
+
# ============================================================================
|
|
52
|
+
packages/aoa-maxitor/client/node_modules/
|
|
53
|
+
packages/aoa-maxitor/client/.vite/
|
|
54
|
+
|
|
55
|
+
# ============================================================================
|
|
56
|
+
# IDE
|
|
57
|
+
# ============================================================================
|
|
58
|
+
.idea/
|
|
59
|
+
.vscode/
|
|
60
|
+
*.swp
|
|
61
|
+
*.swo
|
|
62
|
+
.DS_Store
|
|
63
|
+
|
|
64
|
+
# ============================================================================
|
|
65
|
+
# Logs and temporary files
|
|
66
|
+
# ============================================================================
|
|
67
|
+
*.log
|
|
68
|
+
code_quality.log
|
|
69
|
+
*.tmp
|
|
70
|
+
*.temp
|
|
71
|
+
|
|
72
|
+
# ============================================================================
|
|
73
|
+
# Project specific - ARCHIVE (ваши локальные снимки)
|
|
74
|
+
# ============================================================================
|
|
75
|
+
archive/
|
|
76
|
+
*.ver
|
|
77
|
+
code.txt
|
|
78
|
+
project_structure.txt
|
|
79
|
+
|
|
80
|
+
# ============================================================================
|
|
81
|
+
# Локальные черновики (русские версии документации и примеров)
|
|
82
|
+
# ============================================================================
|
|
83
|
+
*_draft.md
|
|
84
|
+
*_draft.py
|
|
85
|
+
.ipynb_checkpoints/
|
|
86
|
+
docs/ru/
|
|
87
|
+
|
|
88
|
+
# ============================================================================
|
|
89
|
+
# НЕ ИГНОРИРУЕМ - эти файлы должны быть в git!
|
|
90
|
+
# ============================================================================
|
|
91
|
+
!.python-version
|
|
92
|
+
!uv.lock
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `aoa-fastapi-adapter` are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [1.0.0] – 2026-06-24
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Initial standalone release, extracted from `aoa-action-machine`.** `FastApiAdapter`, `FastApiRouteRecord`, and `query_field_before` helpers are now distributed under the `aoa.fastapi` namespace as a separate package (`pip install aoa-fastapi-adapter`). The package depends on `aoa-action-machine`, `fastapi`, and `uvicorn[standard]`. ([#63](https://github.com/bystrovmaxim/aoa/issues/63))
|
|
15
|
+
|
|
16
|
+
For the pre-extraction history of `FastApiAdapter` (originally introduced in the monorepo at `[0.5.5]`), see the [aoa-action-machine CHANGELOG](../aoa-action-machine/CHANGELOG.md).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aoa-fastapi-adapter
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: FastAPI adapter for AOA — expose Actions as HTTP endpoints.
|
|
5
|
+
Project-URL: Homepage, https://github.com/bystrovmaxim/aoa
|
|
6
|
+
Project-URL: Repository, https://github.com/bystrovmaxim/aoa
|
|
7
|
+
Author: @Bystrov.Maxim
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: aoa-action-machine>=1.0.0a5
|
|
11
|
+
Requires-Dist: fastapi<1.0,>=0.100
|
|
12
|
+
Requires-Dist: uvicorn[standard]<1.0,>=0.20
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# packages/aoa-fastapi-adapter/pyproject.toml — publishable distribution for ``aoa.fastapi``.
|
|
2
|
+
[build-system]
|
|
3
|
+
requires = ["hatchling"]
|
|
4
|
+
build-backend = "hatchling.build"
|
|
5
|
+
|
|
6
|
+
[project]
|
|
7
|
+
name = "aoa-fastapi-adapter"
|
|
8
|
+
version = "1.0.0"
|
|
9
|
+
description = "FastAPI adapter for AOA — expose Actions as HTTP endpoints."
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
authors = [{ name = "@Bystrov.Maxim" }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"aoa-action-machine>=1.0.0a5",
|
|
15
|
+
"fastapi>=0.100,<1.0",
|
|
16
|
+
"uvicorn[standard]>=0.20,<1.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/bystrovmaxim/aoa"
|
|
21
|
+
Repository = "https://github.com/bystrovmaxim/aoa"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["src/aoa"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# packages/aoa-fastapi-adapter/src/aoa/fastapi/__init__.py
|
|
2
|
+
"""
|
|
3
|
+
FastAPI adapter for AOA ActionMachine.
|
|
4
|
+
|
|
5
|
+
Install: pip install aoa-fastapi-adapter
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from aoa.fastapi import FastApiAdapter, FastApiRouteRecord
|
|
9
|
+
from aoa.fastapi.query_field_before import QUERY_STR_LIST_BEFORE, coerce_query_str_list
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import fastapi # noqa: F401
|
|
14
|
+
except ImportError:
|
|
15
|
+
raise ImportError(
|
|
16
|
+
"To use aoa-fastapi-adapter, install: pip install aoa-fastapi-adapter"
|
|
17
|
+
) from None
|
|
18
|
+
|
|
19
|
+
from aoa.fastapi.adapter import FastApiAdapter
|
|
20
|
+
from aoa.fastapi.query_field_before import QUERY_STR_LIST_BEFORE, coerce_query_str_list
|
|
21
|
+
from aoa.fastapi.route_record import FastApiRouteRecord
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"QUERY_STR_LIST_BEFORE",
|
|
25
|
+
"FastApiAdapter",
|
|
26
|
+
"FastApiRouteRecord",
|
|
27
|
+
"coerce_query_str_list",
|
|
28
|
+
]
|
|
@@ -0,0 +1,889 @@
|
|
|
1
|
+
# packages/aoa-fastapi-adapter/src/aoa/fastapi/adapter.py
|
|
2
|
+
"""
|
|
3
|
+
FastApiAdapter — HTTP adapter for ActionMachine using FastAPI.
|
|
4
|
+
|
|
5
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
6
|
+
PURPOSE
|
|
7
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
8
|
+
|
|
9
|
+
FastApiAdapter converts Actions into FastAPI HTTP endpoints. One call to a
|
|
10
|
+
protocol method (post/get/put/delete/patch) registers one endpoint.
|
|
11
|
+
All protocol methods return ``self`` for fluent chaining:
|
|
12
|
+
|
|
13
|
+
app = adapter \\
|
|
14
|
+
.get("/api/v1/ping", PingAction, tags=["system"]) \\
|
|
15
|
+
.post("/api/v1/orders", CreateOrderAction, tags=["orders"]) \\
|
|
16
|
+
.build()
|
|
17
|
+
|
|
18
|
+
OpenAPI documentation is generated automatically from metadata already declared
|
|
19
|
+
in code: field descriptions from ``Field(description=...)``, constraints from
|
|
20
|
+
``Field(gt=0, min_length=3, pattern=...)``, summary from ``@meta``, and tags
|
|
21
|
+
from route registration arguments.
|
|
22
|
+
|
|
23
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
24
|
+
REQUIRED AUTHENTICATION
|
|
25
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
26
|
+
|
|
27
|
+
The ``auth_coordinator`` argument is required (inherited from ``BaseAdapter``).
|
|
28
|
+
This prevents accidental auth omission: ``auth_coordinator=None`` fails fast
|
|
29
|
+
with ``TypeError`` instead of becoming a silent production bug. For open APIs,
|
|
30
|
+
use ``NoAuthCoordinator`` explicitly:
|
|
31
|
+
|
|
32
|
+
from aoa.action_machine.intents.check_roles import NoAuthCoordinator
|
|
33
|
+
|
|
34
|
+
adapter = FastApiAdapter(
|
|
35
|
+
machine=machine,
|
|
36
|
+
auth_coordinator=NoAuthCoordinator(context=Context()),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
40
|
+
MAPPER NAMING CONVENTION
|
|
41
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
42
|
+
|
|
43
|
+
Each mapper is named by what it RETURNS:
|
|
44
|
+
|
|
45
|
+
params_mapper -> returns params (transforms request -> params)
|
|
46
|
+
response_mapper -> returns response (transforms result -> response)
|
|
47
|
+
|
|
48
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
49
|
+
ENDPOINT GENERATION STRATEGIES
|
|
50
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
51
|
+
|
|
52
|
+
The adapter uses three endpoint generation strategies depending on HTTP method
|
|
53
|
+
and whether the params model has fields:
|
|
54
|
+
|
|
55
|
+
1. POST/PUT/PATCH with non-empty Params -> parameters are passed in JSON body.
|
|
56
|
+
FastAPI validates the body using the Pydantic model.
|
|
57
|
+
|
|
58
|
+
2. GET/DELETE with non-empty Params -> parameters are passed via query/path.
|
|
59
|
+
If URL has path params (e.g. ``{order_id}``), FastAPI extracts those from
|
|
60
|
+
path and the rest from query string.
|
|
61
|
+
|
|
62
|
+
3. Any method with empty Params (no fields) -> endpoint takes no body/query.
|
|
63
|
+
Empty Params instance is created inside the handler.
|
|
64
|
+
|
|
65
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
66
|
+
ERROR HANDLING
|
|
67
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
68
|
+
|
|
69
|
+
Exception handlers are registered at application level:
|
|
70
|
+
|
|
71
|
+
AuthorizationError -> HTTP 403 {"detail": "..."}
|
|
72
|
+
ValidationFieldError -> HTTP 422 {"detail": "..."}
|
|
73
|
+
|
|
74
|
+
Unhandled exceptions are caught by middleware wrapping each request in
|
|
75
|
+
try/except and returning HTTP 500 for any error not handled above.
|
|
76
|
+
|
|
77
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
78
|
+
TESTING NOTE (HTTP routes)
|
|
79
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
80
|
+
|
|
81
|
+
Route and handler tests should use a real ``ActionProductMachine`` so
|
|
82
|
+
OpenAPI-related wiring and graph metadata match production.
|
|
83
|
+
|
|
84
|
+
To control results or speed, stub ``machine.run`` only — not the whole stack.
|
|
85
|
+
|
|
86
|
+
::
|
|
87
|
+
|
|
88
|
+
Request body / query / path
|
|
89
|
+
|
|
|
90
|
+
v
|
|
91
|
+
FastAPI validation / mappers ---> auth_coordinator ---> machine.run ~~~~ stub
|
|
92
|
+
^___________________________ production ___________________________^
|
|
93
|
+
~~~~
|
|
94
|
+
optional AsyncMock
|
|
95
|
+
|
|
96
|
+
response mapping / JSON response <--- (after run)
|
|
97
|
+
^
|
|
98
|
+
production
|
|
99
|
+
|
|
100
|
+
See ``BaseAdapter`` module docstring (ADAPTER TESTING CONTRACT) for the full
|
|
101
|
+
adapter-level picture.
|
|
102
|
+
|
|
103
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
104
|
+
HEALTH CHECK
|
|
105
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
106
|
+
|
|
107
|
+
Endpoint ``GET /health`` is added automatically during ``build()``.
|
|
108
|
+
Returns ``{"status": "ok"}``.
|
|
109
|
+
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
# Ruff/isort lists first-party ``action_machine`` before FastAPI (known-first-party).
|
|
113
|
+
# pylint: disable=wrong-import-order
|
|
114
|
+
from __future__ import annotations
|
|
115
|
+
|
|
116
|
+
import inspect
|
|
117
|
+
import re
|
|
118
|
+
from collections.abc import Callable, Mapping
|
|
119
|
+
from typing import Annotated, Any, Self, get_origin
|
|
120
|
+
|
|
121
|
+
from pydantic import BaseModel
|
|
122
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
123
|
+
from starlette.requests import Request as StarletteRequest
|
|
124
|
+
from starlette.responses import Response as StarletteResponse
|
|
125
|
+
|
|
126
|
+
from aoa.action_machine.adapters.base_adapter import BaseAdapter
|
|
127
|
+
from aoa.action_machine.adapters.base_route_record import ensure_machine_params, ensure_protocol_response
|
|
128
|
+
from aoa.action_machine.exceptions.authorization_error import AuthorizationError
|
|
129
|
+
from aoa.action_machine.exceptions.validation_field_error import ValidationFieldError
|
|
130
|
+
from aoa.action_machine.graph.core.node_graph_coordinator import NodeGraphCoordinator
|
|
131
|
+
from aoa.action_machine.graph.nodes.action_graph_node import ActionGraphNode
|
|
132
|
+
from aoa.action_machine.model.base_action import BaseAction
|
|
133
|
+
from aoa.action_machine.resources.per_call_connection import ConnectionValue, resolve_connections
|
|
134
|
+
from aoa.action_machine.runtime.action_product_machine import ActionProductMachine
|
|
135
|
+
from aoa.action_machine.system_core.type_introspection import TypeIntrospection
|
|
136
|
+
from aoa.fastapi.route_record import FastApiRouteRecord
|
|
137
|
+
from fastapi import FastAPI, Query, Request
|
|
138
|
+
from fastapi.responses import JSONResponse
|
|
139
|
+
|
|
140
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
141
|
+
# Module-level helper functions
|
|
142
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
143
|
+
|
|
144
|
+
_PATH_PARAM_PATTERN: re.Pattern[str] = re.compile(r"\{(\w+)\}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _fastapi_query_param_annotation(field_name: str, field_info: Any, path_params: set[str]) -> Any:
|
|
148
|
+
"""
|
|
149
|
+
Build the FastAPI endpoint parameter annotation for one query field.
|
|
150
|
+
|
|
151
|
+
FastAPI treats bare ``list[...]`` on GET handlers as a JSON body field; wrap with
|
|
152
|
+
:class:`fastapi.Query` so list values bind from repeated query keys (or a single
|
|
153
|
+
value, depending on client) instead.
|
|
154
|
+
"""
|
|
155
|
+
if field_name in path_params:
|
|
156
|
+
return field_info.annotation if field_info.annotation is not None else str
|
|
157
|
+
ann = field_info.annotation
|
|
158
|
+
if ann is None:
|
|
159
|
+
return str
|
|
160
|
+
if get_origin(ann) is list:
|
|
161
|
+
if field_info.is_required():
|
|
162
|
+
return Annotated[ann, Query()]
|
|
163
|
+
return Annotated[ann, Query(default_factory=list)]
|
|
164
|
+
return ann
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _fastapi_route_label(record: FastApiRouteRecord) -> str:
|
|
168
|
+
return f"{record.method} {record.path}"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _get_action_class_description(
|
|
172
|
+
action_class: type,
|
|
173
|
+
*,
|
|
174
|
+
coordinator: NodeGraphCoordinator | None = None,
|
|
175
|
+
) -> str:
|
|
176
|
+
"""
|
|
177
|
+
Extract description from action ``@meta`` declaration.
|
|
178
|
+
|
|
179
|
+
Used to auto-fill endpoint summary when summary is not provided explicitly
|
|
180
|
+
during route registration.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
action_class: action class.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Description string from the node graph, ``@meta`` scratch, or empty string.
|
|
187
|
+
"""
|
|
188
|
+
if coordinator is not None:
|
|
189
|
+
try:
|
|
190
|
+
node = coordinator.get_node_by_id(
|
|
191
|
+
TypeIntrospection.full_qualname(action_class),
|
|
192
|
+
ActionGraphNode.NODE_TYPE,
|
|
193
|
+
)
|
|
194
|
+
except (LookupError, RuntimeError):
|
|
195
|
+
node = None
|
|
196
|
+
if node is not None:
|
|
197
|
+
return str(node.properties.get("description", "") or "")
|
|
198
|
+
|
|
199
|
+
meta_info = getattr(action_class, "_meta_info", None)
|
|
200
|
+
if meta_info and isinstance(meta_info, dict):
|
|
201
|
+
return str(meta_info.get("description", ""))
|
|
202
|
+
return ""
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _get_model_fields(model: type) -> dict[str, Any]:
|
|
206
|
+
"""
|
|
207
|
+
Return Pydantic model fields as a dictionary.
|
|
208
|
+
|
|
209
|
+
For Pydantic ``BaseModel`` uses ``model_fields``.
|
|
210
|
+
For other types returns an empty dict.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
model: model class (Pydantic ``BaseModel`` or another type).
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Dict of ``{field_name: FieldInfo}`` or empty dict.
|
|
217
|
+
"""
|
|
218
|
+
if isinstance(model, type) and issubclass(model, BaseModel):
|
|
219
|
+
return model.model_fields
|
|
220
|
+
return {}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _extract_path_params(path: str) -> set[str]:
|
|
224
|
+
"""
|
|
225
|
+
Extract path-parameter names from URL template.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
path: URL path with placeholders like ``{param_name}``.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Set of path-parameter names.
|
|
232
|
+
"""
|
|
233
|
+
return set(_PATH_PARAM_PATTERN.findall(path))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _has_body_method(method: str) -> bool:
|
|
237
|
+
"""
|
|
238
|
+
Return whether HTTP method supports request body.
|
|
239
|
+
|
|
240
|
+
POST/PUT/PATCH support body.
|
|
241
|
+
GET/DELETE do not use body in this adapter.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
method: uppercase HTTP method.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
True if method supports body.
|
|
248
|
+
"""
|
|
249
|
+
return method in ("POST", "PUT", "PATCH")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
253
|
+
# Endpoint function factories
|
|
254
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _make_endpoint_with_body(
|
|
258
|
+
record: FastApiRouteRecord,
|
|
259
|
+
machine: ActionProductMachine,
|
|
260
|
+
auth_coordinator: Any,
|
|
261
|
+
) -> Callable[..., Any]:
|
|
262
|
+
"""
|
|
263
|
+
Create endpoint for methods with JSON body (POST, PUT, PATCH).
|
|
264
|
+
|
|
265
|
+
``body`` parameter is annotated with concrete Pydantic model.
|
|
266
|
+
FastAPI validates request body and generates OpenAPI schema automatically.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
record: route configuration.
|
|
270
|
+
machine: action execution machine.
|
|
271
|
+
auth_coordinator: authentication coordinator (required).
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
Async endpoint function for ``app.add_api_route()``.
|
|
275
|
+
"""
|
|
276
|
+
req_model = record.effective_request_model
|
|
277
|
+
has_params_mapper = record.params_mapper is not None
|
|
278
|
+
has_response_mapper = record.response_mapper is not None
|
|
279
|
+
|
|
280
|
+
async def endpoint(request: Request, body: Any) -> Any:
|
|
281
|
+
if has_params_mapper:
|
|
282
|
+
params = record.params_mapper(body) # type: ignore[misc]
|
|
283
|
+
else:
|
|
284
|
+
params = body
|
|
285
|
+
|
|
286
|
+
ensure_machine_params(
|
|
287
|
+
params,
|
|
288
|
+
record.params_type,
|
|
289
|
+
adapter="FastAPI",
|
|
290
|
+
route_label=_fastapi_route_label(record),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
context = await auth_coordinator.process(request)
|
|
294
|
+
if context is None:
|
|
295
|
+
raise AuthorizationError("Authentication required")
|
|
296
|
+
|
|
297
|
+
connections = resolve_connections(record.connections)
|
|
298
|
+
|
|
299
|
+
action = record.action_class()
|
|
300
|
+
result = await machine.run(context, action, params, connections)
|
|
301
|
+
|
|
302
|
+
if has_response_mapper:
|
|
303
|
+
mapped = record.response_mapper(result) # type: ignore[misc]
|
|
304
|
+
ensure_protocol_response(
|
|
305
|
+
mapped,
|
|
306
|
+
record.effective_response_model,
|
|
307
|
+
adapter="FastAPI",
|
|
308
|
+
route_label=_fastapi_route_label(record),
|
|
309
|
+
)
|
|
310
|
+
return mapped
|
|
311
|
+
return result
|
|
312
|
+
|
|
313
|
+
sig_params = [
|
|
314
|
+
inspect.Parameter("request", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Request),
|
|
315
|
+
inspect.Parameter("body", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=req_model),
|
|
316
|
+
]
|
|
317
|
+
endpoint.__signature__ = inspect.Signature(sig_params) # type: ignore[attr-defined]
|
|
318
|
+
|
|
319
|
+
return endpoint
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _make_endpoint_with_query(
|
|
323
|
+
record: FastApiRouteRecord,
|
|
324
|
+
machine: ActionProductMachine,
|
|
325
|
+
auth_coordinator: Any,
|
|
326
|
+
) -> Callable[..., Any]:
|
|
327
|
+
"""
|
|
328
|
+
Create endpoint for GET/DELETE with query/path parameters.
|
|
329
|
+
|
|
330
|
+
Each Params model field becomes a function argument. FastAPI resolves which
|
|
331
|
+
parameters come from path and which from query string using annotations and
|
|
332
|
+
route path placeholders.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
record: route configuration.
|
|
336
|
+
machine: action execution machine.
|
|
337
|
+
auth_coordinator: authentication coordinator (required).
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
Async endpoint function for ``app.add_api_route()``.
|
|
341
|
+
"""
|
|
342
|
+
req_model = record.effective_request_model
|
|
343
|
+
has_params_mapper = record.params_mapper is not None
|
|
344
|
+
has_response_mapper = record.response_mapper is not None
|
|
345
|
+
model_fields = _get_model_fields(req_model)
|
|
346
|
+
path_params = _extract_path_params(record.path)
|
|
347
|
+
|
|
348
|
+
async def endpoint(request: Request, **kwargs: Any) -> Any:
|
|
349
|
+
body = req_model(**kwargs)
|
|
350
|
+
|
|
351
|
+
if has_params_mapper:
|
|
352
|
+
params = record.params_mapper(body) # type: ignore[misc]
|
|
353
|
+
else:
|
|
354
|
+
params = body
|
|
355
|
+
|
|
356
|
+
ensure_machine_params(
|
|
357
|
+
params,
|
|
358
|
+
record.params_type,
|
|
359
|
+
adapter="FastAPI",
|
|
360
|
+
route_label=_fastapi_route_label(record),
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
context = await auth_coordinator.process(request)
|
|
364
|
+
if context is None:
|
|
365
|
+
raise AuthorizationError("Authentication required")
|
|
366
|
+
|
|
367
|
+
connections = resolve_connections(record.connections)
|
|
368
|
+
|
|
369
|
+
action = record.action_class()
|
|
370
|
+
result = await machine.run(context, action, params, connections)
|
|
371
|
+
|
|
372
|
+
if has_response_mapper:
|
|
373
|
+
mapped = record.response_mapper(result) # type: ignore[misc]
|
|
374
|
+
ensure_protocol_response(
|
|
375
|
+
mapped,
|
|
376
|
+
record.effective_response_model,
|
|
377
|
+
adapter="FastAPI",
|
|
378
|
+
route_label=_fastapi_route_label(record),
|
|
379
|
+
)
|
|
380
|
+
return mapped
|
|
381
|
+
return result
|
|
382
|
+
|
|
383
|
+
sig_params = [
|
|
384
|
+
inspect.Parameter("request", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Request),
|
|
385
|
+
]
|
|
386
|
+
|
|
387
|
+
for field_name, field_info in model_fields.items():
|
|
388
|
+
annotation = _fastapi_query_param_annotation(field_name, field_info, path_params)
|
|
389
|
+
|
|
390
|
+
if field_name in path_params:
|
|
391
|
+
if field_info.default is not None and not field_info.is_required():
|
|
392
|
+
sig_params.append(
|
|
393
|
+
inspect.Parameter(
|
|
394
|
+
field_name,
|
|
395
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
396
|
+
annotation=annotation,
|
|
397
|
+
default=field_info.default,
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
else:
|
|
401
|
+
sig_params.append(
|
|
402
|
+
inspect.Parameter(
|
|
403
|
+
field_name,
|
|
404
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
405
|
+
annotation=annotation,
|
|
406
|
+
)
|
|
407
|
+
)
|
|
408
|
+
else:
|
|
409
|
+
default = field_info.default if not field_info.is_required() else inspect.Parameter.empty
|
|
410
|
+
sig_params.append(
|
|
411
|
+
inspect.Parameter(
|
|
412
|
+
field_name,
|
|
413
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
414
|
+
annotation=annotation,
|
|
415
|
+
default=default,
|
|
416
|
+
)
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
endpoint.__signature__ = inspect.Signature(sig_params) # type: ignore[attr-defined]
|
|
420
|
+
|
|
421
|
+
return endpoint
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _make_endpoint_no_params(
|
|
425
|
+
record: FastApiRouteRecord,
|
|
426
|
+
machine: ActionProductMachine,
|
|
427
|
+
auth_coordinator: Any,
|
|
428
|
+
) -> Callable[..., Any]:
|
|
429
|
+
"""
|
|
430
|
+
Create endpoint for actions with empty Params (no fields).
|
|
431
|
+
|
|
432
|
+
Endpoint accepts no body/query parameters. Empty Params instance is created
|
|
433
|
+
inside handler.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
record: route configuration.
|
|
437
|
+
machine: action execution machine.
|
|
438
|
+
auth_coordinator: authentication coordinator (required).
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
Async endpoint function for ``app.add_api_route()``.
|
|
442
|
+
"""
|
|
443
|
+
req_model = record.effective_request_model
|
|
444
|
+
has_params_mapper = record.params_mapper is not None
|
|
445
|
+
has_response_mapper = record.response_mapper is not None
|
|
446
|
+
|
|
447
|
+
async def endpoint(request: Request) -> Any:
|
|
448
|
+
body = req_model()
|
|
449
|
+
|
|
450
|
+
if has_params_mapper:
|
|
451
|
+
params = record.params_mapper(body) # type: ignore[misc]
|
|
452
|
+
else:
|
|
453
|
+
params = body
|
|
454
|
+
|
|
455
|
+
ensure_machine_params(
|
|
456
|
+
params,
|
|
457
|
+
record.params_type,
|
|
458
|
+
adapter="FastAPI",
|
|
459
|
+
route_label=_fastapi_route_label(record),
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
context = await auth_coordinator.process(request)
|
|
463
|
+
if context is None:
|
|
464
|
+
raise AuthorizationError("Authentication required")
|
|
465
|
+
|
|
466
|
+
connections = resolve_connections(record.connections)
|
|
467
|
+
|
|
468
|
+
action = record.action_class()
|
|
469
|
+
result = await machine.run(context, action, params, connections)
|
|
470
|
+
|
|
471
|
+
if has_response_mapper:
|
|
472
|
+
mapped = record.response_mapper(result) # type: ignore[misc]
|
|
473
|
+
ensure_protocol_response(
|
|
474
|
+
mapped,
|
|
475
|
+
record.effective_response_model,
|
|
476
|
+
adapter="FastAPI",
|
|
477
|
+
route_label=_fastapi_route_label(record),
|
|
478
|
+
)
|
|
479
|
+
return mapped
|
|
480
|
+
return result
|
|
481
|
+
|
|
482
|
+
return endpoint
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _make_endpoint(
|
|
486
|
+
record: FastApiRouteRecord,
|
|
487
|
+
machine: ActionProductMachine,
|
|
488
|
+
auth_coordinator: Any,
|
|
489
|
+
) -> Callable[..., Any]:
|
|
490
|
+
"""
|
|
491
|
+
Endpoint factory for FastAPI.
|
|
492
|
+
|
|
493
|
+
Chooses generation strategy by HTTP method and Params model shape:
|
|
494
|
+
|
|
495
|
+
1. Empty model (no fields) -> endpoint without parameters.
|
|
496
|
+
2. POST/PUT/PATCH with fields -> endpoint with JSON body.
|
|
497
|
+
3. GET/DELETE with fields -> endpoint with query/path parameters.
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
record: route configuration.
|
|
501
|
+
machine: action execution machine.
|
|
502
|
+
auth_coordinator: authentication coordinator (required).
|
|
503
|
+
|
|
504
|
+
Returns:
|
|
505
|
+
Async function suitable for ``app.add_api_route()``.
|
|
506
|
+
"""
|
|
507
|
+
model_fields = _get_model_fields(record.effective_request_model)
|
|
508
|
+
|
|
509
|
+
if not model_fields:
|
|
510
|
+
return _make_endpoint_no_params(record, machine, auth_coordinator)
|
|
511
|
+
|
|
512
|
+
if _has_body_method(record.method):
|
|
513
|
+
return _make_endpoint_with_body(record, machine, auth_coordinator)
|
|
514
|
+
|
|
515
|
+
return _make_endpoint_with_query(record, machine, auth_coordinator)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
519
|
+
# Middleware
|
|
520
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
class _CatchAllErrorsMiddleware(BaseHTTPMiddleware):
|
|
524
|
+
"""
|
|
525
|
+
Middleware that catches unhandled exceptions.
|
|
526
|
+
|
|
527
|
+
Wraps each request in try/except and guarantees HTTP 500 for uncaught errors.
|
|
528
|
+
"""
|
|
529
|
+
|
|
530
|
+
async def dispatch(
|
|
531
|
+
self,
|
|
532
|
+
request: StarletteRequest,
|
|
533
|
+
call_next: Callable[..., Any],
|
|
534
|
+
) -> StarletteResponse:
|
|
535
|
+
try:
|
|
536
|
+
response: StarletteResponse = await call_next(request)
|
|
537
|
+
return response
|
|
538
|
+
except Exception:
|
|
539
|
+
return JSONResponse(
|
|
540
|
+
status_code=500,
|
|
541
|
+
content={"detail": "Internal server error"},
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
546
|
+
# Adapter class
|
|
547
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
class FastApiAdapter(BaseAdapter[FastApiRouteRecord]):
|
|
551
|
+
"""
|
|
552
|
+
FastAPI-based HTTP adapter for ActionMachine.
|
|
553
|
+
|
|
554
|
+
Inherits ``BaseAdapter[FastApiRouteRecord]`` and exposes protocol methods
|
|
555
|
+
``post/get/put/delete/patch`` for endpoint registration. All protocol
|
|
556
|
+
methods return ``self`` for fluent chaining. ``build()`` finalizes the
|
|
557
|
+
chain and creates FastAPI application.
|
|
558
|
+
|
|
559
|
+
``auth_coordinator`` is required (inherited from ``BaseAdapter``). For open
|
|
560
|
+
APIs, use ``NoAuthCoordinator(context=Context())`` explicitly.
|
|
561
|
+
|
|
562
|
+
Attributes:
|
|
563
|
+
_title : str
|
|
564
|
+
API title for OpenAPI/Swagger UI.
|
|
565
|
+
|
|
566
|
+
_version : str
|
|
567
|
+
API version for OpenAPI.
|
|
568
|
+
|
|
569
|
+
_description : str
|
|
570
|
+
API description for OpenAPI (Markdown supported).
|
|
571
|
+
"""
|
|
572
|
+
|
|
573
|
+
def __init__(
|
|
574
|
+
self,
|
|
575
|
+
machine: ActionProductMachine,
|
|
576
|
+
auth_coordinator: Any,
|
|
577
|
+
*,
|
|
578
|
+
title: str = "ActionMachine API",
|
|
579
|
+
version: str = "0.1.0",
|
|
580
|
+
description: str = "",
|
|
581
|
+
) -> None:
|
|
582
|
+
"""
|
|
583
|
+
Initialize FastAPI adapter.
|
|
584
|
+
|
|
585
|
+
Args:
|
|
586
|
+
machine: action execution machine (required).
|
|
587
|
+
auth_coordinator: authentication coordinator (required).
|
|
588
|
+
For open APIs use ``NoAuthCoordinator(context=Context())``. ``None`` is invalid.
|
|
589
|
+
title: API title for OpenAPI/Swagger UI.
|
|
590
|
+
version: API version for OpenAPI.
|
|
591
|
+
description: API description for OpenAPI (Markdown supported).
|
|
592
|
+
"""
|
|
593
|
+
super().__init__(
|
|
594
|
+
machine=machine,
|
|
595
|
+
auth_coordinator=auth_coordinator,
|
|
596
|
+
)
|
|
597
|
+
self._title: str = title
|
|
598
|
+
self._version: str = version
|
|
599
|
+
self._description: str = description
|
|
600
|
+
|
|
601
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
602
|
+
# Properties
|
|
603
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
604
|
+
|
|
605
|
+
@property
|
|
606
|
+
def title(self) -> str:
|
|
607
|
+
"""API title for OpenAPI."""
|
|
608
|
+
return self._title
|
|
609
|
+
|
|
610
|
+
@property
|
|
611
|
+
def version(self) -> str:
|
|
612
|
+
"""API version for OpenAPI."""
|
|
613
|
+
return self._version
|
|
614
|
+
|
|
615
|
+
@property
|
|
616
|
+
def api_description(self) -> str:
|
|
617
|
+
"""API description for OpenAPI."""
|
|
618
|
+
return self._description
|
|
619
|
+
|
|
620
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
621
|
+
# Internal registration method (fluent)
|
|
622
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
623
|
+
|
|
624
|
+
def _register(
|
|
625
|
+
self,
|
|
626
|
+
method: str,
|
|
627
|
+
path: str,
|
|
628
|
+
action_class: type[BaseAction[Any, Any]],
|
|
629
|
+
request_model: type | None = None,
|
|
630
|
+
response_model: type | None = None,
|
|
631
|
+
params_mapper: Callable[..., Any] | None = None,
|
|
632
|
+
response_mapper: Callable[..., Any] | None = None,
|
|
633
|
+
tags: list[str] | None = None,
|
|
634
|
+
summary: str = "",
|
|
635
|
+
description: str = "",
|
|
636
|
+
operation_id: str | None = None,
|
|
637
|
+
deprecated: bool = False,
|
|
638
|
+
*,
|
|
639
|
+
connections: Mapping[str, ConnectionValue] | None = None,
|
|
640
|
+
) -> Self:
|
|
641
|
+
"""
|
|
642
|
+
Create ``FastApiRouteRecord``, append to ``_routes``, and return ``self``.
|
|
643
|
+
|
|
644
|
+
If ``summary`` is empty, fill it from action ``@meta`` description.
|
|
645
|
+
"""
|
|
646
|
+
effective_summary = summary or _get_action_class_description(
|
|
647
|
+
action_class,
|
|
648
|
+
coordinator=self.graph_coordinator,
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
record = FastApiRouteRecord(
|
|
652
|
+
action_class=action_class,
|
|
653
|
+
request_model=request_model,
|
|
654
|
+
response_model=response_model,
|
|
655
|
+
params_mapper=params_mapper,
|
|
656
|
+
response_mapper=response_mapper,
|
|
657
|
+
connections=connections,
|
|
658
|
+
method=method,
|
|
659
|
+
path=path,
|
|
660
|
+
tags=tuple(tags or ()),
|
|
661
|
+
summary=effective_summary,
|
|
662
|
+
description=description,
|
|
663
|
+
operation_id=operation_id,
|
|
664
|
+
deprecated=deprecated,
|
|
665
|
+
)
|
|
666
|
+
return self._add_route(record)
|
|
667
|
+
|
|
668
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
669
|
+
# Protocol methods (fluent — return Self)
|
|
670
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
671
|
+
|
|
672
|
+
def post(
|
|
673
|
+
self,
|
|
674
|
+
path: str,
|
|
675
|
+
action_class: type[BaseAction[Any, Any]],
|
|
676
|
+
request_model: type | None = None,
|
|
677
|
+
response_model: type | None = None,
|
|
678
|
+
params_mapper: Callable[..., Any] | None = None,
|
|
679
|
+
response_mapper: Callable[..., Any] | None = None,
|
|
680
|
+
tags: list[str] | None = None,
|
|
681
|
+
summary: str = "",
|
|
682
|
+
description: str = "",
|
|
683
|
+
operation_id: str | None = None,
|
|
684
|
+
deprecated: bool = False,
|
|
685
|
+
*,
|
|
686
|
+
connections: Mapping[str, ConnectionValue] | None = None,
|
|
687
|
+
) -> Self:
|
|
688
|
+
"""Register POST endpoint. Returns self for fluent chain."""
|
|
689
|
+
return self._register(
|
|
690
|
+
"POST", path, action_class, request_model, response_model,
|
|
691
|
+
params_mapper, response_mapper, tags, summary, description,
|
|
692
|
+
operation_id, deprecated, connections=connections,
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
def get(
|
|
696
|
+
self,
|
|
697
|
+
path: str,
|
|
698
|
+
action_class: type[BaseAction[Any, Any]],
|
|
699
|
+
request_model: type | None = None,
|
|
700
|
+
response_model: type | None = None,
|
|
701
|
+
params_mapper: Callable[..., Any] | None = None,
|
|
702
|
+
response_mapper: Callable[..., Any] | None = None,
|
|
703
|
+
tags: list[str] | None = None,
|
|
704
|
+
summary: str = "",
|
|
705
|
+
description: str = "",
|
|
706
|
+
operation_id: str | None = None,
|
|
707
|
+
deprecated: bool = False,
|
|
708
|
+
*,
|
|
709
|
+
connections: Mapping[str, ConnectionValue] | None = None,
|
|
710
|
+
) -> Self:
|
|
711
|
+
"""Register GET endpoint. Returns self for fluent chain."""
|
|
712
|
+
return self._register(
|
|
713
|
+
"GET", path, action_class, request_model, response_model,
|
|
714
|
+
params_mapper, response_mapper, tags, summary, description,
|
|
715
|
+
operation_id, deprecated, connections=connections,
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
def put(
|
|
719
|
+
self,
|
|
720
|
+
path: str,
|
|
721
|
+
action_class: type[BaseAction[Any, Any]],
|
|
722
|
+
request_model: type | None = None,
|
|
723
|
+
response_model: type | None = None,
|
|
724
|
+
params_mapper: Callable[..., Any] | None = None,
|
|
725
|
+
response_mapper: Callable[..., Any] | None = None,
|
|
726
|
+
tags: list[str] | None = None,
|
|
727
|
+
summary: str = "",
|
|
728
|
+
description: str = "",
|
|
729
|
+
operation_id: str | None = None,
|
|
730
|
+
deprecated: bool = False,
|
|
731
|
+
*,
|
|
732
|
+
connections: Mapping[str, ConnectionValue] | None = None,
|
|
733
|
+
) -> Self:
|
|
734
|
+
"""Register PUT endpoint. Returns self for fluent chain."""
|
|
735
|
+
return self._register(
|
|
736
|
+
"PUT", path, action_class, request_model, response_model,
|
|
737
|
+
params_mapper, response_mapper, tags, summary, description,
|
|
738
|
+
operation_id, deprecated, connections=connections,
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
def delete(
|
|
742
|
+
self,
|
|
743
|
+
path: str,
|
|
744
|
+
action_class: type[BaseAction[Any, Any]],
|
|
745
|
+
request_model: type | None = None,
|
|
746
|
+
response_model: type | None = None,
|
|
747
|
+
params_mapper: Callable[..., Any] | None = None,
|
|
748
|
+
response_mapper: Callable[..., Any] | None = None,
|
|
749
|
+
tags: list[str] | None = None,
|
|
750
|
+
summary: str = "",
|
|
751
|
+
description: str = "",
|
|
752
|
+
operation_id: str | None = None,
|
|
753
|
+
deprecated: bool = False,
|
|
754
|
+
*,
|
|
755
|
+
connections: Mapping[str, ConnectionValue] | None = None,
|
|
756
|
+
) -> Self:
|
|
757
|
+
"""Register DELETE endpoint. Returns self for fluent chain."""
|
|
758
|
+
return self._register(
|
|
759
|
+
"DELETE", path, action_class, request_model, response_model,
|
|
760
|
+
params_mapper, response_mapper, tags, summary, description,
|
|
761
|
+
operation_id, deprecated, connections=connections,
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
def patch(
|
|
765
|
+
self,
|
|
766
|
+
path: str,
|
|
767
|
+
action_class: type[BaseAction[Any, Any]],
|
|
768
|
+
request_model: type | None = None,
|
|
769
|
+
response_model: type | None = None,
|
|
770
|
+
params_mapper: Callable[..., Any] | None = None,
|
|
771
|
+
response_mapper: Callable[..., Any] | None = None,
|
|
772
|
+
tags: list[str] | None = None,
|
|
773
|
+
summary: str = "",
|
|
774
|
+
description: str = "",
|
|
775
|
+
operation_id: str | None = None,
|
|
776
|
+
deprecated: bool = False,
|
|
777
|
+
*,
|
|
778
|
+
connections: Mapping[str, ConnectionValue] | None = None,
|
|
779
|
+
) -> Self:
|
|
780
|
+
"""Register PATCH endpoint. Returns self for fluent chain."""
|
|
781
|
+
return self._register(
|
|
782
|
+
"PATCH", path, action_class, request_model, response_model,
|
|
783
|
+
params_mapper, response_mapper, tags, summary, description,
|
|
784
|
+
operation_id, deprecated, connections=connections,
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
788
|
+
# FastAPI application build
|
|
789
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
790
|
+
|
|
791
|
+
def build(self) -> FastAPI:
|
|
792
|
+
"""
|
|
793
|
+
Create FastAPI application from registered routes.
|
|
794
|
+
|
|
795
|
+
Initialization order:
|
|
796
|
+
1. Create FastAPI app with OpenAPI metadata.
|
|
797
|
+
2. Add middleware for uncaught exception handling.
|
|
798
|
+
3. Register exception handlers.
|
|
799
|
+
4. Register health check endpoint ``GET /health``.
|
|
800
|
+
5. Generate/register endpoint for each route.
|
|
801
|
+
|
|
802
|
+
Returns:
|
|
803
|
+
Ready-to-run FastAPI application.
|
|
804
|
+
"""
|
|
805
|
+
app = FastAPI(
|
|
806
|
+
title=self._title,
|
|
807
|
+
version=self._version,
|
|
808
|
+
description=self._description,
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
app.add_middleware(_CatchAllErrorsMiddleware)
|
|
812
|
+
self._register_exception_handlers(app)
|
|
813
|
+
self._register_health_check(app)
|
|
814
|
+
|
|
815
|
+
for record in self._routes:
|
|
816
|
+
self._register_endpoint(app, record)
|
|
817
|
+
|
|
818
|
+
return app
|
|
819
|
+
|
|
820
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
821
|
+
# Endpoint generation
|
|
822
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
823
|
+
|
|
824
|
+
def _register_endpoint(self, app: FastAPI, record: FastApiRouteRecord) -> None:
|
|
825
|
+
"""
|
|
826
|
+
Generate and register one async endpoint from ``FastApiRouteRecord``.
|
|
827
|
+
"""
|
|
828
|
+
endpoint = _make_endpoint(
|
|
829
|
+
record=record,
|
|
830
|
+
machine=self._machine,
|
|
831
|
+
auth_coordinator=self._auth_coordinator,
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
app.add_api_route(
|
|
835
|
+
path=record.path,
|
|
836
|
+
endpoint=endpoint,
|
|
837
|
+
methods=[record.method],
|
|
838
|
+
response_model=record.effective_response_model,
|
|
839
|
+
tags=list(record.tags) if record.tags else None,
|
|
840
|
+
summary=record.summary or None,
|
|
841
|
+
description=record.description or None,
|
|
842
|
+
operation_id=record.operation_id,
|
|
843
|
+
deprecated=record.deprecated or None,
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
847
|
+
# Exception handlers
|
|
848
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
849
|
+
|
|
850
|
+
@staticmethod
|
|
851
|
+
def _register_exception_handlers(app: FastAPI) -> None:
|
|
852
|
+
"""
|
|
853
|
+
Register ActionMachine exception handlers at app level.
|
|
854
|
+
|
|
855
|
+
AuthorizationError -> HTTP 403 Forbidden
|
|
856
|
+
ValidationFieldError -> HTTP 422 Unprocessable Entity
|
|
857
|
+
"""
|
|
858
|
+
|
|
859
|
+
@app.exception_handler(AuthorizationError)
|
|
860
|
+
async def handle_authorization_error(
|
|
861
|
+
request: Request,
|
|
862
|
+
exc: AuthorizationError,
|
|
863
|
+
) -> JSONResponse:
|
|
864
|
+
return JSONResponse(
|
|
865
|
+
status_code=403,
|
|
866
|
+
content={"detail": str(exc)},
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
@app.exception_handler(ValidationFieldError)
|
|
870
|
+
async def handle_validation_error(
|
|
871
|
+
request: Request,
|
|
872
|
+
exc: ValidationFieldError,
|
|
873
|
+
) -> JSONResponse:
|
|
874
|
+
return JSONResponse(
|
|
875
|
+
status_code=422,
|
|
876
|
+
content={"detail": str(exc)},
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
880
|
+
# Health check
|
|
881
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
882
|
+
|
|
883
|
+
@staticmethod
|
|
884
|
+
def _register_health_check(app: FastAPI) -> None:
|
|
885
|
+
"""Add ``GET /health -> {"status": "ok"}`` endpoint."""
|
|
886
|
+
|
|
887
|
+
@app.get("/health", tags=["system"])
|
|
888
|
+
async def health_check() -> dict[str, str]:
|
|
889
|
+
return {"status": "ok"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# packages/aoa-fastapi-adapter/src/aoa/fastapi/query_field_before/__init__.py
|
|
2
|
+
"""
|
|
3
|
+
Reusable Pydantic ``BeforeValidator`` helpers for FastAPI query-shaped inputs.
|
|
4
|
+
|
|
5
|
+
Use with action ``Params`` fields typed as ``list[str]``. Prefer :data:`QUERY_STR_LIST_BEFORE`
|
|
6
|
+
for OpenAPI query arrays (repeated keys, no delimiter splitting inside values).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from aoa.fastapi.query_field_before.query_str_list import (
|
|
10
|
+
QUERY_STR_LIST_BEFORE,
|
|
11
|
+
coerce_query_str_list,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"QUERY_STR_LIST_BEFORE",
|
|
16
|
+
"coerce_query_str_list",
|
|
17
|
+
]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# packages/aoa-fastapi-adapter/src/aoa/fastapi/query_field_before/query_str_list.py
|
|
2
|
+
"""
|
|
3
|
+
Query array → ``list[str]`` coercion (OpenAPI ``explode``, no delimiter splitting).
|
|
4
|
+
|
|
5
|
+
AI-CORE-BEGIN
|
|
6
|
+
ROLE: Normalize FastAPI query inputs into a clean ``list[str]`` before Pydantic validates ``list[str]``.
|
|
7
|
+
CONTRACT: ``None`` / ``""`` → ``[]``; ``str`` → at most one token (strip only); ``list`` / ``tuple`` → strip each item, drop empties.
|
|
8
|
+
INVARIANTS: Does not split on commas or other delimiters inside values.
|
|
9
|
+
AI-CORE-END
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from functools import partial
|
|
15
|
+
|
|
16
|
+
from pydantic import BeforeValidator
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def coerce_query_str_list(value: object) -> list[str]:
|
|
20
|
+
"""
|
|
21
|
+
Normalize a query-style value to ``list[str]`` without delimiter splitting.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
value: ``None``, empty string, one non-empty string, or a sequence of values coercible to ``str``.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
List of stripped non-empty strings, order preserved.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
TypeError: If ``value`` is not ``None``, ``str``, ``list``, or ``tuple``.
|
|
31
|
+
"""
|
|
32
|
+
if value is None or value == "":
|
|
33
|
+
return []
|
|
34
|
+
if isinstance(value, str):
|
|
35
|
+
s = value.strip()
|
|
36
|
+
return [s] if s else []
|
|
37
|
+
if isinstance(value, (list, tuple)):
|
|
38
|
+
out: list[str] = []
|
|
39
|
+
for item in value:
|
|
40
|
+
s = str(item).strip()
|
|
41
|
+
if s:
|
|
42
|
+
out.append(s)
|
|
43
|
+
return out
|
|
44
|
+
msg = f"expected None, str, list, or tuple, got {type(value).__name__}"
|
|
45
|
+
raise TypeError(msg)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
#: Repeated query keys / one string token per value (see :func:`coerce_query_str_list`).
|
|
49
|
+
QUERY_STR_LIST_BEFORE = BeforeValidator(partial(coerce_query_str_list))
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# packages/aoa-fastapi-adapter/src/aoa/fastapi/route_record.py
|
|
2
|
+
"""
|
|
3
|
+
FastApiRouteRecord — frozen route record for FastAPI adapter.
|
|
4
|
+
|
|
5
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
6
|
+
PURPOSE
|
|
7
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
8
|
+
|
|
9
|
+
Concrete ``BaseRouteRecord`` subtype for HTTP transport metadata.
|
|
10
|
+
It stores one endpoint contract consumed by ``FastApiAdapter.build()``.
|
|
11
|
+
|
|
12
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
13
|
+
ARCHITECTURE / DATA FLOW
|
|
14
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
15
|
+
|
|
16
|
+
Protocol registration
|
|
17
|
+
|
|
|
18
|
+
v
|
|
19
|
+
FastApiAdapter.post/get/...(...)
|
|
20
|
+
|
|
|
21
|
+
v
|
|
22
|
+
FastApiRouteRecord(
|
|
23
|
+
action_class + mappers + HTTP metadata
|
|
24
|
+
)
|
|
25
|
+
|
|
|
26
|
+
v
|
|
27
|
+
FastApiAdapter.build()
|
|
28
|
+
|
|
|
29
|
+
v
|
|
30
|
+
FastAPI route + OpenAPI operation
|
|
31
|
+
|
|
32
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
33
|
+
HTTP-SPECIFIC FIELDS
|
|
34
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
35
|
+
|
|
36
|
+
- ``method``: HTTP method, default ``"POST"``.
|
|
37
|
+
- ``path``: endpoint URL path, default ``"/"``.
|
|
38
|
+
- ``tags``: OpenAPI tags, default ``()``.
|
|
39
|
+
- ``summary``: short OpenAPI summary, default ``""``.
|
|
40
|
+
- ``description``: detailed OpenAPI description, default ``""``.
|
|
41
|
+
- ``operation_id``: optional operation identifier, default ``None``.
|
|
42
|
+
- ``deprecated``: deprecation flag, default ``False``.
|
|
43
|
+
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
from dataclasses import dataclass
|
|
49
|
+
|
|
50
|
+
from aoa.action_machine.adapters.base_route_record import BaseRouteRecord
|
|
51
|
+
|
|
52
|
+
# Allowed HTTP methods.
|
|
53
|
+
_ALLOWED_METHODS: frozenset[str] = frozenset(
|
|
54
|
+
{
|
|
55
|
+
"GET",
|
|
56
|
+
"POST",
|
|
57
|
+
"PUT",
|
|
58
|
+
"DELETE",
|
|
59
|
+
"PATCH",
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class FastApiRouteRecord(BaseRouteRecord):
|
|
66
|
+
"""
|
|
67
|
+
AI-CORE-BEGIN
|
|
68
|
+
ROLE: Binds one action contract to one HTTP/OpenAPI endpoint declaration.
|
|
69
|
+
CONTRACT: Extends BaseRouteRecord with method/path/tags/docs metadata.
|
|
70
|
+
INVARIANTS: Frozen instance, validated method, validated path.
|
|
71
|
+
AI-CORE-END
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
# ── HTTP-specific fields ───────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
method: str = "POST"
|
|
77
|
+
path: str = "/"
|
|
78
|
+
tags: tuple[str, ...] = ()
|
|
79
|
+
summary: str = ""
|
|
80
|
+
description: str = ""
|
|
81
|
+
operation_id: str | None = None
|
|
82
|
+
deprecated: bool = False
|
|
83
|
+
|
|
84
|
+
# ── Validation ──────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
def __post_init__(self) -> None:
|
|
87
|
+
"""
|
|
88
|
+
Validate HTTP-specific invariants after instance construction.
|
|
89
|
+
|
|
90
|
+
Order:
|
|
91
|
+
|
|
92
|
+
1. Call ``super().__post_init__()`` to validate BaseRouteRecord
|
|
93
|
+
invariants (action class, mapper contracts, generic extraction).
|
|
94
|
+
|
|
95
|
+
2. Normalize method to uppercase.
|
|
96
|
+
Because this is a frozen dataclass, ``object.__setattr__`` is used.
|
|
97
|
+
|
|
98
|
+
3. Validate method against allowed set.
|
|
99
|
+
|
|
100
|
+
4. Validate non-empty path starting with ``/``.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
TypeError: from BaseRouteRecord when base invariants fail.
|
|
104
|
+
ValueError: from BaseRouteRecord mapper invariants, unsupported
|
|
105
|
+
method, empty path, or missing leading slash.
|
|
106
|
+
"""
|
|
107
|
+
# ── 1. BaseRouteRecord invariants ──
|
|
108
|
+
super().__post_init__()
|
|
109
|
+
|
|
110
|
+
# ── 2. Method normalization ──
|
|
111
|
+
normalized_method = self.method.upper()
|
|
112
|
+
object.__setattr__(self, "method", normalized_method)
|
|
113
|
+
|
|
114
|
+
# ── 3. Method validation ──
|
|
115
|
+
if normalized_method not in _ALLOWED_METHODS:
|
|
116
|
+
allowed = ", ".join(sorted(_ALLOWED_METHODS))
|
|
117
|
+
raise ValueError(f"method must be one of: {allowed}. " f"Got: '{self.method}'.")
|
|
118
|
+
|
|
119
|
+
# ── 4. Path validation ──
|
|
120
|
+
if not self.path or not self.path.strip():
|
|
121
|
+
raise ValueError("path cannot be empty. " "Provide an endpoint path, for example '/api/v1/orders'.")
|
|
122
|
+
|
|
123
|
+
if not self.path.startswith("/"):
|
|
124
|
+
raise ValueError(f"path must start with '/'. " f"Got: '{self.path}'. " f"Use a path like '/{self.path}'.")
|