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