python-flashapi 0.1.2__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. flashapi/__init__.py +8 -7
  2. flashapi/adapters/base.py +13 -12
  3. flashapi/adapters/django.py +771 -165
  4. flashapi/adapters/fastapi.py +879 -293
  5. flashapi/adapters/flask.py +728 -232
  6. flashapi/core/__init__.py +11 -3
  7. flashapi/core/custom_routes.py +5 -5
  8. flashapi/core/pluralize.py +80 -80
  9. flashapi/core/relations.py +59 -61
  10. flashapi/core/response.py +36 -33
  11. flashapi/core/schema.py +145 -83
  12. flashapi/core/visibility.py +46 -0
  13. flashapi/django.py +5 -5
  14. flashapi/docs/openapi.py +298 -223
  15. flashapi/fastapi.py +5 -5
  16. flashapi/features/__init__.py +6 -6
  17. flashapi/features/audit.py +84 -0
  18. flashapi/features/auth.py +134 -0
  19. flashapi/features/dashboard.py +412 -0
  20. flashapi/features/export.py +143 -0
  21. flashapi/features/filtering.py +124 -33
  22. flashapi/features/pagination.py +20 -20
  23. flashapi/features/rate_limit.py +45 -0
  24. flashapi/features/search.py +25 -25
  25. flashapi/features/sorting.py +23 -21
  26. flashapi/features/webhooks.py +84 -0
  27. flashapi/features/websocket.py +129 -0
  28. flashapi/flask.py +5 -5
  29. flashapi/inspectors/__init__.py +3 -3
  30. flashapi/inspectors/base.py +13 -11
  31. flashapi/inspectors/dataclass.py +50 -39
  32. flashapi/inspectors/detect.py +54 -48
  33. flashapi/inspectors/django.py +93 -83
  34. flashapi/inspectors/pydantic.py +99 -84
  35. flashapi/inspectors/sqlalchemy.py +83 -75
  36. flashapi/storage/__init__.py +4 -4
  37. flashapi/storage/auto.py +189 -106
  38. flashapi/storage/base.py +32 -26
  39. flashapi/storage/orm.py +125 -85
  40. flashapi/storage/sqlalchemy.py +56 -13
  41. python_flashapi-0.2.0.dist-info/METADATA +314 -0
  42. python_flashapi-0.2.0.dist-info/RECORD +48 -0
  43. python_flashapi-0.2.0.dist-info/licenses/LICENSE +190 -0
  44. python_flashapi-0.2.0.dist-info/licenses/NOTICE +5 -0
  45. python_flashapi-0.1.2.dist-info/METADATA +0 -259
  46. python_flashapi-0.1.2.dist-info/RECORD +0 -39
  47. python_flashapi-0.1.2.dist-info/licenses/LICENSE +0 -21
  48. {python_flashapi-0.1.2.dist-info → python_flashapi-0.2.0.dist-info}/WHEEL +0 -0
@@ -1,293 +1,879 @@
1
- from typing import Any, Callable, Optional
2
- from datetime import date, datetime, time
3
- import uuid
4
-
5
- from fastapi import FastAPI, HTTPException, Query, Request
6
- from pydantic import BaseModel, create_model
7
-
8
- from flashapi.core.schema import Model, ModelSchema, FieldType
9
- from flashapi.core.response import create_list_response, create_item_response
10
- from flashapi.core.relations import resolve_relations, find_expandable_fields
11
- from flashapi.features import paginate, apply_filters, apply_sorting, apply_search
12
- from flashapi.inspectors import inspect_model
13
- from flashapi.storage.auto import AutoStorage
14
- from flashapi.storage.sqlalchemy import SQLAlchemyStorage
15
-
16
-
17
- FIELD_TYPE_TO_PYTHON = {
18
- FieldType.STRING: str,
19
- FieldType.TEXT: str,
20
- FieldType.INTEGER: int,
21
- FieldType.FLOAT: float,
22
- FieldType.BOOLEAN: bool,
23
- FieldType.DATE: date,
24
- FieldType.DATETIME: datetime,
25
- FieldType.TIME: time,
26
- FieldType.UUID: uuid.UUID,
27
- FieldType.JSON: dict,
28
- FieldType.BINARY: bytes,
29
- }
30
-
31
-
32
- def _build_pydantic_model(schema: ModelSchema, *, all_optional: bool = False) -> type[BaseModel]:
33
- """Create a Pydantic model from a ModelSchema for request body validation."""
34
- fields = {}
35
- for f in schema.fields:
36
- if f.primary_key and f.auto_generated:
37
- continue
38
- python_type = FIELD_TYPE_TO_PYTHON.get(f.type, str)
39
- if f.required and not all_optional:
40
- fields[f.name] = (python_type, ...)
41
- else:
42
- fields[f.name] = (Optional[python_type], None)
43
- suffix = "Update" if all_optional else "Create"
44
- return create_model(f"{schema.name}{suffix}", **fields)
45
-
46
-
47
- class FlashAPI:
48
- """FastAPI adapter generates a full CRUD API from models."""
49
-
50
- def __init__(
51
- self,
52
- models: list,
53
- *,
54
- engine=None,
55
- database: str = "flashapi.db",
56
- docs: bool = True,
57
- formatter: Optional[Callable] = None,
58
- ):
59
- self._app = FastAPI(
60
- title="FlashAPI",
61
- description="Define your models. FlashAPI does the rest.",
62
- docs_url="/docs" if docs else None,
63
- redoc_url="/redoc" if docs else None,
64
- )
65
- self._engine = engine
66
- self._session_factory = None
67
- if engine is not None:
68
- from sqlalchemy.orm import sessionmaker
69
- self._session_factory = sessionmaker(bind=engine)
70
- self._auto_storage = AutoStorage(database) if engine is None else None
71
- self._formatter = formatter
72
- self._schemas: list[ModelSchema] = []
73
- self._storages: dict[str, Any] = {}
74
-
75
- for model_entry in models:
76
- self._prepare_model(model_entry)
77
-
78
- resolve_relations(self._schemas)
79
-
80
- for schema in self._schemas:
81
- self._create_routes(schema)
82
-
83
- self._register_relations()
84
-
85
- def _prepare_model(self, model_entry) -> None:
86
- if isinstance(model_entry, Model):
87
- wrapper = model_entry
88
- else:
89
- wrapper = Model(model_entry)
90
-
91
- schema = inspect_model(wrapper.model_class, plural=wrapper.plural)
92
- schema.permissions = wrapper.permissions
93
-
94
- is_sa = hasattr(wrapper.model_class, "__table__") and hasattr(wrapper.model_class, "__tablename__")
95
-
96
- if is_sa and self._session_factory is not None:
97
- storage = SQLAlchemyStorage(self._session_factory, wrapper.model_class)
98
- else:
99
- self._auto_storage.ensure_table(schema)
100
- storage = self._auto_storage
101
-
102
- self._storages[schema.plural] = storage
103
- self._schemas.append(schema)
104
-
105
- def _register_relations(self) -> None:
106
- parent_to_children = resolve_relations(self._schemas)
107
- formatter = self._formatter
108
-
109
- for parent_plural, relations in parent_to_children.items():
110
- for relation in relations:
111
- self._add_nested_list_route(
112
- parent_plural=parent_plural,
113
- child_plural=relation.target_plural,
114
- foreign_key=relation.foreign_key,
115
- parent_storage=self._storages.get(parent_plural),
116
- child_storage=self._storages.get(relation.target_plural),
117
- formatter=formatter,
118
- )
119
-
120
- def _create_routes(self, schema: ModelSchema) -> None:
121
- table = schema.plural
122
- field_names = {f.name for f in schema.fields if not f.primary_key}
123
- formatter = self._formatter
124
- storage = self._storages[table]
125
- expandable = find_expandable_fields(schema)
126
- create_model_cls = _build_pydantic_model(schema)
127
- update_model_cls = _build_pydantic_model(schema, all_optional=True)
128
-
129
- if "list" in schema.permissions:
130
- self._add_list_route(table, field_names, formatter, storage, schema.name, expandable)
131
-
132
- if "read" in schema.permissions:
133
- self._add_read_route(table, formatter, storage, schema.name, expandable)
134
-
135
- if "create" in schema.permissions:
136
- self._add_create_route(table, field_names, formatter, storage, schema.name, create_model_cls)
137
-
138
- if "update" in schema.permissions:
139
- self._add_update_route(table, field_names, formatter, storage, schema.name, update_model_cls)
140
-
141
- if "delete" in schema.permissions:
142
- self._add_delete_route(table, storage, schema.name)
143
-
144
- def _add_list_route(self, table, field_names, formatter, storage, tag, expandable):
145
- @self._app.get(f"/{table}", tags=[tag], name=f"{table}_list")
146
- async def route(
147
- request: Request,
148
- page: int = Query(1, ge=1),
149
- page_size: int = Query(20, ge=1, le=100),
150
- sort: Optional[str] = None,
151
- search: Optional[str] = None,
152
- expand: Optional[str] = None,
153
- ):
154
- items = storage.list_all(table)
155
- params = dict(request.query_params)
156
- items = apply_filters(items, params, field_names)
157
- items = apply_search(items, search, field_names)
158
- items = apply_sorting(items, sort, field_names)
159
- page_items, total = paginate(items, page, page_size)
160
-
161
- if expand:
162
- page_items = self._expand_items(page_items, expand, expandable)
163
-
164
- return create_list_response(page_items, total, page, page_size, formatter)
165
-
166
- def _add_read_route(self, table, formatter, storage, tag, expandable):
167
- @self._app.get(f"/{table}/{{item_id}}", tags=[tag], name=f"{table}_read")
168
- async def route(item_id: int, expand: Optional[str] = None):
169
- item = storage.get(table, item_id)
170
- if item is None:
171
- raise HTTPException(status_code=404, detail="Not found")
172
-
173
- if expand:
174
- item = self._expand_items([item], expand, expandable)[0]
175
-
176
- return create_item_response(item, formatter)
177
-
178
- def _add_create_route(self, table, field_names, formatter, storage, tag, body_model):
179
- @self._app.post(f"/{table}", status_code=201, tags=[tag], name=f"{table}_create")
180
- async def route(body: body_model):
181
- data = {k: v for k, v in body.model_dump(exclude_unset=True).items() if k in field_names}
182
- item = storage.create(table, data)
183
- return create_item_response(item, formatter)
184
-
185
- def _add_update_route(self, table, field_names, formatter, storage, tag, body_model):
186
- @self._app.put(f"/{table}/{{item_id}}", tags=[tag], name=f"{table}_update")
187
- async def route(item_id: int, body: body_model):
188
- data = {k: v for k, v in body.model_dump(exclude_unset=True).items() if k in field_names}
189
- item = storage.update(table, item_id, data)
190
- if item is None:
191
- raise HTTPException(status_code=404, detail="Not found")
192
- return create_item_response(item, formatter)
193
-
194
- def _add_delete_route(self, table, storage, tag):
195
- @self._app.delete(f"/{table}/{{item_id}}", status_code=204, tags=[tag], name=f"{table}_delete")
196
- async def route(item_id: int):
197
- deleted = storage.delete(table, item_id)
198
- if not deleted:
199
- raise HTTPException(status_code=404, detail="Not found")
200
-
201
- def _add_nested_list_route(self, parent_plural, child_plural, foreign_key, parent_storage, child_storage, formatter):
202
- @self._app.get(
203
- f"/{parent_plural}/{{parent_id}}/{child_plural}",
204
- tags=[parent_plural.title()],
205
- name=f"{parent_plural}_{child_plural}_nested",
206
- )
207
- async def route(
208
- parent_id: int,
209
- page: int = Query(1, ge=1),
210
- page_size: int = Query(20, ge=1, le=100),
211
- sort: Optional[str] = None,
212
- search: Optional[str] = None,
213
- ):
214
- parent = parent_storage.get(parent_plural, parent_id)
215
- if parent is None:
216
- raise HTTPException(status_code=404, detail="Parent not found")
217
-
218
- all_items = child_storage.list_all(child_plural)
219
- items = [i for i in all_items if i.get(foreign_key) == parent_id]
220
-
221
- child_fields = {k for item in items for k in item.keys() if k != "id"}
222
- if search:
223
- items = apply_search(items, search, child_fields)
224
- if sort:
225
- items = apply_sorting(items, sort, child_fields)
226
-
227
- page_items, total = paginate(items, page, page_size)
228
- return create_list_response(page_items, total, page, page_size, formatter)
229
-
230
- def _expand_items(self, items, expand_param, expandable):
231
- expand_fields = [f.strip() for f in expand_param.split(",")]
232
- expanded_items = []
233
-
234
- for item in items:
235
- item_copy = dict(item)
236
- for field_name in expand_fields:
237
- if field_name in expandable:
238
- target_plural = expandable[field_name]
239
- target_storage = self._storages.get(target_plural)
240
- if target_storage is None:
241
- continue
242
- fk_field = f"{field_name}_id"
243
- fk_value = item_copy.get(fk_field)
244
- if fk_value is not None:
245
- related = target_storage.get(target_plural, fk_value)
246
- if related:
247
- item_copy[field_name] = related
248
- expanded_items.append(item_copy)
249
-
250
- return expanded_items
251
-
252
- def get(self, path: str, *, tag: str = "Custom", summary: str = "", **kwargs):
253
- """Register a custom GET route — appears in Swagger docs."""
254
- def decorator(func):
255
- self._app.get(path, tags=[tag], summary=summary or f"GET {path}", **kwargs)(func)
256
- return func
257
- return decorator
258
-
259
- def post(self, path: str, *, tag: str = "Custom", summary: str = "", **kwargs):
260
- """Register a custom POST route — appears in Swagger docs."""
261
- def decorator(func):
262
- self._app.post(path, tags=[tag], summary=summary or f"POST {path}", **kwargs)(func)
263
- return func
264
- return decorator
265
-
266
- def put(self, path: str, *, tag: str = "Custom", summary: str = "", **kwargs):
267
- """Register a custom PUT route — appears in Swagger docs."""
268
- def decorator(func):
269
- self._app.put(path, tags=[tag], summary=summary or f"PUT {path}", **kwargs)(func)
270
- return func
271
- return decorator
272
-
273
- def delete(self, path: str, *, tag: str = "Custom", summary: str = "", **kwargs):
274
- """Register a custom DELETE route — appears in Swagger docs."""
275
- def decorator(func):
276
- self._app.delete(path, tags=[tag], summary=summary or f"DELETE {path}", **kwargs)(func)
277
- return func
278
- return decorator
279
-
280
- def patch(self, path: str, *, tag: str = "Custom", summary: str = "", **kwargs):
281
- """Register a custom PATCH route — appears in Swagger docs."""
282
- def decorator(func):
283
- self._app.patch(path, tags=[tag], summary=summary or f"PATCH {path}", **kwargs)(func)
284
- return func
285
- return decorator
286
-
287
- @property
288
- def app(self):
289
- return self._app
290
-
291
- def run(self, host: str = "0.0.0.0", port: int = 8000, **kwargs):
292
- import uvicorn
293
- uvicorn.run(self._app, host=host, port=port, **kwargs)
1
+ import uuid
2
+ from collections.abc import Callable
3
+ from datetime import date, datetime, time
4
+ from typing import Any
5
+
6
+ from fastapi import FastAPI, Query, Request
7
+ from fastapi.responses import JSONResponse, Response
8
+ from pydantic import BaseModel, create_model
9
+
10
+ from flashapi.core.relations import find_expandable_fields, resolve_relations
11
+ from flashapi.core.response import create_error_response, create_item_response, create_list_response
12
+ from flashapi.core.schema import FieldType, Model, ModelSchema
13
+ from flashapi.core.visibility import export_fields, filter_response, writable_fields
14
+ from flashapi.features import apply_filters, apply_search, apply_sorting, paginate
15
+ from flashapi.features.dashboard import DASHBOARD_HTML, MetricsCollector
16
+ from flashapi.features.export import CONTENT_TYPES, EXPORTERS
17
+ from flashapi.inspectors import inspect_model
18
+ from flashapi.storage.auto import AutoStorage
19
+ from flashapi.storage.sqlalchemy import SQLAlchemyStorage
20
+
21
+ DEFAULT_BASE_PATH = "/api"
22
+
23
+
24
+ def _parse_lookup_id(value: str, lookup_field: str):
25
+ """Parse the URL path parameter to the appropriate type."""
26
+ if lookup_field == "id":
27
+ try:
28
+ return int(value)
29
+ except ValueError:
30
+ return value
31
+ return value
32
+
33
+ FIELD_TYPE_TO_PYTHON = {
34
+ FieldType.STRING: str,
35
+ FieldType.TEXT: str,
36
+ FieldType.INTEGER: int,
37
+ FieldType.FLOAT: float,
38
+ FieldType.BOOLEAN: bool,
39
+ FieldType.DATE: date,
40
+ FieldType.DATETIME: datetime,
41
+ FieldType.TIME: time,
42
+ FieldType.UUID: uuid.UUID,
43
+ FieldType.JSON: dict,
44
+ FieldType.BINARY: bytes,
45
+ }
46
+
47
+
48
+ def _build_pydantic_model(schema: ModelSchema, *, all_optional: bool = False) -> type[BaseModel]:
49
+ fields = {}
50
+ for f in schema.fields:
51
+ if f.primary_key and f.auto_generated:
52
+ continue
53
+ if f.auto_generated or f.auto:
54
+ continue
55
+ if f.hidden or f.readonly:
56
+ continue
57
+ python_type = FIELD_TYPE_TO_PYTHON.get(f.type, str)
58
+ if f.required and not all_optional:
59
+ fields[f.name] = (python_type, ...)
60
+ else:
61
+ fields[f.name] = (python_type | None, None)
62
+ suffix = "Update" if all_optional else "Create"
63
+ return create_model(f"{schema.name}{suffix}", **fields)
64
+
65
+
66
+ class FlashAPI:
67
+ """FastAPI adapter generates a full CRUD API from models."""
68
+
69
+ def __init__(
70
+ self,
71
+ models: list,
72
+ *,
73
+ engine=None,
74
+ base_path: str = DEFAULT_BASE_PATH,
75
+ database: str = "flashapi.db",
76
+ docs: bool = True,
77
+ formatter: Callable | None = None,
78
+ audit: bool = True,
79
+ webhook_urls: list[str] | None = None,
80
+ rate_limit: int | None = None,
81
+ rate_window: int = 60,
82
+ auth_backend=None,
83
+ ) -> None:
84
+ self._app = FastAPI(
85
+ title="FlashAPI",
86
+ description="Define your models. FlashAPI does the rest.",
87
+ docs_url="/docs" if docs else None,
88
+ redoc_url="/redoc" if docs else None,
89
+ )
90
+ self._base_path = base_path.rstrip("/")
91
+ self._engine = engine
92
+ self._session_factory = None
93
+ if engine is not None:
94
+ from sqlalchemy.orm import sessionmaker
95
+ self._session_factory = sessionmaker(bind=engine)
96
+ self._auto_storage = AutoStorage(database) if engine is None else None
97
+ self._formatter = formatter
98
+ self._schemas: list[ModelSchema] = []
99
+ self._storages: dict[str, Any] = {}
100
+
101
+ # Audit
102
+ self._audit = None
103
+ if audit and self._auto_storage:
104
+ from flashapi.features.audit import AuditLog
105
+ self._audit = AuditLog(self._auto_storage._conn)
106
+
107
+ # Webhooks
108
+ self._webhook = None
109
+ if webhook_urls:
110
+ from flashapi.features.webhooks import WebhookDispatcher
111
+ self._webhook = WebhookDispatcher(webhook_urls)
112
+
113
+ # Rate limiting
114
+ self._rate_limiter = None
115
+ if rate_limit:
116
+ from flashapi.features.rate_limit import RateLimiter
117
+ self._rate_limiter = RateLimiter(limit=rate_limit, window=rate_window)
118
+ self._add_rate_limit_middleware()
119
+
120
+ # Metrics
121
+ self._metrics = MetricsCollector()
122
+
123
+ # Auth
124
+ self._auth_backend = auth_backend
125
+
126
+ for model_entry in models:
127
+ self._prepare_model(model_entry)
128
+
129
+ resolve_relations(self._schemas)
130
+
131
+ for schema in self._schemas:
132
+ self._metrics.register_entity(
133
+ schema.name,
134
+ soft_delete=schema.soft_delete,
135
+ audit=schema.audit,
136
+ webhook=bool(webhook_urls),
137
+ rate_limited=bool(rate_limit),
138
+ multi_tenant=schema.scope in ("tenant", "both"),
139
+ )
140
+ self._create_routes(schema)
141
+
142
+ self._register_relations()
143
+ self._add_dashboard_routes()
144
+ self._add_websocket_route()
145
+ self._add_api_root()
146
+
147
+ def _prepare_model(self, model_entry) -> None:
148
+ wrapper = model_entry if isinstance(model_entry, Model) else Model(model_entry)
149
+
150
+ schema = inspect_model(wrapper.model_class, plural=wrapper.plural)
151
+ schema.permissions = wrapper.permissions
152
+ schema.soft_delete = wrapper.soft_delete
153
+ schema.audit = wrapper.audit
154
+ schema.lookup_field = wrapper.lookup_field
155
+ schema.access = wrapper.access
156
+ schema.scope = wrapper.scope
157
+ schema.tenant_field = wrapper.tenant_field
158
+ schema.owner_field = wrapper.owner_field
159
+
160
+ from flashapi.core.schema import validate_soft_delete
161
+ validate_soft_delete(wrapper.model_class, wrapper.soft_delete)
162
+
163
+ is_sa = hasattr(wrapper.model_class, "__table__") and hasattr(wrapper.model_class, "__tablename__")
164
+
165
+ if is_sa and self._session_factory is not None:
166
+ storage = SQLAlchemyStorage(self._session_factory, wrapper.model_class)
167
+ else:
168
+ self._auto_storage.ensure_table(schema, soft_delete=schema.soft_delete)
169
+ storage = self._auto_storage
170
+
171
+ self._storages[schema.plural] = storage
172
+ self._schemas.append(schema)
173
+
174
+ def _add_dashboard_routes(self) -> None:
175
+ bp = self._base_path
176
+ metrics = self._metrics
177
+ webhook = self._webhook
178
+
179
+ @self._app.get(f"{bp}/dashboard", tags=["Dashboard"], name="dashboard_html", include_in_schema=False)
180
+ async def dashboard_html():
181
+ from fastapi.responses import HTMLResponse
182
+ return HTMLResponse(content=DASHBOARD_HTML)
183
+
184
+ @self._app.get(f"{bp}/dashboard/metrics.json", tags=["Dashboard"], name="dashboard_metrics")
185
+ async def dashboard_metrics():
186
+ return metrics.get_metrics(webhook)
187
+
188
+ def _add_websocket_route(self) -> None:
189
+ import json
190
+
191
+ from starlette.websockets import WebSocket, WebSocketDisconnect
192
+
193
+ from flashapi.features.websocket import get_hub
194
+
195
+ bp = self._base_path
196
+
197
+ class _FastAPIConnection:
198
+ def __init__(self, ws: WebSocket) -> None:
199
+ self._ws = ws
200
+
201
+ async def send_message(self, message: str) -> None:
202
+ await self._ws.send_text(message)
203
+
204
+ def __hash__(self):
205
+ return id(self._ws)
206
+
207
+ def __eq__(self, other):
208
+ return isinstance(other, _FastAPIConnection) and self._ws is other._ws
209
+
210
+ @self._app.websocket(f"{bp}/ws")
211
+ async def websocket_endpoint(websocket: WebSocket) -> None:
212
+ await websocket.accept()
213
+ hub = get_hub()
214
+ conn = _FastAPIConnection(websocket)
215
+ try:
216
+ while True:
217
+ text = await websocket.receive_text()
218
+ try:
219
+ msg = json.loads(text)
220
+ except (json.JSONDecodeError, ValueError):
221
+ continue
222
+
223
+ action = msg.get("action")
224
+ topic = msg.get("topic", "")
225
+
226
+ if action == "subscribe" and topic:
227
+ hub.subscribe(topic, conn)
228
+ elif action == "unsubscribe" and topic:
229
+ hub.unsubscribe(topic, conn)
230
+ except WebSocketDisconnect:
231
+ hub.remove_connection(conn)
232
+
233
+ def _add_api_root(self) -> None:
234
+ bp = self._base_path
235
+ schemas = self._schemas
236
+
237
+ @self._app.get(f"{bp}", tags=["Root"], name="api_root", include_in_schema=False)
238
+ @self._app.get(f"{bp}/", tags=["Root"], name="api_root_slash", include_in_schema=False)
239
+ async def api_root(request: Request):
240
+ base = str(request.base_url).rstrip("/") + bp
241
+ if not base.endswith("/"):
242
+ base += "/"
243
+ resources = {s.plural: base + s.plural + "/" for s in schemas}
244
+ links = {
245
+ "docs": base + "docs/",
246
+ "openapi": base + "openapi.json",
247
+ "dashboard": base + "dashboard/",
248
+ }
249
+ return {"resources": resources, "links": links}
250
+
251
+ def _add_rate_limit_middleware(self) -> None:
252
+ from starlette.middleware.base import BaseHTTPMiddleware
253
+
254
+ limiter = self._rate_limiter
255
+
256
+ class RateLimitMiddleware(BaseHTTPMiddleware):
257
+ async def dispatch(self, request, call_next):
258
+ client_ip = request.client.host if request.client else "unknown"
259
+ allowed, remaining, reset = limiter.check(client_ip)
260
+ if not allowed:
261
+ return JSONResponse(
262
+ status_code=429,
263
+ content={"error": "Rate limit exceeded", "status": 429, "retryAfter": reset},
264
+ headers={
265
+ "X-RateLimit-Limit": str(limiter.limit),
266
+ "X-RateLimit-Remaining": "0",
267
+ "X-RateLimit-Reset": str(reset),
268
+ },
269
+ )
270
+ response = await call_next(request)
271
+ response.headers["X-RateLimit-Limit"] = str(limiter.limit)
272
+ response.headers["X-RateLimit-Remaining"] = str(remaining)
273
+ response.headers["X-RateLimit-Reset"] = str(reset)
274
+ return response
275
+
276
+ self._app.add_middleware(RateLimitMiddleware)
277
+
278
+ def _register_relations(self) -> None:
279
+ parent_to_children = resolve_relations(self._schemas)
280
+ formatter = self._formatter
281
+
282
+ for parent_plural, relations in parent_to_children.items():
283
+ for relation in relations:
284
+ self._add_nested_list_route(
285
+ parent_plural=parent_plural,
286
+ child_plural=relation.target_plural,
287
+ foreign_key=relation.foreign_key,
288
+ parent_storage=self._storages.get(parent_plural),
289
+ child_storage=self._storages.get(relation.target_plural),
290
+ formatter=formatter,
291
+ )
292
+
293
+ def _check_auth(self, request: Request, operation: str, schema: ModelSchema):
294
+ """Returns (user, role, error_response). error_response is None if access granted."""
295
+ from flashapi.features.auth import check_access
296
+
297
+ if self._auth_backend is None:
298
+ return None, "admin", None
299
+
300
+ model_access = schema.access
301
+ if model_access is None or model_access == "public" or model_access is True:
302
+ if isinstance(model_access, dict):
303
+ op_access = model_access.get(operation, "public")
304
+ if op_access == "public":
305
+ return None, "public", None
306
+ else:
307
+ return None, "public", None
308
+
309
+ user = self._auth_backend.authenticate(request)
310
+ if user is None:
311
+ if isinstance(model_access, dict):
312
+ op_access = model_access.get(operation, "authenticated")
313
+ if op_access == "public":
314
+ return None, "public", None
315
+ return None, "public", JSONResponse(
316
+ status_code=401,
317
+ content=create_error_response("Authentication required", 401),
318
+ )
319
+
320
+ role = self._auth_backend.get_role(user)
321
+ if not check_access(role, model_access, operation):
322
+ return user, role, JSONResponse(
323
+ status_code=403,
324
+ content=create_error_response("Forbidden", 403),
325
+ )
326
+ return user, role, None
327
+
328
+ def _get_scope_filter(self, user, role: str, schema: ModelSchema):
329
+ """Returns the scope filter dict or None."""
330
+ from flashapi.features.auth import get_scope_filter
331
+ if self._auth_backend is None or user is None:
332
+ return None
333
+ return get_scope_filter(user, self._auth_backend, schema.scope, schema.tenant_field, schema.owner_field, role)
334
+
335
+ def _get_performer(self, user) -> str:
336
+ if self._auth_backend is None or user is None:
337
+ return ""
338
+ return self._auth_backend.get_user_identifier(user)
339
+
340
+ async def _broadcast(self, entity: str, action: str, data: dict | None = None) -> None:
341
+ from flashapi.features.websocket import EVENT_MAP, broadcast_event_async
342
+ event_type = EVENT_MAP.get(action)
343
+ if event_type:
344
+ await broadcast_event_async(entity, event_type, data)
345
+
346
+ def _create_routes(self, schema: ModelSchema) -> None:
347
+ table = schema.plural
348
+ field_names = {f.name for f in schema.fields if not f.primary_key}
349
+ input_fields = writable_fields(schema)
350
+ formatter = self._formatter
351
+ storage = self._storages[table]
352
+ expandable = find_expandable_fields(schema)
353
+ create_model_cls = _build_pydantic_model(schema)
354
+ update_model_cls = _build_pydantic_model(schema, all_optional=True)
355
+ model_schema = schema
356
+ lookup_field = schema.lookup_field
357
+
358
+ if "list" in schema.permissions:
359
+ self._add_list_route(table, field_names, formatter, storage, schema.name, expandable, model_schema)
360
+ self._add_export_route(table, storage, schema.name, model_schema)
361
+
362
+ if "create" in schema.permissions:
363
+ self._add_create_route(table, input_fields, formatter, storage, schema.name, create_model_cls, model_schema)
364
+ self._add_bulk_create_route(table, input_fields, formatter, storage, schema.name, model_schema)
365
+
366
+ if "delete" in schema.permissions and schema.soft_delete:
367
+ self._add_restore_route(table, storage, schema.name, lookup_field, model_schema=schema)
368
+
369
+ if "read" in schema.permissions:
370
+ self._add_read_route(table, formatter, storage, schema.name, expandable, model_schema, lookup_field)
371
+ if schema.audit:
372
+ self._add_history_route(table, schema.name, lookup_field, model_schema=schema)
373
+
374
+ if "update" in schema.permissions:
375
+ self._add_update_route(table, input_fields, formatter, storage, schema.name, update_model_cls, model_schema, lookup_field)
376
+ self._add_bulk_update_route(table, input_fields, formatter, storage, schema.name, model_schema, lookup_field)
377
+
378
+ if "delete" in schema.permissions:
379
+ self._add_delete_route(table, storage, schema.name, lookup_field)
380
+ self._add_bulk_delete_route(table, storage, schema.name, model_schema, lookup_field)
381
+
382
+ def _add_list_route(self, table, field_names, formatter, storage, tag, expandable, model_schema) -> None:
383
+ bp = self._base_path
384
+ metrics = self._metrics
385
+ supports_soft_delete = model_schema.soft_delete
386
+
387
+ @self._app.get(f"{bp}/{table}", tags=[tag], name=f"{table}_list")
388
+ async def route(
389
+ request: Request,
390
+ page: int = Query(0, ge=0),
391
+ size: int = Query(20, ge=1, le=100),
392
+ sort: str | None = None,
393
+ search: str | None = None,
394
+ expand: str | None = None,
395
+ deleted: bool = False,
396
+ ):
397
+ user, role, err = self._check_auth(request, "list", model_schema)
398
+ if err:
399
+ return err
400
+
401
+ only_deleted = deleted and supports_soft_delete
402
+ items = storage.list_all(table, only_deleted=only_deleted)
403
+
404
+ scope_filter = self._get_scope_filter(user, role, model_schema)
405
+ if scope_filter:
406
+ items = [i for i in items if all(i.get(k) == v for k, v in scope_filter.items())]
407
+
408
+ params = dict(request.query_params)
409
+ items = apply_filters(items, params, field_names)
410
+ if search:
411
+ metrics.record("SEARCH", tag)
412
+ items = apply_search(items, search, field_names)
413
+ items = apply_sorting(items, sort, field_names)
414
+ page_items, total = paginate(items, page, size)
415
+
416
+ if expand:
417
+ page_items = self._expand_items(page_items, expand, expandable)
418
+
419
+ metrics.record("READ", tag)
420
+ page_items = [filter_response(item, model_schema) for item in page_items]
421
+ return create_list_response(page_items, total, page, size, formatter)
422
+
423
+ def _add_read_route(self, table, formatter, storage, tag, expandable, model_schema, lookup_field="id") -> None:
424
+ bp = self._base_path
425
+ lf = lookup_field
426
+
427
+ @self._app.get(f"{bp}/{table}/{{item_id}}", tags=[tag], name=f"{table}_read")
428
+ async def route(request: Request, item_id: str, expand: str | None = None):
429
+ user, role, err = self._check_auth(request, "read", model_schema)
430
+ if err:
431
+ return err
432
+
433
+ lookup_id = _parse_lookup_id(item_id, lf)
434
+ item = storage.get(table, lookup_id, lookup_field=lf)
435
+ if item is None:
436
+ return JSONResponse(
437
+ status_code=404,
438
+ content=create_error_response("Not found", 404),
439
+ )
440
+
441
+ scope_filter = self._get_scope_filter(user, role, model_schema)
442
+ if scope_filter and not all(item.get(k) == v for k, v in scope_filter.items()):
443
+ return JSONResponse(
444
+ status_code=404,
445
+ content=create_error_response("Not found", 404),
446
+ )
447
+
448
+ if expand:
449
+ item = self._expand_items([item], expand, expandable)[0]
450
+
451
+ item = filter_response(item, model_schema)
452
+ return create_item_response(item, formatter)
453
+
454
+ def _add_history_route(self, table, entity_name, lookup_field="id", model_schema=None) -> None:
455
+ bp = self._base_path
456
+ audit = self._audit
457
+ _schema = model_schema
458
+
459
+ @self._app.get(f"{bp}/{table}/{{item_id}}/history", tags=[entity_name], name=f"{table}_history")
460
+ async def route(request: Request, item_id: str):
461
+ if _schema:
462
+ _user, _role, err = self._check_auth(request, "read", _schema)
463
+ if err:
464
+ return err
465
+
466
+ if audit is None:
467
+ return JSONResponse(status_code=404, content=create_error_response("Audit not enabled", 404))
468
+ history = audit.get_history(entity_name, item_id)
469
+ return {"data": history}
470
+
471
+ def _add_create_route(self, table, input_fields, formatter, storage, tag, body_model, model_schema) -> None:
472
+ bp = self._base_path
473
+ audit = self._audit
474
+ webhook = self._webhook
475
+ metrics = self._metrics
476
+ entity_audit = model_schema.audit
477
+
478
+ @self._app.post(f"{bp}/{table}", status_code=201, tags=[tag], name=f"{table}_create")
479
+ async def route(request: Request, body: body_model):
480
+ user, role, err = self._check_auth(request, "create", model_schema)
481
+ if err:
482
+ return err
483
+
484
+ data = {k: v for k, v in body.model_dump(exclude_unset=True).items() if k in input_fields}
485
+
486
+ scope_filter = self._get_scope_filter(user, role, model_schema)
487
+ if scope_filter:
488
+ data.update(scope_filter)
489
+
490
+ item = storage.create(table, data)
491
+ metrics.record("CREATE", tag, str(item.get("id", "")))
492
+ if audit and entity_audit:
493
+ audit.record("CREATE", tag, item.get("id", ""), performed_by=self._get_performer(user))
494
+ if webhook:
495
+ webhook.dispatch("CREATE", tag, item.get("id", ""), item)
496
+ await self._broadcast(tag, "CREATE", item)
497
+ item = filter_response(item, model_schema)
498
+ return create_item_response(item, formatter)
499
+
500
+ def _add_update_route(self, table, input_fields, formatter, storage, tag, body_model, model_schema, lookup_field="id") -> None:
501
+ bp = self._base_path
502
+ audit = self._audit
503
+ webhook = self._webhook
504
+ metrics = self._metrics
505
+ lf = lookup_field
506
+ entity_audit = model_schema.audit
507
+
508
+ @self._app.put(f"{bp}/{table}/{{item_id}}", tags=[tag], name=f"{table}_update")
509
+ async def route(request: Request, item_id: str, body: body_model):
510
+ user, role, err = self._check_auth(request, "update", model_schema)
511
+ if err:
512
+ return err
513
+
514
+ lookup_id = _parse_lookup_id(item_id, lf)
515
+ old_item = storage.get(table, lookup_id, lookup_field=lf)
516
+ if old_item is None:
517
+ return JSONResponse(
518
+ status_code=404,
519
+ content=create_error_response("Not found", 404),
520
+ )
521
+
522
+ scope_filter = self._get_scope_filter(user, role, model_schema)
523
+ if scope_filter and not all(old_item.get(k) == v for k, v in scope_filter.items()):
524
+ return JSONResponse(
525
+ status_code=404,
526
+ content=create_error_response("Not found", 404),
527
+ )
528
+
529
+ data = {k: v for k, v in body.model_dump(exclude_unset=True).items() if k in input_fields}
530
+ item = storage.update(table, lookup_id, data, lookup_field=lf)
531
+ if item is None:
532
+ return JSONResponse(
533
+ status_code=404,
534
+ content=create_error_response("Not found", 404),
535
+ )
536
+ metrics.record("UPDATE", tag, str(item_id))
537
+ if audit and entity_audit:
538
+ audit.record("UPDATE", tag, item_id, performed_by=self._get_performer(user), old_data=old_item, new_data=item)
539
+ if webhook:
540
+ webhook.dispatch("UPDATE", tag, item_id, item)
541
+ await self._broadcast(tag, "UPDATE", item)
542
+ item = filter_response(item, model_schema)
543
+ return create_item_response(item, formatter)
544
+
545
+ def _add_delete_route(self, table, storage, tag, lookup_field="id") -> None:
546
+ bp = self._base_path
547
+ audit = self._audit
548
+ webhook = self._webhook
549
+ metrics = self._metrics
550
+ lf = lookup_field
551
+ schema_audit = next((s.audit for s in self._schemas if s.name == tag), False)
552
+ schema_soft_delete = next((s.soft_delete for s in self._schemas if s.name == tag), False)
553
+ _model_schema = next((s for s in self._schemas if s.name == tag), None)
554
+
555
+ @self._app.delete(f"{bp}/{table}/{{item_id}}", status_code=204, tags=[tag], name=f"{table}_delete")
556
+ async def route(request: Request, item_id: str):
557
+ if _model_schema:
558
+ user, role, err = self._check_auth(request, "delete", _model_schema)
559
+ if err:
560
+ return err
561
+ else:
562
+ user, role = None, "admin"
563
+
564
+ lookup_id = _parse_lookup_id(item_id, lf)
565
+ existing = storage.get(table, lookup_id, lookup_field=lf)
566
+ if existing is None:
567
+ return JSONResponse(
568
+ status_code=404,
569
+ content=create_error_response("Not found", 404),
570
+ )
571
+
572
+ if _model_schema:
573
+ scope_filter = self._get_scope_filter(user, role, _model_schema)
574
+ if scope_filter and not all(existing.get(k) == v for k, v in scope_filter.items()):
575
+ return JSONResponse(
576
+ status_code=404,
577
+ content=create_error_response("Not found", 404),
578
+ )
579
+
580
+ deleted = storage.delete(table, lookup_id, soft=schema_soft_delete, lookup_field=lf)
581
+ if not deleted:
582
+ return JSONResponse(
583
+ status_code=404,
584
+ content=create_error_response("Not found", 404),
585
+ )
586
+ metrics.record("DELETE", tag, str(item_id))
587
+ if audit and schema_audit:
588
+ audit.record("DELETE", tag, item_id, performed_by=self._get_performer(user))
589
+ if webhook:
590
+ webhook.dispatch("DELETE", tag, item_id, {})
591
+ await self._broadcast(tag, "DELETE", {"id": str(item_id)})
592
+ return None
593
+
594
+ def _add_export_route(self, table, storage, tag, model_schema) -> None:
595
+ bp = self._base_path
596
+
597
+ @self._app.get(f"{bp}/{table}/export", tags=[tag], name=f"{table}_export")
598
+ async def route(request: Request, format: str = Query("csv"), fields: str = Query("")):
599
+ user, role, err = self._check_auth(request, "list", model_schema)
600
+ if err:
601
+ return err
602
+
603
+ fmt = format.lower()
604
+ if fmt not in EXPORTERS:
605
+ return JSONResponse(
606
+ status_code=400,
607
+ content=create_error_response(f"Unsupported format: {fmt}. Use csv, xlsx, or pdf", 400),
608
+ )
609
+ items = storage.list_all(table)
610
+
611
+ scope_filter = self._get_scope_filter(user, role, model_schema)
612
+ if scope_filter:
613
+ items = [i for i in items if all(i.get(k) == v for k, v in scope_filter.items())]
614
+
615
+ all_fields = sorted(export_fields(model_schema))
616
+ if fields:
617
+ export_cols = [f for f in fields.split(",") if f in all_fields]
618
+ if not export_cols:
619
+ return JSONResponse(
620
+ status_code=400,
621
+ content=create_error_response(f"No valid fields. Available: {', '.join(all_fields)}", 400),
622
+ )
623
+ else:
624
+ export_cols = all_fields
625
+ try:
626
+ content = EXPORTERS[fmt](items, export_cols)
627
+ except ImportError as e:
628
+ return JSONResponse(
629
+ content=create_error_response(str(e), 400), status_code=400,
630
+ )
631
+ return Response(
632
+ content=content,
633
+ media_type=CONTENT_TYPES[fmt],
634
+ headers={"Content-Disposition": f'attachment; filename="{table}.{fmt}"'},
635
+ )
636
+
637
+ def _add_bulk_create_route(self, table, input_fields, formatter, storage, tag, model_schema) -> None:
638
+ bp = self._base_path
639
+
640
+ @self._app.post(f"{bp}/{table}/bulk", status_code=201, tags=[tag], name=f"{table}_bulk_create")
641
+ async def route(request: Request):
642
+ user, role, err = self._check_auth(request, "create", model_schema)
643
+ if err:
644
+ return err
645
+
646
+ body = await request.json()
647
+ if not isinstance(body, list):
648
+ return JSONResponse(
649
+ status_code=400,
650
+ content=create_error_response("Request body must be a JSON array", 400),
651
+ )
652
+
653
+ scope_filter = self._get_scope_filter(user, role, model_schema)
654
+ succeeded = 0
655
+ failed = 0
656
+ results = []
657
+ for item_data in body:
658
+ try:
659
+ data = {k: v for k, v in item_data.items() if k in input_fields}
660
+ if scope_filter:
661
+ data.update(scope_filter)
662
+ item = storage.create(table, data)
663
+ item = filter_response(item, model_schema)
664
+ results.append(item)
665
+ succeeded += 1
666
+ except Exception:
667
+ failed += 1
668
+ return {
669
+ "data": results,
670
+ "meta": {"total": len(body), "succeeded": succeeded, "failed": failed},
671
+ }
672
+
673
+ def _add_bulk_update_route(self, table, input_fields, formatter, storage, tag, model_schema, lookup_field) -> None:
674
+ bp = self._base_path
675
+ lf = lookup_field
676
+
677
+ @self._app.put(f"{bp}/{table}/bulk", status_code=200, tags=[tag], name=f"{table}_bulk_update")
678
+ async def route(request: Request):
679
+ user, role, err = self._check_auth(request, "update", model_schema)
680
+ if err:
681
+ return err
682
+
683
+ body = await request.json()
684
+ if not isinstance(body, list):
685
+ return JSONResponse(
686
+ status_code=400,
687
+ content=create_error_response("Request body must be a JSON array", 400),
688
+ )
689
+
690
+ scope_filter = self._get_scope_filter(user, role, model_schema)
691
+ succeeded = 0
692
+ failed = 0
693
+ results = []
694
+ for item_data in body:
695
+ try:
696
+ item_id = item_data.get(lf)
697
+ if item_id is None:
698
+ failed += 1
699
+ continue
700
+ lookup_id = _parse_lookup_id(str(item_id), lf)
701
+ existing = storage.get(table, lookup_id, lookup_field=lf)
702
+ if existing is None:
703
+ failed += 1
704
+ continue
705
+ if scope_filter and not all(existing.get(k) == v for k, v in scope_filter.items()):
706
+ failed += 1
707
+ continue
708
+ data = {k: v for k, v in item_data.items() if k in input_fields and k != lf}
709
+ item = storage.update(table, lookup_id, data, lookup_field=lf)
710
+ if item:
711
+ item = filter_response(item, model_schema)
712
+ results.append(item)
713
+ succeeded += 1
714
+ else:
715
+ failed += 1
716
+ except Exception:
717
+ failed += 1
718
+ return {
719
+ "data": results,
720
+ "meta": {"total": len(body), "succeeded": succeeded, "failed": failed},
721
+ }
722
+
723
+ def _add_bulk_delete_route(self, table, storage, tag, model_schema, lookup_field) -> None:
724
+ bp = self._base_path
725
+ lf = lookup_field
726
+ soft = model_schema.soft_delete if model_schema else False
727
+
728
+ @self._app.delete(f"{bp}/{table}/bulk", status_code=200, tags=[tag], name=f"{table}_bulk_delete")
729
+ async def route(request: Request):
730
+ user, role, err = self._check_auth(request, "delete", model_schema)
731
+ if err:
732
+ return err
733
+
734
+ body = await request.json()
735
+ if not isinstance(body, list):
736
+ return JSONResponse(
737
+ status_code=400,
738
+ content=create_error_response("Request body must be a JSON array", 400),
739
+ )
740
+
741
+ scope_filter = self._get_scope_filter(user, role, model_schema)
742
+ succeeded = 0
743
+ failed = 0
744
+ for item_id in body:
745
+ try:
746
+ lookup_id = _parse_lookup_id(str(item_id), lf)
747
+ existing = storage.get(table, lookup_id, lookup_field=lf)
748
+ if existing is None:
749
+ failed += 1
750
+ continue
751
+ if scope_filter and not all(existing.get(k) == v for k, v in scope_filter.items()):
752
+ failed += 1
753
+ continue
754
+ deleted = storage.delete(table, lookup_id, soft=soft, lookup_field=lf)
755
+ if deleted:
756
+ succeeded += 1
757
+ else:
758
+ failed += 1
759
+ except Exception:
760
+ failed += 1
761
+ return {
762
+ "data": [],
763
+ "meta": {"total": len(body), "succeeded": succeeded, "failed": failed},
764
+ }
765
+
766
+ def _add_restore_route(self, table, storage, tag, lookup_field="id", model_schema=None) -> None:
767
+ bp = self._base_path
768
+ lf = lookup_field
769
+ _schema = model_schema
770
+
771
+ @self._app.post(f"{bp}/{table}/{{item_id}}/restore", status_code=204, tags=[tag], name=f"{table}_restore")
772
+ async def route(request: Request, item_id: str):
773
+ if _schema:
774
+ _user, _role, err = self._check_auth(request, "delete", _schema)
775
+ if err:
776
+ return err
777
+
778
+ lookup_id = _parse_lookup_id(item_id, lf)
779
+ restored = storage.restore(table, lookup_id, lookup_field=lf)
780
+ if not restored:
781
+ return JSONResponse(
782
+ status_code=404,
783
+ content=create_error_response("Not found", 404),
784
+ )
785
+ return None
786
+
787
+ def _add_nested_list_route(self, parent_plural, child_plural, foreign_key, parent_storage, child_storage, formatter) -> None:
788
+ bp = self._base_path
789
+
790
+ @self._app.get(
791
+ f"{bp}/{parent_plural}/{{parent_id}}/{child_plural}",
792
+ tags=[parent_plural.title()],
793
+ name=f"{parent_plural}_{child_plural}_nested",
794
+ )
795
+ async def route(
796
+ parent_id: int,
797
+ page: int = Query(0, ge=0),
798
+ size: int = Query(20, ge=1, le=100),
799
+ sort: str | None = None,
800
+ search: str | None = None,
801
+ ):
802
+ parent = parent_storage.get(parent_plural, parent_id)
803
+ if parent is None:
804
+ return JSONResponse(
805
+ status_code=404,
806
+ content=create_error_response("Parent not found", 404),
807
+ )
808
+
809
+ all_items = child_storage.list_all(child_plural)
810
+ items = [i for i in all_items if i.get(foreign_key) == parent_id]
811
+
812
+ child_fields = {k for item in items for k in item if k != "id"}
813
+ if search:
814
+ items = apply_search(items, search, child_fields)
815
+ if sort:
816
+ items = apply_sorting(items, sort, child_fields)
817
+
818
+ page_items, total = paginate(items, page, size)
819
+ return create_list_response(page_items, total, page, size, formatter)
820
+
821
+ def _expand_items(self, items, expand_param, expandable):
822
+ expand_fields = [f.strip() for f in expand_param.split(",")]
823
+ expanded_items = []
824
+
825
+ for item in items:
826
+ item_copy = dict(item)
827
+ for field_name in expand_fields:
828
+ if field_name in expandable:
829
+ target_plural = expandable[field_name]
830
+ target_storage = self._storages.get(target_plural)
831
+ if target_storage is None:
832
+ continue
833
+ fk_field = f"{field_name}_id"
834
+ fk_value = item_copy.get(fk_field)
835
+ if fk_value is not None:
836
+ related = target_storage.get(target_plural, fk_value)
837
+ if related:
838
+ item_copy[field_name] = related
839
+ expanded_items.append(item_copy)
840
+
841
+ return expanded_items
842
+
843
+ def get(self, path: str, *, tag: str = "Custom", summary: str = "", **kwargs):
844
+ def decorator(func):
845
+ self._app.get(path, tags=[tag], summary=summary or f"GET {path}", **kwargs)(func)
846
+ return func
847
+ return decorator
848
+
849
+ def post(self, path: str, *, tag: str = "Custom", summary: str = "", **kwargs):
850
+ def decorator(func):
851
+ self._app.post(path, tags=[tag], summary=summary or f"POST {path}", **kwargs)(func)
852
+ return func
853
+ return decorator
854
+
855
+ def put(self, path: str, *, tag: str = "Custom", summary: str = "", **kwargs):
856
+ def decorator(func):
857
+ self._app.put(path, tags=[tag], summary=summary or f"PUT {path}", **kwargs)(func)
858
+ return func
859
+ return decorator
860
+
861
+ def delete(self, path: str, *, tag: str = "Custom", summary: str = "", **kwargs):
862
+ def decorator(func):
863
+ self._app.delete(path, tags=[tag], summary=summary or f"DELETE {path}", **kwargs)(func)
864
+ return func
865
+ return decorator
866
+
867
+ def patch(self, path: str, *, tag: str = "Custom", summary: str = "", **kwargs):
868
+ def decorator(func):
869
+ self._app.patch(path, tags=[tag], summary=summary or f"PATCH {path}", **kwargs)(func)
870
+ return func
871
+ return decorator
872
+
873
+ @property
874
+ def app(self):
875
+ return self._app
876
+
877
+ def run(self, host: str = "0.0.0.0", port: int = 8000, **kwargs) -> None:
878
+ import uvicorn
879
+ uvicorn.run(self._app, host=host, port=port, **kwargs)