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..99520570 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. """ @@ -92,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) @@ -99,9 +102,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 +169,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 +221,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,11 +329,15 @@ 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( + @router.get( "/thing_descriptions/", response_model_exclude_none=True, response_model_by_alias=True, @@ -346,7 +362,7 @@ def thing_descriptions(request: Request) -> Mapping[str, ThingDescription]: 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. @@ -356,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/server/config_model.py b/src/labthings_fastapi/server/config_model.py index 6519aa01..46adf0bc 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=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=( 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 d7a0773b..5d0f9e44 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -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(): @@ -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" 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):