jmri_server.service

JsonService — base class for one topic's request handlers.

Each topic (turnout, sensor, power, …) is one subclass. The dispatcher resolves the topic from the request envelope's type and calls one of on_get(), on_list(), on_put(), on_post(), on_delete() based on the method.

Sub-classes return Python dicts; the dispatcher wraps them in the response envelope. Errors are raised as JsonServiceError; the dispatcher converts them to the JMRI error response format.

Role gating (when JMRI_AUTH_ENABLED=1): every method has a minimum role required to invoke it. Defaults live in REQUIRED_ROLE — viewer for reads (get / list), operator for mutations (post / put / delete). Individual services override by setting REQUIRED_ROLE with their own per-method map — e.g. configProfile requires admin for everything. The dispatcher enforces the check before invoking the handler; a role-gated failure raises JsonServiceError with code 403.

 1"""``JsonService`` — base class for one topic's request handlers.
 2
 3Each topic (``turnout``, ``sensor``, ``power``, …) is one subclass. The
 4dispatcher resolves the topic from the request envelope's ``type`` and
 5calls one of :meth:`on_get`, :meth:`on_list`, :meth:`on_put`,
 6:meth:`on_post`, :meth:`on_delete` based on the method.
 7
 8Sub-classes return Python dicts; the dispatcher wraps them in the response
 9envelope. Errors are raised as :class:`JsonServiceError`; the dispatcher
10converts them to the JMRI error response format.
11
12Role gating (when ``JMRI_AUTH_ENABLED=1``): every method has a minimum
13role required to invoke it. Defaults live in :attr:`REQUIRED_ROLE` —
14``viewer`` for reads (``get`` / ``list``), ``operator`` for mutations
15(``post`` / ``put`` / ``delete``). Individual services override by
16setting :attr:`REQUIRED_ROLE` with their own per-method map — e.g.
17``configProfile`` requires ``admin`` for everything. The dispatcher
18enforces the check before invoking the handler; a role-gated failure
19raises :class:`JsonServiceError` with code 403.
20"""
21from __future__ import annotations
22
23from typing import TYPE_CHECKING, Any
24
25from jmri_server.auth.models import Role
26
27if TYPE_CHECKING:
28    from jmri_server.context import ServerContext
29    from jmri_server.envelope import Method
30
31
32class JsonServiceError(Exception):
33    """Raised by a service to produce a JMRI-format error response."""
34
35    def __init__(self, code: int, message: str) -> None:
36        super().__init__(message)
37        self.code = code
38        self.message = message
39
40
41class JsonService:
42    """Base class for a topic's service handlers.
43
44    Subclasses set :attr:`topic` and override the methods they support;
45    unsupported methods raise 405 by default. Subclasses can also
46    override :attr:`REQUIRED_ROLE` to change per-method role gates.
47    """
48
49    topic: str = ""  # subclass must set
50
51    # Minimum role required to invoke each method. Defaults: reads
52    # are ``viewer``, mutations are ``operator``. Override on a
53    # per-service basis — the class-level dict is merged with the
54    # base defaults so a subclass only lists the methods it changes.
55    REQUIRED_ROLE: dict[str, Role] = {
56        "get": Role.VIEWER,
57        "list": Role.VIEWER,
58        "post": Role.OPERATOR,
59        "put": Role.OPERATOR,
60        "delete": Role.OPERATOR,
61    }
62
63    def required_role(self, method: str) -> Role:
64        """Look up the role required to invoke *method* on this
65        service, defaulting to :data:`Role.OPERATOR` for unknown
66        methods (defense against a future custom verb landing
67        without a role declaration)."""
68        return self.REQUIRED_ROLE.get(method, Role.OPERATOR)
69
70    def __init__(self, ctx: ServerContext) -> None:
71        if not self.topic:
72            raise TypeError(f"{type(self).__name__}.topic must be set")
73        self.ctx = ctx
74
75    # Each method receives the request `data` dict (may be None) and returns
76    # either a dict (single-object reply) or a list of dicts (list reply).
77    Payload = dict[str, Any] | list[dict[str, Any]]
78
79    async def on_get(self, data: dict[str, Any] | None) -> Payload:
80        raise JsonServiceError(405, f"{self.topic!r} does not support method 'get'")
81
82    async def on_list(self, data: dict[str, Any] | None) -> Payload:
83        raise JsonServiceError(405, f"{self.topic!r} does not support method 'list'")
84
85    async def on_put(self, data: dict[str, Any] | None) -> Payload:
86        raise JsonServiceError(405, f"{self.topic!r} does not support method 'put'")
87
88    async def on_post(self, data: dict[str, Any] | None) -> Payload:
89        raise JsonServiceError(405, f"{self.topic!r} does not support method 'post'")
90
91    async def on_delete(self, data: dict[str, Any] | None) -> Payload:
92        raise JsonServiceError(405, f"{self.topic!r} does not support method 'delete'")
class JsonServiceError(builtins.Exception):
33class JsonServiceError(Exception):
34    """Raised by a service to produce a JMRI-format error response."""
35
36    def __init__(self, code: int, message: str) -> None:
37        super().__init__(message)
38        self.code = code
39        self.message = message

Raised by a service to produce a JMRI-format error response.

JsonServiceError(code: int, message: str)
36    def __init__(self, code: int, message: str) -> None:
37        super().__init__(message)
38        self.code = code
39        self.message = message
code
message
class JsonService:
42class JsonService:
43    """Base class for a topic's service handlers.
44
45    Subclasses set :attr:`topic` and override the methods they support;
46    unsupported methods raise 405 by default. Subclasses can also
47    override :attr:`REQUIRED_ROLE` to change per-method role gates.
48    """
49
50    topic: str = ""  # subclass must set
51
52    # Minimum role required to invoke each method. Defaults: reads
53    # are ``viewer``, mutations are ``operator``. Override on a
54    # per-service basis — the class-level dict is merged with the
55    # base defaults so a subclass only lists the methods it changes.
56    REQUIRED_ROLE: dict[str, Role] = {
57        "get": Role.VIEWER,
58        "list": Role.VIEWER,
59        "post": Role.OPERATOR,
60        "put": Role.OPERATOR,
61        "delete": Role.OPERATOR,
62    }
63
64    def required_role(self, method: str) -> Role:
65        """Look up the role required to invoke *method* on this
66        service, defaulting to :data:`Role.OPERATOR` for unknown
67        methods (defense against a future custom verb landing
68        without a role declaration)."""
69        return self.REQUIRED_ROLE.get(method, Role.OPERATOR)
70
71    def __init__(self, ctx: ServerContext) -> None:
72        if not self.topic:
73            raise TypeError(f"{type(self).__name__}.topic must be set")
74        self.ctx = ctx
75
76    # Each method receives the request `data` dict (may be None) and returns
77    # either a dict (single-object reply) or a list of dicts (list reply).
78    Payload = dict[str, Any] | list[dict[str, Any]]
79
80    async def on_get(self, data: dict[str, Any] | None) -> Payload:
81        raise JsonServiceError(405, f"{self.topic!r} does not support method 'get'")
82
83    async def on_list(self, data: dict[str, Any] | None) -> Payload:
84        raise JsonServiceError(405, f"{self.topic!r} does not support method 'list'")
85
86    async def on_put(self, data: dict[str, Any] | None) -> Payload:
87        raise JsonServiceError(405, f"{self.topic!r} does not support method 'put'")
88
89    async def on_post(self, data: dict[str, Any] | None) -> Payload:
90        raise JsonServiceError(405, f"{self.topic!r} does not support method 'post'")
91
92    async def on_delete(self, data: dict[str, Any] | None) -> Payload:
93        raise JsonServiceError(405, f"{self.topic!r} does not support method 'delete'")

Base class for a topic's service handlers.

Subclasses set topic and override the methods they support; unsupported methods raise 405 by default. Subclasses can also override REQUIRED_ROLE to change per-method role gates.

JsonService(ctx: jmri_server.context.ServerContext)
71    def __init__(self, ctx: ServerContext) -> None:
72        if not self.topic:
73            raise TypeError(f"{type(self).__name__}.topic must be set")
74        self.ctx = ctx
topic: str = ''
REQUIRED_ROLE: dict[str, jmri_server.auth.Role] = {'get': <Role.VIEWER: 'viewer'>, 'list': <Role.VIEWER: 'viewer'>, 'post': <Role.OPERATOR: 'operator'>, 'put': <Role.OPERATOR: 'operator'>, 'delete': <Role.OPERATOR: 'operator'>}
def required_role(self, method: str) -> jmri_server.auth.Role:
64    def required_role(self, method: str) -> Role:
65        """Look up the role required to invoke *method* on this
66        service, defaulting to :data:`Role.OPERATOR` for unknown
67        methods (defense against a future custom verb landing
68        without a role declaration)."""
69        return self.REQUIRED_ROLE.get(method, Role.OPERATOR)

Look up the role required to invoke method on this service, defaulting to Role.OPERATOR for unknown methods (defense against a future custom verb landing without a role declaration).

ctx
Payload = dict[str, typing.Any] | list[dict[str, typing.Any]]
async def on_get( self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any] | list[dict[str, typing.Any]]:
80    async def on_get(self, data: dict[str, Any] | None) -> Payload:
81        raise JsonServiceError(405, f"{self.topic!r} does not support method 'get'")
async def on_list( self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any] | list[dict[str, typing.Any]]:
83    async def on_list(self, data: dict[str, Any] | None) -> Payload:
84        raise JsonServiceError(405, f"{self.topic!r} does not support method 'list'")
async def on_put( self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any] | list[dict[str, typing.Any]]:
86    async def on_put(self, data: dict[str, Any] | None) -> Payload:
87        raise JsonServiceError(405, f"{self.topic!r} does not support method 'put'")
async def on_post( self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any] | list[dict[str, typing.Any]]:
89    async def on_post(self, data: dict[str, Any] | None) -> Payload:
90        raise JsonServiceError(405, f"{self.topic!r} does not support method 'post'")
async def on_delete( self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any] | list[dict[str, typing.Any]]:
92    async def on_delete(self, data: dict[str, Any] | None) -> Payload:
93        raise JsonServiceError(405, f"{self.topic!r} does not support method 'delete'")