add-route
Add or modify a Flask endpoint in the Lyrion Dashboard. Use this whenever a task adds a new URL/route, a JSON API endpoint, or a page — anything that registers a handler in `routes/`. Covers the repo'
SKILL.md.claude/skills/add-route/SKILL.md
GitHubAdding a Flask endpoint
This app layers strictly: routes parse the request and shape the response, services do the work (Lyrion calls, DB reads, lyrics), tests cover the route's contract. A route should read like a thin adapter — if a handler grows real logic, that logic belongs in a services/ module.
Where things go
routes/nowplaying.py— the dashboard and its JSON endpoints (/,/now-playing.json,/cover/...,/stats.json,/lyrics.json, ...). Registered asnowplaying_bp.routes/custom.py— the/files/static server. Registered ascustom_bp.- Blueprints are created in the route module and registered in
app.py'screate_app(). A brand-new blueprint must be both created andregister_blueprint-ed there; adding a route to an existing blueprint needs noapp.pychange.
The pattern
Handlers import service functions and translations at the top of the module — they do not build Lyrion payloads or open the DB inline:
from services.lyrion import get_active_now_playing
from services.database import get_stats
@nowplaying_bp.route("/stats.json")
def stats_json():
return jsonify(get_stats())
For anything with new domain logic, add a function to the relevant services/ module first (see the lyrion-api skill for Lyrion calls, services/database.py for reads), then call it from the route.
Validate untrusted input, always
Every value from the client is validated before it reaches a service or an upstream URL. The module keeps compiled regexes for this and clamps numbers:
COVERID_RE = re.compile(r"[0-9a-fA-F]+")
@nowplaying_bp.route("/cover/<coverid>.jpg")
def cover(coverid):
if not COVERID_RE.fullmatch(coverid):
abort(404)
size = request.args.get("size", type=int)
if size is not None:
size = min(max(size, 16), 512) # clamp, don't trust
- Match path/query params against a
fullmatchregex;abort(404)on junk. - Clamp numeric
?limit=/?size=params withmin(max(...))so a hostile value can't blow up a query or a fetch (seemosaic_covers_json). - Anything that fans out to a third party (the web lyrics search) sits behind
services.ratelimit—RateLimiterper IP andCooldownper track. Reuse those, don't roll your own throttle.
Response conventions
- JSON endpoints return
jsonify(...). Keep the endpoint name suffix.json. - Cacheable binary responses set
Cache-Controlexplicitly, e.g. covers useheaders={"Cache-Control": "public, max-age=86400"}. - Security headers are applied app-wide by
set_security_headersinapp.pyviasetdefault(CSPdefault-src 'self',nosniff,SAMEORIGIN). You get them for free. Only set a header on the response yourself to override the default for one route — as/files/does withContent-Security-Policy: sandboxfor untrusted files.
i18n for pages (not JSON)
HTML pages pick the language per-request and hand the whole translation dict to the template. JSON endpoints don't localize. See the i18n skill; the wiring is:
from i18n import pick_lang, TRANSLATIONS
lang = pick_lang(request.accept_languages)
return render_template("nowplaying.html", lang=lang, t=TRANSLATIONS[lang], ...)
Any new user-facing string added to a template needs a key in both fr and en in i18n.py.
Test the route's contract
Every endpoint gets a tests/test_<name>_route.py using unittest + unittest.mock.patch. The setup is deliberately duplicated per file so each stands alone (# pylint: disable=duplicate-code):
- Set
LYRION_HOST,DB_DIR,DB_PERSIST_DIRenv vars viaos.environ.setdefaultbefore importingapp(config reads them at import). - Build a client with
create_app().test_client(). - Patch the service functions the route imports — patch them at the route module path, e.g.
@patch("routes.nowplaying.get_track_lyrics"), never the real Lyrion server or DB. - Assert on status, JSON body, and that services were called with the right args (
mock.assert_called_with(...)).
tests/test_now_playing_route.py is the reference example — mirror its shape.
Keep the docs in sync
The public surface is documented in the READMEs and under docs/, and they must not drift from the app. Each page is an EN/FR pair edited together (see the i18n skill's documentation parity rule):
- New/changed route → the Endpoints table in
docs/endpoints.md(Method / Route / Description). - New config env var the route relies on → the table in
docs/configuration.md, the single place operators discover it. - User-visible feature → the Features list in the README.
Checklist
- Put domain logic in a
services/function; keep the handler thin. - Register the route on the right blueprint (new blueprint → wire it in
app.py). - Validate/clamp every client input;
abort()on bad input. - Set
Cache-Controlon cacheable responses; rely on the global security headers unless overriding. - Localize page strings in both
franden; leave JSON unlocalized. - Add
tests/test_<name>_route.pypatching the imported services. - Public surface changed? Update
docs/endpoints.md,docs/configuration.mdor the README Features list — each in both languages. - Run
python -m unittest discoverandpylint app.py config.py i18n.py routes services scripts tests— both gate CI (.github/workflows/python-ci.yml).