S1: unify error responses + id parsing (responses.py) across notes.py
M9 section S1, commit 2. Add src/thoughtsync/responses.py — the app's single
JSON-error shape and the two guards that pair with it:
- json_error(message, status): the one ({"error": ...}, code) builder
- not_found(): the standard 404, by far the most common note-route error
- parse_uuid(raw): parse a path/body id, None on malformed → pair with not_found()
notes.py adopts them everywhere: ~25 hand-built `jsonify({"error":"not found"}),404`
collapse to not_found(); ~20 other error returns to json_error(...); ~13 repeated
`try: uuid.UUID(x) except: ...` blocks to parse_uuid(). Behavior-preserving — same
bodies and status codes, one definition. jsonify stays for the success responses.
Other blueprints (auth, labels, saved_filters, sync, settings_api) adopt the same
helpers in their own M9 sections.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
+90
-99
@@ -19,6 +19,7 @@ from .auth import login_required
|
||||
from .common import coerce_bool, parse_dt
|
||||
from .config import Config
|
||||
from .db import session_scope
|
||||
from .responses import json_error, not_found, parse_uuid
|
||||
from .settings import get_setting
|
||||
from .unfurl import UnfurlError, unfurl
|
||||
from .models.label import Label, NoteLabel
|
||||
@@ -237,9 +238,8 @@ async def _serialize_notes(db, notes: list) -> list:
|
||||
|
||||
async def _get_owned(db, note_id: str) -> Note | None:
|
||||
"""Fetch a note the current user OWNS (mutations are owner-only in M1/M2)."""
|
||||
try:
|
||||
nid = uuid.UUID(note_id)
|
||||
except (ValueError, TypeError):
|
||||
nid = parse_uuid(note_id)
|
||||
if nid is None:
|
||||
return None
|
||||
return await db.scalar(select(Note).where(Note.id == nid, Note.owner_id == g.user_id))
|
||||
|
||||
@@ -369,7 +369,7 @@ def next_occurrence(remind_at: datetime, recurrence: str, after: datetime) -> da
|
||||
async def list_notes():
|
||||
filter_name = request.args.get("filter", "active")
|
||||
if filter_name not in VALID_FILTERS:
|
||||
return jsonify({"error": "invalid filter"}), 400
|
||||
return json_error("invalid filter", 400)
|
||||
# Combinable facet filters (all optional, AND-ed together) — the rich-search /
|
||||
# saved-filter lens. Multiple ?label= narrow to notes carrying ALL of them.
|
||||
label_params = request.args.getlist("label")
|
||||
@@ -390,18 +390,17 @@ async def list_notes():
|
||||
stmt = select(Note).where(visible_to_user("note", Note.owner_id, Note.id, g.user_id))
|
||||
stmt = apply_filter(stmt, filter_name)
|
||||
for raw_label in label_params:
|
||||
try:
|
||||
lid = uuid.UUID(raw_label)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({"error": "invalid label"}), 400
|
||||
lid = parse_uuid(raw_label)
|
||||
if lid is None:
|
||||
return json_error("invalid label", 400)
|
||||
stmt = stmt.where(Note.id.in_(select(NoteLabel.note_id).where(NoteLabel.label_id == lid)))
|
||||
if color is not None:
|
||||
if color not in NOTE_COLORS:
|
||||
return jsonify({"error": "invalid color"}), 400
|
||||
return json_error("invalid color", 400)
|
||||
stmt = stmt.where(Note.color == color)
|
||||
if kind is not None:
|
||||
if kind not in ("text", "list"):
|
||||
return jsonify({"error": "invalid kind"}), 400
|
||||
return json_error("invalid kind", 400)
|
||||
stmt = stmt.where(Note.kind == kind)
|
||||
if has_reminder:
|
||||
stmt = stmt.where(Note.remind_at.is_not(None))
|
||||
@@ -410,12 +409,12 @@ async def list_notes():
|
||||
if after_param:
|
||||
after_dt = parse_dt(after_param)
|
||||
if after_dt is None:
|
||||
return jsonify({"error": "invalid created_after"}), 400
|
||||
return json_error("invalid created_after", 400)
|
||||
stmt = stmt.where(Note.created_at >= after_dt)
|
||||
if before_param:
|
||||
before_dt = parse_dt(before_param)
|
||||
if before_dt is None:
|
||||
return jsonify({"error": "invalid created_before"}), 400
|
||||
return json_error("invalid created_before", 400)
|
||||
stmt = stmt.where(Note.created_at < before_dt)
|
||||
if query_text:
|
||||
# Full-text match over title+body (generated tsvector, migration 0005),
|
||||
@@ -482,7 +481,7 @@ async def complete_reminder(note_id: str):
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
if note.remind_at is not None and note.recurrence in REMINDER_RECURRENCES:
|
||||
note.remind_at = next_occurrence(note.remind_at, note.recurrence, datetime.now(timezone.utc))
|
||||
else:
|
||||
@@ -505,7 +504,7 @@ async def snooze_reminder(note_id: str):
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
note.remind_at = datetime.now(timezone.utc) + timedelta(minutes=minutes)
|
||||
await db.commit()
|
||||
return jsonify(await _serialize_note(db, note))
|
||||
@@ -877,20 +876,19 @@ async def import_notes():
|
||||
files = await request.files
|
||||
upload = files.get("file")
|
||||
if upload is None:
|
||||
return jsonify({"error": "no file provided"}), 400
|
||||
return json_error("no file provided", 400)
|
||||
raw = upload.stream.read()
|
||||
if not raw:
|
||||
return jsonify({"error": "empty upload"}), 400
|
||||
return json_error("empty upload", 400)
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(raw))
|
||||
except zipfile.BadZipFile:
|
||||
return jsonify({"error": "that file isn't a valid .zip archive"}), 400
|
||||
|
||||
return json_error("that file isn't a valid .zip archive", 400)
|
||||
specs, source = _read_import_specs(zf)
|
||||
if not specs:
|
||||
return jsonify(
|
||||
{"error": "no importable notes found — expected a ThoughtSync export or a Google Keep Takeout zip"}
|
||||
), 400
|
||||
return json_error(
|
||||
"no importable notes found — expected a ThoughtSync export or a Google Keep Takeout zip", 400
|
||||
)
|
||||
|
||||
imported = 0
|
||||
skipped = 0
|
||||
@@ -965,16 +963,15 @@ async def link_search():
|
||||
@bp.get("/<note_id>/backlinks")
|
||||
@login_required
|
||||
async def note_backlinks(note_id: str):
|
||||
try:
|
||||
nid = uuid.UUID(note_id)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({"error": "not found"}), 404
|
||||
nid = parse_uuid(note_id)
|
||||
if nid is None:
|
||||
return not_found()
|
||||
async with session_scope() as db:
|
||||
note = await db.scalar(
|
||||
select(Note).where(Note.id == nid, visible_to_user("note", Note.owner_id, Note.id, g.user_id))
|
||||
)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
if not note.display_title:
|
||||
return jsonify({"backlinks": []})
|
||||
norm = note.display_title.strip().lower()
|
||||
@@ -1005,13 +1002,13 @@ async def reorder_notes():
|
||||
data = await request.get_json(silent=True) or {}
|
||||
ids = data.get("ids")
|
||||
if not isinstance(ids, list):
|
||||
return jsonify({"error": "ids must be a list"}), 400
|
||||
return json_error("ids must be a list", 400)
|
||||
parsed: list = []
|
||||
for rid in ids:
|
||||
try:
|
||||
parsed.append(uuid.UUID(str(rid)))
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({"error": "invalid id"}), 400
|
||||
parsed_id = parse_uuid(rid)
|
||||
if parsed_id is None:
|
||||
return json_error("invalid id", 400)
|
||||
parsed.append(parsed_id)
|
||||
async with session_scope() as db:
|
||||
owned = {
|
||||
n.id: n
|
||||
@@ -1038,9 +1035,9 @@ async def create_note():
|
||||
item_texts = parse_list_items(data.get("items")) if kind == "list" else []
|
||||
if kind == "list":
|
||||
if not (title.strip() or item_texts):
|
||||
return jsonify({"error": "note is empty"}), 400
|
||||
return json_error("note is empty", 400)
|
||||
elif is_empty_note(title, body):
|
||||
return jsonify({"error": "note is empty"}), 400
|
||||
return json_error("note is empty", 400)
|
||||
async with session_scope() as db:
|
||||
# New notes go to the top of the manual order.
|
||||
max_pos = await db.scalar(
|
||||
@@ -1072,16 +1069,15 @@ async def create_note():
|
||||
@bp.get("/<note_id>")
|
||||
@login_required
|
||||
async def get_note(note_id: str):
|
||||
try:
|
||||
nid = uuid.UUID(note_id)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({"error": "not found"}), 404
|
||||
nid = parse_uuid(note_id)
|
||||
if nid is None:
|
||||
return not_found()
|
||||
async with session_scope() as db:
|
||||
note = await db.scalar(
|
||||
select(Note).where(Note.id == nid, visible_to_user("note", Note.owner_id, Note.id, g.user_id))
|
||||
)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
return jsonify(await _serialize_note(db, note))
|
||||
|
||||
|
||||
@@ -1092,7 +1088,7 @@ async def update_note(note_id: str):
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
old_display = note.display_title
|
||||
old_title = note.title
|
||||
old_body = note.body
|
||||
@@ -1117,7 +1113,7 @@ async def update_note(note_id: str):
|
||||
else:
|
||||
remind_dt = parse_dt(raw)
|
||||
if remind_dt is None:
|
||||
return jsonify({"error": "invalid remind_at"}), 400
|
||||
return json_error("invalid remind_at", 400)
|
||||
note.remind_at = remind_dt
|
||||
if "recurrence" in data:
|
||||
note.recurrence = normalize_recurrence(data["recurrence"])
|
||||
@@ -1157,7 +1153,7 @@ async def list_revisions(note_id: str):
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
rows = (
|
||||
await db.scalars(
|
||||
select(NoteRevision)
|
||||
@@ -1172,17 +1168,16 @@ async def list_revisions(note_id: str):
|
||||
@bp.post("/<note_id>/revisions/<rev_id>/restore")
|
||||
@login_required
|
||||
async def restore_revision(note_id: str, rev_id: str):
|
||||
try:
|
||||
rid = uuid.UUID(rev_id)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({"error": "not found"}), 404
|
||||
rid = parse_uuid(rev_id)
|
||||
if rid is None:
|
||||
return not_found()
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
rev = await db.scalar(select(NoteRevision).where(NoteRevision.id == rid, NoteRevision.note_id == note.id))
|
||||
if rev is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
if note.title == rev.title and note.body == rev.body:
|
||||
return jsonify(await _serialize_note(db, note)) # already at this version — no-op
|
||||
# Snapshot the CURRENT state first, so restoring is itself undoable, then apply
|
||||
@@ -1208,17 +1203,17 @@ async def set_note_labels(note_id: str):
|
||||
data = await request.get_json(silent=True) or {}
|
||||
raw_ids = data.get("label_ids")
|
||||
if not isinstance(raw_ids, list):
|
||||
return jsonify({"error": "label_ids must be a list"}), 400
|
||||
return json_error("label_ids must be a list", 400)
|
||||
label_ids: list = []
|
||||
for rid in raw_ids:
|
||||
try:
|
||||
label_ids.append(uuid.UUID(str(rid)))
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({"error": "invalid label id"}), 400
|
||||
parsed_label = parse_uuid(rid)
|
||||
if parsed_label is None:
|
||||
return json_error("invalid label id", 400)
|
||||
label_ids.append(parsed_label)
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
owned: set = set()
|
||||
if label_ids:
|
||||
owned = set(
|
||||
@@ -1244,9 +1239,8 @@ async def set_note_labels(note_id: str):
|
||||
|
||||
|
||||
async def _get_item(db, note: Note, item_id: str) -> NoteItem | None:
|
||||
try:
|
||||
iid = uuid.UUID(item_id)
|
||||
except (ValueError, TypeError):
|
||||
iid = parse_uuid(item_id)
|
||||
if iid is None:
|
||||
return None
|
||||
return await db.scalar(select(NoteItem).where(NoteItem.id == iid, NoteItem.note_id == note.id))
|
||||
|
||||
@@ -1259,7 +1253,7 @@ async def add_item(note_id: str):
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
max_pos = await db.scalar(
|
||||
select(func.coalesce(func.max(NoteItem.position), -1)).where(NoteItem.note_id == note.id)
|
||||
)
|
||||
@@ -1275,10 +1269,10 @@ async def update_item(note_id: str, item_id: str):
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
item = await _get_item(db, note, item_id)
|
||||
if item is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
if "text" in data and isinstance(data["text"], str):
|
||||
item.text = data["text"]
|
||||
if "checked" in data:
|
||||
@@ -1293,10 +1287,10 @@ async def delete_item(note_id: str, item_id: str):
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
item = await _get_item(db, note, item_id)
|
||||
if item is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
await db.delete(item)
|
||||
await db.commit()
|
||||
return jsonify(await _serialize_note(db, note))
|
||||
@@ -1308,11 +1302,11 @@ async def reorder_items(note_id: str):
|
||||
data = await request.get_json(silent=True) or {}
|
||||
order = data.get("item_ids")
|
||||
if not isinstance(order, list):
|
||||
return jsonify({"error": "item_ids must be a list"}), 400
|
||||
return json_error("item_ids must be a list", 400)
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
existing = {
|
||||
str(i.id): i for i in (await db.scalars(select(NoteItem).where(NoteItem.note_id == note.id))).all()
|
||||
}
|
||||
@@ -1351,11 +1345,11 @@ async def upload_attachment(note_id: str):
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
files = await request.files
|
||||
upload = files.get("file")
|
||||
if upload is None:
|
||||
return jsonify({"error": "no file provided"}), 400
|
||||
return json_error("no file provided", 400)
|
||||
# Any file type is allowed — images render inline, everything else downloads.
|
||||
mime = (upload.content_type or "application/octet-stream").split(";")[0].strip().lower()
|
||||
filename = _safe_filename(upload.filename)
|
||||
@@ -1366,10 +1360,10 @@ async def upload_attachment(note_id: str):
|
||||
raw_id = (form.get("id") or "").strip()
|
||||
att_id = uuid.uuid4()
|
||||
if raw_id:
|
||||
try:
|
||||
att_id = uuid.UUID(raw_id)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({"error": "invalid attachment id"}), 400
|
||||
parsed_att = parse_uuid(raw_id)
|
||||
if parsed_att is None:
|
||||
return json_error("invalid attachment id", 400)
|
||||
att_id = parsed_att
|
||||
existing = await db.scalar(
|
||||
select(NoteAttachment).where(NoteAttachment.id == att_id, NoteAttachment.note_id == note.id)
|
||||
)
|
||||
@@ -1378,7 +1372,7 @@ async def upload_attachment(note_id: str):
|
||||
raw = upload.stream.read()
|
||||
max_mb = int(await get_setting(db, "max_attachment_mb"))
|
||||
if len(raw) > max_mb * 1024 * 1024:
|
||||
return jsonify({"error": f"file is too large (max {max_mb} MB)"}), 413
|
||||
return json_error(f"file is too large (max {max_mb} MB)", 413)
|
||||
rel = os.path.join(str(note.id), f"{att_id}{ext}")
|
||||
dest = Config.media_root() / rel
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -1401,26 +1395,25 @@ async def upload_attachment(note_id: str):
|
||||
@bp.get("/<note_id>/attachments/<att_id>")
|
||||
@login_required
|
||||
async def get_attachment(note_id: str, att_id: str):
|
||||
try:
|
||||
nid = uuid.UUID(note_id)
|
||||
aid = uuid.UUID(att_id)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({"error": "not found"}), 404
|
||||
nid = parse_uuid(note_id)
|
||||
aid = parse_uuid(att_id)
|
||||
if nid is None or aid is None:
|
||||
return not_found()
|
||||
async with session_scope() as db:
|
||||
# Owner OR shared may view (rule 47).
|
||||
note = await db.scalar(
|
||||
select(Note).where(Note.id == nid, visible_to_user("note", Note.owner_id, Note.id, g.user_id))
|
||||
)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
att = await db.scalar(select(NoteAttachment).where(NoteAttachment.id == aid, NoteAttachment.note_id == nid))
|
||||
if att is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
mime = att.mime
|
||||
filename = att.filename or os.path.basename(att.path)
|
||||
file_path = Config.media_root() / att.path
|
||||
if not file_path.is_file():
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
response = await send_file(str(file_path), mimetype=mime)
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Cache-Control"] = "private, max-age=86400"
|
||||
@@ -1433,17 +1426,16 @@ async def get_attachment(note_id: str, att_id: str):
|
||||
@bp.delete("/<note_id>/attachments/<att_id>")
|
||||
@login_required
|
||||
async def delete_attachment(note_id: str, att_id: str):
|
||||
try:
|
||||
aid = uuid.UUID(att_id)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({"error": "not found"}), 404
|
||||
aid = parse_uuid(att_id)
|
||||
if aid is None:
|
||||
return not_found()
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
att = await db.scalar(select(NoteAttachment).where(NoteAttachment.id == aid, NoteAttachment.note_id == note.id))
|
||||
if att is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
file_path = Config.media_root() / att.path
|
||||
await db.delete(att)
|
||||
await db.commit()
|
||||
@@ -1463,22 +1455,22 @@ async def unfurl_link(note_id: str):
|
||||
data = await request.get_json(silent=True) or {}
|
||||
url = (data.get("url") or "").strip()
|
||||
if not url:
|
||||
return jsonify({"error": "url is required"}), 400
|
||||
return json_error("url is required", 400)
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
if not await get_setting(db, "enable_url_unfurl"):
|
||||
return jsonify({"error": "link previews are disabled"}), 403
|
||||
return json_error("link previews are disabled", 403)
|
||||
# Fetch OUTSIDE the DB session — network IO shouldn't hold a connection.
|
||||
try:
|
||||
preview = await unfurl(url)
|
||||
except UnfurlError as e:
|
||||
return jsonify({"error": str(e)}), 502
|
||||
return json_error(str(e), 502)
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
# Keyed by the ORIGINAL pasted url (what the note body contains, so the client
|
||||
# matches it) — re-unfurling the same link updates the cached preview in place.
|
||||
row = await db.scalar(
|
||||
@@ -1498,19 +1490,18 @@ async def unfurl_link(note_id: str):
|
||||
@bp.delete("/<note_id>/previews/<preview_id>")
|
||||
@login_required
|
||||
async def delete_preview(note_id: str, preview_id: str):
|
||||
try:
|
||||
pid = uuid.UUID(preview_id)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({"error": "not found"}), 404
|
||||
pid = parse_uuid(preview_id)
|
||||
if pid is None:
|
||||
return not_found()
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
row = await db.scalar(
|
||||
select(NoteLinkPreview).where(NoteLinkPreview.id == pid, NoteLinkPreview.note_id == note.id)
|
||||
)
|
||||
if row is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
await db.delete(row)
|
||||
await db.commit()
|
||||
return jsonify(await _serialize_note(db, note))
|
||||
@@ -1522,7 +1513,7 @@ async def trash_note(note_id: str):
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
note.deleted_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
@@ -1535,7 +1526,7 @@ async def restore_note(note_id: str):
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
note.deleted_at = None
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
@@ -1548,9 +1539,9 @@ async def delete_note(note_id: str):
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return not_found()
|
||||
if note.deleted_at is None:
|
||||
return jsonify({"error": "note must be trashed before permanent delete"}), 409
|
||||
return json_error("note must be trashed before permanent delete", 409)
|
||||
await db.delete(note)
|
||||
await db.commit()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from quart import jsonify
|
||||
|
||||
# The app's single JSON error shape + the two guards that pair with it, so every
|
||||
# blueprint returns errors and parses path ids the same way instead of hand-building
|
||||
# `jsonify({"error": ...}), code` and try/except uuid blocks at ~35 call sites.
|
||||
|
||||
|
||||
def json_error(message: str, status: int):
|
||||
"""A JSON error body + status: ({"error": message}, status)."""
|
||||
return jsonify({"error": message}), status
|
||||
|
||||
|
||||
def not_found():
|
||||
"""404 with the standard body — by far the most common error in the note routes."""
|
||||
return json_error("not found", 404)
|
||||
|
||||
|
||||
def parse_uuid(raw: object) -> uuid.UUID | None:
|
||||
"""Parse a path/body UUID, returning None on anything malformed. Pair with
|
||||
not_found() for the ubiquitous 'bad id in the URL → 404' guard."""
|
||||
try:
|
||||
return uuid.UUID(str(raw))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
Reference in New Issue
Block a user