Skip to content

Commit 5d15e9a

Browse files
authored
Merge pull request #268 from labthings/api-prefix
API prefix
2 parents f6032b1 + c350111 commit 5d15e9a

File tree

7 files changed

+155
-27
lines changed

7 files changed

+155
-27
lines changed

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 & 12 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: A 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
@@ -96,16 +98,17 @@ def __init__(
9698
self._config = ThingServerConfig(
9799
things=things,
98100
settings_folder=settings_folder,
101+
api_prefix=api_prefix,
99102
application_config=application_config,
100103
)
101104
self.app = FastAPI(lifespan=self.lifespan)
102105
self._set_cors_middleware()
103106
self._set_url_for_middleware()
104107
self.settings_folder = settings_folder or "./settings"
105108
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()
109+
self.app.include_router(self.action_manager.router(), prefix=self._api_prefix)
110+
self.app.include_router(blob.router, prefix=self._api_prefix)
111+
self.app.include_router(self._things_view_router(), prefix=self._api_prefix)
109112
self.blocking_portal: Optional[BlockingPortal] = None
110113
self.startup_status: dict[str, str | dict] = {"things": {}}
111114
global _thing_servers # noqa: F824
@@ -171,6 +174,15 @@ def application_config(self) -> Mapping[str, Any] | None:
171174
"""
172175
return self._config.application_config
173176

177+
@property
178+
def _api_prefix(self) -> str:
179+
r"""A string that prefixes all URLs in the application.
180+
181+
This will either be empty, or start with a slash and not
182+
end with a slash. Validation is performed in `.ThingServerConfig`\ .
183+
"""
184+
return self._config.api_prefix
185+
174186
ThingInstance = TypeVar("ThingInstance", bound=Thing)
175187

176188
def things_by_class(self, cls: type[ThingInstance]) -> Sequence[ThingInstance]:
@@ -214,7 +226,7 @@ def path_for_thing(self, name: str) -> str:
214226
"""
215227
if name not in self._things:
216228
raise KeyError(f"No thing named {name} has been added to this server.")
217-
return f"/{name}/"
229+
return f"{self._api_prefix}/{name}/"
218230

219231
def _create_things(self) -> Mapping[str, Thing]:
220232
r"""Create the Things, add them to the server, and connect them up if needed.
@@ -322,11 +334,15 @@ async def lifespan(self, app: FastAPI) -> AsyncGenerator[None, None]:
322334

323335
self.blocking_portal = None
324336

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

329-
@self.app.get(
345+
@router.get(
330346
"/thing_descriptions/",
331347
response_model_exclude_none=True,
332348
response_model_by_alias=True,
@@ -347,11 +363,13 @@ def thing_descriptions(request: Request) -> Mapping[str, ThingDescription]:
347363
dictionaries.
348364
"""
349365
return {
350-
name: thing.thing_description(name + "/", base=str(request.base_url))
366+
name: thing.thing_description(
367+
path=f"{self._api_prefix}/{name}/", base=str(request.base_url)
368+
)
351369
for name, thing in thing_server.things.items()
352370
}
353371

354-
@self.app.get("/things/")
372+
@router.get("/things/")
355373
def thing_paths(request: Request) -> Mapping[str, str]:
356374
"""URLs pointing to the Thing Descriptions of each Thing.
357375
@@ -361,6 +379,8 @@ def thing_paths(request: Request) -> Mapping[str, str]:
361379
URLs will return the :ref:`wot_td` of one `.Thing` each.
362380
""" # noqa: D403 (URLs is correct capitalisation)
363381
return {
364-
t: f"{str(request.base_url).rstrip('/')}{t}"
382+
t: str(request.url_for(f"things.{t}"))
365383
for t in thing_server.things.keys()
366384
}
385+
386+
return router

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=r"^(\/[\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=(

src/labthings_fastapi/thing.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ def attach_to_server(self, server: ThingServer) -> None:
176176

177177
@server.app.get(
178178
self.path,
179+
name=f"things.{self.name}",
179180
summary=get_summary(self.thing_description),
180181
description=get_docstring(self.thing_description),
181182
response_model_exclude_none=True,

tests/test_server.py

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
import pytest
88
import labthings_fastapi as lt
99
from fastapi.testclient import TestClient
10+
from starlette.routing import Route
11+
12+
from labthings_fastapi.example_things import MyThing
1013

1114

1215
def test_server_from_config_non_thing_error():
@@ -32,7 +35,8 @@ def test_server_thing_descriptions():
3235
"class": "labthings_fastapi.example_things:MyThing",
3336
"kwargs": {},
3437
},
35-
}
38+
},
39+
"api_prefix": "/api",
3640
}
3741

3842
thing_names = ["thing1", "thing2"]
@@ -48,7 +52,7 @@ def test_server_thing_descriptions():
4852

4953
server = lt.ThingServer.from_config(conf)
5054
with TestClient(server.app) as client:
51-
response = client.get("/thing_descriptions/")
55+
response = client.get("/api/thing_descriptions/")
5256
response.raise_for_status()
5357
thing_descriptions = response.json()
5458

@@ -60,10 +64,76 @@ def test_server_thing_descriptions():
6064

6165
for action_name in actions:
6266
action = thing_description["actions"][action_name]
63-
expected_href = thing_name + "/" + action_name
67+
expected_href = f"/api/{thing_name}/{action_name}"
6468
assert action["forms"][0]["href"] == expected_href
6569

6670
for prop_name in props:
6771
prop = thing_description["properties"][prop_name]
68-
expected_href = thing_name + "/" + prop_name
72+
expected_href = f"/api/{thing_name}/{prop_name}"
6973
assert prop["forms"][0]["href"] == expected_href
74+
75+
76+
@pytest.mark.parametrize("api_prefix", ["/api/v3", "/v1", "/custom/prefix"])
77+
def test_api_prefix(api_prefix):
78+
"""Check we can add a prefix to the URLs on a server."""
79+
80+
class Example(lt.Thing):
81+
"""An example Thing"""
82+
83+
server = lt.ThingServer(things={"example": Example}, api_prefix=api_prefix)
84+
paths = [route.path for route in server.app.routes if isinstance(route, Route)]
85+
86+
# Dynamically generate expected paths based on the parametrized prefix
87+
expected_paths = [
88+
f"{api_prefix}/action_invocations",
89+
f"{api_prefix}/action_invocations/{{id}}",
90+
f"{api_prefix}/action_invocations/{{id}}/output",
91+
f"{api_prefix}/blob/{{blob_id}}",
92+
f"{api_prefix}/thing_descriptions/",
93+
f"{api_prefix}/things/",
94+
f"{api_prefix}/example/",
95+
]
96+
97+
for expected_path in expected_paths:
98+
assert expected_path in paths
99+
100+
prefix_with_slash = f"{api_prefix}/"
101+
unprefixed_paths = {p for p in paths if not p.startswith(prefix_with_slash)}
102+
103+
assert unprefixed_paths == {
104+
"/openapi.json",
105+
"/docs",
106+
"/docs/oauth2-redirect",
107+
"/redoc",
108+
}
109+
110+
111+
def test_things_endpoints():
112+
"""Test that the two endpoints for listing Things work."""
113+
server = lt.ThingServer(
114+
{
115+
"thing_a": MyThing,
116+
"thing_b": MyThing,
117+
}
118+
)
119+
with TestClient(server.app) as client:
120+
# Check the thing_descriptions endpoint
121+
response = client.get("/thing_descriptions/")
122+
response.raise_for_status()
123+
tds = response.json()
124+
assert "thing_a" in tds
125+
assert "thing_b" in tds
126+
127+
# Check the things endpoint. This should map names to URLs
128+
response = client.get("/things/")
129+
response.raise_for_status()
130+
things = response.json()
131+
assert "thing_a" in things
132+
assert "thing_b" in things
133+
134+
# Fetch a thing description from the URL in `things`
135+
response = client.get(things["thing_a"])
136+
response.raise_for_status()
137+
td = response.json()
138+
assert td["title"] == "MyThing"
139+
assert tds["thing_a"] == td

tests/test_server_config_model.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,16 @@ def test_ThingServerConfig():
100100
with pytest.raises(ValidationError):
101101
ThingServerConfig(things={name: MyThing})
102102

103+
# Check some good prefixes
104+
for prefix in ["", "/api", "/api/v2", "/api-v2"]:
105+
config = ThingServerConfig(things={}, api_prefix=prefix)
106+
assert config.api_prefix == prefix
107+
108+
# Check some bad prefixes
109+
for prefix in ["api", "/api/", "api/", "api/v2", "/badchars!"]:
110+
with pytest.raises(ValidationError):
111+
ThingServerConfig(things={}, api_prefix=prefix)
112+
103113

104114
def test_unimportable_modules():
105115
"""Test that unimportable modules raise errors as expected."""

tests/test_thing_client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,9 @@ def throw_value_error(self) -> None:
6363
@pytest.fixture
6464
def thing_client_and_thing():
6565
"""Yield a test client connected to a ThingServer and the Thing itself."""
66-
server = lt.ThingServer({"test_thing": ThingToTest})
66+
server = lt.ThingServer({"test_thing": ThingToTest}, api_prefix="/api/v1")
6767
with TestClient(server.app) as client:
68-
thing_client = lt.ThingClient.from_url("/test_thing/", client=client)
68+
thing_client = lt.ThingClient.from_url("/api/v1/test_thing/", client=client)
6969
thing = server.things["test_thing"]
7070
yield thing_client, thing
7171

0 commit comments

Comments
 (0)