From 3f702c6a3ce8b7ea8016962ef8b2abea16363088 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 25 Feb 2026 23:24:37 +0000 Subject: [PATCH 1/6] 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 0aaf760a..b5838a19 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 @@ -71,7 +71,7 @@ from .thing import Thing -__all__ = ["ACTION_INVOCATIONS_PATH", "Invocation", "ActionManager"] +__all__ = ["Invocation", "ActionManager"] ACTION_INVOCATIONS_PATH = "/action_invocations" @@ -438,17 +438,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"}}, ) @@ -473,7 +474,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={ @@ -521,7 +522,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={ @@ -561,6 +562,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 73f2672f..95d21966 100644 --- a/src/labthings_fastapi/server/__init__.py +++ b/src/labthings_fastapi/server/__init__.py @@ -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 @@ -65,6 +65,7 @@ def __init__( self, things: ThingsConfig, settings_folder: Optional[str] = None, + api_prefix: str = "", application_config: Optional[Mapping[str, Any]] = None, debug: bool = False, ) -> None: @@ -83,8 +84,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. :param debug: If ``True``, set the log level for `.Thing` instances to @@ -103,9 +105,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 @@ -171,6 +173,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]: @@ -214,7 +225,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. @@ -322,15 +333,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. @@ -351,6 +361,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 4f7a4455a543d923fde46671b7be3a58d3f7b73a Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 26 Feb 2026 00:01:50 +0000 Subject: [PATCH 2/6] 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 95d21966..486b01fc 100644 --- a/src/labthings_fastapi/server/__init__.py +++ b/src/labthings_fastapi/server/__init__.py @@ -98,6 +98,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 2337f057..bb00aa40 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(): @@ -67,3 +68,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 6f9cb525..97e12587 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 b8e5b663..04333a52 100644 --- a/tests/test_thing_client.py +++ b/tests/test_thing_client.py @@ -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 From 7ae8d403bfc4fb5c72af1fc9bce2c80c2d867199 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 26 Feb 2026 00:21:03 +0000 Subject: [PATCH 3/6] 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 486b01fc..b61161ed 100644 --- a/src/labthings_fastapi/server/__init__.py +++ b/src/labthings_fastapi/server/__init__.py @@ -342,6 +342,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. @@ -362,16 +367,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. @@ -381,6 +377,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 0731940c..401cf1be 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 bb00aa40..57ed4969 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.""" @@ -85,6 +87,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 @@ -96,3 +99,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" From e42e5dcd8b39287cf8ccfed34066727621cd0dbb Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Tue, 31 Mar 2026 22:56:27 +0100 Subject: [PATCH 4/6] Include API prefix in thing_descriptions endpoint. Individual thing descriptions were being generated correctly, but the combined endpoint passed the wrong path to the `thing_description` methods. This should fix the form URLs included in that endpoint, which is very important for the web app. --- src/labthings_fastapi/server/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/labthings_fastapi/server/__init__.py b/src/labthings_fastapi/server/__init__.py index b61161ed..f9e3c5de 100644 --- a/src/labthings_fastapi/server/__init__.py +++ b/src/labthings_fastapi/server/__init__.py @@ -363,7 +363,9 @@ def thing_descriptions(request: Request) -> Mapping[str, ThingDescription]: dictionaries. """ return { - name: thing.thing_description(name + "/", base=str(request.base_url)) + name: thing.thing_description( + path=f"{self._api_prefix}/{name}/", base=str(request.base_url) + ) for name, thing in thing_server.things.items() } From a2d8cdcc07a4dfe4dfa734007b0836fba6efa433 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Tue, 31 Mar 2026 23:25:09 +0100 Subject: [PATCH 5/6] Improve tests for API prefix This adds an API prefix to `test_server_thing_descriptions` and fixes the expected URLs so the test no longer fails. Form URLs now include the API prefix (and the `base` of thing descriptions should just be the server and port). I've also added a further test that the Thing Descriptions returned individually (by e.g. `/mything/`) are identical to those returned by `/thing_descriptions/`. This PR is the second time I've seen inconsistencies there, and this test should help eliminate them. --- tests/test_server.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/test_server.py b/tests/test_server.py index 57ed4969..1668454a 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -35,7 +35,8 @@ def test_server_thing_descriptions(): "class": "labthings_fastapi.example_things:MyThing", "kwargs": {}, }, - } + }, + "api_prefix": "/api", } thing_names = ["thing1", "thing2"] @@ -51,7 +52,7 @@ def test_server_thing_descriptions(): server = lt.ThingServer.from_config(conf) with TestClient(server.app) as client: - response = client.get("/thing_descriptions/") + response = client.get("/api/thing_descriptions/") response.raise_for_status() thing_descriptions = response.json() @@ -63,12 +64,12 @@ def test_server_thing_descriptions(): for action_name in actions: action = thing_description["actions"][action_name] - expected_href = thing_name + "/" + action_name + expected_href = f"/api/{thing_name}/{action_name}" assert action["forms"][0]["href"] == expected_href for prop_name in props: prop = thing_description["properties"][prop_name] - expected_href = thing_name + "/" + prop_name + expected_href = f"/api/{thing_name}/{prop_name}" assert prop["forms"][0]["href"] == expected_href @@ -129,3 +130,4 @@ def test_things_endpoints(): response.raise_for_status() td = response.json() assert td["title"] == "MyThing" + assert tds["thing_a"] == td From c3501112f3f37d24e3680e52746bd5d3e096d7d5 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 2 Apr 2026 15:54:03 +0100 Subject: [PATCH 6/6] Apply suggestions from code review. Thanks @bprobert97 - a couple of nice improvements to tests and docstrings. --- src/labthings_fastapi/server/__init__.py | 8 +++--- tests/test_server.py | 32 ++++++++++++++---------- tests/test_server_config_model.py | 2 +- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/labthings_fastapi/server/__init__.py b/src/labthings_fastapi/server/__init__.py index f9e3c5de..0cba0f89 100644 --- a/src/labthings_fastapi/server/__init__.py +++ b/src/labthings_fastapi/server/__init__.py @@ -84,7 +84,7 @@ 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 + :param api_prefix: A 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 @@ -176,10 +176,10 @@ def application_config(self) -> Mapping[str, Any] | None: @property def _api_prefix(self) -> str: - """A string that prefixes all URLs in the application. + r"""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. + This will either be empty, or start with a slash and not + end with a slash. Validation is performed in `.ThingServerConfig`\ . """ return self._config.api_prefix diff --git a/tests/test_server.py b/tests/test_server.py index 1668454a..455229e5 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -73,27 +73,33 @@ def test_server_thing_descriptions(): assert prop["forms"][0]["href"] == expected_href -def test_api_prefix(): +@pytest.mark.parametrize("api_prefix", ["/api/v3", "/v1", "/custom/prefix"]) +def test_api_prefix(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") + server = lt.ThingServer(things={"example": Example}, api_prefix=api_prefix) 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/", - ]: + + # Dynamically generate expected paths based on the parametrized prefix + expected_paths = [ + f"{api_prefix}/action_invocations", + f"{api_prefix}/action_invocations/{{id}}", + f"{api_prefix}/action_invocations/{{id}}/output", + f"{api_prefix}/blob/{{blob_id}}", + f"{api_prefix}/thing_descriptions/", + f"{api_prefix}/things/", + f"{api_prefix}/example/", + ] + + for expected_path in expected_paths: assert expected_path in paths - unprefixed_paths = {p for p in paths if not p.startswith("/api/v3/")} + prefix_with_slash = f"{api_prefix}/" + unprefixed_paths = {p for p in paths if not p.startswith(prefix_with_slash)} + assert unprefixed_paths == { "/openapi.json", "/docs", diff --git a/tests/test_server_config_model.py b/tests/test_server_config_model.py index 97e12587..76267d92 100644 --- a/tests/test_server_config_model.py +++ b/tests/test_server_config_model.py @@ -106,7 +106,7 @@ def test_ThingServerConfig(): assert config.api_prefix == prefix # Check some bad prefixes - for prefix in ["api", "/api/", "api/v2", "/badchars!"]: + for prefix in ["api", "/api/", "api/", "api/v2", "/badchars!"]: with pytest.raises(ValidationError): ThingServerConfig(things={}, api_prefix=prefix)