jmri_server.routes

FastAPI routes — HTTP POST /json and WebSocket WS /json.

The HTTP endpoint accepts one request, dispatches, returns one response. The WebSocket endpoint accepts a stream of requests (and pushes unsolicited state updates) for the lifetime of the connection.

  1"""FastAPI routes — HTTP ``POST /json`` and WebSocket ``WS /json``.
  2
  3The HTTP endpoint accepts one request, dispatches, returns one response.
  4The WebSocket endpoint accepts a stream of requests (and pushes unsolicited
  5state updates) for the lifetime of the connection.
  6"""
  7from __future__ import annotations
  8
  9import json
 10from typing import TYPE_CHECKING
 11
 12from fastapi import APIRouter, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
 13from fastapi.responses import FileResponse
 14
 15from jmri_server.envelope import JmriRequest, JmriResponse
 16from jmri_server.roster_media import (
 17    media_content_type,
 18    resolve_media_path,
 19)
 20from jmri_server.service import JsonServiceError
 21
 22if TYPE_CHECKING:
 23    from jmri_server.context import ServerContext
 24    from jmri_server.push import PushManager
 25    from jmri_server.registry import ServiceRegistry
 26
 27
 28def install_routes(
 29    app: FastAPI,
 30    ctx: ServerContext,
 31    registry: ServiceRegistry,
 32    push: PushManager,
 33) -> None:
 34    router = APIRouter()
 35
 36    @router.get("/api")
 37    async def root() -> dict[str, str]:
 38        """Status endpoint. Lives at ``/api`` (not ``/``) so it doesn't shadow a
 39        web UI home page when jmri-web mounts on the same app."""
 40        return {"service": "jmri-server", "version": ctx.metadata.jmri_version}
 41
 42    @router.get("/json/hello")
 43    async def hello() -> dict[str, object]:
 44        svc = registry.get("hello")
 45        assert svc is not None
 46        return await svc.on_get(None)  # type: ignore[return-value]
 47
 48    @router.post("/json/")
 49    async def post_json(req: JmriRequest, request: Request) -> JmriResponse:
 50        # Role + username populated by AuthMiddleware. Both fall
 51        # back to admin / None when the middleware short-circuits
 52        # (auth off) or when a test calls the route without going
 53        # through middleware — the registry's ``dispatch``
 54        # defaults match, so this doesn't leak permission over a
 55        # naked test call.
 56        role = getattr(request.state, "role", None)
 57        user = getattr(request.state, "user", None)
 58        username = user.username if user is not None else None
 59        try:
 60            if role is None:
 61                return await registry.dispatch(req, username=username)
 62            return await registry.dispatch(req, role=role, username=username)
 63        except JsonServiceError as e:
 64            raise HTTPException(status_code=e.code, detail=e.message) from None
 65
 66    async def _serve_roster_media(entry_id: str, kind: str) -> FileResponse:
 67        """Shared implementation for ``/roster/{id}/image`` and
 68        ``/roster/{id}/icon``. 404 on any missing piece; never leaks a
 69        server-side error message about the underlying filesystem."""
 70        from jmri_server.services.roster import _lazy_roster_store  # noqa: PLC0415
 71        store = _lazy_roster_store(ctx)
 72        if store is None:
 73            raise HTTPException(status_code=404, detail="roster unavailable")
 74        entry = store.get(entry_id)
 75        if entry is None:
 76            raise HTTPException(
 77                status_code=404, detail=f"no roster entry {entry_id!r}",
 78            )
 79        raw = (
 80            entry.image_file_path if kind == "image"
 81            else entry.icon_file_path
 82        )
 83        resolved = resolve_media_path(store.profile, raw)
 84        if resolved is None:
 85            raise HTTPException(
 86                status_code=404,
 87                detail=f"no {kind} media for roster entry {entry_id!r}",
 88            )
 89        return FileResponse(
 90            path=resolved,
 91            media_type=media_content_type(resolved),
 92            filename=resolved.name,
 93        )
 94
 95    @router.get("/roster/{entry_id}/image")
 96    async def roster_image(entry_id: str) -> FileResponse:
 97        return await _serve_roster_media(entry_id, "image")
 98
 99    @router.get("/roster/{entry_id}/icon")
100    async def roster_icon(entry_id: str) -> FileResponse:
101        return await _serve_roster_media(entry_id, "icon")
102
103    @app.websocket("/json")
104    async def ws_json(ws: WebSocket) -> None:
105        await ws.accept()
106        await push.attach(ws)
107        # Greet on connect, per JMRI's convention.
108        hello_svc = registry.get("hello")
109        if hello_svc is not None:
110            greeting = await hello_svc.on_get(None)
111            await ws.send_text(json.dumps({"type": "hello", "data": greeting}))
112        try:
113            while True:
114                raw = await ws.receive_text()
115                await _handle_ws_text(ws, registry, raw)
116        except WebSocketDisconnect:
117            pass
118        finally:
119            await push.detach(ws)
120
121    app.include_router(router)
122
123
124async def _handle_ws_text(
125    ws: WebSocket, registry: ServiceRegistry, raw: str
126) -> None:
127    """Parse one WebSocket text frame, dispatch, write back the response."""
128    try:
129        parsed = json.loads(raw)
130    except json.JSONDecodeError:
131        await ws.send_text(json.dumps({
132            "type": "error",
133            "data": {"code": 400, "message": "invalid JSON"},
134        }))
135        return
136
137    # JMRI allows batched messages: either a single envelope or a list.
138    items = parsed if isinstance(parsed, list) else [parsed]
139    for item in items:
140        try:
141            req = JmriRequest.model_validate(item)
142            resp = await registry.dispatch(req)
143            await ws.send_text(resp.model_dump_json(exclude_none=True))
144        except JsonServiceError as e:
145            await ws.send_text(json.dumps({
146                "type": "error",
147                "data": {"code": e.code, "message": e.message},
148                "id": item.get("id") if isinstance(item, dict) else None,
149            }))
150        except Exception as e:  # noqa: BLE001 — turn into wire-format error
151            await ws.send_text(json.dumps({
152                "type": "error",
153                "data": {"code": 500, "message": str(e)},
154            }))
def install_routes( app: fastapi.applications.FastAPI, ctx: jmri_server.context.ServerContext, registry: jmri_server.registry.ServiceRegistry, push: jmri_server.push.PushManager) -> None:
 29def install_routes(
 30    app: FastAPI,
 31    ctx: ServerContext,
 32    registry: ServiceRegistry,
 33    push: PushManager,
 34) -> None:
 35    router = APIRouter()
 36
 37    @router.get("/api")
 38    async def root() -> dict[str, str]:
 39        """Status endpoint. Lives at ``/api`` (not ``/``) so it doesn't shadow a
 40        web UI home page when jmri-web mounts on the same app."""
 41        return {"service": "jmri-server", "version": ctx.metadata.jmri_version}
 42
 43    @router.get("/json/hello")
 44    async def hello() -> dict[str, object]:
 45        svc = registry.get("hello")
 46        assert svc is not None
 47        return await svc.on_get(None)  # type: ignore[return-value]
 48
 49    @router.post("/json/")
 50    async def post_json(req: JmriRequest, request: Request) -> JmriResponse:
 51        # Role + username populated by AuthMiddleware. Both fall
 52        # back to admin / None when the middleware short-circuits
 53        # (auth off) or when a test calls the route without going
 54        # through middleware — the registry's ``dispatch``
 55        # defaults match, so this doesn't leak permission over a
 56        # naked test call.
 57        role = getattr(request.state, "role", None)
 58        user = getattr(request.state, "user", None)
 59        username = user.username if user is not None else None
 60        try:
 61            if role is None:
 62                return await registry.dispatch(req, username=username)
 63            return await registry.dispatch(req, role=role, username=username)
 64        except JsonServiceError as e:
 65            raise HTTPException(status_code=e.code, detail=e.message) from None
 66
 67    async def _serve_roster_media(entry_id: str, kind: str) -> FileResponse:
 68        """Shared implementation for ``/roster/{id}/image`` and
 69        ``/roster/{id}/icon``. 404 on any missing piece; never leaks a
 70        server-side error message about the underlying filesystem."""
 71        from jmri_server.services.roster import _lazy_roster_store  # noqa: PLC0415
 72        store = _lazy_roster_store(ctx)
 73        if store is None:
 74            raise HTTPException(status_code=404, detail="roster unavailable")
 75        entry = store.get(entry_id)
 76        if entry is None:
 77            raise HTTPException(
 78                status_code=404, detail=f"no roster entry {entry_id!r}",
 79            )
 80        raw = (
 81            entry.image_file_path if kind == "image"
 82            else entry.icon_file_path
 83        )
 84        resolved = resolve_media_path(store.profile, raw)
 85        if resolved is None:
 86            raise HTTPException(
 87                status_code=404,
 88                detail=f"no {kind} media for roster entry {entry_id!r}",
 89            )
 90        return FileResponse(
 91            path=resolved,
 92            media_type=media_content_type(resolved),
 93            filename=resolved.name,
 94        )
 95
 96    @router.get("/roster/{entry_id}/image")
 97    async def roster_image(entry_id: str) -> FileResponse:
 98        return await _serve_roster_media(entry_id, "image")
 99
100    @router.get("/roster/{entry_id}/icon")
101    async def roster_icon(entry_id: str) -> FileResponse:
102        return await _serve_roster_media(entry_id, "icon")
103
104    @app.websocket("/json")
105    async def ws_json(ws: WebSocket) -> None:
106        await ws.accept()
107        await push.attach(ws)
108        # Greet on connect, per JMRI's convention.
109        hello_svc = registry.get("hello")
110        if hello_svc is not None:
111            greeting = await hello_svc.on_get(None)
112            await ws.send_text(json.dumps({"type": "hello", "data": greeting}))
113        try:
114            while True:
115                raw = await ws.receive_text()
116                await _handle_ws_text(ws, registry, raw)
117        except WebSocketDisconnect:
118            pass
119        finally:
120            await push.detach(ws)
121
122    app.include_router(router)