Skip to content

Commit

Permalink
Merge pull request #61 from rwb27/endpoint-decorator
Browse files Browse the repository at this point in the history
Endpoint decorator
  • Loading branch information
rwb27 authored Jan 5, 2024
2 parents 8cd8d4e + 8ea5570 commit 04ee7e1
Show file tree
Hide file tree
Showing 6 changed files with 166 additions and 12 deletions.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ dependencies = [
"pydantic>=2.0.0",
"jsonschema",
"typing_extensions",
"anyio ~=4.0",
]

[project.optional-dependencies]
Expand Down
16 changes: 15 additions & 1 deletion src/labthings_fastapi/decorators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@

from functools import wraps, partial
from typing import Optional, Callable
from ..descriptors import ActionDescriptor, PropertyDescriptor
from ..descriptors import (
ActionDescriptor,
PropertyDescriptor,
EndpointDescriptor,
HTTPMethod,
)
from ..utilities.introspection import return_type


Expand Down Expand Up @@ -85,3 +90,12 @@ def __get__(self, obj, objtype=None):
observable=False,
getter=func,
)


def fastapi_endpoint(method: HTTPMethod, path: Optional[str] = None, **kwargs):
"""Add a function to FastAPI as an endpoint"""

def decorator(func):
return EndpointDescriptor(func, http_method=method, path=path, **kwargs)

return decorator
9 changes: 4 additions & 5 deletions src/labthings_fastapi/descriptors/__init__.py
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
88 changes: 88 additions & 0 deletions src/labthings_fastapi/descriptors/endpoint.py
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)
23 changes: 17 additions & 6 deletions src/labthings_fastapi/descriptors/property.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Annotated, Any, Callable, Optional
from typing_extensions import Self
from labthings_fastapi.utilities.introspection import get_summary, get_docstring
from pydantic import BaseModel, RootModel
from fastapi import Body, FastAPI
from weakref import WeakSet
Expand Down Expand Up @@ -45,21 +46,31 @@ def __init__(
self.readonly = readonly
self.observable = observable
self.initial_value = initial_value
self.description = description
self.title = title
self._description = description
self._title = title
# The lines below allow _getter and _setter to be specified by subclasses
self._setter = setter or getattr(self, "_setter", None)
self._getter = getter or getattr(self, "_getter", None)
if self.description and not self.title:
self.title = self.description.partition("\n")[0]
# Try to generate a DataSchema, so that we can raise an error that's easy to
# link to the offending PropertyDescriptor
type_to_dataschema(self.model)

def __set_name__(self, owner, name: str):
self._name = name
if not self.title:
self.title = name

@property
def title(self):
"""A human-readable title"""
if self._title:
return self._title
if self._getter and get_summary(self._getter):
return get_summary(self._getter)
return self.name

@property
def description(self):
"""A description of the property"""
return self._description or get_docstring(self._getter, remove_summary=True)

def __get__(self, obj, type=None) -> Any:
"""The value of the property
Expand Down
41 changes: 41 additions & 0 deletions tests/test_endpoint_decorator.py
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"

0 comments on commit 04ee7e1

Please sign in to comment.