Skip to content

Commit b0310db

Browse files
committed
First attempt rest api
1 parent bdab4fa commit b0310db

File tree

6 files changed

+221
-1
lines changed

6 files changed

+221
-1
lines changed

pyproject.toml

Lines changed: 3 additions & 1 deletion
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]
@@ -61,7 +63,7 @@ version_file = "src/fastcs/_version.py"
6163

6264
[tool.pyright]
6365
typeCheckingMode = "standard"
64-
reportMissingImports = false # Ignore missing stubs in imported modules
66+
reportMissingImports = false # Ignore missing stubs in imported modules
6567

6668
[tool.pytest.ini_options]
6769
# Run pytest with all our checkers, and don't spam us with massive tracebacks on error

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

tests/backends/rest/test_rest.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from fastcs.attributes import AttrR
2+
from fastcs.datatypes import Bool, Float, Int
3+
4+
5+
class TestRestServer:
6+
def test_read_int(self, rest_client):
7+
response = rest_client.get("/ReadInt")
8+
assert response.status_code == 200
9+
assert response.json() == {"value": AttrR(Int())._value}
10+
11+
def test_read_write_int(self, rest_client):
12+
response = rest_client.get("/ReadWriteInt")
13+
assert response.status_code == 200
14+
assert response.json() == {"value": AttrR(Int())._value}
15+
response = rest_client.put("/ReadWriteInt", json={"value": AttrR(Int())._value})
16+
assert response.status_code == 204
17+
18+
def test_read_write_float(self, rest_client):
19+
response = rest_client.get("/ReadWriteFloat")
20+
assert response.status_code == 200
21+
assert response.json() == {"value": AttrR(Float())._value}
22+
response = rest_client.put(
23+
"/ReadWriteFloat", json={"value": AttrR(Float())._value}
24+
)
25+
assert response.status_code == 204
26+
27+
def test_read_bool(self, rest_client):
28+
response = rest_client.get("/ReadBool")
29+
assert response.status_code == 200
30+
assert response.json() == {"value": AttrR(Bool())._value}
31+
32+
def test_write_bool(self, rest_client):
33+
response = rest_client.put("/WriteBool", json={"value": AttrR(Bool())._value})
34+
assert response.status_code == 204
35+
36+
# # We need to discuss enums
37+
# def test_string_enum(self, rest_client):
38+
39+
def test_big_enum(self, rest_client):
40+
response = rest_client.get("/BigEnum")
41+
assert response.status_code == 200
42+
assert response.json() == {
43+
"value": AttrR(Int(), allowed_values=list(range(1, 18)))._value
44+
}
45+
46+
def test_go(self, rest_client):
47+
response = rest_client.put("/Go")
48+
assert response.status_code == 204

tests/conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88

99
import pytest
1010
from aioca import purge_channel_caches
11+
from fastapi.testclient import TestClient
1112

1213
from fastcs.attributes import AttrR, AttrRW, AttrW, Handler, Sender, Updater
14+
from fastcs.backends.rest.backend import RestBackend
1315
from fastcs.controller import Controller
1416
from fastcs.datatypes import Bool, Float, Int, String
1517
from fastcs.mapping import Mapping
@@ -120,3 +122,10 @@ def ioc():
120122
except ValueError:
121123
# Someone else already called communicate
122124
pass
125+
126+
127+
@pytest.fixture(scope="class")
128+
def rest_client():
129+
app = RestBackend(TestController())._server._app
130+
client = TestClient(app)
131+
return client

0 commit comments

Comments
 (0)