Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions src/labthings_fastapi/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
)
from weakref import WeakSet
import weakref
from fastapi import FastAPI, HTTPException, Request, Body, BackgroundTasks
from fastapi import APIRouter, FastAPI, HTTPException, Request, Body, BackgroundTasks
from pydantic import BaseModel, create_model

from .middleware.url_for import URLFor
Expand Down Expand Up @@ -72,7 +72,7 @@
from .thing import Thing


__all__ = ["ACTION_INVOCATIONS_PATH", "Invocation", "ActionManager"]
__all__ = ["Invocation", "ActionManager"]


ACTION_INVOCATIONS_PATH = "/action_invocations"
Expand Down Expand Up @@ -442,17 +442,18 @@
for k in to_delete:
del self._invocations[k]

def attach_to_app(self, app: FastAPI) -> None:
"""Add /action_invocations and /action_invocation/{id} endpoints to FastAPI.
def router(self) -> APIRouter:
"""Create a FastAPI Router with action-related endpoints.

:param app: The `fastapi.FastAPI` application to which we add the endpoints.
:return: a Router with all action-related endpoints.
"""
router = APIRouter()

@app.get(ACTION_INVOCATIONS_PATH, response_model=list[InvocationModel])
@router.get(ACTION_INVOCATIONS_PATH, response_model=list[InvocationModel])
def list_all_invocations(request: Request) -> list[InvocationModel]:
return self.list_invocations(request=request)

@app.get(
@router.get(
ACTION_INVOCATIONS_PATH + "/{id}",
responses={404: {"description": "Invocation ID not found"}},
)
Expand All @@ -477,7 +478,7 @@
detail="No action invocation found with ID {id}",
) from e

@app.get(
@router.get(
ACTION_INVOCATIONS_PATH + "/{id}/output",
response_model=Any,
responses={
Expand Down Expand Up @@ -508,8 +509,8 @@
with self._invocations_lock:
try:
invocation: Any = self._invocations[id]
except KeyError as e:
raise HTTPException(

Check warning on line 513 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

512-513 lines are not covered with tests
status_code=404,
detail="No action invocation found with ID {id}",
) from e
Expand All @@ -522,10 +523,10 @@
invocation.output.response
):
# TODO: honour "accept" header
return invocation.output.response()

Check warning on line 526 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

526 line is not covered with tests
return invocation.output

@app.delete(
@router.delete(
ACTION_INVOCATIONS_PATH + "/{id}",
response_model=None,
responses={
Expand All @@ -547,8 +548,8 @@
with self._invocations_lock:
try:
invocation: Any = self._invocations[id]
except KeyError as e:
raise HTTPException(

Check warning on line 552 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

551-552 lines are not covered with tests
status_code=404,
detail="No action invocation found with ID {id}",
) from e
Expand All @@ -565,6 +566,8 @@
)
invocation.cancel()

return router


ACTION_POST_NOTICE = """
## Important note
Expand Down Expand Up @@ -721,7 +724,7 @@
"""
super().__set_name__(owner, name)
if self.name != self.func.__name__:
raise ValueError(

Check warning on line 727 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

727 line is not covered with tests
f"Action name '{self.name}' does not match function name "
f"'{self.func.__name__}'",
)
Expand Down Expand Up @@ -865,14 +868,14 @@
try:
responses[200]["model"] = self.output_model
pass
except AttributeError:
print(f"Failed to generate response model for action {self.name}")

Check warning on line 872 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

871-872 lines are not covered with tests
# Add an additional media type if we may return a file
if hasattr(self.output_model, "media_type"):
responses[200]["content"][self.output_model.media_type] = {}

Check warning on line 875 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

875 line is not covered with tests
# Now we can add the endpoint to the app.
if thing.path is None:
raise NotConnectedToServerError(

Check warning on line 878 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

878 line is not covered with tests
"Can't add the endpoint without thing.path!"
)
app.post(
Expand Down Expand Up @@ -920,7 +923,7 @@
"""
path = path or thing.path
if path is None:
raise NotConnectedToServerError("Can't generate forms without a path!")

Check warning on line 926 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

926 line is not covered with tests
forms = [
Form[ActionOp](href=path + self.name, op=[ActionOp.invokeaction]),
]
Expand Down
40 changes: 29 additions & 11 deletions src/labthings_fastapi/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from typing_extensions import Self
import os

from fastapi import FastAPI, Request
from fastapi import APIRouter, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from anyio.from_thread import BlockingPortal
from contextlib import asynccontextmanager, AsyncExitStack
Expand Down Expand Up @@ -65,6 +65,7 @@
self,
things: ThingsConfig,
settings_folder: Optional[str] = None,
api_prefix: str = "",
application_config: Optional[Mapping[str, Any]] = None,
) -> None:
r"""Initialise a LabThings server.
Expand All @@ -82,8 +83,9 @@
arguments, and any connections to other `.Thing`\ s.
:param settings_folder: the location on disk where `.Thing`
settings will be saved.
:param api_prefix: An optional prefix for all API routes. This must either
be empty, or start with a slash and not end with a slash.
:param application_config: A mapping containing custom configuration for the
application. This is not processed by LabThings. Each `.Thing` can access
application. This is not processed by LabThings. Each `.Thing` can access
this via the Thing-Server interface.
"""
Expand All @@ -92,16 +94,17 @@
self._config = ThingServerConfig(
things=things,
settings_folder=settings_folder,
api_prefix=api_prefix,
application_config=application_config,
)
self.app = FastAPI(lifespan=self.lifespan)
self._set_cors_middleware()
self._set_url_for_middleware()
self.settings_folder = settings_folder or "./settings"
self.action_manager = ActionManager()
self.action_manager.attach_to_app(self.app)
self.app.include_router(blob.router) # include blob download endpoint
self._add_things_view_to_app()
self.app.include_router(self.action_manager.router(), prefix=self._api_prefix)
self.app.include_router(blob.router, prefix=self._api_prefix)
self.app.include_router(self._things_view_router(), prefix=self._api_prefix)
self.blocking_portal: Optional[BlockingPortal] = None
self.startup_status: dict[str, str | dict] = {"things": {}}
global _thing_servers # noqa: F824
Expand Down Expand Up @@ -166,6 +169,15 @@
"""
return self._config.application_config

@property
def _api_prefix(self) -> str:
"""A string that prefixes all URLs in the application.

This must either be empty, or start with a slash and not
end with a slash.
"""
return self._config.api_prefix

ThingInstance = TypeVar("ThingInstance", bound=Thing)

def things_by_class(self, cls: type[ThingInstance]) -> Sequence[ThingInstance]:
Expand Down Expand Up @@ -194,7 +206,7 @@
instances = self.things_by_class(cls)
if len(instances) == 1:
return instances[0]
raise RuntimeError(

Check warning on line 209 in src/labthings_fastapi/server/__init__.py

View workflow job for this annotation

GitHub Actions / coverage

209 line is not covered with tests
f"There are {len(instances)} Things of class {cls}, expected 1."
)

Expand All @@ -209,7 +221,7 @@
"""
if name not in self._things:
raise KeyError(f"No thing named {name} has been added to this server.")
return f"/{name}/"
return f"{self._api_prefix}/{name}/"

def _create_things(self) -> Mapping[str, Thing]:
r"""Create the Things, add them to the server, and connect them up if needed.
Expand Down Expand Up @@ -317,11 +329,15 @@

self.blocking_portal = None

def _add_things_view_to_app(self) -> None:
"""Add an endpoint that shows the list of attached things."""
def _things_view_router(self) -> APIRouter:
"""Create a router for the endpoint that shows the list of attached things.

:returns: an APIRouter with the `thing_descriptions` endpoint.
"""
router = APIRouter()
thing_server = self

@self.app.get(
@router.get(
"/thing_descriptions/",
response_model_exclude_none=True,
response_model_by_alias=True,
Expand All @@ -346,7 +362,7 @@
for name, thing in thing_server.things.items()
}

@self.app.get("/things/")
@router.get("/things/")
def thing_paths(request: Request) -> Mapping[str, str]:
"""URLs pointing to the Thing Descriptions of each Thing.

Expand All @@ -356,6 +372,8 @@
URLs will return the :ref:`wot_td` of one `.Thing` each.
""" # noqa: D403 (URLs is correct capitalisation)
return {
t: f"{str(request.base_url).rstrip('/')}{t}"
t: str(request.url_for(f"things.{t}"))
for t in thing_server.things.keys()
}

return router
24 changes: 24 additions & 0 deletions src/labthings_fastapi/server/config_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,30 @@ def thing_configs(self) -> Mapping[ThingName, ThingConfig]:
description="The location of the settings folder.",
)

api_prefix: str = Field(
default="",
pattern=r"^(\/[\w-]+)*$",
description=(
"""A prefix added to all endpoints, including Things.

The prefix must either be empty, or start with a forward
slash, but not end with one. This is enforced by a regex validator
on this field.

By default, LabThings creates a few LabThings-specific endpoints
(`/action_invocations/` and `/blob/` for example) as well as
endpoints for attributes of `Thing`s. This prefix will apply to
all of those endpoints.

For example, if `api_prefix` is set to `/api/v1` then a `Thing`
called `my_thing` might appear at `/api/v1/my_thing/` and the
blob download URL would be `/api/v1/blob/{id}`.

Leading and trailing slashes will be normalised.
"""
),
)

application_config: dict[str, Any] | None = Field(
default=None,
description=(
Expand Down
1 change: 1 addition & 0 deletions src/labthings_fastapi/thing.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@

@server.app.get(
self.path,
name=f"things.{self.name}",
summary=get_summary(self.thing_description),
description=get_docstring(self.thing_description),
response_model_exclude_none=True,
Expand Down Expand Up @@ -219,7 +220,7 @@
with open(setting_storage_path, "r", encoding="utf-8") as file_obj:
settings = json.load(file_obj)
if not isinstance(settings, Mapping):
raise TypeError("The settings file must be a JSON object.")

Check warning on line 223 in src/labthings_fastapi/thing.py

View workflow job for this annotation

GitHub Actions / coverage

223 line is not covered with tests
for name, value in settings.items():
try:
setting = self.settings[name]
Expand Down
62 changes: 62 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
import pytest
import labthings_fastapi as lt
from fastapi.testclient import TestClient
from starlette.routing import Route

from labthings_fastapi.example_things import MyThing


def test_server_from_config_non_thing_error():
Expand Down Expand Up @@ -63,3 +66,62 @@ def test_server_thing_descriptions():
prop = thing_description["properties"][prop_name]
expected_href = thing_name + "/" + prop_name
assert prop["forms"][0]["href"] == expected_href


def test_api_prefix():
"""Check we can add a prefix to the URLs on a server."""

class Example(lt.Thing):
"""An example Thing"""

server = lt.ThingServer(things={"example": Example}, api_prefix="/api/v3")
paths = [route.path for route in server.app.routes if isinstance(route, Route)]
for expected_path in [
"/api/v3/action_invocations",
"/api/v3/action_invocations/{id}",
"/api/v3/action_invocations/{id}/output",
"/api/v3/action_invocations/{id}",
"/api/v3/blob/{blob_id}",
"/api/v3/thing_descriptions/",
"/api/v3/things/",
"/api/v3/example/",
]:
assert expected_path in paths

unprefixed_paths = {p for p in paths if not p.startswith("/api/v3/")}
assert unprefixed_paths == {
"/openapi.json",
"/docs",
"/docs/oauth2-redirect",
"/redoc",
}


def test_things_endpoints():
"""Test that the two endpoints for listing Things work."""
server = lt.ThingServer(
{
"thing_a": MyThing,
"thing_b": MyThing,
}
)
with TestClient(server.app) as client:
# Check the thing_descriptions endpoint
response = client.get("/thing_descriptions/")
response.raise_for_status()
tds = response.json()
assert "thing_a" in tds
assert "thing_b" in tds

# Check the things endpoint. This should map names to URLs
response = client.get("/things/")
response.raise_for_status()
things = response.json()
assert "thing_a" in things
assert "thing_b" in things

# Fetch a thing description from the URL in `things`
response = client.get(things["thing_a"])
response.raise_for_status()
td = response.json()
assert td["title"] == "MyThing"
10 changes: 10 additions & 0 deletions tests/test_server_config_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,16 @@ def test_ThingServerConfig():
with pytest.raises(ValidationError):
ThingServerConfig(things={name: MyThing})

# Check some good prefixes
for prefix in ["", "/api", "/api/v2", "/api-v2"]:
config = ThingServerConfig(things={}, api_prefix=prefix)
assert config.api_prefix == prefix

# Check some bad prefixes
for prefix in ["api", "/api/", "api/v2", "/badchars!"]:
with pytest.raises(ValidationError):
ThingServerConfig(things={}, api_prefix=prefix)


def test_unimportable_modules():
"""Test that unimportable modules raise errors as expected."""
Expand Down
4 changes: 2 additions & 2 deletions tests/test_thing_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,9 @@ def throw_value_error(self) -> None:
@pytest.fixture
def thing_client():
"""Yield a test client connected to a ThingServer."""
server = lt.ThingServer({"test_thing": ThingToTest})
server = lt.ThingServer({"test_thing": ThingToTest}, api_prefix="/api/v1")
with TestClient(server.app) as client:
yield lt.ThingClient.from_url("/test_thing/", client=client)
yield lt.ThingClient.from_url("/api/v1/test_thing/", client=client)


def test_reading_and_setting_properties(thing_client):
Expand Down