Skip to content

Commit 5d2e00e

Browse files
committed
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.
1 parent 32a9cd1 commit 5d2e00e

3 files changed

Lines changed: 68 additions & 22 deletions

File tree

src/labthings_fastapi/actions.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
)
3737
from weakref import WeakSet
3838
import weakref
39-
from fastapi import FastAPI, HTTPException, Request, Body, BackgroundTasks
39+
from fastapi import APIRouter, FastAPI, HTTPException, Request, Body, BackgroundTasks
4040
from pydantic import BaseModel, create_model
4141

4242
from .middleware.url_for import URLFor
@@ -72,7 +72,7 @@
7272
from .thing import Thing
7373

7474

75-
__all__ = ["ACTION_INVOCATIONS_PATH", "Invocation", "ActionManager"]
75+
__all__ = ["Invocation", "ActionManager"]
7676

7777

7878
ACTION_INVOCATIONS_PATH = "/action_invocations"
@@ -442,17 +442,18 @@ def expire_invocations(self) -> None:
442442
for k in to_delete:
443443
del self._invocations[k]
444444

445-
def attach_to_app(self, app: FastAPI) -> None:
446-
"""Add /action_invocations and /action_invocation/{id} endpoints to FastAPI.
445+
def router(self) -> APIRouter:
446+
"""Create a FastAPI Router with action-related endpoints.
447447
448-
:param app: The `fastapi.FastAPI` application to which we add the endpoints.
448+
:return: a Router with all action-related endpoints.
449449
"""
450+
router = APIRouter()
450451

451-
@app.get(ACTION_INVOCATIONS_PATH, response_model=list[InvocationModel])
452+
@router.get(ACTION_INVOCATIONS_PATH, response_model=list[InvocationModel])
452453
def list_all_invocations(request: Request) -> list[InvocationModel]:
453454
return self.list_invocations(request=request)
454455

455-
@app.get(
456+
@router.get(
456457
ACTION_INVOCATIONS_PATH + "/{id}",
457458
responses={404: {"description": "Invocation ID not found"}},
458459
)
@@ -477,7 +478,7 @@ def action_invocation(id: uuid.UUID, request: Request) -> InvocationModel:
477478
detail="No action invocation found with ID {id}",
478479
) from e
479480

480-
@app.get(
481+
@router.get(
481482
ACTION_INVOCATIONS_PATH + "/{id}/output",
482483
response_model=Any,
483484
responses={
@@ -525,7 +526,7 @@ def action_invocation_output(id: uuid.UUID) -> Any:
525526
return invocation.output.response()
526527
return invocation.output
527528

528-
@app.delete(
529+
@router.delete(
529530
ACTION_INVOCATIONS_PATH + "/{id}",
530531
response_model=None,
531532
responses={
@@ -565,6 +566,8 @@ def delete_invocation(id: uuid.UUID) -> None:
565566
)
566567
invocation.cancel()
567568

569+
return router
570+
568571

569572
ACTION_POST_NOTICE = """
570573
## Important note

src/labthings_fastapi/server/__init__.py

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from typing_extensions import Self
1212
import os
1313

14-
from fastapi import FastAPI, Request
14+
from fastapi import APIRouter, FastAPI, Request
1515
from fastapi.middleware.cors import CORSMiddleware
1616
from anyio.from_thread import BlockingPortal
1717
from contextlib import asynccontextmanager, AsyncExitStack
@@ -65,6 +65,7 @@ def __init__(
6565
self,
6666
things: ThingsConfig,
6767
settings_folder: Optional[str] = None,
68+
api_prefix: str = "",
6869
application_config: Optional[Mapping[str, Any]] = None,
6970
) -> None:
7071
r"""Initialise a LabThings server.
@@ -82,8 +83,9 @@ def __init__(
8283
arguments, and any connections to other `.Thing`\ s.
8384
:param settings_folder: the location on disk where `.Thing`
8485
settings will be saved.
86+
:param api_prefix: An optional prefix for all API routes. This must either
87+
be empty, or start with a slash and not end with a slash.
8588
:param application_config: A mapping containing custom configuration for the
86-
application. This is not processed by LabThings. Each `.Thing` can access
8789
application. This is not processed by LabThings. Each `.Thing` can access
8890
this via the Thing-Server interface.
8991
"""
@@ -99,9 +101,9 @@ def __init__(
99101
self._set_url_for_middleware()
100102
self.settings_folder = settings_folder or "./settings"
101103
self.action_manager = ActionManager()
102-
self.action_manager.attach_to_app(self.app)
103-
self.app.include_router(blob.router) # include blob download endpoint
104-
self._add_things_view_to_app()
104+
self.app.include_router(self.action_manager.router(), prefix=self._api_prefix)
105+
self.app.include_router(blob.router, prefix=self._api_prefix)
106+
self.app.include_router(self._things_view_router(), prefix=self._api_prefix)
105107
self.blocking_portal: Optional[BlockingPortal] = None
106108
self.startup_status: dict[str, str | dict] = {"things": {}}
107109
global _thing_servers # noqa: F824
@@ -166,6 +168,15 @@ def application_config(self) -> Mapping[str, Any] | None:
166168
"""
167169
return self._config.application_config
168170

171+
@property
172+
def _api_prefix(self) -> str:
173+
"""A string that prefixes all URLs in the application.
174+
175+
This must either be empty, or start with a slash and not
176+
end with a slash.
177+
"""
178+
return self._config.api_prefix
179+
169180
ThingInstance = TypeVar("ThingInstance", bound=Thing)
170181

171182
def things_by_class(self, cls: type[ThingInstance]) -> Sequence[ThingInstance]:
@@ -209,7 +220,7 @@ def path_for_thing(self, name: str) -> str:
209220
"""
210221
if name not in self._things:
211222
raise KeyError(f"No thing named {name} has been added to this server.")
212-
return f"/{name}/"
223+
return f"{self._api_prefix}/{name}/"
213224

214225
def _create_things(self) -> Mapping[str, Thing]:
215226
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]:
317328

318329
self.blocking_portal = None
319330

320-
def _add_things_view_to_app(self) -> None:
321-
"""Add an endpoint that shows the list of attached things."""
331+
def _things_view_router(self) -> APIRouter:
332+
"""Create a router for the endpoint that shows the list of attached things.
333+
334+
:returns: an APIRouter with the `thing_descriptions` endpoint.
335+
"""
336+
router = APIRouter()
322337
thing_server = self
323338

324-
@self.app.get(
325-
"/thing_descriptions/",
326-
response_model_exclude_none=True,
327-
response_model_by_alias=True,
328-
)
329339
def thing_descriptions(request: Request) -> Mapping[str, ThingDescription]:
330340
"""Describe all the things available from this server.
331341
@@ -346,6 +356,15 @@ def thing_descriptions(request: Request) -> Mapping[str, ThingDescription]:
346356
for name, thing in thing_server.things.items()
347357
}
348358

359+
router.add_api_route(
360+
"/thing_descriptions/",
361+
thing_descriptions,
362+
response_model_exclude_none=True,
363+
response_model_by_alias=True,
364+
)
365+
366+
return router
367+
349368
@self.app.get("/things/")
350369
def thing_paths(request: Request) -> Mapping[str, str]:
351370
"""URLs pointing to the Thing Descriptions of each Thing.

src/labthings_fastapi/server/config_model.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,30 @@ def thing_configs(self) -> Mapping[ThingName, ThingConfig]:
180180
description="The location of the settings folder.",
181181
)
182182

183+
api_prefix: str = Field(
184+
default="",
185+
pattern="(\/[\w-]+)*",
186+
description=(
187+
"""A prefix added to all endpoints, including Things.
188+
189+
The prefix must either be empty, or start with a forward
190+
slash, but not end with one. This is enforced by a regex validator
191+
on this field.
192+
193+
By default, LabThings creates a few LabThings-specific endpoints
194+
(`/action_invocations/` and `/blob/` for example) as well as
195+
endpoints for attributes of `Thing`s. This prefix will apply to
196+
all of those endpoints.
197+
198+
For example, if `api_prefix` is set to `/api/v1` then a `Thing`
199+
called `my_thing` might appear at `/api/v1/my_thing/` and the
200+
blob download URL would be `/api/v1/blob/{id}`.
201+
202+
Leading and trailing slashes will be normalised.
203+
"""
204+
),
205+
)
206+
183207
application_config: dict[str, Any] | None = Field(
184208
default=None,
185209
description=(

0 commit comments

Comments
 (0)