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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. flashapi/__init__.py +8 -7
  2. flashapi/adapters/base.py +13 -12
  3. flashapi/adapters/django.py +771 -165
  4. flashapi/adapters/fastapi.py +879 -293
  5. flashapi/adapters/flask.py +728 -232
  6. flashapi/core/__init__.py +11 -3
  7. flashapi/core/custom_routes.py +5 -5
  8. flashapi/core/pluralize.py +80 -80
  9. flashapi/core/relations.py +59 -61
  10. flashapi/core/response.py +36 -33
  11. flashapi/core/schema.py +145 -83
  12. flashapi/core/visibility.py +46 -0
  13. flashapi/django.py +5 -5
  14. flashapi/docs/openapi.py +298 -223
  15. flashapi/fastapi.py +5 -5
  16. flashapi/features/__init__.py +6 -6
  17. flashapi/features/audit.py +84 -0
  18. flashapi/features/auth.py +134 -0
  19. flashapi/features/dashboard.py +412 -0
  20. flashapi/features/export.py +143 -0
  21. flashapi/features/filtering.py +124 -33
  22. flashapi/features/pagination.py +20 -20
  23. flashapi/features/rate_limit.py +45 -0
  24. flashapi/features/search.py +25 -25
  25. flashapi/features/sorting.py +23 -21
  26. flashapi/features/webhooks.py +84 -0
  27. flashapi/features/websocket.py +129 -0
  28. flashapi/flask.py +5 -5
  29. flashapi/inspectors/__init__.py +3 -3
  30. flashapi/inspectors/base.py +13 -11
  31. flashapi/inspectors/dataclass.py +50 -39
  32. flashapi/inspectors/detect.py +54 -48
  33. flashapi/inspectors/django.py +93 -83
  34. flashapi/inspectors/pydantic.py +99 -84
  35. flashapi/inspectors/sqlalchemy.py +83 -75
  36. flashapi/storage/__init__.py +4 -4
  37. flashapi/storage/auto.py +189 -106
  38. flashapi/storage/base.py +32 -26
  39. flashapi/storage/orm.py +125 -85
  40. flashapi/storage/sqlalchemy.py +56 -13
  41. python_flashapi-0.2.0.dist-info/METADATA +314 -0
  42. python_flashapi-0.2.0.dist-info/RECORD +48 -0
  43. python_flashapi-0.2.0.dist-info/licenses/LICENSE +190 -0
  44. python_flashapi-0.2.0.dist-info/licenses/NOTICE +5 -0
  45. python_flashapi-0.1.2.dist-info/METADATA +0 -259
  46. python_flashapi-0.1.2.dist-info/RECORD +0 -39
  47. python_flashapi-0.1.2.dist-info/licenses/LICENSE +0 -21
  48. {python_flashapi-0.1.2.dist-info → python_flashapi-0.2.0.dist-info}/WHEEL +0 -0
@@ -1,232 +1,728 @@
1
- from __future__ import annotations
2
-
3
- from typing import Callable
4
-
5
- from flashapi.core.schema import Model, ModelSchema
6
- from flashapi.core.response import create_list_response, create_item_response
7
- from flashapi.core.relations import resolve_relations, find_expandable_fields
8
- from flashapi.core.custom_routes import (
9
- CustomRoute, custom_routes_to_openapi_paths, discover_flask_views,
10
- )
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
- from flashapi.docs.openapi import generate_openapi_schema, get_swagger_html
16
-
17
-
18
- def register_models(
19
- app,
20
- models: list[type | Model],
21
- *,
22
- engine=None,
23
- custom_routes: list[CustomRoute] | None = None,
24
- database: str = "flashapi.db",
25
- docs: bool = True,
26
- formatter: Callable | None = None,
27
- ):
28
- """Register models on an existing Flask app."""
29
- from flask import Blueprint
30
-
31
- session_factory = None
32
- if engine is not None:
33
- from sqlalchemy.orm import sessionmaker
34
- session_factory = sessionmaker(bind=engine)
35
-
36
- auto_storage = AutoStorage(database) if engine is None else None
37
- blueprint = Blueprint("flashapi", __name__)
38
- all_schemas: list[ModelSchema] = []
39
- storages: dict[str, any] = {}
40
-
41
- for model_entry in models:
42
- if isinstance(model_entry, Model):
43
- wrapper = model_entry
44
- else:
45
- wrapper = Model(model_entry)
46
-
47
- schema = inspect_model(wrapper.model_class, plural=wrapper.plural)
48
- schema.permissions = wrapper.permissions
49
-
50
- is_sa = hasattr(wrapper.model_class, "__table__") and hasattr(wrapper.model_class, "__tablename__")
51
-
52
- if is_sa and session_factory is not None:
53
- storage = SQLAlchemyStorage(session_factory, wrapper.model_class)
54
- else:
55
- auto_storage.ensure_table(schema)
56
- storage = auto_storage
57
-
58
- storages[schema.plural] = storage
59
- all_schemas.append(schema)
60
- expandable = find_expandable_fields(schema)
61
- _create_flask_routes(blueprint, schema, storage, formatter, expandable)
62
-
63
- parent_to_children = resolve_relations(all_schemas)
64
- for parent_plural, relations in parent_to_children.items():
65
- for relation in relations:
66
- _create_nested_route(
67
- blueprint, parent_plural, relation.target_plural,
68
- relation.foreign_key, storages.get(relation.target_plural, storage), formatter,
69
- )
70
-
71
- if docs:
72
- _add_docs_routes(blueprint, all_schemas, custom_routes or [], flask_app=app)
73
-
74
- app.register_blueprint(blueprint)
75
-
76
-
77
- def _add_docs_routes(blueprint, schemas: list[ModelSchema], custom_routes: list[CustomRoute], flask_app=None) -> None:
78
- from flask import jsonify, Response
79
-
80
- openapi_spec = generate_openapi_schema(schemas)
81
-
82
- # Method 1: explicit CustomRoute objects
83
- if custom_routes:
84
- custom_paths = custom_routes_to_openapi_paths(custom_routes)
85
- openapi_spec["paths"].update(custom_paths)
86
-
87
- # Method 2: auto-discover @api_doc decorated views (done lazily on first request)
88
- _discovered = {"done": False}
89
-
90
- @blueprint.route("/openapi.json", methods=["GET"], endpoint="flashapi_openapi")
91
- def openapi_json():
92
- if not _discovered["done"] and flask_app is not None:
93
- discovered = discover_flask_views(flask_app)
94
- openapi_spec["paths"].update(discovered)
95
- _discovered["done"] = True
96
- return jsonify(openapi_spec)
97
-
98
- @blueprint.route("/docs", methods=["GET"], endpoint="flashapi_docs")
99
- def docs_ui():
100
- html = get_swagger_html(title="FlashAPI", openapi_url="/openapi.json")
101
- return Response(html, content_type="text/html")
102
-
103
-
104
- def _create_nested_route(blueprint, parent_plural, child_plural, foreign_key, storage, formatter):
105
- from flask import request, jsonify
106
-
107
- @blueprint.route(
108
- f"/{parent_plural}/<int:parent_id>/{child_plural}",
109
- methods=["GET"],
110
- endpoint=f"{parent_plural}_{child_plural}_nested",
111
- )
112
- def nested_list(parent_id, _pp=parent_plural, _cp=child_plural, _fk=foreign_key):
113
- parent = storage.get(_pp, parent_id)
114
- if parent is None:
115
- return jsonify({"error": "Parent not found"}), 404
116
-
117
- all_items = storage.list_all(_cp)
118
- items = [i for i in all_items if i.get(_fk) == parent_id]
119
-
120
- params = dict(request.args)
121
- page = int(params.get("page", 1))
122
- page_size = int(params.get("page_size", 20))
123
- sort = params.get("sort")
124
- search = params.get("search")
125
-
126
- child_fields = {k for item in items for k in item.keys() if k != "id"}
127
- if search:
128
- items = apply_search(items, search, child_fields)
129
- if sort:
130
- items = apply_sorting(items, sort, child_fields)
131
-
132
- page_items, total = paginate(items, page, page_size)
133
- return jsonify(create_list_response(page_items, total, page, page_size, formatter))
134
-
135
-
136
- def _expand_items(items, expand_param, expandable, storage):
137
- expand_fields = [f.strip() for f in expand_param.split(",")]
138
- expanded_items = []
139
-
140
- for item in items:
141
- item_copy = dict(item)
142
- for field_name in expand_fields:
143
- if field_name in expandable:
144
- fk_field = f"{field_name}_id"
145
- fk_value = item_copy.get(fk_field)
146
- if fk_value is not None:
147
- related = storage.get(expandable[field_name], fk_value)
148
- if related:
149
- item_copy[field_name] = related
150
- expanded_items.append(item_copy)
151
-
152
- return expanded_items
153
-
154
-
155
- def _create_flask_routes(
156
- blueprint,
157
- schema: ModelSchema,
158
- storage: AutoStorage,
159
- formatter: Callable | None,
160
- expandable: dict,
161
- ) -> None:
162
- from flask import request, jsonify
163
-
164
- table = schema.plural
165
- field_names = {f.name for f in schema.fields if not f.primary_key}
166
-
167
- if "list" in schema.permissions:
168
- @blueprint.route(f"/{table}", methods=["GET"], endpoint=f"{table}_list")
169
- def list_items(_table=table, _fields=field_names, _exp=expandable):
170
- items = storage.list_all(_table)
171
- params = dict(request.args)
172
- try:
173
- page = max(1, int(params.get("page", 1)))
174
- page_size = max(1, min(100, int(params.get("page_size", 20))))
175
- except (ValueError, TypeError):
176
- return jsonify({"error": "Invalid page or page_size parameter"}), 400
177
- sort = params.get("sort")
178
- search = params.get("search")
179
- expand = params.get("expand")
180
-
181
- items = apply_filters(items, params, _fields)
182
- items = apply_search(items, search, _fields)
183
- items = apply_sorting(items, sort, _fields)
184
- page_items, total = paginate(items, page, page_size)
185
-
186
- if expand:
187
- page_items = _expand_items(page_items, expand, _exp, storage)
188
-
189
- return jsonify(create_list_response(page_items, total, page, page_size, formatter))
190
-
191
- if "read" in schema.permissions:
192
- @blueprint.route(f"/{table}/<int:item_id>", methods=["GET"], endpoint=f"{table}_get")
193
- def get_item(item_id, _table=table, _exp=expandable):
194
- item = storage.get(_table, item_id)
195
- if item is None:
196
- return jsonify({"error": "Not found"}), 404
197
-
198
- expand = request.args.get("expand")
199
- if expand:
200
- item = _expand_items([item], expand, _exp, storage)[0]
201
-
202
- return jsonify(create_item_response(item, formatter))
203
-
204
- if "create" in schema.permissions:
205
- @blueprint.route(f"/{table}", methods=["POST"], endpoint=f"{table}_create")
206
- def create_item(_table=table, _fields=field_names):
207
- body = request.get_json(silent=True)
208
- if not body:
209
- return jsonify({"error": "Request body is required"}), 400
210
- data = {k: v for k, v in body.items() if k in _fields}
211
- item = storage.create(_table, data)
212
- return jsonify(create_item_response(item, formatter)), 201
213
-
214
- if "update" in schema.permissions:
215
- @blueprint.route(f"/{table}/<int:item_id>", methods=["PUT"], endpoint=f"{table}_update")
216
- def update_item(item_id, _table=table, _fields=field_names):
217
- body = request.get_json(silent=True)
218
- if not body:
219
- return jsonify({"error": "Request body is required"}), 400
220
- data = {k: v for k, v in body.items() if k in _fields}
221
- item = storage.update(_table, item_id, data)
222
- if item is None:
223
- return jsonify({"error": "Not found"}), 404
224
- return jsonify(create_item_response(item, formatter))
225
-
226
- if "delete" in schema.permissions:
227
- @blueprint.route(f"/{table}/<int:item_id>", methods=["DELETE"], endpoint=f"{table}_delete")
228
- def delete_item(item_id, _table=table):
229
- deleted = storage.delete(_table, item_id)
230
- if not deleted:
231
- return jsonify({"error": "Not found"}), 404
232
- return "", 204
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
+ )