Skip to content

REST Transport: Missing Features for Odin-Control Replacement #378

@coretl

Description

@coretl

This document summarises the four gaps between the current FastCS REST transport
and the odin-control REST API surface, and sketches how each should be
implemented.


Background

Odin-control's REST API is built around the ParameterTree: every adapter
exposes a hierarchical dict of parameters reachable by path. A GET on any
prefix returns the entire sub-tree as JSON; a PUT merges a partial dict at
that path. FastCS currently exposes one endpoint per attribute with typed
Pydantic models and no sub-tree access.

The URL shapes are structurally equivalent:

System URL pattern "id" analogue
Odin-control GET /api/0.1/{adapter}/{sub/path} adapter name
FastCS REST GET /{id}/{sub-path}/{attr} YAML id: entry

A client pointing at http://host:8080/DETECTOR/ instead of
http://host:8080/api/0.1/detector/ should receive the same data. The four
gaps below are what prevent that.


Gap 1 — Tree-level GET /id/ returns the whole sub-tree

What odin does

GET /api/0.1/detector/
→ {"status": {"state": "idle", "frames": 0},
   "config":  {"rate": 100.0, "mode": "frame"}}

GET /api/0.1/detector/config/
→ {"rate": 100.0, "mode": "frame"}

GET /api/0.1/detector/config/rate
→ {"value": 100.0}

Every path prefix is a valid endpoint. The leaf response is wrapped in
{"value": v}; a branch response is the dict at that node.

FastCS already does the leaf form correctly ({"value": v}). It falls over at
any prefix because there is no wildcard/catch-all route.

Implementation sketch

Add a catch-all GET /{path:path} route after all specific attribute routes
(FastAPI resolves more-specific routes first). The handler walks
root_controller_api.walk_api() to find the sub-tree rooted at path and
serialises it.

# rest.py  (additions)

def _subtree_at_path(
    root_controller_api: ControllerAPI, path_segments: list[str]
) -> dict[str, Any] | None:
    """
    Walk the controller tree to the node identified by ``path_segments``,
    then serialise everything from that node downward.
    Returns None if the path doesn't resolve to a known node.
    """
    # Find the ControllerAPI node whose .path tail matches path_segments
    for api in root_controller_api.walk_api():
        tail = api.path[len(root_controller_api.path) :]  # relative path
        if tail == path_segments:
            return _serialise_subtree(api)
    return None


def _serialise_subtree(api: ControllerAPI) -> dict[str, Any]:
    result: dict[str, Any] = {}
    for attr_name, attribute in api.attributes.items():
        key = attr_name.replace("_", "-")
        result[key] = {"value": cast_to_rest_type(attribute.datatype, attribute.get())}
    for name, sub_api in api.sub_apis.items():
        result[name] = _serialise_subtree(sub_api)
    return result


def _add_subtree_get_route(app: FastAPI, root_controller_api: ControllerAPI) -> None:
    controller_id = root_controller_api.path[0]

    async def subtree_get(path: str = "") -> dict[str, Any]:
        segments = [s for s in path.split("/") if s]
        data = _subtree_at_path(root_controller_api, segments)
        if data is None:
            from fastapi import HTTPException
            raise HTTPException(status_code=404, detail=f"Path not found: {path!r}")
        return data

    app.add_api_route(
        f"/{controller_id}/{{path:path}}",  # catch-all, resolved last
        subtree_get,
        methods=["GET"],
        status_code=200,
    )
    app.add_api_route(
        f"/{controller_id}",               # root with no trailing path
        subtree_get,
        methods=["GET"],
        status_code=200,
    )

RestServer._create_app should call _add_subtree_get_route after the
per-attribute routes so FastAPI's route resolution picks the specific route
first:

def _create_app(self):
    app = FastAPI()
    for controller_api in self._controller_apis:
        _add_attribute_api_routes(app, controller_api)
        _add_command_api_routes(app, controller_api)
    for controller_api in self._controller_apis:
        _add_subtree_get_route(app, controller_api)   # catch-all last
    return app

Gap 2 — Tree-level PUT /id/path/ accepts a partial dict

What odin does

PUT /api/0.1/detector/config/
body: {"rate": 100.0, "mode": "frame"}
→ 200, returns result of subsequent GET

PUT /api/0.1/detector/config/rate
body: {"value": 100.0}
→ 200

A partial dict may touch multiple attributes at any depth in one HTTP
round-trip. odin merges the incoming dict into the parameter tree recursively.

FastCS currently only supports single-attribute PUT
({"value": v} body, 204 no content).

Implementation sketch

Add a catch-all PUT /{path:path} that recursively fans the incoming dict out
to individual attribute puts.

# rest.py  (additions)

async def _apply_dict_to_subtree(
    api: ControllerAPI, data: dict[str, Any]
) -> None:
    """Recursively apply ``data`` to the controller tree rooted at ``api``."""
    for key, value in data.items():
        attr_name = key.replace("-", "_")
        if attr_name in api.attributes:
            attribute = api.attributes[attr_name]
            if isinstance(attribute, AttrW | AttrRW):
                # unwrap odin-style {"value": v} or accept bare v
                if isinstance(value, dict) and "value" in value:
                    value = value["value"]
                await attribute.put(cast_from_rest_type(attribute.datatype, value))
        elif attr_name in api.sub_apis:
            if isinstance(value, dict):
                await _apply_dict_to_subtree(api.sub_apis[attr_name], value)


def _add_subtree_put_route(app: FastAPI, root_controller_api: ControllerAPI) -> None:
    controller_id = root_controller_api.path[0]

    async def subtree_put(body: dict[str, Any], path: str = "") -> dict[str, Any]:
        segments = [s for s in path.split("/") if s]
        # Locate target node
        target: ControllerAPI | None = None
        for api in root_controller_api.walk_api():
            if api.path[len(root_controller_api.path):] == segments:
                target = api
                break
        if target is None:
            from fastapi import HTTPException
            raise HTTPException(status_code=404, detail=f"Path not found: {path!r}")
        await _apply_dict_to_subtree(target, body)
        # Return the updated sub-tree (odin behaviour)
        return _serialise_subtree(target)

    app.add_api_route(
        f"/{controller_id}/{{path:path}}",
        subtree_put,
        methods=["PUT"],
        status_code=200,
    )
    app.add_api_route(
        f"/{controller_id}",
        subtree_put,
        methods=["PUT"],
        status_code=200,
    )

The existing single-attribute PUT /{route} routes remain; they continue to
accept {"value": v} bodies and return 204. The catch-all routes handle the
batch/tree form and return 200 with the updated sub-tree.


Gap 3 — ?metadata=true returns type and writability per attribute

What odin does

GET /api/0.1/detector/config/rate?metadata=true
→ {"value": 100.0, "type": "float", "writeable": true,
   "min": 0.1, "max": 500.0, "units": "Hz"}

odin UIs (React panels, auto-generated forms) use metadata to render the
correct widget and enforce limits. The type, writeable, min, max,
units, and allowed_values fields are sourced from the ParameterAccessor.

FastCS exposes static OpenAPI/JSON-schema metadata at /openapi.json but not
per-path at runtime.

Implementation sketch

FastCS DataType objects already carry units, min, max, min_alarm,
max_alarm, and prec. Expose this as a query parameter.

# rest.py  (additions)

def _metadata_for_attribute(attr_name: str, attribute: AttrR) -> dict[str, Any]:
    from fastcs.attributes import AttrRW, AttrW
    dt = attribute.datatype
    meta: dict[str, Any] = {
        "type": dt.dtype.__name__,
        "writeable": isinstance(attribute, (AttrRW, AttrW)),
    }
    # Pull well-known fields from the datatype dataclass if present
    for field in ("units", "min", "max", "min_alarm", "max_alarm", "prec"):
        val = getattr(dt, field, None)
        if val is not None:
            meta[field] = val
    if attribute.description:
        meta["description"] = attribute.description
    return meta


def _serialise_subtree_with_metadata(api: ControllerAPI) -> dict[str, Any]:
    result: dict[str, Any] = {}
    for attr_name, attribute in api.attributes.items():
        key = attr_name.replace("_", "-")
        entry = {"value": cast_to_rest_type(attribute.datatype, attribute.get())}
        entry.update(_metadata_for_attribute(attr_name, attribute))
        result[key] = entry
    for name, sub_api in api.sub_apis.items():
        result[name] = _serialise_subtree_with_metadata(sub_api)
    return result

Then in _add_subtree_get_route add an optional metadata query param:

from fastapi import Query as QParam

async def subtree_get(
    path: str = "",
    metadata: bool = QParam(False, alias="metadata"),
) -> dict[str, Any]:
    segments = [s for s in path.split("/") if s]
    node = _find_node(root_controller_api, segments)
    if node is None:
        raise HTTPException(404, ...)
    if metadata:
        return _serialise_subtree_with_metadata(node)
    return _serialise_subtree(node)

Individual leaf routes also grow an optional ?metadata=true variant:

def _wrap_attr_get_with_metadata(attr_name: str, attribute: AttrR[DType_T]):
    async def attr_get(metadata: bool = QParam(False)) -> dict[str, Any]:
        value = {"value": cast_to_rest_type(attribute.datatype, attribute.get())}
        if metadata:
            value.update(_metadata_for_attribute(attr_name, attribute))
        return value
    return attr_get

Gap 4 — /api/{version}/ URL prefix

What odin does

All odin URLs are prefixed with /api/0.1/ (or /api/1.0/ for v2 servers).
Clients use this to negotiate API version and to distinguish the control-plane
REST API from any other HTTP service running on the same host.

FastCS currently serves routes at the root path. An odin client configured with
http://host:8080/api/0.1/ will get 404 on every request.

Implementation sketch

Add a prefix field to RestServerOptions:

@dataclass
class RestServerOptions:
    host: str = "localhost"
    port: int = 8080
    log_level: str = "info"
    prefix: str = ""           # e.g. "/api/0.1" for odin drop-in mode

Mount the existing FastAPI app under the prefix using FastAPI routers:

# rest.py
class RestServer:
    def __init__(self, controller_apis: list[ControllerAPI], prefix: str = ""):
        self._controller_apis = controller_apis
        self._prefix = prefix.rstrip("/")
        self._app = self._create_app()

    def _create_app(self):
        root = FastAPI()
        api = FastAPI()              # inner app carries all routes
        for controller_api in self._controller_apis:
            _add_attribute_api_routes(api, controller_api)
            _add_command_api_routes(api, controller_api)
        for controller_api in self._controller_apis:
            _add_subtree_get_route(api, controller_api)
            _add_subtree_put_route(api, controller_api)
        root.mount(self._prefix or "/", api)
        return root

RestTransport.connect passes self.rest.prefix through:

self._server = RestServer(controller_apis, prefix=self.rest.prefix)

YAML:

transport:
  - rest:
      host: 0.0.0.0
      port: 8080
      prefix: "/api/0.1"

Summary table

Gap Effort Odin parity unlocked
1. Sub-tree GET Low — one catch-all GET route per controller Dashboard/bulk-read clients
2. Sub-tree PUT Medium — async fan-out, path resolution Batch-write clients
3. ?metadata=true Low — surface existing DataType fields Dynamic-UI clients
4. /api/{version}/ prefix Trivial — one prefix field + FastAPI mount Drop-in URL compatibility

Items 1, 3 and 4 can be done independently. Item 2 depends on item 1 sharing
the path-resolution helper.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions