Skip to content

Commit 59f76b5

Browse files
committed
First attempt rest api
1 parent bdab4fa commit 59f76b5

File tree

6 files changed

+306
-2
lines changed

6 files changed

+306
-2
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: 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(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 getattr(controller, method.__name__)()
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: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import copy
2+
import re
3+
from typing import Any
4+
5+
import pytest
6+
from fastapi.testclient import TestClient
7+
from pytest_mock import MockerFixture
8+
9+
from fastcs.attributes import AttrR
10+
from fastcs.backends.rest.backend import RestBackend
11+
from fastcs.datatypes import Bool, Float, Int
12+
13+
14+
def pascal_2_snake(input: list[str]) -> list[str]:
15+
snake_list = copy.deepcopy(input)
16+
snake_list[-1] = re.sub(r"(?<!^)(?=[A-Z])", "_", snake_list[-1]).lower()
17+
return snake_list
18+
19+
20+
class TestRestServer:
21+
@pytest.fixture(autouse=True)
22+
def setup_tests(self, assertable_controller):
23+
self.controller = assertable_controller
24+
app = RestBackend(self.controller)._server._app
25+
self.client = TestClient(app)
26+
27+
def client_read(self, path: list[str], expected: Any):
28+
route = "/" + "/".join(path)
29+
with self.controller.assertPerformed(pascal_2_snake(path), "READ"):
30+
response = self.client.get(route)
31+
assert response.status_code == 200
32+
assert response.json()["value"] == expected
33+
34+
def client_write(self, path: list[str], value: Any):
35+
route = "/" + "/".join(path)
36+
with self.controller.assertPerformed(pascal_2_snake(path), "WRITE"):
37+
response = self.client.put(route, json={"value": value})
38+
assert response.status_code == 204
39+
40+
def client_exec(self, path: list[str]):
41+
route = "/" + "/".join(path)
42+
with self.controller.assertPerformed(pascal_2_snake(path), "EXECUTE"):
43+
response = self.client.put(route)
44+
assert response.status_code == 204
45+
46+
def test_read_int(self):
47+
self.client_read(["ReadInt"], AttrR(Int())._value)
48+
49+
def test_read_write_int(self):
50+
self.client_read(["ReadWriteInt"], AttrR(Int())._value)
51+
self.client_write(["ReadWriteInt"], AttrR(Int())._value)
52+
53+
def test_read_write_float(self):
54+
self.client_read(["ReadWriteFloat"], AttrR(Float())._value)
55+
self.client_write(["ReadWriteFloat"], AttrR(Float())._value)
56+
57+
def test_read_bool(self):
58+
self.client_read(["ReadBool"], AttrR(Bool())._value)
59+
60+
def test_write_bool(self):
61+
self.client_write(["WriteBool"], AttrR(Bool())._value)
62+
63+
# # We need to discuss enums
64+
# def test_string_enum(self, rest_client):
65+
66+
def test_big_enum(self):
67+
self.client_read(
68+
["BigEnum"], AttrR(Int(), allowed_values=list(range(1, 18)))._value
69+
)
70+
71+
def test_go(self):
72+
self.client_exec(["Go"])
73+
74+
def test_read_child1(self):
75+
self.client_read(["SubController01", "ReadInt"], AttrR(Int())._value)
76+
77+
def test_read_child2(self):
78+
self.client_read(["SubController02", "ReadInt"], AttrR(Int())._value)

tests/conftest.py

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
1+
import copy
12
import os
23
import random
34
import string
45
import subprocess
56
import time
7+
from contextlib import contextmanager
68
from pathlib import Path
7-
from typing import Any
9+
from typing import Any, Literal
810

911
import pytest
1012
from aioca import purge_channel_caches
13+
from pytest_mock import MockerFixture
1114

1215
from fastcs.attributes import AttrR, AttrRW, AttrW, Handler, Sender, Updater
13-
from fastcs.controller import Controller
16+
from fastcs.controller import Controller, SubController
1417
from fastcs.datatypes import Bool, Float, Int, String
1518
from fastcs.mapping import Mapping
1619
from fastcs.wrappers import command, scan
@@ -49,7 +52,20 @@ class TestHandler(Handler, TestUpdater, TestSender):
4952
pass
5053

5154

55+
class TestSubController(SubController):
56+
read_int: AttrR = AttrR(Int(), handler=TestUpdater())
57+
58+
5259
class TestController(Controller):
60+
def __init__(self) -> None:
61+
super().__init__()
62+
63+
self._sub_controllers: list[TestSubController] = []
64+
for index in range(1, 3):
65+
controller = TestSubController()
66+
self._sub_controllers.append(controller)
67+
self.register_sub_controller(f"SubController{index:02d}", controller)
68+
5369
read_int: AttrR = AttrR(Int(), handler=TestUpdater())
5470
read_write_int: AttrRW = AttrRW(Int(), handler=TestHandler())
5571
read_write_float: AttrRW = AttrRW(Float())
@@ -80,11 +96,58 @@ async def counter(self):
8096
self.count += 1
8197

8298

99+
class AssertableController(TestController):
100+
def __init__(self, mocker: MockerFixture) -> None:
101+
super().__init__()
102+
self.mocker = mocker
103+
104+
@contextmanager
105+
def assertPerformed(
106+
self, path: list[str], action: Literal["READ", "WRITE", "EXECUTE"]
107+
):
108+
queue = copy.deepcopy(path)
109+
match action:
110+
case "READ":
111+
method = "get"
112+
case "WRITE":
113+
method = "process"
114+
case "EXECUTE":
115+
method = ""
116+
117+
# Navigate to subcontroller
118+
controller = self
119+
item_name = queue.pop(-1)
120+
for item in queue:
121+
controllers = controller.get_sub_controllers()
122+
controller = controllers[item]
123+
124+
# create probe
125+
if method:
126+
attr = getattr(controller, item_name)
127+
spy = self.mocker.spy(attr, method)
128+
else:
129+
spy = self.mocker.spy(controller, item_name)
130+
initial = spy.call_count
131+
try:
132+
yield # Enter context
133+
finally: # Exit context
134+
final = spy.call_count
135+
assert final == initial + 1, (
136+
f"Expected {'.'.join(path + [method] if method else path)} "
137+
f"to be called once, but it was called {final - initial} times."
138+
)
139+
140+
83141
@pytest.fixture
84142
def controller():
85143
return TestController()
86144

87145

146+
@pytest.fixture
147+
def assertable_controller(mocker):
148+
return AssertableController(mocker)
149+
150+
88151
@pytest.fixture
89152
def mapping(controller):
90153
return Mapping(controller)

0 commit comments

Comments
 (0)