-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #61 from rwb27/endpoint-decorator
Endpoint decorator
- Loading branch information
Showing
6 changed files
with
166 additions
and
12 deletions.
There are no files selected for viewing
This file contains 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
This file contains 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
This file contains 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 |
---|---|---|
@@ -1,5 +1,4 @@ | ||
from .action import ActionDescriptor | ||
from .property import PropertyDescriptor | ||
|
||
__all__ = ["ActionDescriptor", "PropertyDescriptor"] | ||
_ignore_unused_imports = [ActionDescriptor, PropertyDescriptor] | ||
from .action import ActionDescriptor as ActionDescriptor | ||
from .property import PropertyDescriptor as PropertyDescriptor | ||
from .endpoint import EndpointDescriptor as EndpointDescriptor | ||
from .endpoint import HTTPMethod as HTTPMethod |
This file contains 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 @@ | ||
from __future__ import annotations | ||
from functools import partial, wraps | ||
|
||
from labthings_fastapi.utilities.introspection import get_docstring, get_summary | ||
|
||
from typing import ( | ||
Callable, | ||
Literal, | ||
Mapping, | ||
Optional, | ||
Union, | ||
overload, | ||
TYPE_CHECKING, | ||
) | ||
from typing_extensions import Self # 3.9, 3.10 compatibility | ||
from fastapi import FastAPI | ||
|
||
if TYPE_CHECKING: | ||
from ..thing import Thing | ||
|
||
HTTPMethod = Literal["get", "post", "put", "delete"] | ||
|
||
|
||
class EndpointDescriptor: | ||
"""A descriptor to allow Things to easily add other endpoints""" | ||
|
||
def __init__( | ||
self, | ||
func: Callable, | ||
http_method: HTTPMethod = "get", | ||
path: Optional[str] = None, | ||
**kwargs: Mapping, | ||
): | ||
self.func = func | ||
self.http_method = http_method | ||
self._path = path | ||
self.kwargs = kwargs | ||
|
||
@overload | ||
def __get__(self, obj: Literal[None], type=None) -> Self: | ||
... | ||
|
||
@overload | ||
def __get__(self, obj: Thing, type=None) -> Callable: | ||
... | ||
|
||
def __get__(self, obj: Optional[Thing], type=None) -> Union[Self, Callable]: | ||
"""The function, bound to an object as for a normal method. | ||
If `obj` is None, the descriptor is returned, so we can get | ||
the descriptor conveniently as an attribute of the class. | ||
""" | ||
if obj is None: | ||
return self | ||
return wraps(self.func)(partial(self.func, obj)) | ||
|
||
@property | ||
def name(self): | ||
"""The name of the wrapped function""" | ||
return self.func.__name__ | ||
|
||
@property | ||
def path(self): | ||
"""The path of the endpoint (relative to the Thing)""" | ||
return self._path or self.name | ||
|
||
@property | ||
def title(self): | ||
"""A human-readable title""" | ||
return get_summary(self.func) or self.name | ||
|
||
@property | ||
def description(self): | ||
"""A description of the endpoint""" | ||
return get_docstring(self.func, remove_summary=True) | ||
|
||
def add_to_fastapi(self, app: FastAPI, thing: Thing): | ||
"""Add this function to a FastAPI app, bound to a particular Thing.""" | ||
# fastapi_endpoint is equivalent to app.get/app.post/whatever | ||
fastapi_endpoint = getattr(app, self.http_method) | ||
bound_function = partial(self.func, thing) | ||
# NB the line above can't use self.__get__ as wraps() confuses FastAPI | ||
kwargs = { # Auto-populate description and summary | ||
"description": f"## {self.title}\n\n {self.description}", | ||
"summary": self.title, | ||
} | ||
kwargs.update(self.kwargs) | ||
fastapi_endpoint(thing.path + self.path, **kwargs)(bound_function) |
This file contains 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
This file contains 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,41 @@ | ||
from fastapi.testclient import TestClient | ||
from labthings_fastapi.thing_server import ThingServer | ||
from labthings_fastapi.thing import Thing | ||
from labthings_fastapi.decorators import fastapi_endpoint | ||
from pydantic import BaseModel | ||
|
||
|
||
class PostBodyModel(BaseModel): | ||
a: int | ||
b: int | ||
|
||
|
||
class TestThing(Thing): | ||
@fastapi_endpoint("get") | ||
def path_from_name(self) -> str: | ||
return "path_from_name" | ||
|
||
@fastapi_endpoint("get", path="path_from_path") | ||
def get_method(self) -> str: | ||
return "get_method" | ||
|
||
@fastapi_endpoint("post", path="path_from_path") | ||
def post_method(self, body: PostBodyModel) -> str: | ||
return f"post_method {body.a} {body.b}" | ||
|
||
|
||
def test_endpoints(): | ||
server = ThingServer() | ||
server.add_thing(TestThing(), "/thing") | ||
with TestClient(server.app) as client: | ||
r = client.get("/thing/path_from_name") | ||
r.raise_for_status() | ||
assert r.json() == "path_from_name" | ||
|
||
r = client.get("/thing/path_from_path") | ||
r.raise_for_status() | ||
assert r.json() == "get_method" | ||
|
||
r = client.post("/thing/path_from_path", json={"a": 1, "b": 2}) | ||
r.raise_for_status() | ||
assert r.json() == "post_method 1 2" |