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.
@@ -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}'.")