diff --git a/crudkit/api/flask_api.py b/crudkit/api/flask_api.py index 0d6babe..39e7c49 100644 --- a/crudkit/api/flask_api.py +++ b/crudkit/api/flask_api.py @@ -2,181 +2,194 @@ from __future__ import annotations -from flask import Blueprint, jsonify, request, abort +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.api._cursor import encode_cursor, decode_cursor 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): +def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, rest: bool = True, rpc: bool = True): """ - RPC-ish blueprint that exposes CRUDService methods 1:1: + 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 - 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() - - POST /api//create -> service.create(payload) - - PATCH /api//update?id=123 -> service.update(id, payload) - - DELETE /api//delete?id=123[&hard=1] -> service.delete(id, hard) - - Query params for filters/sorts/fields/includes all still pass straight through. - Cursor behavior for seek_window is preserved, with Link headers. + 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] """ - name = (model.__name__ if base_prefix is None else base_prefix).lower() - bp = Blueprint(name, __name__, url_prefix=f"/api/{name}") + 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" - # -------- READS -------- + bp = Blueprint(plural, __name__, url_prefix=f"/api/{plural}") - @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: - item = service.get(id_, request.args) + @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) - return jsonify(item.as_dict()) - except Exception as e: - return jsonify({"status": "error", "error": str(e)}), 400 - - @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) + 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 - 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) + @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) - 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 + @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) - # -------- WRITES -------- + @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) - @bp.post("/create") - def rpc_create(): - 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 + # ---------- 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.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) - return jsonify(obj.as_dict()) - except Exception as e: - # If you ever decide to throw custom exceptions, map them here like an adult. - return jsonify({"status": "error", "error": str(e)}), 400 + @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.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: - 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 + @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 diff --git a/crudkit/core/base.py b/crudkit/core/base.py index c42b90e..d73a04f 100644 --- a/crudkit/core/base.py +++ b/crudkit/core/base.py @@ -1,18 +1,66 @@ -from typing import Any, Dict, Iterable, List, Tuple, Set +from functools import lru_cache +from typing import Any, Dict, Iterable, List, Optional, Tuple, Set, cast from sqlalchemy import Column, Integer, DateTime, Boolean, String, JSON, func, inspect -from sqlalchemy.orm import declarative_mixin, declarative_base, NO_VALUE, RelationshipProperty +from sqlalchemy.orm import declarative_mixin, declarative_base, NO_VALUE, RelationshipProperty, Mapper +from sqlalchemy.orm.state import InstanceState Base = declarative_base() -def _safe_get_loaded_attr(obj, name): +@lru_cache(maxsize=512) +def _column_names_for_model(cls: type) -> tuple[str, ...]: + try: + mapper = inspect(cls) + return tuple(prop.key for prop in mapper.column_attrs) + except Exception: + names: list[str] = [] + for c in cls.__mro__: + if hasattr(c, "__table__"): + names.extend(col.name for col in c.__table__.columns) + return tuple(dict.fromkeys(names)) + +def _sa_state(obj: Any) -> Optional[InstanceState[Any]]: + """Safely get SQLAlchemy InstanceState (or None).""" try: st = inspect(obj) - attr = st.attrs.get(name) + return cast(Optional[InstanceState[Any]], st) + except Exception: + return None + +def _sa_mapper(obj: Any) -> Optional[Mapper]: + """Safely get Mapper for a maooed instance (or None).""" + try: + st = inspect(obj) + mapper = getattr(st, "mapper", None) + return cast(Optional[Mapper], mapper) + except Exception: + return None + +def _safe_get_loaded_attr(obj, name): + st = _sa_state(obj) + if st is None: + return None + try: + st_dict = getattr(st, "dict", {}) + if name in st_dict: + return st_dict[name] + + attrs = getattr(st, "attrs", None) + attr = None + if attrs is not None: + try: + attr = attrs[name] + except Exception: + try: + get = getattr(attrs, "get", None) + if callable(get): + attr = get(name) + except Exception: + attr = None + if attr is not None: val = attr.loaded_value return None if val is NO_VALUE else val - if name in st.dict: - return st.dict.get(name) + return None except Exception: return None @@ -33,14 +81,11 @@ def _is_collection_rel(prop: RelationshipProperty) -> bool: def _serialize_simple_obj(obj) -> Dict[str, Any]: """Columns only (no relationships).""" out: Dict[str, Any] = {} - for cls in obj.__class__.__mro__: - if hasattr(cls, "__table__"): - for col in cls.__table__.columns: - name = col.name - try: - out[name] = getattr(obj, name) - except Exception: - out[name] = None + for name in _column_names_for_model(type(obj)): + try: + out[name] = getattr(obj, name) + except Exception: + out[name] = None return out def _serialize_loaded_rel(obj, name, *, depth: int, seen: Set[Tuple[type, Any]], embed: Set[str]) -> Any: @@ -204,12 +249,16 @@ class CRUDMixin: # Determine which relationships to consider try: - st = inspect(self) - mapper = st.mapper - embed_set = set(str(x).split(".", 1)[0] for x in (embed or [])) # top-level names + mapper = _sa_mapper(self) + embed_set = set(str(x).split(".", 1)[0] for x in (embed or [])) + if mapper is None: + return data + st = _sa_state(self) + if st is None: + return data for name, prop in mapper.relationships.items(): # Only touch relationships that are already loaded; never lazy-load here. - rel_loaded = st.attrs.get(name) + rel_loaded = getattr(st, "attrs", {}).get(name) if rel_loaded is None or rel_loaded.loaded_value is NO_VALUE: continue @@ -266,13 +315,10 @@ class CRUDMixin: val = None # If it's a scalar ORM object (relationship), serialize its columns - try: - st = inspect(val) # will raise if not an ORM object - if getattr(st, "mapper", None) is not None: - out[name] = _serialize_simple_obj(val) - continue - except Exception: - pass + mapper = _sa_mapper(val) + if mapper is not None: + out[name] = _serialize_simple_obj(val) + continue # If it's a collection and no subfields were requested, emit a light list if isinstance(val, (list, tuple)): diff --git a/crudkit/ui/fragments.py b/crudkit/ui/fragments.py index 08903de..ab4c3ba 100644 --- a/crudkit/ui/fragments.py +++ b/crudkit/ui/fragments.py @@ -639,9 +639,14 @@ def _normalize_field_spec(spec, mapper, session, label_specs_model_default): "template": spec.get("template"), "template_name": spec.get("template_name"), "template_ctx": spec.get("template_ctx"), - "label_spec": spec.get("label_spec") + "label_spec": spec.get("label_spec"), } + if "link" in spec: + field["link"] = spec["link"] + if "label_deps" in spec: + field["label_deps"] = spec["label_deps"] + if rel_prop: if field["type"] is None: field["type"] = "select" @@ -1061,6 +1066,7 @@ def render_field(field, value): label_attrs=_sanitize_attrs(field.get('label_attrs') or {}), help=field.get('help'), value_label=field.get('value_label'), + link_href=field.get("link_href"), ) @@ -1231,6 +1237,15 @@ def render_form( if vl2 is not None: f["value_label"] = vl2 + link_spec = f.get("link") + if link_spec: + try: + href = _build_href(link_spec, values_map, instance) + except Exception: + href = None + if href: + f["link_href"] = href + # Build rows (supports nested layout with parents) rows_map = _normalize_rows_layout(layout) rows_tree = _assign_fields_to_rows(fields, rows_map) diff --git a/crudkit/ui/templates/field.html b/crudkit/ui/templates/field.html index 0efe44b..aecb234 100644 --- a/crudkit/ui/templates/field.html +++ b/crudkit/ui/templates/field.html @@ -4,7 +4,13 @@ {% if label_attrs %}{% for k,v in label_attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}"{% endif %} {% endfor %}{% endif %}> - {{ field_label }} + {% if link_href %} + + {% endif %} + {{ field_label }} + {% if link_href %} + + {% endif %} {% endif %}