-
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 all commits
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,158 @@ | ||
| from collections.abc import Awaitable, Callable, Coroutine | ||
| from dataclasses import dataclass | ||
| 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_attribute_api_routes(app, self._mapping) | ||
| _add_command_api_routes(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]): | ||
| """ | ||
| Creates a pydantic model for each datatype which defines the schema | ||
| of the PUT request body | ||
| """ | ||
| type_name = str(attribute.datatype.dtype.__name__).title() | ||
| # key=(type, ...) to declare a field without default value | ||
| return create_model( | ||
| f"Put{type_name}Value", | ||
| value=(attribute.datatype.dtype, ...), | ||
| ) | ||
|
|
||
|
|
||
| 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]): | ||
| """ | ||
| Creates a pydantic model for each datatype which defines the schema | ||
| of the GET request body | ||
| """ | ||
| type_name = str(attribute.datatype.dtype.__name__).title() | ||
| # key=(type, ...) to declare a field without default value | ||
| return create_model( | ||
| f"Get{type_name}Value", | ||
| value=(attribute.datatype.dtype, ...), | ||
| ) | ||
|
|
||
|
|
||
| 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_attribute_api_routes(app: FastAPI, mapping: Mapping) -> None: | ||
| 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.replace("_", "-") | ||
| route = f"{'/'.join(path)}/{attr_name}" if path else attr_name | ||
|
|
||
| match attribute: | ||
| # https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods | ||
| case AttrRW(): | ||
| app.add_api_route( | ||
| f"/{route}", | ||
| _wrap_attr_get(attribute), | ||
| methods=["GET"], # Idempotent and safe data retrieval, | ||
| 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"/{route}", | ||
| _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"/{route}", | ||
| _wrap_attr_get(attribute), | ||
| methods=["GET"], | ||
| status_code=200, | ||
| response_model=_get_response_body(attribute), | ||
| ) | ||
| case AttrW(): | ||
| app.add_api_route( | ||
| f"/{route}", | ||
| _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_command_api_routes(app: FastAPI, mapping: Mapping) -> None: | ||
| 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.replace("_", "-") | ||
| route = f"/{'/'.join(path)}/{cmd_name}" if path else cmd_name | ||
| app.add_api_route( | ||
| f"/{route}", | ||
| _wrap_command( | ||
| method.fn, | ||
| single_mapping.controller, | ||
| ), | ||
| methods=["PUT"], | ||
| 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,88 @@ | ||
| import pytest | ||
| from fastapi.testclient import TestClient | ||
|
|
||
| from fastcs.backends.rest.backend import RestBackend | ||
|
|
||
|
|
||
| class TestRestServer: | ||
| @pytest.fixture(scope="class") | ||
| def client(self, assertable_controller): | ||
| app = RestBackend(assertable_controller)._server._app | ||
| return TestClient(app) | ||
|
|
||
| def test_read_int(self, assertable_controller, client): | ||
| expect = 0 | ||
| with assertable_controller.assert_read_here(["read_int"]): | ||
| response = client.get("/read-int") | ||
| assert response.status_code == 200 | ||
| assert response.json()["value"] == expect | ||
|
|
||
| def test_read_write_int(self, assertable_controller, client): | ||
| expect = 0 | ||
| with assertable_controller.assert_read_here(["read_write_int"]): | ||
| response = client.get("/read-write-int") | ||
| assert response.status_code == 200 | ||
| assert response.json()["value"] == expect | ||
| new = 9 | ||
| with assertable_controller.assert_write_here(["read_write_int"]): | ||
| response = client.put("/read-write-int", json={"value": new}) | ||
| assert client.get("/read-write-int").json()["value"] == new | ||
|
|
||
| def test_read_write_float(self, assertable_controller, client): | ||
| expect = 0 | ||
| with assertable_controller.assert_read_here(["read_write_float"]): | ||
| response = client.get("/read-write-float") | ||
| assert response.status_code == 200 | ||
| assert response.json()["value"] == expect | ||
| new = 0.5 | ||
| with assertable_controller.assert_write_here(["read_write_float"]): | ||
| response = client.put("/read-write-float", json={"value": new}) | ||
| assert client.get("/read-write-float").json()["value"] == new | ||
|
|
||
| def test_read_bool(self, assertable_controller, client): | ||
| expect = False | ||
| with assertable_controller.assert_read_here(["read_bool"]): | ||
| response = client.get("/read-bool") | ||
| assert response.status_code == 200 | ||
| assert response.json()["value"] == expect | ||
|
|
||
| def test_write_bool(self, assertable_controller, client): | ||
| with assertable_controller.assert_write_here(["write_bool"]): | ||
| client.put("/write-bool", json={"value": True}) | ||
|
|
||
| def test_string_enum(self, assertable_controller, client): | ||
| expect = "" | ||
| with assertable_controller.assert_read_here(["string_enum"]): | ||
| response = client.get("/string-enum") | ||
| assert response.status_code == 200 | ||
| assert response.json()["value"] == expect | ||
| new = "new" | ||
| with assertable_controller.assert_write_here(["string_enum"]): | ||
| response = client.put("/string-enum", json={"value": new}) | ||
| assert client.get("/string-enum").json()["value"] == new | ||
|
|
||
| def test_big_enum(self, assertable_controller, client): | ||
| expect = 0 | ||
| with assertable_controller.assert_read_here(["big_enum"]): | ||
| response = client.get("/big-enum") | ||
| assert response.status_code == 200 | ||
| assert response.json()["value"] == expect | ||
|
|
||
| def test_go(self, assertable_controller, client): | ||
| with assertable_controller.assert_execute_here(["go"]): | ||
| response = client.put("/go") | ||
| assert response.status_code == 204 | ||
|
|
||
| def test_read_child1(self, assertable_controller, client): | ||
| expect = 0 | ||
| with assertable_controller.assert_read_here(["SubController01", "read_int"]): | ||
| response = client.get("/SubController01/read-int") | ||
| assert response.status_code == 200 | ||
| assert response.json()["value"] == expect | ||
|
|
||
| def test_read_child2(self, assertable_controller, client): | ||
| expect = 0 | ||
| with assertable_controller.assert_read_here(["SubController02", "read_int"]): | ||
| response = client.get("/SubController02/read-int") | ||
| assert response.status_code == 200 | ||
| assert response.json()["value"] == expect |
Oops, something went wrong.
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.