Skip to content

Commit 9d3d9bf

Browse files
authored
Add Rest API Backend (#69)
1 parent 5256be3 commit 9d3d9bf

File tree

5 files changed

+262
-0
lines changed

5 files changed

+262
-0
lines changed

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ classifiers = [
1212
description = "Control system agnostic framework for building Device support in Python that will work for both EPICS and Tango"
1313
dependencies = [
1414
"aioserial",
15+
"fastapi[standard]",
1516
"numpy",
1617
"pydantic",
1718
"pvi~=0.10.0",
@@ -43,6 +44,7 @@ dev = [
4344
"types-mock",
4445
"aioca",
4546
"p4p",
47+
"httpx",
4648
]
4749

4850
[project.scripts]

src/fastcs/backends/rest/__init__.py

Whitespace-only changes.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from fastcs.backend import Backend
2+
from fastcs.controller import Controller
3+
4+
from .rest import RestServer
5+
6+
7+
class RestBackend(Backend):
8+
def __init__(self, controller: Controller):
9+
super().__init__(controller)
10+
11+
self._server = RestServer(self._mapping)
12+
13+
def _run(self):
14+
self._server.run()

src/fastcs/backends/rest/rest.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
from collections.abc import Awaitable, Callable, Coroutine
2+
from dataclasses import dataclass
3+
from typing import Any
4+
5+
import uvicorn
6+
from fastapi import FastAPI
7+
from pydantic import create_model
8+
9+
from fastcs.attributes import AttrR, AttrRW, AttrW, T
10+
from fastcs.controller import BaseController
11+
from fastcs.mapping import Mapping
12+
13+
14+
@dataclass
15+
class RestServerOptions:
16+
host: str = "localhost"
17+
port: int = 8080
18+
log_level: str = "info"
19+
20+
21+
class RestServer:
22+
def __init__(self, mapping: Mapping):
23+
self._mapping = mapping
24+
self._app = self._create_app()
25+
26+
def _create_app(self):
27+
app = FastAPI()
28+
_add_attribute_api_routes(app, self._mapping)
29+
_add_command_api_routes(app, self._mapping)
30+
31+
return app
32+
33+
def run(self, options: RestServerOptions | None = None) -> None:
34+
if options is None:
35+
options = RestServerOptions()
36+
37+
uvicorn.run(
38+
self._app,
39+
host=options.host,
40+
port=options.port,
41+
log_level=options.log_level,
42+
)
43+
44+
45+
def _put_request_body(attribute: AttrW[T]):
46+
"""
47+
Creates a pydantic model for each datatype which defines the schema
48+
of the PUT request body
49+
"""
50+
type_name = str(attribute.datatype.dtype.__name__).title()
51+
# key=(type, ...) to declare a field without default value
52+
return create_model(
53+
f"Put{type_name}Value",
54+
value=(attribute.datatype.dtype, ...),
55+
)
56+
57+
58+
def _wrap_attr_put(
59+
attribute: AttrW[T],
60+
) -> Callable[[T], Coroutine[Any, Any, None]]:
61+
async def attr_set(request):
62+
await attribute.process(request.value)
63+
64+
# Fast api uses type annotations for validation, schema, conversions
65+
attr_set.__annotations__["request"] = _put_request_body(attribute)
66+
67+
return attr_set
68+
69+
70+
def _get_response_body(attribute: AttrR[T]):
71+
"""
72+
Creates a pydantic model for each datatype which defines the schema
73+
of the GET request body
74+
"""
75+
type_name = str(attribute.datatype.dtype.__name__).title()
76+
# key=(type, ...) to declare a field without default value
77+
return create_model(
78+
f"Get{type_name}Value",
79+
value=(attribute.datatype.dtype, ...),
80+
)
81+
82+
83+
def _wrap_attr_get(
84+
attribute: AttrR[T],
85+
) -> Callable[[], Coroutine[Any, Any, Any]]:
86+
async def attr_get() -> Any: # Must be any as response_model is set
87+
value = attribute.get() # type: ignore
88+
return {"value": value}
89+
90+
return attr_get
91+
92+
93+
def _add_attribute_api_routes(app: FastAPI, mapping: Mapping) -> None:
94+
for single_mapping in mapping.get_controller_mappings():
95+
path = single_mapping.controller.path
96+
97+
for attr_name, attribute in single_mapping.attributes.items():
98+
attr_name = attr_name.replace("_", "-")
99+
route = f"{'/'.join(path)}/{attr_name}" if path else attr_name
100+
101+
match attribute:
102+
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods
103+
case AttrRW():
104+
app.add_api_route(
105+
f"/{route}",
106+
_wrap_attr_get(attribute),
107+
methods=["GET"], # Idempotent and safe data retrieval,
108+
status_code=200, # https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET
109+
response_model=_get_response_body(attribute),
110+
)
111+
app.add_api_route(
112+
f"/{route}",
113+
_wrap_attr_put(attribute),
114+
methods=["PUT"], # Idempotent state change
115+
status_code=204, # https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PUT
116+
)
117+
case AttrR():
118+
app.add_api_route(
119+
f"/{route}",
120+
_wrap_attr_get(attribute),
121+
methods=["GET"],
122+
status_code=200,
123+
response_model=_get_response_body(attribute),
124+
)
125+
case AttrW():
126+
app.add_api_route(
127+
f"/{route}",
128+
_wrap_attr_put(attribute),
129+
methods=["PUT"],
130+
status_code=204,
131+
)
132+
133+
134+
def _wrap_command(
135+
method: Callable, controller: BaseController
136+
) -> Callable[..., Awaitable[None]]:
137+
async def command() -> None:
138+
await getattr(controller, method.__name__)()
139+
140+
return command
141+
142+
143+
def _add_command_api_routes(app: FastAPI, mapping: Mapping) -> None:
144+
for single_mapping in mapping.get_controller_mappings():
145+
path = single_mapping.controller.path
146+
147+
for name, method in single_mapping.command_methods.items():
148+
cmd_name = name.replace("_", "-")
149+
route = f"/{'/'.join(path)}/{cmd_name}" if path else cmd_name
150+
app.add_api_route(
151+
f"/{route}",
152+
_wrap_command(
153+
method.fn,
154+
single_mapping.controller,
155+
),
156+
methods=["PUT"],
157+
status_code=204,
158+
)

tests/backends/rest/test_rest.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import pytest
2+
from fastapi.testclient import TestClient
3+
4+
from fastcs.backends.rest.backend import RestBackend
5+
6+
7+
class TestRestServer:
8+
@pytest.fixture(scope="class")
9+
def client(self, assertable_controller):
10+
app = RestBackend(assertable_controller)._server._app
11+
return TestClient(app)
12+
13+
def test_read_int(self, assertable_controller, client):
14+
expect = 0
15+
with assertable_controller.assert_read_here(["read_int"]):
16+
response = client.get("/read-int")
17+
assert response.status_code == 200
18+
assert response.json()["value"] == expect
19+
20+
def test_read_write_int(self, assertable_controller, client):
21+
expect = 0
22+
with assertable_controller.assert_read_here(["read_write_int"]):
23+
response = client.get("/read-write-int")
24+
assert response.status_code == 200
25+
assert response.json()["value"] == expect
26+
new = 9
27+
with assertable_controller.assert_write_here(["read_write_int"]):
28+
response = client.put("/read-write-int", json={"value": new})
29+
assert client.get("/read-write-int").json()["value"] == new
30+
31+
def test_read_write_float(self, assertable_controller, client):
32+
expect = 0
33+
with assertable_controller.assert_read_here(["read_write_float"]):
34+
response = client.get("/read-write-float")
35+
assert response.status_code == 200
36+
assert response.json()["value"] == expect
37+
new = 0.5
38+
with assertable_controller.assert_write_here(["read_write_float"]):
39+
response = client.put("/read-write-float", json={"value": new})
40+
assert client.get("/read-write-float").json()["value"] == new
41+
42+
def test_read_bool(self, assertable_controller, client):
43+
expect = False
44+
with assertable_controller.assert_read_here(["read_bool"]):
45+
response = client.get("/read-bool")
46+
assert response.status_code == 200
47+
assert response.json()["value"] == expect
48+
49+
def test_write_bool(self, assertable_controller, client):
50+
with assertable_controller.assert_write_here(["write_bool"]):
51+
client.put("/write-bool", json={"value": True})
52+
53+
def test_string_enum(self, assertable_controller, client):
54+
expect = ""
55+
with assertable_controller.assert_read_here(["string_enum"]):
56+
response = client.get("/string-enum")
57+
assert response.status_code == 200
58+
assert response.json()["value"] == expect
59+
new = "new"
60+
with assertable_controller.assert_write_here(["string_enum"]):
61+
response = client.put("/string-enum", json={"value": new})
62+
assert client.get("/string-enum").json()["value"] == new
63+
64+
def test_big_enum(self, assertable_controller, client):
65+
expect = 0
66+
with assertable_controller.assert_read_here(["big_enum"]):
67+
response = client.get("/big-enum")
68+
assert response.status_code == 200
69+
assert response.json()["value"] == expect
70+
71+
def test_go(self, assertable_controller, client):
72+
with assertable_controller.assert_execute_here(["go"]):
73+
response = client.put("/go")
74+
assert response.status_code == 204
75+
76+
def test_read_child1(self, assertable_controller, client):
77+
expect = 0
78+
with assertable_controller.assert_read_here(["SubController01", "read_int"]):
79+
response = client.get("/SubController01/read-int")
80+
assert response.status_code == 200
81+
assert response.json()["value"] == expect
82+
83+
def test_read_child2(self, assertable_controller, client):
84+
expect = 0
85+
with assertable_controller.assert_read_here(["SubController02", "read_int"]):
86+
response = client.get("/SubController02/read-int")
87+
assert response.status_code == 200
88+
assert response.json()["value"] == expect

0 commit comments

Comments
 (0)