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/flask.py
CHANGED
|
@@ -1,232 +1,728 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from typing import
|
|
4
|
-
|
|
5
|
-
from flashapi.core.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
from flashapi.
|
|
12
|
-
from flashapi.
|
|
13
|
-
from flashapi.
|
|
14
|
-
from flashapi.
|
|
15
|
-
from flashapi.
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from flashapi.core.custom_routes import (
|
|
6
|
+
CustomRoute,
|
|
7
|
+
custom_routes_to_openapi_paths,
|
|
8
|
+
discover_flask_views,
|
|
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 Model, ModelSchema
|
|
13
|
+
from flashapi.core.visibility import export_fields, filter_response, writable_fields
|
|
14
|
+
from flashapi.docs.openapi import generate_openapi_schema, get_swagger_html
|
|
15
|
+
from flashapi.features import apply_filters, apply_search, apply_sorting, paginate
|
|
16
|
+
from flashapi.inspectors import inspect_model
|
|
17
|
+
from flashapi.storage.auto import AutoStorage
|
|
18
|
+
from flashapi.storage.sqlalchemy import SQLAlchemyStorage
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
|
|
23
|
+
DEFAULT_BASE_PATH = "/api"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def register_models(
|
|
27
|
+
app,
|
|
28
|
+
models: list[type | Model],
|
|
29
|
+
*,
|
|
30
|
+
engine=None,
|
|
31
|
+
base_path: str = DEFAULT_BASE_PATH,
|
|
32
|
+
custom_routes: list[CustomRoute] | None = None,
|
|
33
|
+
database: str = "flashapi.db",
|
|
34
|
+
docs: bool = True,
|
|
35
|
+
formatter: Callable | None = None,
|
|
36
|
+
webhook_urls: list[str] | None = None,
|
|
37
|
+
rate_limit: int | None = None,
|
|
38
|
+
rate_window: int = 60,
|
|
39
|
+
auth_backend=None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Register models on an existing Flask app."""
|
|
42
|
+
from flask import Blueprint
|
|
43
|
+
|
|
44
|
+
session_factory = None
|
|
45
|
+
if engine is not None:
|
|
46
|
+
from sqlalchemy.orm import sessionmaker
|
|
47
|
+
session_factory = sessionmaker(bind=engine)
|
|
48
|
+
|
|
49
|
+
auto_storage = AutoStorage(database) if engine is None else None
|
|
50
|
+
blueprint = Blueprint("flashapi", __name__, url_prefix=base_path)
|
|
51
|
+
all_schemas: list[ModelSchema] = []
|
|
52
|
+
storages: dict[str, any] = {}
|
|
53
|
+
|
|
54
|
+
# Audit
|
|
55
|
+
audit_log = None
|
|
56
|
+
if auto_storage:
|
|
57
|
+
from flashapi.features.audit import AuditLog
|
|
58
|
+
audit_log = AuditLog(auto_storage._conn)
|
|
59
|
+
|
|
60
|
+
# Webhooks
|
|
61
|
+
webhook = None
|
|
62
|
+
if webhook_urls:
|
|
63
|
+
from flashapi.features.webhooks import WebhookDispatcher
|
|
64
|
+
webhook = WebhookDispatcher(webhook_urls)
|
|
65
|
+
|
|
66
|
+
# Rate limiting
|
|
67
|
+
rate_limiter = None
|
|
68
|
+
if rate_limit:
|
|
69
|
+
from flashapi.features.rate_limit import RateLimiter
|
|
70
|
+
rate_limiter = RateLimiter(limit=rate_limit, window=rate_window)
|
|
71
|
+
|
|
72
|
+
# Metrics
|
|
73
|
+
from flashapi.features.dashboard import MetricsCollector
|
|
74
|
+
metrics = MetricsCollector()
|
|
75
|
+
|
|
76
|
+
for model_entry in models:
|
|
77
|
+
wrapper = model_entry if isinstance(model_entry, Model) else Model(model_entry)
|
|
78
|
+
|
|
79
|
+
schema = inspect_model(wrapper.model_class, plural=wrapper.plural)
|
|
80
|
+
schema.permissions = wrapper.permissions
|
|
81
|
+
schema.soft_delete = wrapper.soft_delete
|
|
82
|
+
schema.audit = wrapper.audit
|
|
83
|
+
schema.lookup_field = wrapper.lookup_field
|
|
84
|
+
schema.access = wrapper.access
|
|
85
|
+
schema.scope = wrapper.scope
|
|
86
|
+
schema.tenant_field = wrapper.tenant_field
|
|
87
|
+
schema.owner_field = wrapper.owner_field
|
|
88
|
+
|
|
89
|
+
from flashapi.core.schema import validate_soft_delete
|
|
90
|
+
validate_soft_delete(wrapper.model_class, wrapper.soft_delete)
|
|
91
|
+
|
|
92
|
+
is_sa = hasattr(wrapper.model_class, "__table__") and hasattr(wrapper.model_class, "__tablename__")
|
|
93
|
+
|
|
94
|
+
if is_sa and session_factory is not None:
|
|
95
|
+
storage = SQLAlchemyStorage(session_factory, wrapper.model_class)
|
|
96
|
+
else:
|
|
97
|
+
auto_storage.ensure_table(schema, soft_delete=schema.soft_delete)
|
|
98
|
+
storage = auto_storage
|
|
99
|
+
|
|
100
|
+
storages[schema.plural] = storage
|
|
101
|
+
all_schemas.append(schema)
|
|
102
|
+
expandable = find_expandable_fields(schema)
|
|
103
|
+
|
|
104
|
+
metrics.register_entity(
|
|
105
|
+
schema.name,
|
|
106
|
+
soft_delete=schema.soft_delete,
|
|
107
|
+
audit=schema.audit,
|
|
108
|
+
webhook=bool(webhook_urls),
|
|
109
|
+
rate_limited=bool(rate_limit),
|
|
110
|
+
multi_tenant=schema.scope in ("tenant", "both"),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
_create_flask_routes(
|
|
114
|
+
blueprint, schema, storage, formatter, expandable, schema,
|
|
115
|
+
audit_log=audit_log, webhook=webhook, metrics=metrics,
|
|
116
|
+
auth_backend=auth_backend,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
parent_to_children = resolve_relations(all_schemas)
|
|
120
|
+
for parent_plural, relations in parent_to_children.items():
|
|
121
|
+
for relation in relations:
|
|
122
|
+
_create_nested_route(
|
|
123
|
+
blueprint, parent_plural, relation.target_plural,
|
|
124
|
+
relation.foreign_key, storages.get(relation.target_plural, storage), formatter,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Dashboard
|
|
128
|
+
_add_dashboard_routes(blueprint, metrics, webhook)
|
|
129
|
+
|
|
130
|
+
# WebSocket
|
|
131
|
+
_add_websocket_route(app, base_path)
|
|
132
|
+
|
|
133
|
+
# Rate limit middleware
|
|
134
|
+
if rate_limiter:
|
|
135
|
+
_add_rate_limit_middleware(app, rate_limiter)
|
|
136
|
+
|
|
137
|
+
if docs:
|
|
138
|
+
_add_docs_routes(blueprint, all_schemas, custom_routes or [], flask_app=app)
|
|
139
|
+
|
|
140
|
+
_add_api_root_route(blueprint, all_schemas, base_path, docs)
|
|
141
|
+
|
|
142
|
+
app.register_blueprint(blueprint)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _add_websocket_route(app, base_path: str) -> None:
|
|
146
|
+
"""Add WebSocket endpoint via flask-sock (optional dependency)."""
|
|
147
|
+
try:
|
|
148
|
+
from flask_sock import Sock
|
|
149
|
+
except ImportError:
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
import json
|
|
153
|
+
|
|
154
|
+
from flashapi.features.websocket import get_hub
|
|
155
|
+
|
|
156
|
+
sock = Sock(app)
|
|
157
|
+
|
|
158
|
+
class _FlaskConnection:
|
|
159
|
+
def __init__(self, ws) -> None:
|
|
160
|
+
self._ws = ws
|
|
161
|
+
|
|
162
|
+
def send_message(self, message: str) -> None:
|
|
163
|
+
self._ws.send(message)
|
|
164
|
+
|
|
165
|
+
def __hash__(self):
|
|
166
|
+
return id(self._ws)
|
|
167
|
+
|
|
168
|
+
def __eq__(self, other):
|
|
169
|
+
return isinstance(other, _FlaskConnection) and self._ws is other._ws
|
|
170
|
+
|
|
171
|
+
@sock.route(f"{base_path}/ws")
|
|
172
|
+
def websocket_endpoint(ws) -> None:
|
|
173
|
+
hub = get_hub()
|
|
174
|
+
conn = _FlaskConnection(ws)
|
|
175
|
+
try:
|
|
176
|
+
while True:
|
|
177
|
+
text = ws.receive()
|
|
178
|
+
if text is None:
|
|
179
|
+
break
|
|
180
|
+
try:
|
|
181
|
+
msg = json.loads(text)
|
|
182
|
+
except (json.JSONDecodeError, ValueError):
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
action = msg.get("action")
|
|
186
|
+
topic = msg.get("topic", "")
|
|
187
|
+
|
|
188
|
+
if action == "subscribe" and topic:
|
|
189
|
+
hub.subscribe(topic, conn)
|
|
190
|
+
elif action == "unsubscribe" and topic:
|
|
191
|
+
hub.unsubscribe(topic, conn)
|
|
192
|
+
except Exception:
|
|
193
|
+
pass
|
|
194
|
+
finally:
|
|
195
|
+
hub.remove_connection(conn)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _add_api_root_route(blueprint, schemas: list[ModelSchema], base_path: str, docs: bool) -> None:
|
|
199
|
+
from flask import jsonify, request
|
|
200
|
+
|
|
201
|
+
@blueprint.route("/", endpoint="flashapi_root")
|
|
202
|
+
def api_root():
|
|
203
|
+
base = request.url_root.rstrip("/") + base_path
|
|
204
|
+
if not base.endswith("/"):
|
|
205
|
+
base += "/"
|
|
206
|
+
resources = {s.plural: base + s.plural + "/" for s in schemas}
|
|
207
|
+
links = {}
|
|
208
|
+
if docs:
|
|
209
|
+
links["docs"] = base + "docs/"
|
|
210
|
+
links["openapi"] = base + "openapi.json"
|
|
211
|
+
links["dashboard"] = base + "dashboard/"
|
|
212
|
+
return jsonify({"resources": resources, "links": links})
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _add_rate_limit_middleware(app, rate_limiter) -> None:
|
|
216
|
+
from flask import jsonify, request
|
|
217
|
+
|
|
218
|
+
@app.before_request
|
|
219
|
+
def _check_rate_limit():
|
|
220
|
+
client_ip = request.remote_addr or "unknown"
|
|
221
|
+
allowed, _remaining, reset = rate_limiter.check(client_ip)
|
|
222
|
+
if not allowed:
|
|
223
|
+
response = jsonify({"error": "Rate limit exceeded", "status": 429, "retryAfter": reset})
|
|
224
|
+
response.status_code = 429
|
|
225
|
+
response.headers["X-RateLimit-Limit"] = str(rate_limiter.limit)
|
|
226
|
+
response.headers["X-RateLimit-Remaining"] = "0"
|
|
227
|
+
response.headers["X-RateLimit-Reset"] = str(reset)
|
|
228
|
+
return response
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
@app.after_request
|
|
232
|
+
def _add_rate_limit_headers(response):
|
|
233
|
+
client_ip = request.remote_addr or "unknown"
|
|
234
|
+
_allowed, remaining, reset = rate_limiter.check(client_ip)
|
|
235
|
+
response.headers["X-RateLimit-Limit"] = str(rate_limiter.limit)
|
|
236
|
+
response.headers["X-RateLimit-Remaining"] = str(remaining)
|
|
237
|
+
response.headers["X-RateLimit-Reset"] = str(reset)
|
|
238
|
+
return response
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _add_dashboard_routes(blueprint, metrics, webhook) -> None:
|
|
242
|
+
from flask import Response, jsonify
|
|
243
|
+
|
|
244
|
+
from flashapi.features.dashboard import DASHBOARD_HTML
|
|
245
|
+
|
|
246
|
+
@blueprint.route("/dashboard", methods=["GET"], endpoint="flashapi_dashboard")
|
|
247
|
+
def dashboard_html():
|
|
248
|
+
return Response(DASHBOARD_HTML, content_type="text/html")
|
|
249
|
+
|
|
250
|
+
@blueprint.route("/dashboard/metrics.json", methods=["GET"], endpoint="flashapi_dashboard_metrics")
|
|
251
|
+
def dashboard_metrics():
|
|
252
|
+
return jsonify(metrics.get_metrics(webhook))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _add_docs_routes(blueprint, schemas: list[ModelSchema], custom_routes: list[CustomRoute], flask_app=None) -> None:
|
|
256
|
+
from flask import Response, jsonify
|
|
257
|
+
|
|
258
|
+
openapi_spec = generate_openapi_schema(schemas)
|
|
259
|
+
|
|
260
|
+
if custom_routes:
|
|
261
|
+
custom_paths = custom_routes_to_openapi_paths(custom_routes)
|
|
262
|
+
openapi_spec["paths"].update(custom_paths)
|
|
263
|
+
|
|
264
|
+
_discovered = {"done": False}
|
|
265
|
+
|
|
266
|
+
@blueprint.route("/openapi.json", methods=["GET"], endpoint="flashapi_openapi")
|
|
267
|
+
def openapi_json():
|
|
268
|
+
if not _discovered["done"] and flask_app is not None:
|
|
269
|
+
discovered = discover_flask_views(flask_app)
|
|
270
|
+
openapi_spec["paths"].update(discovered)
|
|
271
|
+
_discovered["done"] = True
|
|
272
|
+
return jsonify(openapi_spec)
|
|
273
|
+
|
|
274
|
+
@blueprint.route("/docs", methods=["GET"], endpoint="flashapi_docs")
|
|
275
|
+
def docs_ui():
|
|
276
|
+
html = get_swagger_html(title="FlashAPI", openapi_url="/api/openapi.json")
|
|
277
|
+
return Response(html, content_type="text/html")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _create_nested_route(blueprint, parent_plural, child_plural, foreign_key, storage, formatter) -> None:
|
|
281
|
+
from flask import jsonify, request
|
|
282
|
+
|
|
283
|
+
@blueprint.route(
|
|
284
|
+
f"/{parent_plural}/<int:parent_id>/{child_plural}",
|
|
285
|
+
methods=["GET"],
|
|
286
|
+
endpoint=f"{parent_plural}_{child_plural}_nested",
|
|
287
|
+
)
|
|
288
|
+
def nested_list(parent_id, _pp=parent_plural, _cp=child_plural, _fk=foreign_key):
|
|
289
|
+
parent = storage.get(_pp, parent_id)
|
|
290
|
+
if parent is None:
|
|
291
|
+
return jsonify(create_error_response("Parent not found", 404)), 404
|
|
292
|
+
|
|
293
|
+
all_items = storage.list_all(_cp)
|
|
294
|
+
items = [i for i in all_items if i.get(_fk) == parent_id]
|
|
295
|
+
|
|
296
|
+
params = dict(request.args)
|
|
297
|
+
page = int(params.get("page", 0))
|
|
298
|
+
size = int(params.get("size", 20))
|
|
299
|
+
sort = params.get("sort")
|
|
300
|
+
search = params.get("search")
|
|
301
|
+
|
|
302
|
+
child_fields = {k for item in items for k in item if k != "id"}
|
|
303
|
+
if search:
|
|
304
|
+
items = apply_search(items, search, child_fields)
|
|
305
|
+
if sort:
|
|
306
|
+
items = apply_sorting(items, sort, child_fields)
|
|
307
|
+
|
|
308
|
+
page_items, total = paginate(items, page, size)
|
|
309
|
+
return jsonify(create_list_response(page_items, total, page, size, formatter))
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _expand_items(items, expand_param, expandable, storage):
|
|
313
|
+
expand_fields = [f.strip() for f in expand_param.split(",")]
|
|
314
|
+
expanded_items = []
|
|
315
|
+
|
|
316
|
+
for item in items:
|
|
317
|
+
item_copy = dict(item)
|
|
318
|
+
for field_name in expand_fields:
|
|
319
|
+
if field_name in expandable:
|
|
320
|
+
fk_field = f"{field_name}_id"
|
|
321
|
+
fk_value = item_copy.get(fk_field)
|
|
322
|
+
if fk_value is not None:
|
|
323
|
+
related = storage.get(expandable[field_name], fk_value)
|
|
324
|
+
if related:
|
|
325
|
+
item_copy[field_name] = related
|
|
326
|
+
expanded_items.append(item_copy)
|
|
327
|
+
|
|
328
|
+
return expanded_items
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _create_flask_routes(
|
|
332
|
+
blueprint,
|
|
333
|
+
schema: ModelSchema,
|
|
334
|
+
storage,
|
|
335
|
+
formatter: Callable | None,
|
|
336
|
+
expandable: dict,
|
|
337
|
+
model_schema: ModelSchema,
|
|
338
|
+
*,
|
|
339
|
+
audit_log=None,
|
|
340
|
+
webhook=None,
|
|
341
|
+
metrics=None,
|
|
342
|
+
auth_backend=None,
|
|
343
|
+
) -> None:
|
|
344
|
+
from flask import jsonify, request
|
|
345
|
+
|
|
346
|
+
from flashapi.features.auth import check_access, get_scope_filter
|
|
347
|
+
|
|
348
|
+
table = schema.plural
|
|
349
|
+
field_names = {f.name for f in schema.fields if not f.primary_key}
|
|
350
|
+
input_fields = writable_fields(model_schema)
|
|
351
|
+
lookup_field = schema.lookup_field
|
|
352
|
+
supports_soft_delete = schema.soft_delete
|
|
353
|
+
entity_audit = schema.audit
|
|
354
|
+
entity_name = schema.name
|
|
355
|
+
model_access = schema.access
|
|
356
|
+
model_scope = schema.scope
|
|
357
|
+
model_tenant_field = schema.tenant_field
|
|
358
|
+
model_owner_field = schema.owner_field
|
|
359
|
+
|
|
360
|
+
id_converter = "int" if lookup_field == "id" else "string"
|
|
361
|
+
|
|
362
|
+
def _check_auth(operation):
|
|
363
|
+
if auth_backend is None:
|
|
364
|
+
return None, "admin", None
|
|
365
|
+
|
|
366
|
+
if model_access is None or model_access == "public" or model_access is True:
|
|
367
|
+
if isinstance(model_access, dict):
|
|
368
|
+
op_access = model_access.get(operation, "public")
|
|
369
|
+
if op_access == "public":
|
|
370
|
+
return None, "public", None
|
|
371
|
+
else:
|
|
372
|
+
return None, "public", None
|
|
373
|
+
|
|
374
|
+
user = auth_backend.authenticate(request)
|
|
375
|
+
if user is None:
|
|
376
|
+
if isinstance(model_access, dict):
|
|
377
|
+
op_access = model_access.get(operation, "authenticated")
|
|
378
|
+
if op_access == "public":
|
|
379
|
+
return None, "public", None
|
|
380
|
+
return None, "public", (jsonify(create_error_response("Authentication required", 401)), 401)
|
|
381
|
+
|
|
382
|
+
role = auth_backend.get_role(user)
|
|
383
|
+
if not check_access(role, model_access, operation):
|
|
384
|
+
return user, role, (jsonify(create_error_response("Forbidden", 403)), 403)
|
|
385
|
+
return user, role, None
|
|
386
|
+
|
|
387
|
+
def _get_scope(user, role):
|
|
388
|
+
if auth_backend is None or user is None:
|
|
389
|
+
return None
|
|
390
|
+
return get_scope_filter(user, auth_backend, model_scope, model_tenant_field, model_owner_field, role)
|
|
391
|
+
|
|
392
|
+
def _get_performer(user):
|
|
393
|
+
if auth_backend is None or user is None:
|
|
394
|
+
return ""
|
|
395
|
+
return auth_backend.get_user_identifier(user)
|
|
396
|
+
|
|
397
|
+
def _broadcast(entity: str, action: str, data: dict | None = None) -> None:
|
|
398
|
+
from flashapi.features.websocket import EVENT_MAP, broadcast_event
|
|
399
|
+
event_type = EVENT_MAP.get(action)
|
|
400
|
+
if event_type:
|
|
401
|
+
broadcast_event(entity, event_type, data)
|
|
402
|
+
|
|
403
|
+
if "list" in schema.permissions:
|
|
404
|
+
@blueprint.route(f"/{table}", methods=["GET"], endpoint=f"{table}_list")
|
|
405
|
+
def list_items(_table=table, _fields=field_names, _exp=expandable, _schema=model_schema,
|
|
406
|
+
_metrics=metrics):
|
|
407
|
+
user, role, err = _check_auth("list")
|
|
408
|
+
if err:
|
|
409
|
+
return err
|
|
410
|
+
|
|
411
|
+
deleted_param = request.args.get("deleted", "false").lower() == "true"
|
|
412
|
+
only_deleted = deleted_param and supports_soft_delete
|
|
413
|
+
items = storage.list_all(_table, only_deleted=only_deleted)
|
|
414
|
+
|
|
415
|
+
scope_filter = _get_scope(user, role)
|
|
416
|
+
if scope_filter:
|
|
417
|
+
items = [i for i in items if all(i.get(k) == v for k, v in scope_filter.items())]
|
|
418
|
+
|
|
419
|
+
params = dict(request.args)
|
|
420
|
+
try:
|
|
421
|
+
page = max(0, int(params.get("page", 0)))
|
|
422
|
+
size = max(1, min(100, int(params.get("size", 20))))
|
|
423
|
+
except (ValueError, TypeError):
|
|
424
|
+
return jsonify(create_error_response("Invalid page or size parameter", 400)), 400
|
|
425
|
+
sort = params.get("sort")
|
|
426
|
+
search = params.get("search")
|
|
427
|
+
expand = params.get("expand")
|
|
428
|
+
|
|
429
|
+
items = apply_filters(items, params, _fields)
|
|
430
|
+
if search and _metrics:
|
|
431
|
+
_metrics.record("SEARCH", entity_name)
|
|
432
|
+
items = apply_search(items, search, _fields)
|
|
433
|
+
items = apply_sorting(items, sort, _fields)
|
|
434
|
+
page_items, total = paginate(items, page, size)
|
|
435
|
+
|
|
436
|
+
if expand:
|
|
437
|
+
page_items = _expand_items(page_items, expand, _exp, storage)
|
|
438
|
+
|
|
439
|
+
if _metrics:
|
|
440
|
+
_metrics.record("READ", entity_name)
|
|
441
|
+
page_items = [filter_response(item, _schema) for item in page_items]
|
|
442
|
+
return jsonify(create_list_response(page_items, total, page, size, formatter))
|
|
443
|
+
|
|
444
|
+
if "read" in schema.permissions:
|
|
445
|
+
@blueprint.route(f"/{table}/<{id_converter}:item_id>", methods=["GET"], endpoint=f"{table}_get")
|
|
446
|
+
def get_item(item_id, _table=table, _exp=expandable, _schema=model_schema, _lf=lookup_field):
|
|
447
|
+
user, role, err = _check_auth("read")
|
|
448
|
+
if err:
|
|
449
|
+
return err
|
|
450
|
+
|
|
451
|
+
item = storage.get(_table, item_id, lookup_field=_lf)
|
|
452
|
+
if item is None:
|
|
453
|
+
return jsonify(create_error_response("Not found", 404)), 404
|
|
454
|
+
|
|
455
|
+
scope_filter = _get_scope(user, role)
|
|
456
|
+
if scope_filter and not all(item.get(k) == v for k, v in scope_filter.items()):
|
|
457
|
+
return jsonify(create_error_response("Not found", 404)), 404
|
|
458
|
+
|
|
459
|
+
expand = request.args.get("expand")
|
|
460
|
+
if expand:
|
|
461
|
+
item = _expand_items([item], expand, _exp, storage)[0]
|
|
462
|
+
|
|
463
|
+
item = filter_response(item, _schema)
|
|
464
|
+
return jsonify(create_item_response(item, formatter))
|
|
465
|
+
|
|
466
|
+
if "read" in schema.permissions and entity_audit:
|
|
467
|
+
@blueprint.route(f"/{table}/<{id_converter}:item_id>/history", methods=["GET"], endpoint=f"{table}_history")
|
|
468
|
+
def history_item(item_id, _table=table, _entity=entity_name, _audit=audit_log):
|
|
469
|
+
_user, _role, err = _check_auth("read")
|
|
470
|
+
if err:
|
|
471
|
+
return err
|
|
472
|
+
|
|
473
|
+
if _audit is None:
|
|
474
|
+
return jsonify(create_error_response("Audit not enabled", 404)), 404
|
|
475
|
+
history = _audit.get_history(_entity, str(item_id))
|
|
476
|
+
return jsonify({"data": history})
|
|
477
|
+
|
|
478
|
+
if "create" in schema.permissions:
|
|
479
|
+
@blueprint.route(f"/{table}", methods=["POST"], endpoint=f"{table}_create")
|
|
480
|
+
def create_item(_table=table, _input=input_fields, _schema=model_schema,
|
|
481
|
+
_audit=audit_log, _webhook=webhook, _metrics=metrics):
|
|
482
|
+
user, role, err = _check_auth("create")
|
|
483
|
+
if err:
|
|
484
|
+
return err
|
|
485
|
+
|
|
486
|
+
body = request.get_json(silent=True)
|
|
487
|
+
if not body:
|
|
488
|
+
return jsonify(create_error_response("Request body is required", 400)), 400
|
|
489
|
+
data = {k: v for k, v in body.items() if k in _input}
|
|
490
|
+
|
|
491
|
+
scope_filter = _get_scope(user, role)
|
|
492
|
+
if scope_filter:
|
|
493
|
+
data.update(scope_filter)
|
|
494
|
+
|
|
495
|
+
item = storage.create(_table, data)
|
|
496
|
+
if _metrics:
|
|
497
|
+
_metrics.record("CREATE", entity_name, str(item.get("id", "")))
|
|
498
|
+
if _audit and entity_audit:
|
|
499
|
+
_audit.record("CREATE", entity_name, item.get("id", ""), performed_by=_get_performer(user))
|
|
500
|
+
if _webhook:
|
|
501
|
+
_webhook.dispatch("CREATE", entity_name, item.get("id", ""), item)
|
|
502
|
+
_broadcast(entity_name, "CREATE", item)
|
|
503
|
+
item = filter_response(item, _schema)
|
|
504
|
+
return jsonify(create_item_response(item, formatter)), 201
|
|
505
|
+
|
|
506
|
+
if "update" in schema.permissions:
|
|
507
|
+
@blueprint.route(f"/{table}/<{id_converter}:item_id>", methods=["PUT"], endpoint=f"{table}_update")
|
|
508
|
+
def update_item(item_id, _table=table, _input=input_fields, _schema=model_schema,
|
|
509
|
+
_lf=lookup_field, _audit=audit_log, _webhook=webhook, _metrics=metrics):
|
|
510
|
+
user, role, err = _check_auth("update")
|
|
511
|
+
if err:
|
|
512
|
+
return err
|
|
513
|
+
|
|
514
|
+
body = request.get_json(silent=True)
|
|
515
|
+
if not body:
|
|
516
|
+
return jsonify(create_error_response("Request body is required", 400)), 400
|
|
517
|
+
|
|
518
|
+
old_item = storage.get(_table, item_id, lookup_field=_lf)
|
|
519
|
+
if old_item is None:
|
|
520
|
+
return jsonify(create_error_response("Not found", 404)), 404
|
|
521
|
+
|
|
522
|
+
scope_filter = _get_scope(user, role)
|
|
523
|
+
if scope_filter and not all(old_item.get(k) == v for k, v in scope_filter.items()):
|
|
524
|
+
return jsonify(create_error_response("Not found", 404)), 404
|
|
525
|
+
|
|
526
|
+
data = {k: v for k, v in body.items() if k in _input}
|
|
527
|
+
item = storage.update(_table, item_id, data, lookup_field=_lf)
|
|
528
|
+
if item is None:
|
|
529
|
+
return jsonify(create_error_response("Not found", 404)), 404
|
|
530
|
+
if _metrics:
|
|
531
|
+
_metrics.record("UPDATE", entity_name, str(item_id))
|
|
532
|
+
if _audit and entity_audit:
|
|
533
|
+
_audit.record("UPDATE", entity_name, item_id, performed_by=_get_performer(user), old_data=old_item, new_data=item)
|
|
534
|
+
if _webhook:
|
|
535
|
+
_webhook.dispatch("UPDATE", entity_name, item_id, item)
|
|
536
|
+
_broadcast(entity_name, "UPDATE", item)
|
|
537
|
+
item = filter_response(item, _schema)
|
|
538
|
+
return jsonify(create_item_response(item, formatter))
|
|
539
|
+
|
|
540
|
+
if "delete" in schema.permissions:
|
|
541
|
+
@blueprint.route(f"/{table}/<{id_converter}:item_id>", methods=["DELETE"], endpoint=f"{table}_delete")
|
|
542
|
+
def delete_item(item_id, _table=table, _lf=lookup_field, _audit=audit_log,
|
|
543
|
+
_webhook=webhook, _metrics=metrics):
|
|
544
|
+
user, role, err = _check_auth("delete")
|
|
545
|
+
if err:
|
|
546
|
+
return err
|
|
547
|
+
|
|
548
|
+
existing = storage.get(_table, item_id, lookup_field=_lf)
|
|
549
|
+
if existing is None:
|
|
550
|
+
return jsonify(create_error_response("Not found", 404)), 404
|
|
551
|
+
|
|
552
|
+
scope_filter = _get_scope(user, role)
|
|
553
|
+
if scope_filter and not all(existing.get(k) == v for k, v in scope_filter.items()):
|
|
554
|
+
return jsonify(create_error_response("Not found", 404)), 404
|
|
555
|
+
|
|
556
|
+
deleted = storage.delete(_table, item_id, soft=supports_soft_delete, lookup_field=_lf)
|
|
557
|
+
if not deleted:
|
|
558
|
+
return jsonify(create_error_response("Not found", 404)), 404
|
|
559
|
+
if _metrics:
|
|
560
|
+
_metrics.record("DELETE", entity_name, str(item_id))
|
|
561
|
+
if _audit and entity_audit:
|
|
562
|
+
_audit.record("DELETE", entity_name, item_id, performed_by=_get_performer(user))
|
|
563
|
+
if _webhook:
|
|
564
|
+
_webhook.dispatch("DELETE", entity_name, item_id, {})
|
|
565
|
+
_broadcast(entity_name, "DELETE", {"id": str(item_id)})
|
|
566
|
+
return "", 204
|
|
567
|
+
|
|
568
|
+
if supports_soft_delete:
|
|
569
|
+
@blueprint.route(f"/{table}/<{id_converter}:item_id>/restore", methods=["POST"], endpoint=f"{table}_restore")
|
|
570
|
+
def restore_item(item_id, _table=table, _lf=lookup_field):
|
|
571
|
+
_user, _role, err = _check_auth("delete")
|
|
572
|
+
if err:
|
|
573
|
+
return err
|
|
574
|
+
|
|
575
|
+
restored = storage.restore(_table, item_id, lookup_field=_lf)
|
|
576
|
+
if not restored:
|
|
577
|
+
return jsonify(create_error_response("Not found", 404)), 404
|
|
578
|
+
_broadcast(entity_name, "RESTORE", {"id": str(item_id)})
|
|
579
|
+
return "", 204
|
|
580
|
+
|
|
581
|
+
if "create" in schema.permissions:
|
|
582
|
+
@blueprint.route(f"/{table}/bulk", methods=["POST"], endpoint=f"{table}_bulk_create")
|
|
583
|
+
def bulk_create(_table=table, _input=input_fields, _schema=model_schema):
|
|
584
|
+
user, role, err = _check_auth("create")
|
|
585
|
+
if err:
|
|
586
|
+
return err
|
|
587
|
+
|
|
588
|
+
body = request.get_json(silent=True)
|
|
589
|
+
if not isinstance(body, list):
|
|
590
|
+
return jsonify(create_error_response("Request body must be a JSON array", 400)), 400
|
|
591
|
+
|
|
592
|
+
scope_filter = _get_scope(user, role)
|
|
593
|
+
succeeded = 0
|
|
594
|
+
failed = 0
|
|
595
|
+
results = []
|
|
596
|
+
for item_data in body:
|
|
597
|
+
try:
|
|
598
|
+
data = {k: v for k, v in item_data.items() if k in _input}
|
|
599
|
+
if scope_filter:
|
|
600
|
+
data.update(scope_filter)
|
|
601
|
+
item = storage.create(_table, data)
|
|
602
|
+
item = filter_response(item, _schema)
|
|
603
|
+
results.append(item)
|
|
604
|
+
succeeded += 1
|
|
605
|
+
except Exception:
|
|
606
|
+
failed += 1
|
|
607
|
+
return jsonify({
|
|
608
|
+
"data": results,
|
|
609
|
+
"meta": {"total": len(body), "succeeded": succeeded, "failed": failed},
|
|
610
|
+
}), 201
|
|
611
|
+
|
|
612
|
+
if "update" in schema.permissions:
|
|
613
|
+
@blueprint.route(f"/{table}/bulk", methods=["PUT"], endpoint=f"{table}_bulk_update")
|
|
614
|
+
def bulk_update(_table=table, _input=input_fields, _schema=model_schema, _lf=lookup_field):
|
|
615
|
+
user, role, err = _check_auth("update")
|
|
616
|
+
if err:
|
|
617
|
+
return err
|
|
618
|
+
|
|
619
|
+
body = request.get_json(silent=True)
|
|
620
|
+
if not isinstance(body, list):
|
|
621
|
+
return jsonify(create_error_response("Request body must be a JSON array", 400)), 400
|
|
622
|
+
|
|
623
|
+
scope_filter = _get_scope(user, role)
|
|
624
|
+
succeeded = 0
|
|
625
|
+
failed = 0
|
|
626
|
+
results = []
|
|
627
|
+
for item_data in body:
|
|
628
|
+
try:
|
|
629
|
+
item_id = item_data.get(_lf)
|
|
630
|
+
if item_id is None:
|
|
631
|
+
failed += 1
|
|
632
|
+
continue
|
|
633
|
+
existing = storage.get(_table, item_id, lookup_field=_lf)
|
|
634
|
+
if existing is None:
|
|
635
|
+
failed += 1
|
|
636
|
+
continue
|
|
637
|
+
if scope_filter and not all(existing.get(k) == v for k, v in scope_filter.items()):
|
|
638
|
+
failed += 1
|
|
639
|
+
continue
|
|
640
|
+
data = {k: v for k, v in item_data.items() if k in _input and k != _lf}
|
|
641
|
+
item = storage.update(_table, item_id, data, lookup_field=_lf)
|
|
642
|
+
if item:
|
|
643
|
+
item = filter_response(item, _schema)
|
|
644
|
+
results.append(item)
|
|
645
|
+
succeeded += 1
|
|
646
|
+
else:
|
|
647
|
+
failed += 1
|
|
648
|
+
except Exception:
|
|
649
|
+
failed += 1
|
|
650
|
+
return jsonify({
|
|
651
|
+
"data": results,
|
|
652
|
+
"meta": {"total": len(body), "succeeded": succeeded, "failed": failed},
|
|
653
|
+
}), 200
|
|
654
|
+
|
|
655
|
+
if "delete" in schema.permissions:
|
|
656
|
+
@blueprint.route(f"/{table}/bulk", methods=["DELETE"], endpoint=f"{table}_bulk_delete")
|
|
657
|
+
def bulk_delete(_table=table, _lf=lookup_field, _schema=model_schema):
|
|
658
|
+
user, role, err = _check_auth("delete")
|
|
659
|
+
if err:
|
|
660
|
+
return err
|
|
661
|
+
|
|
662
|
+
body = request.get_json(silent=True)
|
|
663
|
+
if not isinstance(body, list):
|
|
664
|
+
return jsonify(create_error_response("Request body must be a JSON array", 400)), 400
|
|
665
|
+
|
|
666
|
+
scope_filter = _get_scope(user, role)
|
|
667
|
+
succeeded = 0
|
|
668
|
+
failed = 0
|
|
669
|
+
for item_id in body:
|
|
670
|
+
try:
|
|
671
|
+
existing = storage.get(_table, item_id, lookup_field=_lf)
|
|
672
|
+
if existing is None:
|
|
673
|
+
failed += 1
|
|
674
|
+
continue
|
|
675
|
+
if scope_filter and not all(existing.get(k) == v for k, v in scope_filter.items()):
|
|
676
|
+
failed += 1
|
|
677
|
+
continue
|
|
678
|
+
deleted = storage.delete(_table, item_id, soft=supports_soft_delete, lookup_field=_lf)
|
|
679
|
+
if deleted:
|
|
680
|
+
succeeded += 1
|
|
681
|
+
else:
|
|
682
|
+
failed += 1
|
|
683
|
+
except Exception:
|
|
684
|
+
failed += 1
|
|
685
|
+
return jsonify({
|
|
686
|
+
"data": [],
|
|
687
|
+
"meta": {"total": len(body), "succeeded": succeeded, "failed": failed},
|
|
688
|
+
}), 200
|
|
689
|
+
|
|
690
|
+
if "list" in schema.permissions:
|
|
691
|
+
from flashapi.features.export import CONTENT_TYPES, EXPORTERS
|
|
692
|
+
|
|
693
|
+
@blueprint.route(f"/{table}/export", methods=["GET"], endpoint=f"{table}_export")
|
|
694
|
+
def export_items(_table=table, _schema=model_schema):
|
|
695
|
+
from flask import Response as FlaskResponse
|
|
696
|
+
|
|
697
|
+
user, role, err = _check_auth("list")
|
|
698
|
+
if err:
|
|
699
|
+
return err
|
|
700
|
+
|
|
701
|
+
fmt = request.args.get("format", "csv").lower()
|
|
702
|
+
if fmt not in EXPORTERS:
|
|
703
|
+
return jsonify(create_error_response(
|
|
704
|
+
f"Unsupported format: {fmt}. Use csv, xlsx, or pdf", 400,
|
|
705
|
+
)), 400
|
|
706
|
+
items = storage.list_all(_table)
|
|
707
|
+
|
|
708
|
+
scope_filter = _get_scope(user, role)
|
|
709
|
+
if scope_filter:
|
|
710
|
+
items = [i for i in items if all(i.get(k) == v for k, v in scope_filter.items())]
|
|
711
|
+
|
|
712
|
+
all_fields = sorted(export_fields(_schema))
|
|
713
|
+
requested = request.args.get("fields", "")
|
|
714
|
+
if requested:
|
|
715
|
+
fields = [f for f in requested.split(",") if f in all_fields]
|
|
716
|
+
if not fields:
|
|
717
|
+
return jsonify(create_error_response(f"No valid fields. Available: {', '.join(all_fields)}", 400)), 400
|
|
718
|
+
else:
|
|
719
|
+
fields = all_fields
|
|
720
|
+
try:
|
|
721
|
+
content = EXPORTERS[fmt](items, fields)
|
|
722
|
+
except ImportError as e:
|
|
723
|
+
return jsonify(create_error_response(str(e), 400)), 400
|
|
724
|
+
return FlaskResponse(
|
|
725
|
+
content,
|
|
726
|
+
mimetype=CONTENT_TYPES[fmt],
|
|
727
|
+
headers={"Content-Disposition": f'attachment; filename="{_table}.{fmt}"'},
|
|
728
|
+
)
|