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 @@ -71,7 +71,7 @@
from .thing import Thing


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


ACTION_INVOCATIONS_PATH = "/action_invocations"
Expand Down Expand Up @@ -438,17 +438,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 @@ -473,7 +474,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 @@ -504,8 +505,8 @@
with self._invocations_lock:
try:
invocation: Any = self._invocations[id]
except KeyError as e:
raise HTTPException(

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

View workflow job for this annotation

GitHub Actions / coverage

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

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

View workflow job for this annotation

GitHub Actions / coverage

522 line is not covered with tests
return invocation.output

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

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

View workflow job for this annotation

GitHub Actions / coverage

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

return router


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

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

View workflow job for this annotation

GitHub Actions / coverage

723 line is not covered with tests
f"Action name '{self.name}' does not match function name "
f"'{self.func.__name__}'",
)
Expand Down Expand Up @@ -859,14 +862,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 866 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

865-866 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 869 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

869 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 872 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

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

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

View workflow job for this annotation

GitHub Actions / coverage

920 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 @@ -12,7 +12,7 @@
import os
import logging

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,
debug: bool = False,
) -> None:
Expand All @@ -83,8 +84,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.
:param debug: If ``True``, set the log level for `.Thing` instances to
Expand All @@ -96,16 +98,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 @@ -171,6 +174,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 All @@ -182,7 +194,7 @@

:return: all instances of ``cls`` that have been added to this server.
"""
return [t for t in self.things.values() if isinstance(t, cls)]

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

View workflow job for this annotation

GitHub Actions / coverage

197 line is not covered with tests

def thing_by_class(self, cls: type[ThingInstance]) -> ThingInstance:
"""Return the instance of ``cls`` attached to this server.
Expand All @@ -196,10 +208,10 @@

:raise RuntimeError: if there is not exactly one matching Thing.
"""
instances = self.things_by_class(cls)
if len(instances) == 1:
return instances[0]
raise RuntimeError(

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

View workflow job for this annotation

GitHub Actions / coverage

211-214 lines are not covered with tests
f"There are {len(instances)} Things of class {cls}, expected 1."
)

Expand All @@ -214,7 +226,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 @@ -322,11 +334,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 @@ -351,7 +367,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 @@ -361,6 +377,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 @@ def attach_to_server(self, server: ThingServer) -> None:

@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
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 @@ -63,9 +63,9 @@ def throw_value_error(self) -> None:
@pytest.fixture
def thing_client_and_thing():
"""Yield a test client connected to a ThingServer and the Thing itself."""
server = lt.ThingServer({"test_thing": ThingToTest})
server = lt.ThingServer({"test_thing": ThingToTest}, api_prefix="/api/v1")
with TestClient(server.app) as client:
thing_client = lt.ThingClient.from_url("/test_thing/", client=client)
thing_client = lt.ThingClient.from_url("/api/v1/test_thing/", client=client)
thing = server.things["test_thing"]
yield thing_client, thing

Expand Down
Loading