Skip to content

Commit da33bc0

Browse files
committed
refactor: add structure to api routes
1 parent 4701980 commit da33bc0

12 files changed

Lines changed: 675 additions & 656 deletions

File tree

‎src/blueapi/service/main.py‎

Lines changed: 27 additions & 631 deletions
Large diffs are not rendered by default.

‎src/blueapi/service/routes/__init__.py‎

Whitespace-only changes.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from typing import Annotated
2+
3+
from fastapi import Depends, HTTPException, status
4+
from fastapi.requests import HTTPConnection
5+
from observability_utils.tracing import get_tracer
6+
7+
from blueapi.config import ApplicationConfig
8+
from blueapi.service import interface
9+
from blueapi.service.authentication import Fedid
10+
from blueapi.service.authorization import OpaUserClient, opa
11+
from blueapi.service.model import WorkerTask
12+
from blueapi.service.runner import WorkerDispatcher
13+
14+
TRACER = get_tracer("interface")
15+
16+
RUNNER: WorkerDispatcher | None = None
17+
18+
19+
def _runner() -> WorkerDispatcher:
20+
"""Intended to be used only with FastAPI Depends"""
21+
if RUNNER is None:
22+
raise ValueError()
23+
return RUNNER
24+
25+
26+
Runner = Annotated[WorkerDispatcher, Depends(_runner)]
27+
Opa = Annotated[OpaUserClient | None, Depends(opa)]
28+
29+
30+
def setup_runner(
31+
config: ApplicationConfig | None = None,
32+
runner: WorkerDispatcher | None = None,
33+
):
34+
global RUNNER
35+
runner = runner or WorkerDispatcher(config)
36+
runner.start()
37+
38+
RUNNER = runner
39+
40+
41+
def teardown_runner():
42+
global RUNNER
43+
if RUNNER is None:
44+
return
45+
RUNNER.stop()
46+
RUNNER = None
47+
48+
49+
async def access_task_permission(
50+
opa: Opa,
51+
task_id: str,
52+
fedid: Fedid,
53+
runner: Runner,
54+
):
55+
task = runner.run(interface.get_task_by_id, task_id)
56+
57+
if (
58+
opa
59+
and not await opa.admin()
60+
and (task and fedid != task.task.metadata.get("user"))
61+
):
62+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
63+
64+
65+
# start_task_permission is used when there is WorkerTask
66+
async def start_task_permission(
67+
task: WorkerTask,
68+
opa: Opa,
69+
fedid: Fedid,
70+
runner: Runner,
71+
):
72+
if not task.task_id:
73+
raise HTTPException(
74+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
75+
detail="No task id provided",
76+
)
77+
await access_task_permission(opa, task.task_id, fedid, runner)
78+
79+
80+
def get_passthrough_headers(request: HTTPConnection) -> dict[str, str]:
81+
return {
82+
key: value
83+
for key, value in request.headers.items()
84+
if key.casefold() in ApplicationConfig.PROPAGATED_HEADERS
85+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from fastapi import APIRouter
2+
from observability_utils.tracing import start_as_current_span
3+
4+
from blueapi.config import Tag
5+
from blueapi.service import interface
6+
from blueapi.service.model import DeviceModel, DeviceResponse
7+
8+
from .dependencies import TRACER, Runner
9+
10+
router = APIRouter(tags=[Tag.DEVICE])
11+
12+
13+
@router.get("/devices")
14+
@start_as_current_span(TRACER)
15+
def get_devices(runner: Runner) -> DeviceResponse:
16+
"""Retrieve information about all available devices."""
17+
devices = runner.run(interface.get_devices)
18+
return DeviceResponse(devices=devices)
19+
20+
21+
@router.get("/devices/{name}")
22+
@start_as_current_span(TRACER, "name")
23+
def get_device_by_name(name: str, runner: Runner) -> DeviceModel:
24+
"""Retrieve information about a devices by its (unique) name."""
25+
return runner.run(interface.get_device, name)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from fastapi import APIRouter, BackgroundTasks
2+
from observability_utils.tracing import start_as_current_span
3+
4+
from blueapi.config import Tag
5+
from blueapi.service import interface
6+
from blueapi.service.model import (
7+
EnvironmentResponse,
8+
PythonEnvironmentResponse,
9+
SourceInfo,
10+
)
11+
12+
from .dependencies import TRACER, Runner
13+
14+
router = APIRouter(tags=[Tag.ENV])
15+
16+
17+
@router.get("/environment")
18+
@start_as_current_span(TRACER)
19+
def get_environment(runner: Runner) -> EnvironmentResponse:
20+
"""Get the current state of the environment, i.e. initialization state."""
21+
return runner.state
22+
23+
24+
@router.delete("/environment")
25+
async def delete_environment(
26+
background_tasks: BackgroundTasks,
27+
runner: Runner,
28+
) -> EnvironmentResponse:
29+
"""Delete the current environment, causing internal components to be reloaded."""
30+
environment_id = runner.state.environment_id
31+
if runner.state.initialized or runner.state.error_message is not None:
32+
background_tasks.add_task(runner.reload)
33+
return EnvironmentResponse(environment_id=environment_id, initialized=False)
34+
35+
36+
@router.get("/python_environment")
37+
@start_as_current_span(TRACER)
38+
def get_python_environment(
39+
runner: Runner,
40+
name: str | None = None,
41+
source: SourceInfo | None = None,
42+
) -> PythonEnvironmentResponse:
43+
"""
44+
Retrieve the Python environment details.
45+
This endpoint fetches information about the Python environment,
46+
such as the installed packages and scratch packages.
47+
"""
48+
return runner.run(interface.get_python_env, name, source)

‎src/blueapi/service/routes/meta.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import urllib.parse
2+
3+
from fastapi import APIRouter, HTTPException, Response, status
4+
from fastapi.responses import RedirectResponse
5+
from observability_utils.tracing import start_as_current_span
6+
7+
from blueapi.config import ApplicationConfig, OIDCConfig, Tag
8+
from blueapi.service import interface
9+
from blueapi.service.model import Health, HealthProbeResponse
10+
11+
from .dependencies import TRACER, Runner
12+
13+
open_router = APIRouter()
14+
router = APIRouter()
15+
16+
17+
@open_router.get("/", include_in_schema=False)
18+
def root_redirect() -> RedirectResponse:
19+
"""Redirect to docs url"""
20+
return RedirectResponse(
21+
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
22+
url=ApplicationConfig.DOCS_ENDPOINT,
23+
)
24+
25+
26+
@open_router.get(
27+
"/config/oidc",
28+
tags=[Tag.META],
29+
responses={
30+
status.HTTP_204_NO_CONTENT: {"description": "No Authentication configured"}
31+
},
32+
)
33+
@start_as_current_span(TRACER)
34+
def get_oidc_config(runner: Runner) -> OIDCConfig:
35+
"""Retrieve the OpenID Connect (OIDC) configuration for the server."""
36+
config = runner.run(interface.get_oidc_config)
37+
if config is None:
38+
raise HTTPException(status_code=status.HTTP_204_NO_CONTENT)
39+
return config
40+
41+
42+
@open_router.get("/healthz", status_code=status.HTTP_200_OK, tags=[Tag.META])
43+
def health_probe() -> HealthProbeResponse:
44+
"""If able to serve this, server is live and ready for requests."""
45+
return HealthProbeResponse(status=Health.OK)
46+
47+
48+
@router.get("/logout", include_in_schema=False)
49+
def logout(runner: Runner) -> Response:
50+
"""Redirect to logout url"""
51+
config = runner.run(interface.get_oidc_config)
52+
if config is None or not config.logout_redirect_endpoint:
53+
raise HTTPException(status_code=status.HTTP_205_RESET_CONTENT)
54+
55+
encoded_url = urllib.parse.quote_plus(config.end_session_endpoint)
56+
return RedirectResponse(
57+
status_code=status.HTTP_308_PERMANENT_REDIRECT,
58+
url=config.logout_redirect_endpoint.rstrip("/") + "?rd=" + encoded_url,
59+
)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from fastapi import APIRouter
2+
from observability_utils.tracing import start_as_current_span
3+
4+
from blueapi.config import Tag
5+
from blueapi.service import interface
6+
from blueapi.service.model import PlanModel, PlanResponse
7+
8+
from .dependencies import TRACER, Runner
9+
10+
router = APIRouter(tags=[Tag.PLAN])
11+
12+
13+
@router.get("/plans")
14+
@start_as_current_span(TRACER)
15+
def get_plans(runner: Runner) -> PlanResponse:
16+
"""Retrieve information about all available plans."""
17+
plans = runner.run(interface.get_plans)
18+
return PlanResponse(plans=plans)
19+
20+
21+
@router.get("/plans/{name}")
22+
@start_as_current_span(TRACER, "name")
23+
def get_plan_by_name(name: str, runner: Runner) -> PlanModel:
24+
"""Retrieve information about a plan by its (unique) name."""
25+
return runner.run(interface.get_plan, name)
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import logging
2+
3+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
4+
from pydantic import ValidationError
5+
from starlette.status import WS_1007_INVALID_FRAME_PAYLOAD_DATA, WS_1013_TRY_AGAIN_LATER
6+
7+
from blueapi.service import interface, protocol
8+
from blueapi.service.authentication import Fedid
9+
from blueapi.service.model import WorkerTask
10+
from blueapi.service.protocol import (
11+
InvalidArgs,
12+
PlanNotFound,
13+
ServerBusy,
14+
Submit,
15+
Unauthorized,
16+
Update,
17+
)
18+
from blueapi.worker.event import WorkerEvent
19+
from blueapi.worker.task import TaskParameterError
20+
from blueapi.worker.worker_errors import WorkerBusyError
21+
22+
from .dependencies import Opa, Runner, get_passthrough_headers
23+
24+
LOGGER = logging.getLogger(__name__)
25+
26+
router = APIRouter()
27+
28+
29+
@router.websocket("/run_plan")
30+
async def run_plan(
31+
ws: WebSocket,
32+
runner: Runner,
33+
user: Fedid,
34+
opa: Opa,
35+
):
36+
LOGGER.info("Starting WS plan as %s", user)
37+
await ws.accept()
38+
rq = await ws.receive_text()
39+
try:
40+
task_request = Submit.model_validate_json(rq)
41+
except ValidationError:
42+
LOGGER.info("Failed to deserialize request: %r", rq, exc_info=True)
43+
await ws.close(
44+
code=WS_1007_INVALID_FRAME_PAYLOAD_DATA, reason="Invalid Request"
45+
)
46+
return
47+
LOGGER.info("Plan request: %s", task_request)
48+
49+
if opa:
50+
try:
51+
await opa.can_submit_task(task_request.task)
52+
except Exception as e:
53+
LOGGER.info(
54+
"User %s does not have permission to run task", user, exc_info=e
55+
)
56+
await ws.send_text(Unauthorized().model_dump_json())
57+
await ws.close(code=protocol.AUTHZ_ERROR, reason="Unauthorized")
58+
return
59+
60+
try:
61+
task_id: str = runner.run(
62+
interface.submit_task, task_request.task, {"user": user}
63+
)
64+
LOGGER.info("Task ID: %s", task_id)
65+
except TaskParameterError as tpe:
66+
LOGGER.info("Plan args not valid: %s - %s", task_request, tpe)
67+
await ws.send_text(InvalidArgs.from_parameter_error(tpe).model_dump_json())
68+
await ws.close(code=protocol.INVALID_ARGS, reason="Invalid Args")
69+
return
70+
except KeyError as ke:
71+
LOGGER.info("Plan %r not recognised", ke.args[0])
72+
await ws.send_text(PlanNotFound(plan_name=ke.args[0]).model_dump_json())
73+
await ws.close(code=protocol.UNKNOWN_PLAN, reason="Unknown Plan")
74+
return
75+
76+
try:
77+
with runner.event_pipe() as events:
78+
active_task = runner.run(interface.get_active_task)
79+
if active_task is not None and not active_task.is_complete:
80+
raise WorkerBusyError("Task already running")
81+
runner.run(
82+
interface.begin_task,
83+
task=WorkerTask(task_id=task_id),
84+
pass_through_headers=get_passthrough_headers(ws),
85+
)
86+
async for evt in events:
87+
if evt.task_id != task_id:
88+
continue
89+
LOGGER.debug("Event: %s", evt)
90+
await ws.send_text(Update(data=evt).model_dump_json())
91+
if isinstance(evt, WorkerEvent) and evt.is_complete():
92+
LOGGER.debug("End of stream")
93+
break
94+
except WorkerBusyError:
95+
LOGGER.error("Worker was busy")
96+
await ws.send_text(ServerBusy().model_dump_json())
97+
await ws.close(code=WS_1013_TRY_AGAIN_LATER, reason="Worker busy")
98+
except WebSocketDisconnect:
99+
LOGGER.info("Client disconnected")
100+
runner.run(
101+
interface.cancel_active_task, failure=True, reason="Client disconnected"
102+
)
103+
else:
104+
LOGGER.info("Plan complete")
105+
await ws.close()

0 commit comments

Comments
 (0)