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}")
class ServiceRegistry:
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.

ServiceRegistry(ctx: jmri_server.context.ServerContext)
23    def __init__(self, ctx: ServerContext) -> None:
24        self.ctx = ctx
25        self._services: dict[str, JsonService] = {}
ctx
def register( self, service_cls: type[jmri_server.service.JsonService]) -> jmri_server.service.JsonService:
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).

def get(self, topic: str) -> jmri_server.service.JsonService | None:
39    def get(self, topic: str) -> JsonService | None:
40        return self._services.get(topic)
def topics(self) -> list[str]:
42    def topics(self) -> list[str]:
43        return sorted(self._services)
async def dispatch( self, req: jmri_server.envelope.JmriRequest, *, role: jmri_server.auth.Role = <Role.ADMIN: 'admin'>, username: str | None = None) -> jmri_server.envelope.JmriResponse:
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.