# crudkit/api/flask_api.py from __future__ import annotations from flask import Blueprint, jsonify, request, abort, current_app, url_for from hashlib import md5 from urllib.parse import urlencode from werkzeug.exceptions import HTTPException from crudkit.core.service import _is_truthy MAX_JSON = 1_000_000 def _etag_for(obj) -> str: v = getattr(obj, "updated_at", None) or obj.id return md5(str(v).encode()).hexdigest() def _json_payload() -> dict: if request.content_length and request.content_length > MAX_JSON: abort(413) if not request.is_json: abort(415) payload = request.get_json(silent=False) if not isinstance(payload, dict): abort(400) return payload def _args_flat() -> dict[str, str]: return request.args.to_dict(flat=True) # type: ignore[arg-type] def _json_error(e: Exception, status: int = 400): if isinstance(e, HTTPException): status = e.code or status msg = e.description else: msg = str(e) if current_app.debug: return jsonify({"status": "error", "error": msg, "type": e.__class__.__name__}), status return jsonify({"status": "error", "error": msg}), status def _bool_param(d: dict[str, str], key: str, default: bool) -> bool: return _is_truthy(d.get(key, "1" if default else "0")) def _safe_int(value: str | None, default: int) -> int: try: return int(value) if value is not None else default except Exception: return default def _link_with_params(base_url: str, **params) -> str: q = {k: v for k, v in params.items() if v is not None} return f"{base_url}?{urlencode(q)}" def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, rest: bool = True, rpc: bool = True): """ REST: GET /api// -> list (filters via ?q=..., sort=..., limit=..., cursor=...) GET /api// -> get POST /api// -> create PATCH /api// -> update (partial) DELETE /api//[?hard=1] -> delete RPC (legacy): GET /api//get?id=123 GET /api//list GET /api//seek_window GET /api//page POST /api//create PATCH /api//update?id=123 DELETE /api//delete?id=123[&hard=1] """ model_name = model.__name__.lower() # bikeshed if you want pluralization; this is the least-annoying default collection = (base_prefix or model_name).lower() plural = collection if collection.endswith('s') else f"{collection}s" bp = Blueprint(plural, __name__, url_prefix=f"/api/{plural}") @bp.errorhandler(Exception) def _handle_any(e: Exception): return _json_error(e) @bp.errorhandler(404) def _not_found(_e): return jsonify({"status": "error", "error": "not found"}), 404 # ---------- REST ---------- if rest: @bp.get("/") def rest_list(): args = _args_flat() # support cursor pagination transparently; fall back to limit/offset try: items = service.list(args) return jsonify([o.as_dict() for o in items]) except Exception as e: return _json_error(e) @bp.get("/") def rest_get(obj_id: int): item = service.get(obj_id, request.args) if item is None: abort(404) etag = _etag_for(item) if request.if_none_match and (etag in request.if_none_match): return "", 304 resp = jsonify(item.as_dict()) resp.set_etag(etag) return resp @bp.post("/") def rest_create(): payload = _json_payload() try: obj = service.create(payload) resp = jsonify(obj.as_dict()) resp.status_code = 201 resp.headers["Location"] = url_for(f"{plural}.rest_get", obj_id=obj.id, _external=False) return resp except Exception as e: return _json_error(e) @bp.patch("/") def rest_update(obj_id: int): payload = _json_payload() try: obj = service.update(obj_id, payload) return jsonify(obj.as_dict()) except Exception as e: return _json_error(e) @bp.delete("/") def rest_delete(obj_id: int): hard = _bool_param(_args_flat(), "hard", False) # type: ignore[arg-type] try: obj = service.delete(obj_id, hard=hard) if obj is None: abort(404) return ("", 204) except Exception as e: return _json_error(e) # ---------- RPC (your existing routes) ---------- if rpc: @bp.get("/get") def rpc_get(): print("⚠️ WARNING: Deprecated RPC call used: /get") id_ = int(request.args.get("id", 0)) if not id_: return jsonify({"status": "error", "error": "missing required param: id"}), 400 try: item = service.get(id_, request.args) if item is None: abort(404) return jsonify(item.as_dict()) except Exception as e: return _json_error(e) @bp.get("/list") def rpc_list(): print("⚠️ WARNING: Deprecated RPC call used: /list") args = _args_flat() try: items = service.list(args) return jsonify([obj.as_dict() for obj in items]) except Exception as e: return _json_error(e) @bp.post("/create") def rpc_create(): print("⚠️ WARNING: Deprecated RPC call used: /create") payload = _json_payload() try: obj = service.create(payload) return jsonify(obj.as_dict()), 201 except Exception as e: return _json_error(e) @bp.patch("/update") def rpc_update(): print("⚠️ WARNING: Deprecated RPC call used: /update") id_ = int(request.args.get("id", 0)) if not id_: return jsonify({"status": "error", "error": "missing required param: id"}), 400 payload = _json_payload() try: obj = service.update(id_, payload) return jsonify(obj.as_dict()) except Exception as e: return _json_error(e) @bp.delete("/delete") def rpc_delete(): print("⚠️ WARNING: Deprecated RPC call used: /delete") id_ = int(request.args.get("id", 0)) if not id_: return jsonify({"status": "error", "error": "missing required param: id"}), 400 hard = _bool_param(_args_flat(), "hard", False) # type: ignore[arg-type] try: obj = service.delete(id_, hard=hard) return ("", 204) if obj is not None else abort(404) except Exception as e: return _json_error(e) return bp