S1: unify error responses + id parsing (responses.py) across notes.py
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 33s

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:
2026-07-23 19:29:46 -04:00
co-authored by Claude Opus 4.8
parent 2abed7132c
commit 6abc82c783
2 changed files with 118 additions and 99 deletions
+90 -99
View File
@@ -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})
+28
View File
@@ -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