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.
- flashapi/__init__.py +8 -7
- flashapi/adapters/base.py +13 -12
- flashapi/adapters/django.py +771 -165
- flashapi/adapters/fastapi.py +879 -293
- flashapi/adapters/flask.py +728 -232
- flashapi/core/__init__.py +11 -3
- flashapi/core/custom_routes.py +5 -5
- flashapi/core/pluralize.py +80 -80
- flashapi/core/relations.py +59 -61
- flashapi/core/response.py +36 -33
- flashapi/core/schema.py +145 -83
- flashapi/core/visibility.py +46 -0
- flashapi/django.py +5 -5
- flashapi/docs/openapi.py +298 -223
- flashapi/fastapi.py +5 -5
- flashapi/features/__init__.py +6 -6
- flashapi/features/audit.py +84 -0
- flashapi/features/auth.py +134 -0
- flashapi/features/dashboard.py +412 -0
- flashapi/features/export.py +143 -0
- flashapi/features/filtering.py +124 -33
- flashapi/features/pagination.py +20 -20
- flashapi/features/rate_limit.py +45 -0
- flashapi/features/search.py +25 -25
- flashapi/features/sorting.py +23 -21
- flashapi/features/webhooks.py +84 -0
- flashapi/features/websocket.py +129 -0
- flashapi/flask.py +5 -5
- flashapi/inspectors/__init__.py +3 -3
- flashapi/inspectors/base.py +13 -11
- flashapi/inspectors/dataclass.py +50 -39
- flashapi/inspectors/detect.py +54 -48
- flashapi/inspectors/django.py +93 -83
- flashapi/inspectors/pydantic.py +99 -84
- flashapi/inspectors/sqlalchemy.py +83 -75
- flashapi/storage/__init__.py +4 -4
- flashapi/storage/auto.py +189 -106
- flashapi/storage/base.py +32 -26
- flashapi/storage/orm.py +125 -85
- flashapi/storage/sqlalchemy.py +56 -13
- python_flashapi-0.2.0.dist-info/METADATA +314 -0
- python_flashapi-0.2.0.dist-info/RECORD +48 -0
- python_flashapi-0.2.0.dist-info/licenses/LICENSE +190 -0
- python_flashapi-0.2.0.dist-info/licenses/NOTICE +5 -0
- python_flashapi-0.1.2.dist-info/METADATA +0 -259
- python_flashapi-0.1.2.dist-info/RECORD +0 -39
- python_flashapi-0.1.2.dist-info/licenses/LICENSE +0 -21
- {python_flashapi-0.1.2.dist-info → python_flashapi-0.2.0.dist-info}/WHEEL +0 -0
flashapi/adapters/fastapi.py
CHANGED
|
@@ -1,293 +1,879 @@
|
|
|
1
|
-
|
|
2
|
-
from
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
from
|
|
7
|
-
|
|
8
|
-
from
|
|
9
|
-
|
|
10
|
-
from flashapi.core.relations import
|
|
11
|
-
from flashapi.
|
|
12
|
-
from flashapi.
|
|
13
|
-
from flashapi.
|
|
14
|
-
from flashapi.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
self
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
self.
|
|
103
|
-
self.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
@self._app.
|
|
180
|
-
async def
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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)
|