DiamondLightSource / DiamondLightSource/fastcs

REST Transport: Missing Features for Odin-Control Replacement

Open
#378 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Python
Stars
6
Forks
8
Avg merge
2d 12h
Merged PRs (30d)
3

Description

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.

```python
# 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:

```python
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.

```python
# 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.

```python
# 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:

```python
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:

```python
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`:

```python
@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:

```python
# 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:

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

YAML:

```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.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.