Skip to content

Commit 0f4b2ae

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 6a1a5fc commit 0f4b2ae

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
@@ -71,7 +71,7 @@
7171
from .thing import Thing
7272

7373

74-
__all__ = ["ACTION_INVOCATIONS_PATH", "Invocation", "ActionManager"]
74+
__all__ = ["Invocation", "ActionManager"]
7575

7676

7777
ACTION_INVOCATIONS_PATH = "/action_invocations"
@@ -438,17 +438,18 @@ def expire_invocations(self) -> None:
438438
for k in to_delete:
439439
del self._invocations[k]
440440

441-
def attach_to_app(self, app: FastAPI) -> None:
442-
"""Add /action_invocations and /action_invocation/{id} endpoints to FastAPI.
441+
def router(self) -> APIRouter:
442+
"""Create a FastAPI Router with action-related endpoints.
443443
444-
:param app: The `fastapi.FastAPI` application to which we add the endpoints.
444+
:return: a Router with all action-related endpoints.
445445
"""
446+
router = APIRouter()
446447

447-
@app.get(ACTION_INVOCATIONS_PATH, response_model=list[InvocationModel])
448+
@router.get(ACTION_INVOCATIONS_PATH, response_model=list[InvocationModel])
448449
def list_all_invocations(request: Request) -> list[InvocationModel]:
449450
return self.list_invocations(request=request)
450451

451-
@app.get(
452+
@router.get(
452453
ACTION_INVOCATIONS_PATH + "/{id}",
453454
responses={404: {"description": "Invocation ID not found"}},
454455
)
@@ -473,7 +474,7 @@ def action_invocation(id: uuid.UUID, request: Request) -> InvocationModel:
473474
detail="No action invocation found with ID {id}",
474475
) from e
475476

476-
@app.get(
477+
@router.get(
477478
ACTION_INVOCATIONS_PATH + "/{id}/output",
478479
response_model=Any,
479480
responses={
@@ -521,7 +522,7 @@ def action_invocation_output(id: uuid.UUID) -> Any:
521522
return invocation.output.response()
522523
return invocation.output
523524

524-
@app.delete(
525+
@router.delete(
525526
ACTION_INVOCATIONS_PATH + "/{id}",
526527
response_model=None,
527528
responses={
@@ -561,6 +562,8 @@ def delete_invocation(id: uuid.UUID) -> None:
561562
)
562563
invocation.cancel()
563564

565+
return router
566+
564567

565568
ACTION_POST_NOTICE = """
566569
## Important note

src/labthings_fastapi/server/__init__.py

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import os
1313
import logging
1414

15-
from fastapi import FastAPI, Request
15+
from fastapi import APIRouter, FastAPI, Request
1616
from fastapi.middleware.cors import CORSMiddleware
1717
from anyio.from_thread import BlockingPortal
1818
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
debug: bool = False,
7071
) -> None:
@@ -83,8 +84,9 @@ def __init__(
8384
arguments, and any connections to other `.Thing`\ s.
8485
:param settings_folder: the location on disk where `.Thing`
8586
settings will be saved.
87+
:param api_prefix: An optional prefix for all API routes. This must either
88+
be empty, or start with a slash and not end with a slash.
8689
:param application_config: A mapping containing custom configuration for the
87-
application. This is not processed by LabThings. Each `.Thing` can access
8890
application. This is not processed by LabThings. Each `.Thing` can access
8991
this via the Thing-Server interface.
9092
:param debug: If ``True``, set the log level for `.Thing` instances to
@@ -103,9 +105,9 @@ def __init__(
103105
self._set_url_for_middleware()
104106
self.settings_folder = settings_folder or "./settings"
105107
self.action_manager = ActionManager()
106-
self.action_manager.attach_to_app(self.app)
107-
self.app.include_router(blob.router) # include blob download endpoint
108-
self._add_things_view_to_app()
108+
self.app.include_router(self.action_manager.router(), prefix=self._api_prefix)
109+
self.app.include_router(blob.router, prefix=self._api_prefix)
110+
self.app.include_router(self._things_view_router(), prefix=self._api_prefix)
109111
self.blocking_portal: Optional[BlockingPortal] = None
110112
self.startup_status: dict[str, str | dict] = {"things": {}}
111113
global _thing_servers # noqa: F824
@@ -171,6 +173,15 @@ def application_config(self) -> Mapping[str, Any] | None:
171173
"""
172174
return self._config.application_config
173175

176+
@property
177+
def _api_prefix(self) -> str:
178+
"""A string that prefixes all URLs in the application.
179+
180+
This must either be empty, or start with a slash and not
181+
end with a slash.
182+
"""
183+
return self._config.api_prefix
184+
174185
ThingInstance = TypeVar("ThingInstance", bound=Thing)
175186

176187
def things_by_class(self, cls: type[ThingInstance]) -> Sequence[ThingInstance]:
@@ -214,7 +225,7 @@ def path_for_thing(self, name: str) -> str:
214225
"""
215226
if name not in self._things:
216227
raise KeyError(f"No thing named {name} has been added to this server.")
217-
return f"/{name}/"
228+
return f"{self._api_prefix}/{name}/"
218229

219230
def _create_things(self) -> Mapping[str, Thing]:
220231
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]:
322333

323334
self.blocking_portal = None
324335

325-
def _add_things_view_to_app(self) -> None:
326-
"""Add an endpoint that shows the list of attached things."""
336+
def _things_view_router(self) -> APIRouter:
337+
"""Create a router for the endpoint that shows the list of attached things.
338+
339+
:returns: an APIRouter with the `thing_descriptions` endpoint.
340+
"""
341+
router = APIRouter()
327342
thing_server = self
328343

329-
@self.app.get(
330-
"/thing_descriptions/",
331-
response_model_exclude_none=True,
332-
response_model_by_alias=True,
333-
)
334344
def thing_descriptions(request: Request) -> Mapping[str, ThingDescription]:
335345
"""Describe all the things available from this server.
336346
@@ -351,6 +361,15 @@ def thing_descriptions(request: Request) -> Mapping[str, ThingDescription]:
351361
for name, thing in thing_server.things.items()
352362
}
353363

364+
router.add_api_route(
365+
"/thing_descriptions/",
366+
thing_descriptions,
367+
response_model_exclude_none=True,
368+
response_model_by_alias=True,
369+
)
370+
371+
return router
372+
354373
@self.app.get("/things/")
355374
def thing_paths(request: Request) -> Mapping[str, str]:
356375
"""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)