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)