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.
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
GETon anyprefix returns the entire sub-tree as JSON; a
PUTmerges a partial dict atthat path. FastCS currently exposes one endpoint per attribute with typed
Pydantic models and no sub-tree access.
The URL shapes are structurally equivalent:
GET /api/0.1/{adapter}/{sub/path}GET /{id}/{sub-path}/{attr}id:entryA client pointing at
http://host:8080/DETECTOR/instead ofhttp://host:8080/api/0.1/detector/should receive the same data. The fourgaps below are what prevent that.
Gap 1 — Tree-level
GET /id/returns the whole sub-treeWhat odin does
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 atany 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 atpathandserialises it.
RestServer._create_appshould call_add_subtree_get_routeafter theper-attribute routes so FastAPI's route resolution picks the specific route
first:
Gap 2 — Tree-level
PUT /id/path/accepts a partial dictWhat odin does
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 outto individual attribute puts.
The existing single-attribute
PUT /{route}routes remain; they continue toaccept
{"value": v}bodies and return 204. The catch-all routes handle thebatch/tree form and return 200 with the updated sub-tree.
Gap 3 —
?metadata=truereturns type and writability per attributeWhat odin does
odin UIs (React panels, auto-generated forms) use metadata to render the
correct widget and enforce limits. The
type,writeable,min,max,units, andallowed_valuesfields are sourced from theParameterAccessor.FastCS exposes static OpenAPI/JSON-schema metadata at
/openapi.jsonbut notper-path at runtime.
Implementation sketch
FastCS
DataTypeobjects already carryunits,min,max,min_alarm,max_alarm, andprec. Expose this as a query parameter.Then in
_add_subtree_get_routeadd an optionalmetadataquery param:Individual leaf routes also grow an optional
?metadata=truevariant:Gap 4 —
/api/{version}/URL prefixWhat 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
prefixfield toRestServerOptions:Mount the existing FastAPI app under the prefix using FastAPI routers:
RestTransport.connectpassesself.rest.prefixthrough:YAML:
Summary table
?metadata=true/api/{version}/prefixprefixfield + FastAPI mountItems 1, 3 and 4 can be done independently. Item 2 depends on item 1 sharing
the path-resolution helper.