API more matched up to Service.
This commit is contained in:
parent
b8b3f2e1b8
commit
9ca52a6f52
2 changed files with 126 additions and 71 deletions
|
|
@ -1,3 +1,5 @@
|
||||||
|
# crudkit/api/flask_api.py
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, abort
|
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:
|
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}
|
q = {k: v for k, v in params.items() if v is not None}
|
||||||
return f"{base_url}?{urlencode(q)}"
|
return f"{base_url}?{urlencode(q)}"
|
||||||
|
|
||||||
|
|
||||||
def generate_crud_blueprint(model, service):
|
def generate_crud_blueprint(model, service, *, base_prefix: str | None = None):
|
||||||
bp = Blueprint(model.__name__.lower(), __name__)
|
"""
|
||||||
|
RPC-ish blueprint that exposes CRUDService methods 1:1:
|
||||||
|
|
||||||
@bp.get("/")
|
GET /api/<model>/get?id=123&... -> service.get()
|
||||||
def list_items():
|
GET /api/<model>/list?... -> service.list()
|
||||||
# Work from a copy so we don't mutate request.args
|
GET /api/<model>/seek_window?... -> service.seek_window()
|
||||||
args = request.args.to_dict(flat=True)
|
GET /api/<model>/page?page=2&per_page=50&... -> service.page()
|
||||||
|
|
||||||
legacy_offset = "offset" in args or "page" in args
|
POST /api/<model>/create -> service.create(payload)
|
||||||
|
|
||||||
limit = _safe_int(args.get("limit"), 50)
|
PATCH /api/<model>/update?id=123 -> service.update(id, payload)
|
||||||
args["limit"] = limit
|
|
||||||
|
|
||||||
if legacy_offset:
|
DELETE /api/<model>/delete?id=123[&hard=1] -> service.delete(id, hard)
|
||||||
# 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
|
Query params for filters/sorts/fields/includes all still pass straight through.
|
||||||
cursor_token = args.get("cursor")
|
Cursor behavior for seek_window is preserved, with Link headers.
|
||||||
key, desc_from_cursor, backward = decode_cursor(cursor_token)
|
"""
|
||||||
|
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(
|
# -------- READS --------
|
||||||
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.
|
@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:
|
try:
|
||||||
desc_flags = list(window.order.desc)
|
item = service.get(id_, request.args)
|
||||||
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("/<int:id>")
|
|
||||||
def get_item(id):
|
|
||||||
try:
|
|
||||||
item = service.get(id, request.args)
|
|
||||||
if item is None:
|
if item is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
return jsonify(item.as_dict())
|
return jsonify(item.as_dict())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Could be validation, auth, or just you forgetting an index again
|
|
||||||
return jsonify({"status": "error", "error": str(e)}), 400
|
return jsonify({"status": "error", "error": str(e)}), 400
|
||||||
|
|
||||||
@bp.post("/")
|
@bp.get("/list")
|
||||||
def create_item():
|
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 {}
|
payload = request.get_json(silent=True) or {}
|
||||||
try:
|
try:
|
||||||
obj = service.create(payload)
|
obj = service.create(payload)
|
||||||
|
|
@ -105,22 +153,29 @@ def generate_crud_blueprint(model, service):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"status": "error", "error": str(e)}), 400
|
return jsonify({"status": "error", "error": str(e)}), 400
|
||||||
|
|
||||||
@bp.patch("/<int:id>")
|
@bp.patch("/update")
|
||||||
def update_item(id):
|
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 {}
|
payload = request.get_json(silent=True) or {}
|
||||||
try:
|
try:
|
||||||
obj = service.update(id, payload)
|
obj = service.update(id_, payload)
|
||||||
return jsonify(obj.as_dict())
|
return jsonify(obj.as_dict())
|
||||||
except Exception as e:
|
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
|
return jsonify({"status": "error", "error": str(e)}), 400
|
||||||
|
|
||||||
@bp.delete("/<int:id>")
|
@bp.delete("/delete")
|
||||||
def delete_item(id):
|
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:
|
try:
|
||||||
service.delete(id)
|
obj = service.delete(id_, hard=hard)
|
||||||
# 204 means "no content" so don't send any.
|
# 204 if actually deleted or soft-deleted; return body if you feel chatty
|
||||||
return ("", 204)
|
return ("", 204) if obj is not None else abort(404)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"status": "error", "error": str(e)}), 400
|
return jsonify({"status": "error", "error": str(e)}), 400
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if app.config.get("PROFILE", True):
|
if app.config.get("PROFILE", False):
|
||||||
# Use an absolute dir under the instance path (always writable)
|
# Use an absolute dir under the instance path (always writable)
|
||||||
inst_dir = Path(app.instance_path)
|
inst_dir = Path(app.instance_path)
|
||||||
inst_dir.mkdir(parents=True, exist_ok=True)
|
inst_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue