from __future__ import annotations from flask import Blueprint, jsonify, request, abort from urllib.parse import urlencode from crudkit.api._cursor import encode_cursor, decode_cursor from crudkit.core.service import _is_truthy 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: # Filter out None, encode safely 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): bp = Blueprint(model.__name__.lower(), __name__) @bp.get("/") def list_items(): # Work from a copy so we don't mutate request.args args = request.args.to_dict(flat=True) legacy_offset = "offset" in args or "page" in args limit = _safe_int(args.get("limit"), 50) args["limit"] = limit if legacy_offset: # Old behavior: honor limit/offset, same CRUDSpec goodies items = service.list(args) return jsonify([obj.as_dict() for obj in items]) # New behavior: keyset pagination with cursors cursor_token = args.get("cursor") key, desc_from_cursor, backward = decode_cursor(cursor_token) window = service.seek_window( args, key=key, backward=backward, include_total=_bool_param(args, "include_total", True), ) # Prefer the order actually used by the window; fall back to desc_from_cursor if needed. try: desc_flags = list(window.order.desc) except Exception: desc_flags = desc_from_cursor or [] body = { "items": [obj.as_dict() for obj in window.items], "limit": window.limit, "next_cursor": encode_cursor(window.last_key, desc_flags, backward=False), "prev_cursor": encode_cursor(window.first_key, desc_flags, backward=True), "total": window.total, } resp = jsonify(body) # Preserve user’s other query params like include_total, filters, sorts, etc. base_url = request.base_url base_params = {k: v for k, v in args.items() if k not in {"cursor"}} link_parts = [] if body["next_cursor"]: link_parts.append( f'<{_link_with_params(base_url, **base_params, cursor=body["next_cursor"])}>; rel="next"' ) if body["prev_cursor"]: link_parts.append( f'<{_link_with_params(base_url, **base_params, cursor=body["prev_cursor"])}>; rel="prev"' ) if link_parts: resp.headers["Link"] = ", ".join(link_parts) return resp @bp.get("/") def get_item(id): try: item = service.get(id, request.args) if item is None: abort(404) return jsonify(item.as_dict()) except Exception as e: # Could be validation, auth, or just you forgetting an index again return jsonify({"status": "error", "error": str(e)}), 400 @bp.post("/") def create_item(): payload = request.get_json(silent=True) or {} try: obj = service.create(payload) return jsonify(obj.as_dict()), 201 except Exception as e: return jsonify({"status": "error", "error": str(e)}), 400 @bp.patch("/") def update_item(id): payload = request.get_json(silent=True) or {} try: obj = service.update(id, payload) return jsonify(obj.as_dict()) except Exception as e: # 404 if not found, 400 if validation. Your service can throw specific exceptions if you ever feel like being professional. return jsonify({"status": "error", "error": str(e)}), 400 @bp.delete("/") def delete_item(id): try: service.delete(id) # 204 means "no content" so don't send any. return ("", 204) except Exception as e: return jsonify({"status": "error", "error": str(e)}), 400 return bp