Files
siftlode/backend/app/routes/_common.py
T
peter c5533b0ac5 refactor(api): shared serializer + ownership + write-scope helpers (R6 S1a)
Establish the R6 API-contract infra before the S1b payload:dict migration:
- iso(dt) in utils.py replaces the ~16 hand-rolled `x.isoformat() if x else None`
  serializer idioms across the routes.
- get_or_404 / owned_or_404 in routes/_common.py — one 404 + existence-leak policy;
  plex's _own_playlist_or_404 and saved_views' _own_view now delegate to it.
- require_write_scope dependency in auth.py collapses the inline has_write_scope→403
  checks (channels subscribe/unsubscribe, playlists push/push-plan) that had drifted
  into three different messages into one; the conditional delete_playlist guard stays
  inline (its message was already canonical).

No response-shape change (iso == isoformat for a present value). Gate: ruff clean,
pytest 163 green (+ test_utils for iso).
2026-07-26 04:17:08 +02:00

45 lines
1.5 KiB
Python

"""Route-layer shared helpers for the API contract (R6 S1a).
Small, dependency-light guards that several route modules had hand-rolled with subtle
drift. Keeping them here (imported by the route modules) means the "fetch a row or 404"
and "fetch a row this user owns or 404" idioms have one definition, one status code, and
one existence-leak policy.
"""
from typing import Any, TypeVar
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.db import Base
from app.models import User
M = TypeVar("M", bound=Base)
def get_or_404(db: Session, model: type[M], id_: Any, *, detail: str = "Not found") -> M:
"""Fetch a row by primary key or raise 404. Replaces the `db.get(Model, id); if None: 404`
idiom repeated across routes."""
obj = db.get(model, id_)
if obj is None:
raise HTTPException(status_code=404, detail=detail)
return obj
def owned_or_404(
db: Session,
model: type[M],
id_: Any,
user: User,
*,
owner_attr: str = "user_id",
detail: str = "Not found",
) -> M:
"""Fetch a row by primary key that `user` owns, or raise 404. A row owned by someone
else 404s (NOT 403) on purpose — a 403 would confirm the id exists to a stranger.
Generalizes the near-identical per-route ownership helpers (e.g. plex's
`_own_playlist_or_404`, the saved-views owner check)."""
obj = get_or_404(db, model, id_, detail=detail)
if getattr(obj, owner_attr) != user.id:
raise HTTPException(status_code=404, detail=detail)
return obj