-
Notifications
You must be signed in to change notification settings - Fork 6
Add Rest API Backend #69
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
0790c73
First attempt rest api
marcelldls 669310e
Process review comments
marcelldls bd0f505
Fix other tests
marcelldls 35e62c8
Remove dictionary unpack
marcelldls 5105e25
Use kebab-case for attr/command slug
marcelldls efd6520
Remove unused import
marcelldls a5e18ce
Fix schema naming
marcelldls 5c9731a
Fix missed unpack removal for get
marcelldls 262fde4
Apply testing feedback from upstream
marcelldls ff7da53
Merge branch 'main' into rest-backend
marcelldls File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| from fastcs.backend import Backend | ||
| from fastcs.controller import Controller | ||
|
|
||
| from .rest import RestServer | ||
|
|
||
|
|
||
| class RestBackend(Backend): | ||
| def __init__(self, controller: Controller): | ||
| super().__init__(controller) | ||
|
|
||
| self._server = RestServer(self._mapping) | ||
|
|
||
| def _run(self): | ||
| self._server.run() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,147 @@ | ||
| from collections.abc import Awaitable, Callable, Coroutine | ||
| from dataclasses import dataclass | ||
| from types import MethodType | ||
| from typing import Any | ||
|
|
||
| import uvicorn | ||
| from fastapi import FastAPI | ||
| from pydantic import create_model | ||
|
|
||
| from fastcs.attributes import AttrR, AttrRW, AttrW, T | ||
| from fastcs.controller import BaseController | ||
| from fastcs.mapping import Mapping | ||
|
|
||
|
|
||
| @dataclass | ||
| class RestServerOptions: | ||
| host: str = "localhost" | ||
| port: int = 8080 | ||
| log_level: str = "info" | ||
|
|
||
|
|
||
| class RestServer: | ||
| def __init__(self, mapping: Mapping): | ||
| self._mapping = mapping | ||
| self._app = self._create_app() | ||
|
|
||
| def _create_app(self): | ||
| app = FastAPI() | ||
| _add_dev_attributes(app, self._mapping) | ||
| _add_dev_commands(app, self._mapping) | ||
|
|
||
| return app | ||
|
|
||
| def run(self, options: RestServerOptions | None = None) -> None: | ||
| if options is None: | ||
| options = RestServerOptions() | ||
|
|
||
| uvicorn.run( | ||
| self._app, | ||
| host=options.host, | ||
| port=options.port, | ||
| log_level=options.log_level, | ||
| ) | ||
|
|
||
|
|
||
| def _put_request_body(attribute: AttrW[T]): | ||
| return create_model( | ||
| f"Put{str(attribute.datatype.dtype)}Value", | ||
| **{"value": (attribute.datatype.dtype, ...)}, # type: ignore | ||
| ) | ||
|
|
||
|
|
||
| def _wrap_attr_put( | ||
| attribute: AttrW[T], | ||
| ) -> Callable[[T], Coroutine[Any, Any, None]]: | ||
| async def attr_set(request): | ||
| await attribute.process(request.value) | ||
|
|
||
| # Fast api uses type annotations for validation, schema, conversions | ||
| attr_set.__annotations__["request"] = _put_request_body(attribute) | ||
|
|
||
| return attr_set | ||
|
|
||
|
|
||
| def _get_response_body(attribute: AttrR[T]): | ||
| return create_model( | ||
| f"Get{str(attribute.datatype.dtype)}Value", | ||
| **{"value": (attribute.datatype.dtype, ...)}, # type: ignore | ||
| ) | ||
|
|
||
|
|
||
| def _wrap_attr_get( | ||
| attribute: AttrR[T], | ||
| ) -> Callable[[], Coroutine[Any, Any, Any]]: | ||
| async def attr_get() -> Any: # Must be any as response_model is set | ||
| value = attribute.get() # type: ignore | ||
| return {"value": value} | ||
|
|
||
| return attr_get | ||
|
|
||
|
|
||
| def _add_dev_attributes(app: FastAPI, mapping: Mapping) -> None: | ||
GDYendell marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| for single_mapping in mapping.get_controller_mappings(): | ||
| path = single_mapping.controller.path | ||
|
|
||
| for attr_name, attribute in single_mapping.attributes.items(): | ||
| attr_name = attr_name.title().replace("_", "") | ||
| d_attr_name = f"{'/'.join(path)}/{attr_name}" if path else attr_name | ||
GDYendell marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| match attribute: | ||
| # https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods | ||
| case AttrRW(): | ||
| app.add_api_route( | ||
| f"/{d_attr_name}", | ||
| _wrap_attr_get(attribute), | ||
| methods=["GET"], # Idemponent and safe data retrieval, | ||
GDYendell marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| status_code=200, # https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET | ||
| response_model=_get_response_body(attribute), | ||
| ) | ||
| app.add_api_route( | ||
| f"/{d_attr_name}", | ||
| _wrap_attr_put(attribute), | ||
| methods=["PUT"], # Idempotent state change | ||
| status_code=204, # https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PUT | ||
| ) | ||
| case AttrR(): | ||
| app.add_api_route( | ||
| f"/{d_attr_name}", | ||
| _wrap_attr_get(attribute), | ||
| methods=["GET"], | ||
| status_code=200, | ||
| response_model=_get_response_body(attribute), | ||
| ) | ||
| case AttrW(): | ||
| app.add_api_route( | ||
| f"/{d_attr_name}", | ||
| _wrap_attr_put(attribute), | ||
| methods=["PUT"], | ||
| status_code=204, | ||
| ) | ||
|
|
||
|
|
||
| def _wrap_command( | ||
| method: Callable, controller: BaseController | ||
| ) -> Callable[..., Awaitable[None]]: | ||
| async def command() -> None: | ||
| await getattr(controller, method.__name__)() | ||
|
|
||
| return command | ||
|
|
||
|
|
||
| def _add_dev_commands(app: FastAPI, mapping: Mapping) -> None: | ||
GDYendell marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| for single_mapping in mapping.get_controller_mappings(): | ||
| path = single_mapping.controller.path | ||
|
|
||
| for name, method in single_mapping.command_methods.items(): | ||
| cmd_name = name.title().replace("_", "") | ||
| d_cmd_name = f"{'/'.join(path)}/{cmd_name}" if path else cmd_name | ||
| app.add_api_route( | ||
| f"/{d_cmd_name}", | ||
| _wrap_command( | ||
| method.fn, | ||
| single_mapping.controller, | ||
| ), | ||
| methods=["PUT"], | ||
GDYendell marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| status_code=204, | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| import copy | ||
| import re | ||
| from typing import Any | ||
|
|
||
| import pytest | ||
| from fastapi.testclient import TestClient | ||
|
|
||
| from fastcs.attributes import AttrR | ||
| from fastcs.backends.rest.backend import RestBackend | ||
| from fastcs.datatypes import Bool, Float, Int | ||
|
|
||
|
|
||
| def pascal_2_snake(input: list[str]) -> list[str]: | ||
| snake_list = copy.deepcopy(input) | ||
| snake_list[-1] = re.sub(r"(?<!^)(?=[A-Z])", "_", snake_list[-1]).lower() | ||
| return snake_list | ||
|
|
||
|
|
||
| class TestRestServer: | ||
| @pytest.fixture(scope="class", autouse=True) | ||
| def setup_class(self, assertable_controller): | ||
| self.controller = assertable_controller | ||
|
|
||
| @pytest.fixture(scope="class") | ||
| def client(self): | ||
| app = RestBackend(self.controller)._server._app | ||
| return TestClient(app) | ||
|
|
||
| @pytest.fixture(scope="class") | ||
| def client_read(self, client): | ||
| def _client_read(path: list[str], expected: Any): | ||
| route = "/" + "/".join(path) | ||
| with self.controller.assertPerformed(pascal_2_snake(path), "READ"): | ||
| response = client.get(route) | ||
| assert response.status_code == 200 | ||
| assert response.json()["value"] == expected | ||
|
|
||
| return _client_read | ||
|
|
||
| @pytest.fixture(scope="class") | ||
| def client_write(self, client): | ||
| def _client_write(path: list[str], value: Any): | ||
| route = "/" + "/".join(path) | ||
| with self.controller.assertPerformed(pascal_2_snake(path), "WRITE"): | ||
| response = client.put(route, json={"value": value}) | ||
| assert response.status_code == 204 | ||
|
|
||
| return _client_write | ||
|
|
||
| @pytest.fixture(scope="class") | ||
| def client_exec(self, client): | ||
| def _client_exec(path: list[str]): | ||
| route = "/" + "/".join(path) | ||
| with self.controller.assertPerformed(pascal_2_snake(path), "EXECUTE"): | ||
| response = client.put(route) | ||
| assert response.status_code == 204 | ||
|
|
||
| return _client_exec | ||
|
|
||
| def test_read_int(self, client_read): | ||
| client_read(["ReadInt"], AttrR(Int())._value) | ||
|
|
||
| def test_read_write_int(self, client_read, client_write): | ||
| client_read(["ReadWriteInt"], AttrR(Int())._value) | ||
| client_write(["ReadWriteInt"], AttrR(Int())._value) | ||
|
|
||
| def test_read_write_float(self, client_read, client_write): | ||
| client_read(["ReadWriteFloat"], AttrR(Float())._value) | ||
| client_write(["ReadWriteFloat"], AttrR(Float())._value) | ||
|
|
||
| def test_read_bool(self, client_read): | ||
| client_read(["ReadBool"], AttrR(Bool())._value) | ||
|
|
||
| def test_write_bool(self, client_write): | ||
| client_write(["WriteBool"], AttrR(Bool())._value) | ||
|
|
||
| # # We need to discuss enums | ||
| # def test_string_enum(self, client_read, client_write): | ||
|
|
||
| def test_big_enum(self, client_read): | ||
| client_read(["BigEnum"], AttrR(Int(), allowed_values=list(range(1, 18)))._value) | ||
|
|
||
| def test_go(self, client_exec): | ||
| client_exec(["Go"]) | ||
|
|
||
| def test_read_child1(self, client_read): | ||
| client_read(["SubController01", "ReadInt"], AttrR(Int())._value) | ||
|
|
||
| def test_read_child2(self, client_read): | ||
| client_read(["SubController02", "ReadInt"], AttrR(Int())._value) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.