From 5d2e00ecaea610225a7a9f22fd5d4eab6edb128c Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 25 Feb 2026 23:24:37 +0000 Subject: [PATCH 1/3] Add an API prefix It's now possible to configure an API prefix, which affects all LabThings-generated URLs. I've also switched a couple of places from passing the app around to creating an APIRouter, which feels much cleaner. So far, tests pass but I've not tried to set a prefix. --- src/labthings_fastapi/actions.py | 21 +++++---- src/labthings_fastapi/server/__init__.py | 45 ++++++++++++++------ src/labthings_fastapi/server/config_model.py | 24 +++++++++++ 3 files changed, 68 insertions(+), 22 deletions(-) diff --git a/src/labthings_fastapi/actions.py b/src/labthings_fastapi/actions.py index 433a56b8..1dbf7c6f 100644 --- a/src/labthings_fastapi/actions.py +++ b/src/labthings_fastapi/actions.py @@ -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 @@ -72,7 +72,7 @@ from .thing import Thing -__all__ = ["ACTION_INVOCATIONS_PATH", "Invocation", "ActionManager"] +__all__ = ["Invocation", "ActionManager"] ACTION_INVOCATIONS_PATH = "/action_invocations" @@ -442,17 +442,18 @@ def expire_invocations(self) -> None: 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"}}, ) @@ -477,7 +478,7 @@ def action_invocation(id: uuid.UUID, request: Request) -> InvocationModel: detail="No action invocation found with ID {id}", ) from e - @app.get( + @router.get( ACTION_INVOCATIONS_PATH + "/{id}/output", response_model=Any, responses={ @@ -525,7 +526,7 @@ def action_invocation_output(id: uuid.UUID) -> Any: return invocation.output.response() return invocation.output - @app.delete( + @router.delete( ACTION_INVOCATIONS_PATH + "/{id}", response_model=None, responses={ @@ -565,6 +566,8 @@ def delete_invocation(id: uuid.UUID) -> None: ) invocation.cancel() + return router + ACTION_POST_NOTICE = """ ## Important note diff --git a/src/labthings_fastapi/server/__init__.py b/src/labthings_fastapi/server/__init__.py index 01c7f73d..e574c897 100644 --- a/src/labthings_fastapi/server/__init__.py +++ b/src/labthings_fastapi/server/__init__.py @@ -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 @@ -65,6 +65,7 @@ def __init__( self, things: ThingsConfig, settings_folder: Optional[str] = None, + api_prefix: str = "", application_config: Optional[Mapping[str, Any]] = None, ) -> None: r"""Initialise a LabThings server. @@ -82,8 +83,9 @@ def __init__( 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. """ @@ -99,9 +101,9 @@ def __init__( 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 @@ -166,6 +168,15 @@ def application_config(self) -> Mapping[str, Any] | None: """ 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]: @@ -209,7 +220,7 @@ def path_for_thing(self, name: str) -> str: """ 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. @@ -317,15 +328,14 @@ async def lifespan(self, app: FastAPI) -> AsyncGenerator[None, None]: 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( - "/thing_descriptions/", - response_model_exclude_none=True, - response_model_by_alias=True, - ) def thing_descriptions(request: Request) -> Mapping[str, ThingDescription]: """Describe all the things available from this server. @@ -346,6 +356,15 @@ def thing_descriptions(request: Request) -> Mapping[str, ThingDescription]: for name, thing in thing_server.things.items() } + router.add_api_route( + "/thing_descriptions/", + thing_descriptions, + response_model_exclude_none=True, + response_model_by_alias=True, + ) + + return router + @self.app.get("/things/") def thing_paths(request: Request) -> Mapping[str, str]: """URLs pointing to the Thing Descriptions of each Thing. diff --git a/src/labthings_fastapi/server/config_model.py b/src/labthings_fastapi/server/config_model.py index 6519aa01..d5de8b0e 100644 --- a/src/labthings_fastapi/server/config_model.py +++ b/src/labthings_fastapi/server/config_model.py @@ -180,6 +180,30 @@ def thing_configs(self) -> Mapping[ThingName, ThingConfig]: description="The location of the settings folder.", ) + api_prefix: str = Field( + default="", + pattern="(\/[\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=( From 9f8da532ef52e05fd786bf4edda6eb1f21a409c6 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 26 Feb 2026 00:01:50 +0000 Subject: [PATCH 2/3] Add tests for the API prefix and use it. The previous commit laid the groundwork but failed to actually set the API prefix. This is now fixed. The API prefix is tested in a couple of places: validation is tested in `test_server_config_model`, and the endpoints are checked in `test_server` explicitly, and `test_thing_client` implicitly (because we use a prefix for the thing that's tested). --- src/labthings_fastapi/server/__init__.py | 1 + src/labthings_fastapi/server/config_model.py | 2 +- tests/test_server.py | 29 ++++++++++++++++++++ tests/test_server_config_model.py | 10 +++++++ tests/test_thing_client.py | 4 +-- 5 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/labthings_fastapi/server/__init__.py b/src/labthings_fastapi/server/__init__.py index e574c897..61daa5fe 100644 --- a/src/labthings_fastapi/server/__init__.py +++ b/src/labthings_fastapi/server/__init__.py @@ -94,6 +94,7 @@ def __init__( self._config = ThingServerConfig( things=things, settings_folder=settings_folder, + api_prefix=api_prefix, application_config=application_config, ) self.app = FastAPI(lifespan=self.lifespan) diff --git a/src/labthings_fastapi/server/config_model.py b/src/labthings_fastapi/server/config_model.py index d5de8b0e..46adf0bc 100644 --- a/src/labthings_fastapi/server/config_model.py +++ b/src/labthings_fastapi/server/config_model.py @@ -182,7 +182,7 @@ def thing_configs(self) -> Mapping[ThingName, ThingConfig]: api_prefix: str = Field( default="", - pattern="(\/[\w-]+)*", + pattern=r"^(\/[\w-]+)*$", description=( """A prefix added to all endpoints, including Things. diff --git a/tests/test_server.py b/tests/test_server.py index d7a0773b..239309e1 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -7,6 +7,7 @@ import pytest import labthings_fastapi as lt from fastapi.testclient import TestClient +from starlette.routing import Route def test_server_from_config_non_thing_error(): @@ -63,3 +64,31 @@ 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/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", + } diff --git a/tests/test_server_config_model.py b/tests/test_server_config_model.py index 11010282..3b518f6d 100644 --- a/tests/test_server_config_model.py +++ b/tests/test_server_config_model.py @@ -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.""" diff --git a/tests/test_thing_client.py b/tests/test_thing_client.py index 518907bd..756aaa9d 100644 --- a/tests/test_thing_client.py +++ b/tests/test_thing_client.py @@ -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): From 8059d4e289792aac380e1e3ba033c5690abb8e1c Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 26 Feb 2026 00:21:03 +0000 Subject: [PATCH 3/3] Fix `thing_descriptions` and `things` endpoints. I'd accidentally modified these endpoints (and deleted `things`) when I changed the function that added them. I've now added tests for these endpoints, and fixed the URL generation in `things`. --- src/labthings_fastapi/server/__init__.py | 20 +++++++------- src/labthings_fastapi/thing.py | 1 + tests/test_server.py | 33 ++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/labthings_fastapi/server/__init__.py b/src/labthings_fastapi/server/__init__.py index 61daa5fe..99520570 100644 --- a/src/labthings_fastapi/server/__init__.py +++ b/src/labthings_fastapi/server/__init__.py @@ -337,6 +337,11 @@ def _things_view_router(self) -> APIRouter: router = APIRouter() thing_server = self + @router.get( + "/thing_descriptions/", + response_model_exclude_none=True, + response_model_by_alias=True, + ) def thing_descriptions(request: Request) -> Mapping[str, ThingDescription]: """Describe all the things available from this server. @@ -357,16 +362,7 @@ def thing_descriptions(request: Request) -> Mapping[str, ThingDescription]: for name, thing in thing_server.things.items() } - router.add_api_route( - "/thing_descriptions/", - thing_descriptions, - response_model_exclude_none=True, - response_model_by_alias=True, - ) - - return router - - @self.app.get("/things/") + @router.get("/things/") def thing_paths(request: Request) -> Mapping[str, str]: """URLs pointing to the Thing Descriptions of each Thing. @@ -376,6 +372,8 @@ def thing_paths(request: Request) -> Mapping[str, str]: 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 diff --git a/src/labthings_fastapi/thing.py b/src/labthings_fastapi/thing.py index 2f245714..1d0d5b89 100644 --- a/src/labthings_fastapi/thing.py +++ b/src/labthings_fastapi/thing.py @@ -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, diff --git a/tests/test_server.py b/tests/test_server.py index 239309e1..5d0f9e44 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -9,6 +9,8 @@ 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(): """Test a typeerror is raised if something that's not a Thing is added.""" @@ -81,6 +83,7 @@ class Example(lt.Thing): "/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 @@ -92,3 +95,33 @@ class Example(lt.Thing): "/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"