jmri_server.registry
Service registry — topic → JsonService instance.
A single ServiceRegistry owns one JsonService instance per
topic, all sharing the same ServerContext. The registry is the
dispatch surface used by both the HTTP route and the WebSocket handler.
1"""Service registry — topic → JsonService instance. 2 3A single :class:`ServiceRegistry` owns one :class:`JsonService` instance per 4topic, all sharing the same :class:`ServerContext`. The registry is the 5dispatch surface used by both the HTTP route and the WebSocket handler. 6""" 7from __future__ import annotations 8 9from typing import TYPE_CHECKING, Any 10 11from jmri_server.auth.models import Role 12from jmri_server.envelope import JmriRequest, JmriResponse, Method, normalize_request 13from jmri_server.service import JsonService, JsonServiceError 14 15if TYPE_CHECKING: 16 from jmri_server.context import ServerContext 17 18 19class ServiceRegistry: 20 """Holds and dispatches to one service per topic.""" 21 22 def __init__(self, ctx: ServerContext) -> None: 23 self.ctx = ctx 24 self._services: dict[str, JsonService] = {} 25 26 def register(self, service_cls: type[JsonService]) -> JsonService: 27 """Construct *service_cls*, share the context, and register it. 28 29 Returns the instance so the caller can hook into it (e.g. to wire 30 state-change listeners for WebSocket push). 31 """ 32 svc = service_cls(self.ctx) 33 if svc.topic in self._services: 34 raise ValueError(f"duplicate service for topic {svc.topic!r}") 35 self._services[svc.topic] = svc 36 return svc 37 38 def get(self, topic: str) -> JsonService | None: 39 return self._services.get(topic) 40 41 def topics(self) -> list[str]: 42 return sorted(self._services) 43 44 async def dispatch( 45 self, 46 req: JmriRequest, 47 *, 48 role: Role = Role.ADMIN, 49 username: str | None = None, 50 ) -> JmriResponse: 51 """Resolve method + topic, call the service, return the wrapped response. 52 53 *role* is the authenticated caller's role — defaults to 54 :data:`Role.ADMIN` so callers (tests, WebSocket handshake 55 with auth off) that don't wire the middleware still work. 56 When ``JMRI_AUTH_ENABLED=1`` the HTTP route reads 57 ``request.state.role`` and passes it in; a method requiring 58 a higher role than the caller has raises 59 :class:`JsonServiceError` with code 403. 60 61 Raises :class:`JsonServiceError` on unknown topics, insufficient 62 role, or service-level errors; the caller (HTTP route / 63 WebSocket handler) decides whether to translate to an HTTP 64 error or a JMRI error message. 65 """ 66 topic, method, data = normalize_request(req) 67 svc = self._services.get(topic) 68 if svc is None: 69 raise JsonServiceError(404, f"unknown type {topic!r}") 70 needed = svc.required_role(method) 71 if role < needed: 72 raise JsonServiceError( 73 403, 74 f"role {role.value!r} cannot invoke {topic!r}.{method} " 75 f"(requires {needed.value!r})", 76 ) 77 # A service that needs the caller's identity (e.g. 78 # ``configUser`` for self-service password change) reads 79 # from the context; stash it there so we don't broaden 80 # the JsonService signature for a feature only one 81 # service uses. 82 self.ctx._current_username = username # type: ignore[attr-defined] 83 payload = await _call_service_method(svc, method, data) 84 # ``list`` requests always answer with a "list" type wrapper: 85 # ``{"type": "list", "data": [...]}``. JMRI's convention. 86 response_type = "list" if method == "list" else topic 87 return JmriResponse( 88 type=response_type, 89 data=payload, 90 method=method, 91 id=req.id, 92 ) 93 94 95async def _call_service_method( 96 svc: JsonService, method: Method, data: dict[str, Any] | None 97) -> dict[str, Any] | list[dict[str, Any]]: 98 if method == "get": 99 return await svc.on_get(data) 100 if method == "list": 101 return await svc.on_list(data) 102 if method == "put": 103 return await svc.on_put(data) 104 if method == "post": 105 return await svc.on_post(data) 106 if method == "delete": 107 return await svc.on_delete(data) 108 raise JsonServiceError(400, f"unknown method {method!r}")
20class ServiceRegistry: 21 """Holds and dispatches to one service per topic.""" 22 23 def __init__(self, ctx: ServerContext) -> None: 24 self.ctx = ctx 25 self._services: dict[str, JsonService] = {} 26 27 def register(self, service_cls: type[JsonService]) -> JsonService: 28 """Construct *service_cls*, share the context, and register it. 29 30 Returns the instance so the caller can hook into it (e.g. to wire 31 state-change listeners for WebSocket push). 32 """ 33 svc = service_cls(self.ctx) 34 if svc.topic in self._services: 35 raise ValueError(f"duplicate service for topic {svc.topic!r}") 36 self._services[svc.topic] = svc 37 return svc 38 39 def get(self, topic: str) -> JsonService | None: 40 return self._services.get(topic) 41 42 def topics(self) -> list[str]: 43 return sorted(self._services) 44 45 async def dispatch( 46 self, 47 req: JmriRequest, 48 *, 49 role: Role = Role.ADMIN, 50 username: str | None = None, 51 ) -> JmriResponse: 52 """Resolve method + topic, call the service, return the wrapped response. 53 54 *role* is the authenticated caller's role — defaults to 55 :data:`Role.ADMIN` so callers (tests, WebSocket handshake 56 with auth off) that don't wire the middleware still work. 57 When ``JMRI_AUTH_ENABLED=1`` the HTTP route reads 58 ``request.state.role`` and passes it in; a method requiring 59 a higher role than the caller has raises 60 :class:`JsonServiceError` with code 403. 61 62 Raises :class:`JsonServiceError` on unknown topics, insufficient 63 role, or service-level errors; the caller (HTTP route / 64 WebSocket handler) decides whether to translate to an HTTP 65 error or a JMRI error message. 66 """ 67 topic, method, data = normalize_request(req) 68 svc = self._services.get(topic) 69 if svc is None: 70 raise JsonServiceError(404, f"unknown type {topic!r}") 71 needed = svc.required_role(method) 72 if role < needed: 73 raise JsonServiceError( 74 403, 75 f"role {role.value!r} cannot invoke {topic!r}.{method} " 76 f"(requires {needed.value!r})", 77 ) 78 # A service that needs the caller's identity (e.g. 79 # ``configUser`` for self-service password change) reads 80 # from the context; stash it there so we don't broaden 81 # the JsonService signature for a feature only one 82 # service uses. 83 self.ctx._current_username = username # type: ignore[attr-defined] 84 payload = await _call_service_method(svc, method, data) 85 # ``list`` requests always answer with a "list" type wrapper: 86 # ``{"type": "list", "data": [...]}``. JMRI's convention. 87 response_type = "list" if method == "list" else topic 88 return JmriResponse( 89 type=response_type, 90 data=payload, 91 method=method, 92 id=req.id, 93 )
Holds and dispatches to one service per topic.
27 def register(self, service_cls: type[JsonService]) -> JsonService: 28 """Construct *service_cls*, share the context, and register it. 29 30 Returns the instance so the caller can hook into it (e.g. to wire 31 state-change listeners for WebSocket push). 32 """ 33 svc = service_cls(self.ctx) 34 if svc.topic in self._services: 35 raise ValueError(f"duplicate service for topic {svc.topic!r}") 36 self._services[svc.topic] = svc 37 return svc
Construct service_cls, share the context, and register it.
Returns the instance so the caller can hook into it (e.g. to wire state-change listeners for WebSocket push).
45 async def dispatch( 46 self, 47 req: JmriRequest, 48 *, 49 role: Role = Role.ADMIN, 50 username: str | None = None, 51 ) -> JmriResponse: 52 """Resolve method + topic, call the service, return the wrapped response. 53 54 *role* is the authenticated caller's role — defaults to 55 :data:`Role.ADMIN` so callers (tests, WebSocket handshake 56 with auth off) that don't wire the middleware still work. 57 When ``JMRI_AUTH_ENABLED=1`` the HTTP route reads 58 ``request.state.role`` and passes it in; a method requiring 59 a higher role than the caller has raises 60 :class:`JsonServiceError` with code 403. 61 62 Raises :class:`JsonServiceError` on unknown topics, insufficient 63 role, or service-level errors; the caller (HTTP route / 64 WebSocket handler) decides whether to translate to an HTTP 65 error or a JMRI error message. 66 """ 67 topic, method, data = normalize_request(req) 68 svc = self._services.get(topic) 69 if svc is None: 70 raise JsonServiceError(404, f"unknown type {topic!r}") 71 needed = svc.required_role(method) 72 if role < needed: 73 raise JsonServiceError( 74 403, 75 f"role {role.value!r} cannot invoke {topic!r}.{method} " 76 f"(requires {needed.value!r})", 77 ) 78 # A service that needs the caller's identity (e.g. 79 # ``configUser`` for self-service password change) reads 80 # from the context; stash it there so we don't broaden 81 # the JsonService signature for a feature only one 82 # service uses. 83 self.ctx._current_username = username # type: ignore[attr-defined] 84 payload = await _call_service_method(svc, method, data) 85 # ``list`` requests always answer with a "list" type wrapper: 86 # ``{"type": "list", "data": [...]}``. JMRI's convention. 87 response_type = "list" if method == "list" else topic 88 return JmriResponse( 89 type=response_type, 90 data=payload, 91 method=method, 92 id=req.id, 93 )
Resolve method + topic, call the service, return the wrapped response.
role is the authenticated caller's role — defaults to
Role.ADMIN so callers (tests, WebSocket handshake
with auth off) that don't wire the middleware still work.
When JMRI_AUTH_ENABLED=1 the HTTP route reads
request.state.role and passes it in; a method requiring
a higher role than the caller has raises
JsonServiceError with code 403.
Raises JsonServiceError on unknown topics, insufficient
role, or service-level errors; the caller (HTTP route /
WebSocket handler) decides whether to translate to an HTTP
error or a JMRI error message.