From 9ca52a6f52d22a6431e812d2d80c3fddfc13c818 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Tue, 7 Oct 2025 09:59:00 -0500 Subject: [PATCH] API more matched up to Service. --- crudkit/api/flask_api.py | 195 +++++++++++++++++++++++++-------------- inventory/__init__.py | 2 +- 2 files changed, 126 insertions(+), 71 deletions(-) diff --git a/crudkit/api/flask_api.py b/crudkit/api/flask_api.py index 3b061e2..0d6babe 100644 --- a/crudkit/api/flask_api.py +++ b/crudkit/api/flask_api.py @@ -1,3 +1,5 @@ +# crudkit/api/flask_api.py + from __future__ import annotations from flask import Blueprint, jsonify, request, abort @@ -19,85 +21,131 @@ def _safe_int(value: str | None, default: int) -> int: 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__) +def generate_crud_blueprint(model, service, *, base_prefix: str | None = None): + """ + RPC-ish blueprint that exposes CRUDService methods 1:1: - @bp.get("/") - def list_items(): - # Work from a copy so we don't mutate request.args - args = request.args.to_dict(flat=True) + GET /api//get?id=123&... -> service.get() + GET /api//list?... -> service.list() + GET /api//seek_window?... -> service.seek_window() + GET /api//page?page=2&per_page=50&... -> service.page() - legacy_offset = "offset" in args or "page" in args + POST /api//create -> service.create(payload) - limit = _safe_int(args.get("limit"), 50) - args["limit"] = limit + PATCH /api//update?id=123 -> service.update(id, payload) - if legacy_offset: - # Old behavior: honor limit/offset, same CRUDSpec goodies - items = service.list(args) - return jsonify([obj.as_dict() for obj in items]) + DELETE /api//delete?id=123[&hard=1] -> service.delete(id, hard) - # New behavior: keyset pagination with cursors - cursor_token = args.get("cursor") - key, desc_from_cursor, backward = decode_cursor(cursor_token) + Query params for filters/sorts/fields/includes all still pass straight through. + Cursor behavior for seek_window is preserved, with Link headers. + """ + name = (model.__name__ if base_prefix is None else base_prefix).lower() + bp = Blueprint(name, __name__, url_prefix=f"/api/{name}") - window = service.seek_window( - args, - key=key, - backward=backward, - include_total=_bool_param(args, "include_total", True), - ) + # -------- READS -------- - # Prefer the order actually used by the window; fall back to desc_from_cursor if needed. + @bp.get("/get") + def rpc_get(): + id_ = _safe_int(request.args.get("id"), 0) + if not id_: + return jsonify({"status": "error", "error": "missing required param: id"}), 400 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) + 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(): + @bp.get("/list") + def rpc_list(): + # Keep legacy limit/offset behavior. Everything else passes through. + args = request.args.to_dict(flat=True) + # If the caller provides offset or page, honor normal list() pagination rules. + legacy_offset = ("offset" in args) or ("page" in args) + if not legacy_offset: + # We still allow limit to cap the result set if provided. + limit = _safe_int(args.get("limit"), 50) + args["limit"] = limit + try: + items = service.list(args) + return jsonify([obj.as_dict() for obj in items]) + except Exception as e: + return jsonify({"status": "error", "error": str(e)}), 400 + + @bp.get("/seek_window") + def rpc_seek_window(): + args = request.args.to_dict(flat=True) + + # Keep keyset & cursor mechanics intact + cursor_token = args.get("cursor") + key, desc_from_cursor, backward_from_cursor = decode_cursor(cursor_token) + + backward = _bool_param(args, "backward", backward_from_cursor if backward_from_cursor is not None else False) + include_total = _bool_param(args, "include_total", True) + + try: + window = service.seek_window( + args, + key=key, + backward=backward, + include_total=include_total, + ) + 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) + + # Build Link headers preserving all non-cursor args + 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 + except Exception as e: + return jsonify({"status": "error", "error": str(e)}), 400 + + @bp.get("/page") + def rpc_page(): + args = request.args.to_dict(flat=True) + page = _safe_int(args.get("page"), 1) + per_page = _safe_int(args.get("per_page"), 50) + include_total = _bool_param(args, "include_total", True) + + try: + result = service.page(args, page=page, per_page=per_page, include_total=include_total) + # Already includes: items, page, per_page, total, pages, order + # Items come back as model instances; serialize to dicts + result = { + **result, + "items": [obj.as_dict() for obj in result["items"]], + } + return jsonify(result) + except Exception as e: + return jsonify({"status": "error", "error": str(e)}), 400 + + # -------- WRITES -------- + + @bp.post("/create") + def rpc_create(): payload = request.get_json(silent=True) or {} try: obj = service.create(payload) @@ -105,22 +153,29 @@ def generate_crud_blueprint(model, service): except Exception as e: return jsonify({"status": "error", "error": str(e)}), 400 - @bp.patch("/") - def update_item(id): + @bp.patch("/update") + def rpc_update(): + id_ = _safe_int(request.args.get("id"), 0) + if not id_: + return jsonify({"status": "error", "error": "missing required param: id"}), 400 payload = request.get_json(silent=True) or {} try: - obj = service.update(id, payload) + 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. + # If you ever decide to throw custom exceptions, map them here like an adult. return jsonify({"status": "error", "error": str(e)}), 400 - @bp.delete("/") - def delete_item(id): + @bp.delete("/delete") + def rpc_delete(): + id_ = _safe_int(request.args.get("id"), 0) + if not id_: + return jsonify({"status": "error", "error": "missing required param: id"}), 400 + hard = _bool_param(request.args, "hard", False) try: - service.delete(id) - # 204 means "no content" so don't send any. - return ("", 204) + obj = service.delete(id_, hard=hard) + # 204 if actually deleted or soft-deleted; return body if you feel chatty + return ("", 204) if obj is not None else abort(404) except Exception as e: return jsonify({"status": "error", "error": str(e)}), 400 diff --git a/inventory/__init__.py b/inventory/__init__.py index 6692858..478dfed 100644 --- a/inventory/__init__.py +++ b/inventory/__init__.py @@ -89,7 +89,7 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask: except Exception: pass - if app.config.get("PROFILE", True): + if app.config.get("PROFILE", False): # Use an absolute dir under the instance path (always writable) inst_dir = Path(app.instance_path) inst_dir.mkdir(parents=True, exist_ok=True)