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

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