From dffab7a4eaa77affa92a3598cd2a264b921a12ba 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 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 000778167e709549e3f5652c35f669719c4be042 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 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 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 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 7f7ae994be1f4e2872e5080e5e99f88e8a2c2359 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 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 71b3b3f0..184aae1e 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"