inventory/crudkit/api/flask_api.py
2025-10-27 15:57:07 -05:00

192 lines
6.6 KiB
Python

# 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 generate_crud_blueprint(model, service, *, base_prefix: str | None = None, rest: bool = True, rpc: bool = True):
"""
REST:
GET /api/<models>/ -> list (filters via ?q=..., sort=..., limit=..., cursor=...)
GET /api/<models>/<id> -> get
POST /api/<models>/ -> create
PATCH /api/<models>/<id> -> update (partial)
DELETE /api/<models>/<id>[?hard=1] -> delete
RPC (legacy):
GET /api/<model>/get?id=123
GET /api/<model>/list
GET /api/<model>/seek_window
GET /api/<model>/page
POST /api/<model>/create
PATCH /api/<model>/update?id=123
DELETE /api/<model>/delete?id=123[&hard=1]
"""
model_name = model.__name__.lower()
bp = Blueprint(model_name, __name__, url_prefix=f"/api/{model_name}")
@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("/<int:obj_id>")
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"{bp.name}.rest_get", obj_id=obj.id, _external=False)
return resp
except Exception as e:
return _json_error(e)
@bp.patch("/<int:obj_id>")
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("/<int:obj_id>")
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