diff --git a/robot-server/robot_server/app_setup.py b/robot-server/robot_server/app_setup.py index 2a4d8b1f3da..265a6270033 100644 --- a/robot-server/robot_server/app_setup.py +++ b/robot-server/robot_server/app_setup.py @@ -114,7 +114,7 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]: ) # main router -app.include_router(router=router) +router.install_on_app(app) def _get_persistence_directory(settings: RobotServerSettings) -> Optional[Path]: diff --git a/robot-server/robot_server/client_data/router.py b/robot-server/robot_server/client_data/router.py index 4a2850cb8c1..847c5da59a9 100644 --- a/robot-server/robot_server/client_data/router.py +++ b/robot-server/robot_server/client_data/router.py @@ -4,6 +4,7 @@ from typing import Annotated, Literal import fastapi +from server_utils.fastapi_utils.light_router import LightRouter from robot_server.client_data.store import ( ClientData, @@ -18,7 +19,7 @@ get_client_data_publisher, ) -router = fastapi.APIRouter() +router = LightRouter() Key = Annotated[ diff --git a/robot-server/robot_server/commands/router.py b/robot-server/robot_server/commands/router.py index 5d99f3e04f7..4d23bb73fbe 100644 --- a/robot-server/robot_server/commands/router.py +++ b/robot-server/robot_server/commands/router.py @@ -1,7 +1,8 @@ """Router for top-level /commands endpoints.""" from typing import Annotated, Final, List, Literal, Optional, cast -from fastapi import APIRouter, Depends, Query, status +from fastapi import Depends, Query, status +from server_utils.fastapi_utils.light_router import LightRouter from opentrons.protocol_engine import CommandIntent from opentrons.protocol_engine.errors import CommandDoesNotExistError @@ -25,7 +26,7 @@ _DEFAULT_COMMAND_LIST_LENGTH: Final = 20 -commands_router = APIRouter() +commands_router = LightRouter() class CommandNotFound(ErrorDetails): diff --git a/robot-server/robot_server/data_files/router.py b/robot-server/robot_server/data_files/router.py index f9c61afb77a..ba5cac2f7fd 100644 --- a/robot-server/robot_server/data_files/router.py +++ b/robot-server/robot_server/data_files/router.py @@ -4,8 +4,9 @@ from textwrap import dedent from typing import Annotated, Optional, Literal, Union -from fastapi import APIRouter, UploadFile, File, Form, Depends, Response, status +from fastapi import UploadFile, File, Form, Depends, Response, status from opentrons.protocol_reader import FileHasher, FileReaderWriter +from server_utils.fastapi_utils.light_router import LightRouter from robot_server.service.json_api import ( SimpleBody, @@ -32,7 +33,7 @@ from ..protocols.dependencies import get_file_hasher, get_file_reader_writer from ..service.dependencies import get_current_time, get_unique_id -datafiles_router = APIRouter() +datafiles_router = LightRouter() class MultipleDataFileSources(ErrorDetails): diff --git a/robot-server/robot_server/deck_configuration/router.py b/robot-server/robot_server/deck_configuration/router.py index 77ad38a3975..ce80acafb2a 100644 --- a/robot-server/robot_server/deck_configuration/router.py +++ b/robot-server/robot_server/deck_configuration/router.py @@ -6,6 +6,7 @@ import fastapi from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY +from server_utils.fastapi_utils.light_router import LightRouter from opentrons_shared_data.deck.types import DeckDefinitionV5 @@ -21,7 +22,7 @@ from .store import DeckConfigurationStore -router = fastapi.APIRouter() +router = LightRouter() @PydanticResponse.wrap_route( diff --git a/robot-server/robot_server/error_recovery/settings/router.py b/robot-server/robot_server/error_recovery/settings/router.py index 27dde185f0b..d3c4467a301 100644 --- a/robot-server/robot_server/error_recovery/settings/router.py +++ b/robot-server/robot_server/error_recovery/settings/router.py @@ -4,13 +4,14 @@ from typing import Annotated import fastapi +from server_utils.fastapi_utils.light_router import LightRouter from robot_server.service.json_api import PydanticResponse, RequestModel, SimpleBody from .models import RequestData, ResponseData from .store import ErrorRecoverySettingStore, get_error_recovery_setting_store -router = fastapi.APIRouter() +router = LightRouter() _PATH = "/errorRecovery/settings" diff --git a/robot-server/robot_server/instruments/router.py b/robot-server/robot_server/instruments/router.py index e059876eb37..d0cc4a212ed 100644 --- a/robot-server/robot_server/instruments/router.py +++ b/robot-server/robot_server/instruments/router.py @@ -1,7 +1,8 @@ """Instruments routes.""" from typing import Annotated, Optional, Dict, List, cast -from fastapi import APIRouter, status, Depends +from fastapi import status, Depends +from server_utils.fastapi_utils.light_router import LightRouter from opentrons.hardware_control.instruments.ot3.instrument_calibration import ( PipetteOffsetSummary, @@ -50,7 +51,7 @@ from opentrons.hardware_control import OT3HardwareControlAPI -instruments_router = APIRouter() +instruments_router = LightRouter() def _pipette_dict_to_pipette_res( diff --git a/robot-server/robot_server/labware_offsets/router.py b/robot-server/robot_server/labware_offsets/router.py index 241f5b31505..6aca03e4b18 100644 --- a/robot-server/robot_server/labware_offsets/router.py +++ b/robot-server/robot_server/labware_offsets/router.py @@ -6,6 +6,8 @@ from typing import Annotated, Literal import fastapi +from server_utils.fastapi_utils.light_router import LightRouter + from opentrons.protocol_engine import LabwareOffset, LabwareOffsetCreate, ModuleModel from opentrons.types import DeckSlotName @@ -24,12 +26,12 @@ from .fastapi_dependencies import get_labware_offset_store -router = fastapi.APIRouter(prefix="/labwareOffsets") +router = LightRouter() @PydanticResponse.wrap_route( router.post, - path="", + path="/labwareOffsets", summary="Store a labware offset", description=textwrap.dedent( """\ @@ -63,7 +65,7 @@ async def post_labware_offset( # noqa: D103 @PydanticResponse.wrap_route( router.get, - path="", + path="/labwareOffsets", summary="Search for labware offsets", description=( "Get a filtered list of all the labware offsets currently stored on the robot." @@ -158,7 +160,7 @@ async def get_labware_offsets( # noqa: D103 @PydanticResponse.wrap_route( router.delete, - path="/{id}", + path="/labwareOffsets/{id}", summary="Delete a single labware offset", description="Delete a single labware offset. The deleted offset is returned.", ) @@ -181,7 +183,7 @@ async def delete_labware_offset( # noqa: D103 @PydanticResponse.wrap_route( router.delete, - path="", + path="/labwareOffsets", summary="Delete all labware offsets", ) async def delete_all_labware_offsets( # noqa: D103 diff --git a/robot-server/robot_server/maintenance_runs/router/__init__.py b/robot-server/robot_server/maintenance_runs/router/__init__.py index 11cdd30950c..b017a0a6adf 100644 --- a/robot-server/robot_server/maintenance_runs/router/__init__.py +++ b/robot-server/robot_server/maintenance_runs/router/__init__.py @@ -1,12 +1,12 @@ """Maintenance Runs router.""" -from fastapi import APIRouter +from server_utils.fastapi_utils.light_router import LightRouter from .base_router import base_router from .commands_router import commands_router from .labware_router import labware_router -maintenance_runs_router = APIRouter() +maintenance_runs_router = LightRouter() maintenance_runs_router.include_router(base_router) maintenance_runs_router.include_router(commands_router) diff --git a/robot-server/robot_server/maintenance_runs/router/base_router.py b/robot-server/robot_server/maintenance_runs/router/base_router.py index 6f6abaf89b5..3dd2060c2cd 100644 --- a/robot-server/robot_server/maintenance_runs/router/base_router.py +++ b/robot-server/robot_server/maintenance_runs/router/base_router.py @@ -8,8 +8,9 @@ from typing import Annotated, Optional, Callable from typing_extensions import Literal -from fastapi import APIRouter, Depends, status +from fastapi import Depends, status from pydantic import BaseModel, Field +from server_utils.fastapi_utils.light_router import LightRouter from robot_server.errors.error_responses import ErrorDetails, ErrorBody from robot_server.service.dependencies import get_current_time, get_unique_id @@ -42,7 +43,7 @@ from robot_server.service.notifications import get_pe_notify_publishers log = logging.getLogger(__name__) -base_router = APIRouter() +base_router = LightRouter() # TODO (spp, 2023-04-10): move all error types from maintenance & regular runs diff --git a/robot-server/robot_server/maintenance_runs/router/commands_router.py b/robot-server/robot_server/maintenance_runs/router/commands_router.py index 6e043006ec3..0bb3d64c192 100644 --- a/robot-server/robot_server/maintenance_runs/router/commands_router.py +++ b/robot-server/robot_server/maintenance_runs/router/commands_router.py @@ -3,7 +3,8 @@ from typing import Annotated, Optional, Union from typing_extensions import Final, Literal -from fastapi import APIRouter, Depends, Query, status +from fastapi import Depends, Query, status +from server_utils.fastapi_utils.light_router import LightRouter from opentrons.protocol_engine import ( CommandPointer, @@ -39,7 +40,7 @@ _DEFAULT_COMMAND_LIST_LENGTH: Final = 20 -commands_router = APIRouter() +commands_router = LightRouter() class CommandNotFound(ErrorDetails): diff --git a/robot-server/robot_server/maintenance_runs/router/labware_router.py b/robot-server/robot_server/maintenance_runs/router/labware_router.py index 938cee77af7..100fafe910a 100644 --- a/robot-server/robot_server/maintenance_runs/router/labware_router.py +++ b/robot-server/robot_server/maintenance_runs/router/labware_router.py @@ -1,7 +1,9 @@ """Router for /maintenance_runs endpoints dealing with labware offsets and definitions.""" from typing import Annotated import logging -from fastapi import APIRouter, Depends, status + +from fastapi import Depends, status +from server_utils.fastapi_utils.light_router import LightRouter from opentrons.protocol_engine import LabwareOffsetCreate, LabwareOffset from opentrons.protocols.models import LabwareDefinition @@ -15,7 +17,7 @@ from .base_router import RunNotFound, RunNotIdle, get_run_data_from_url log = logging.getLogger(__name__) -labware_router = APIRouter() +labware_router = LightRouter() @PydanticResponse.wrap_route( diff --git a/robot-server/robot_server/modules/router.py b/robot-server/robot_server/modules/router.py index 2f566eb7207..8bbf8a6ae7a 100644 --- a/robot-server/robot_server/modules/router.py +++ b/robot-server/robot_server/modules/router.py @@ -1,7 +1,9 @@ """Modules routes.""" -from fastapi import APIRouter, Depends, status from typing import Annotated, List, Dict +from fastapi import Depends, status +from server_utils.fastapi_utils.light_router import LightRouter + from opentrons.hardware_control import HardwareControlAPI from opentrons.hardware_control.modules import module_calibration from opentrons.protocol_engine.types import Vec3f @@ -21,7 +23,7 @@ from .module_identifier import ModuleIdentifier from .module_data_mapper import ModuleDataMapper -modules_router = APIRouter() +modules_router = LightRouter() @PydanticResponse.wrap_route( diff --git a/robot-server/robot_server/protocols/router.py b/robot-server/robot_server/protocols/router.py index 28294f99c7d..574f9621004 100644 --- a/robot-server/robot_server/protocols/router.py +++ b/robot-server/robot_server/protocols/router.py @@ -15,7 +15,6 @@ from opentrons.util.performance_helpers import TrackingFunctions from fastapi import ( - APIRouter, Depends, File, HTTPException, @@ -26,6 +25,7 @@ ) from fastapi.responses import PlainTextResponse from pydantic import BaseModel, Field +from server_utils.fastapi_utils.light_router import LightRouter from opentrons.protocol_reader import ( ProtocolReader, @@ -151,7 +151,7 @@ class ProtocolLinks(BaseModel): ) -protocols_router = APIRouter() +protocols_router = LightRouter() @PydanticResponse.wrap_route( diff --git a/robot-server/robot_server/robot/control/router.py b/robot-server/robot_server/robot/control/router.py index a39e7fcab7e..037ef1dc96a 100644 --- a/robot-server/robot_server/robot/control/router.py +++ b/robot-server/robot_server/robot/control/router.py @@ -1,7 +1,9 @@ """Router for /robot/control endpoints.""" -from fastapi import APIRouter, status, Depends from typing import Annotated, TYPE_CHECKING +from fastapi import status, Depends +from server_utils.fastapi_utils.light_router import LightRouter + from opentrons_shared_data.robot.types import RobotType from opentrons_shared_data.robot.types import RobotTypeEnum from robot_server.hardware import get_robot_type @@ -22,7 +24,7 @@ if TYPE_CHECKING: from opentrons.hardware_control.ot3api import OT3API # noqa: F401 -control_router = APIRouter() +control_router = LightRouter() async def _get_estop_status_response( diff --git a/robot-server/robot_server/robot/router.py b/robot-server/robot_server/robot/router.py index 9fff861d4a6..6c6dd6acc05 100644 --- a/robot-server/robot_server/robot/router.py +++ b/robot-server/robot_server/robot/router.py @@ -1,8 +1,8 @@ """Router for /robot endpoints.""" -from fastapi import APIRouter +from server_utils.fastapi_utils.light_router import LightRouter from .control.router import control_router -robot_router = APIRouter() +robot_router = LightRouter() robot_router.include_router(router=control_router) diff --git a/robot-server/robot_server/router.py b/robot-server/robot_server/router.py index 49d835d7eb9..ab5af067637 100644 --- a/robot-server/robot_server/router.py +++ b/robot-server/robot_server/router.py @@ -1,5 +1,7 @@ """Application routes.""" -from fastapi import APIRouter, Depends, status +from fastapi import Depends, status +from server_utils.fastapi_utils.light_router import LightRouter + from .constants import V1_TAG from .errors.error_responses import LegacyErrorResponse @@ -26,24 +28,31 @@ from .subsystems.router import subsystems_router from .system.router import system_router -router = APIRouter() +router = LightRouter() # Legacy routes router.include_router( router=legacy_routes, tags=[V1_TAG], + # todo(mm, 2024-12-19): This `responses` setting is preventing us from + # porting legacy_routes from fastapi.APIRouter to our LightRouter. + # Either teach LightRouter how to handle `responses` or stop doing + # a custom 422 response on these endpoints. responses={ status.HTTP_422_UNPROCESSABLE_ENTITY: { "model": LegacyErrorResponse, } }, ) - router.include_router( router=health_router, tags=["Health", V1_TAG], dependencies=[Depends(check_version_header)], responses={ + # todo(mm, 2024-12-19): This `responses` setting is preventing us from + # porting health_router from fastapi.APIRouter to our LightRouter. + # Either teach LightRouter how to handle `responses` or stop doing + # a custom 422 response on these endpoints. status.HTTP_422_UNPROCESSABLE_ENTITY: { "model": LegacyErrorResponse, } diff --git a/robot-server/robot_server/runs/router/__init__.py b/robot-server/robot_server/runs/router/__init__.py index d7a1640edb8..fa8be0cee67 100644 --- a/robot-server/robot_server/runs/router/__init__.py +++ b/robot-server/robot_server/runs/router/__init__.py @@ -1,5 +1,5 @@ """Runs router.""" -from fastapi import APIRouter +from server_utils.fastapi_utils.light_router import LightRouter from .base_router import base_router from .commands_router import commands_router @@ -7,7 +7,7 @@ from .labware_router import labware_router from .error_recovery_policy_router import error_recovery_policy_router -runs_router = APIRouter() +runs_router = LightRouter() runs_router.include_router(base_router) runs_router.include_router(commands_router) diff --git a/robot-server/robot_server/runs/router/actions_router.py b/robot-server/robot_server/runs/router/actions_router.py index 80a461f3b59..0bfb16185c7 100644 --- a/robot-server/robot_server/runs/router/actions_router.py +++ b/robot-server/robot_server/runs/router/actions_router.py @@ -1,10 +1,12 @@ """Router for /runs actions endpoints.""" import logging -from fastapi import APIRouter, Depends, status from datetime import datetime from typing import Annotated, Literal, Union +from fastapi import Depends, status +from server_utils.fastapi_utils.light_router import LightRouter + from robot_server.errors.error_responses import ErrorDetails, ErrorBody from robot_server.service.dependencies import get_current_time, get_unique_id from robot_server.service.json_api import RequestModel, SimpleBody, PydanticResponse @@ -37,7 +39,7 @@ ) log = logging.getLogger(__name__) -actions_router = APIRouter() +actions_router = LightRouter() class RunActionNotAllowed(ErrorDetails): diff --git a/robot-server/robot_server/runs/router/base_router.py b/robot-server/robot_server/runs/router/base_router.py index c51c02de1e4..8e478c08de9 100644 --- a/robot-server/robot_server/runs/router/base_router.py +++ b/robot-server/robot_server/runs/router/base_router.py @@ -8,8 +8,10 @@ from textwrap import dedent from typing import Annotated, Callable, Final, Literal, Optional, Union -from fastapi import APIRouter, Depends, status, Query +from fastapi import Depends, status, Query from pydantic import BaseModel, Field +from server_utils.fastapi_utils.light_router import LightRouter + from opentrons_shared_data.errors import ErrorCodes from opentrons_shared_data.robot.types import RobotTypeEnum @@ -87,7 +89,7 @@ from robot_server.service.notifications import get_pe_notify_publishers log = logging.getLogger(__name__) -base_router = APIRouter() +base_router = LightRouter() _DEFAULT_COMMAND_ERROR_LIST_LENGTH: Final = 20 diff --git a/robot-server/robot_server/runs/router/commands_router.py b/robot-server/robot_server/runs/router/commands_router.py index 55e1826d5e8..b2cbb79b2bb 100644 --- a/robot-server/robot_server/runs/router/commands_router.py +++ b/robot-server/robot_server/runs/router/commands_router.py @@ -2,7 +2,8 @@ import textwrap from typing import Annotated, Final, Literal, Optional, Union -from fastapi import APIRouter, Depends, Query, status +from fastapi import Depends, Query, status +from server_utils.fastapi_utils.light_router import LightRouter from opentrons.protocol_engine import ( CommandPointer, @@ -44,7 +45,7 @@ _DEFAULT_COMMAND_LIST_LENGTH: Final = 20 -commands_router = APIRouter() +commands_router = LightRouter() class CommandNotFound(ErrorDetails): diff --git a/robot-server/robot_server/runs/router/error_recovery_policy_router.py b/robot-server/robot_server/runs/router/error_recovery_policy_router.py index a5c3ae0543d..2b1c786b9d4 100644 --- a/robot-server/robot_server/runs/router/error_recovery_policy_router.py +++ b/robot-server/robot_server/runs/router/error_recovery_policy_router.py @@ -4,7 +4,8 @@ from textwrap import dedent from typing import Annotated -from fastapi import status, APIRouter, Depends +from fastapi import status, Depends +from server_utils.fastapi_utils.light_router import LightRouter from robot_server.errors.error_responses import ErrorBody from robot_server.service.json_api.request import RequestModel @@ -20,7 +21,7 @@ from ..error_recovery_models import ErrorRecoveryPolicy -error_recovery_policy_router = APIRouter() +error_recovery_policy_router = LightRouter() @PydanticResponse.wrap_route( diff --git a/robot-server/robot_server/runs/router/labware_router.py b/robot-server/robot_server/runs/router/labware_router.py index 2a0396b3b86..06c16f8b71a 100644 --- a/robot-server/robot_server/runs/router/labware_router.py +++ b/robot-server/robot_server/runs/router/labware_router.py @@ -3,12 +3,14 @@ import logging from typing import Annotated, Union -from fastapi import APIRouter, Depends, status +from fastapi import Depends, status from opentrons_shared_data.labware.labware_definition import ( LabwareDefinition as SD_LabwareDefinition, ) +from server_utils.fastapi_utils.light_router import LightRouter + from opentrons.protocol_engine import LabwareOffsetCreate, LabwareOffset from opentrons.protocols.models import LabwareDefinition @@ -26,7 +28,7 @@ from .base_router import RunNotFound, RunStopped, RunNotIdle, get_run_data_from_url log = logging.getLogger(__name__) -labware_router = APIRouter() +labware_router = LightRouter() @PydanticResponse.wrap_route( diff --git a/robot-server/robot_server/service/labware/router.py b/robot-server/robot_server/service/labware/router.py index a90ed1ca867..6d44f2faeee 100644 --- a/robot-server/robot_server/service/labware/router.py +++ b/robot-server/robot_server/service/labware/router.py @@ -6,7 +6,8 @@ from typing import Annotated, Optional from typing_extensions import Literal, NoReturn -from fastapi import APIRouter, Depends, status +from fastapi import Depends, status +from server_utils.fastapi_utils.light_router import LightRouter from opentrons_shared_data.errors import ErrorCodes from robot_server.errors.error_responses import ErrorDetails, ErrorBody @@ -15,7 +16,7 @@ from robot_server.service.errors import RobotServerError, CommonErrorDef -router = APIRouter() +router = LightRouter() class LabwareCalibrationEndpointsRemoved(ErrorDetails): diff --git a/robot-server/robot_server/subsystems/router.py b/robot-server/robot_server/subsystems/router.py index 0ce265cc7e6..950cf0505e2 100644 --- a/robot-server/robot_server/subsystems/router.py +++ b/robot-server/robot_server/subsystems/router.py @@ -3,9 +3,11 @@ from datetime import datetime from typing import Annotated, Optional, TYPE_CHECKING -from fastapi import APIRouter, status, Depends, Response, Request +from fastapi import status, Depends, Response, Request from typing_extensions import Literal +from server_utils.fastapi_utils.light_router import LightRouter + from robot_server.service.json_api import ( SimpleMultiBody, PydanticResponse, @@ -45,7 +47,7 @@ if TYPE_CHECKING: from opentrons.hardware_control.ot3api import OT3API # noqa: F401 -subsystems_router = APIRouter() +subsystems_router = LightRouter() def status_route_for(subsystem: SubSystem) -> str: diff --git a/robot-server/robot_server/system/router.py b/robot-server/robot_server/system/router.py index fab9b598ba3..2735dfca01d 100644 --- a/robot-server/robot_server/system/router.py +++ b/robot-server/robot_server/system/router.py @@ -5,7 +5,8 @@ - /system/time: allows the client to read & update robot system time """ from datetime import datetime -from fastapi import APIRouter + +from server_utils.fastapi_utils.light_router import LightRouter from robot_server.service.json_api.resource_links import ResourceLinkKey, ResourceLink @@ -13,7 +14,7 @@ from .time_utils import get_system_time, set_system_time -system_router = APIRouter() +system_router = LightRouter() """Router for /system endpoints.""" diff --git a/server-utils/server_utils/fastapi_utils/light_router.py b/server-utils/server_utils/fastapi_utils/light_router.py new file mode 100644 index 00000000000..82d82893151 --- /dev/null +++ b/server-utils/server_utils/fastapi_utils/light_router.py @@ -0,0 +1,306 @@ +"""See the `LightRouter` class.""" + +from __future__ import annotations + +import dataclasses +import enum +import typing +import typing_extensions + +import fastapi + + +_FASTAPI_ROUTE_METHOD_NAMES = { + "get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace", +} + +if typing.TYPE_CHECKING: + # This is some chicanery so that @router.get(...), @router.post(...), etc. give us + # type-checking and autocomplete that exactly match the regular FastAPI version. + # These methods have a lot of parameters with complicated types and it would be + # a bear to manually keep them in sync with FastAPI. + + _P = typing.ParamSpec("_P") + _ReturnT = typing.TypeVar("_ReturnT") + + # `_CallableLike(FastAPI.foo)` produces a callable with the same signature + # as `FastAPI.foo()`. + class _CallableLike(typing.Generic[_P, _ReturnT]): + def __init__( + self, + method_to_mimic: typing.Callable[ + typing.Concatenate[ + fastapi.FastAPI, # The `self` parameter, which we throw away. + _P, # The actual args and kwargs, which we preserve. + ], + _ReturnT, + ], + ) -> None: + raise NotImplementedError("This is only for type-checking, not runtime.") + + def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _ReturnT: + raise NotImplementedError("This is only for type-checking, not runtime.") + + class _FastAPIRouteMethods: + get: typing.Final = _CallableLike(fastapi.FastAPI.get) + put: typing.Final = _CallableLike(fastapi.FastAPI.put) + post: typing.Final = _CallableLike(fastapi.FastAPI.post) + delete: typing.Final = _CallableLike(fastapi.FastAPI.delete) + options: typing.Final = _CallableLike(fastapi.FastAPI.options) + head: typing.Final = _CallableLike(fastapi.FastAPI.head) + patch: typing.Final = _CallableLike(fastapi.FastAPI.patch) + trace: typing.Final = _CallableLike(fastapi.FastAPI.trace) + +else: + + class _FastAPIRouteMethods: + pass + + +class LightRouter(_FastAPIRouteMethods): + """A lightweight drop-in replacement for `fastapi.APIRouter`. + + Use it like `fastapi.APIRouter`: + + foo_router = LightRouter() + + @router.get("/foo/{id}") + def get_health(id: str) -> Response: + ... + + bar_router = ... + + root_router = LightRouter() + root_router.include_router(foo_router) + root_router.include_router(bar_router) + + app = fastapi.FastAPI() + root_router.install_on_app(app) + + Rationale: + + With FastAPI's standard `FastAPI` and `APIRouter` classes, the `.include_router()` + method has a lot of overhead, accounting for something like 30-40% of + robot-server's startup time, which is multiple minutes long at the time of writing. + (https://github.com/pydantic/pydantic/issues/6768#issuecomment-1644532429) + + We could avoid the overhead by adding endpoints directly to the top-level FastAPI + app, "flat," instead of using `.include_router()`. But that would be bad for code + organization; we want to keep our tree of sub-routers. So this class reimplements + the important parts of `fastapi.APIRouter`, so we can keep our router tree, but + in a lighter-weight way. + + When you call `@router.get()` or `router.include_router()` on this class, it appends + to a lightweight internal structure and completely avoids slow calls into FastAPI. + Later on, when you do `router.install_on_app()`, everything in the tree is added to + the FastAPI app. + """ + + def __init__(self) -> None: + self._routes: list[_Endpoint | _IncludedRouter] = [] + + def __getattr__(self, name: str) -> object: + """Supply the optimized version of `@router.get()`, `@router.post()`, etc. + + See the FastAPI docs for usage details. + """ + if name in _FASTAPI_ROUTE_METHOD_NAMES: + return _EndpointCaptor(method_name=name, on_capture=self._routes.append) + else: + raise AttributeError(name=name) + + def include_router( + self, + router: LightRouter | fastapi.APIRouter, + **kwargs: typing_extensions.Unpack[_RouterIncludeKwargs], + ) -> None: + """The optimized version of `fastapi.APIRouter.include_router()`. + + See the FastAPI docs for argument details. + """ # noqa: D402 + self._routes.append(_IncludedRouter(router=router, inclusion_kwargs=kwargs)) + + def install_on_app( + self, + app: fastapi.FastAPI, + **kwargs: typing_extensions.Unpack[_RouterIncludeKwargs], + ) -> None: + """The optimized version of `fastapi.FastAPI.include_router()`. + + See the FastAPI docs for argument details.. + """ + for route in self._routes: + if isinstance(route, _IncludedRouter): + router = route.router + combined_kwargs = _merge_kwargs(kwargs, route.inclusion_kwargs) + if isinstance(router, fastapi.APIRouter): + app.include_router(router, **combined_kwargs) + elif isinstance(route.router, LightRouter): + router.install_on_app(app, **combined_kwargs) + else: + typing_extensions.assert_type(route, _Endpoint) + combined_kwargs = _merge_kwargs( + kwargs, + route.kwargs, # type: ignore[arg-type] + ) + fastapi_method = getattr(app, route.method_name) + fastapi_decorator = fastapi_method(*route.args, **combined_kwargs) + fastapi_decorator(route.function) + + +class _RouterIncludeKwargs(typing.TypedDict): + """The keyword arguments of FastAPI's `.include_router()` method. + + (At least the arguments that we actually use, anyway.) + """ + + # Arguments with defaults should be annotated as `NotRequired`. + # For example, `foo: str | None = None` becomes `NotRequired[str | None]`. + + tags: typing_extensions.NotRequired[list[str | enum.Enum] | None] + responses: typing_extensions.NotRequired[ + dict[int | str, dict[str, typing.Any]] | None + ] + dependencies: typing_extensions.NotRequired[ + typing.Sequence[ + # FastAPI does not publicly expose the type of the result of a + # Depends(...) call, so this needs to be Any. + typing.Any + ] + | None + ] + + +def _merge_kwargs( + from_parent: _RouterIncludeKwargs, from_child: _RouterIncludeKwargs +) -> _RouterIncludeKwargs: + """Merge kwargs from different levels of a FastAPI router tree. + + FastAPI keyword arguments can be specified at multiple levels in the router tree. + For example, the top-level router, subrouters, and finally the endpoint function + can each specify their own `tags`. The different levels need to be merged + carefully and in argument-specific ways if we want to match FastAPI behavior. + For example, the final `tags` value should be the concatenation of the values + from all levels. + """ + merge_result: _RouterIncludeKwargs = {} + remaining_from_parent = from_parent.copy() + remaining_from_child = from_child.copy() + + # When we know how to merge a specific argument's values, do so. + # This takes care to leave the argument unset if it's unset in both parent and + # child, in order to leave the defaulting up to FastAPI. + if "tags" in remaining_from_parent or "tags" in remaining_from_child: + merge_result["tags"] = [ + *(remaining_from_parent.get("tags") or []), + *(remaining_from_child.get("tags") or []), + ] + remaining_from_parent.pop("tags", None) + remaining_from_child.pop("tags", None) + + # For any argument whose values we don't know how to merge, we can just pass it + # along opaquely, as long as the parent and child aren't both trying to set it. + # + # If the parent and child *are* both trying to set it, then we have a problem. + # It would likely be wrong to arbitrarily choose one to override the other, + # so we can only raise an error. + colliding_keys = set(remaining_from_parent.keys()).intersection( + remaining_from_child.keys() + ) + if not colliding_keys: + merge_result.update(remaining_from_parent) + merge_result.update(remaining_from_child) + else: + a_collisions: dict[object, object] = { + k: remaining_from_parent[k] for k in colliding_keys # type: ignore[literal-required] + } + b_collisions: dict[object, object] = { + k: remaining_from_child[k] for k in colliding_keys # type: ignore[literal-required] + } + raise NotImplementedError( + f"These FastAPI keyword arguments appear at different levels " + f"in the router tree, and we don't know how to merge their values:\n" + f"{a_collisions}\n{b_collisions}\n" + f"Modify {__name__} to handle the merge, or avoid the problem by " + f"setting the argument at only one level of the router tree." + ) + + return merge_result + + +@dataclasses.dataclass +class _IncludedRouter: + router: fastapi.APIRouter | LightRouter + inclusion_kwargs: _RouterIncludeKwargs + + +_DecoratedFunctionT = typing.TypeVar( + "_DecoratedFunctionT", bound=typing.Callable[..., object] +) + + +class _EndpointCaptor: + """A callable that pretends to be a FastAPI path operation decorator. + + `method_name` is the FastAPI method to pretend to be, e.g. "get" or "post". + + Supposing you have an `_EndpointCaptor` named `get`, when this whole enchilada + happens: + + @get("/foo/{id}", description="blah blah") + def get_some_endpoint(id: str) -> Response: + ... + + Then information about the whole enchilada is sent to the `on_capture` callback. + """ + + def __init__( + self, + method_name: str, + on_capture: typing.Callable[[_Endpoint], None], + ) -> None: + self._method_name = method_name + self._on_capture = on_capture + + def __call__( + self, *fastapi_decorator_args: object, **fastapi_decorator_kwargs: object + ) -> typing.Callable[[_DecoratedFunctionT], _DecoratedFunctionT]: + def decorate(decorated_function: _DecoratedFunctionT) -> _DecoratedFunctionT: + self._on_capture( + _Endpoint( + method_name=self._method_name, + args=fastapi_decorator_args, + kwargs=fastapi_decorator_kwargs, + function=decorated_function, + ) + ) + return decorated_function + + return decorate + + +@dataclasses.dataclass +class _Endpoint: + """Information about an endpoint that's been added to a router.""" + + method_name: str + """The name of the method on the FastAPI class, e.g. "get".""" + + args: tuple[object, ...] + """The positional arguments passed to the FastAPI method, e.g. the URL path.""" + + kwargs: dict[str, object] + """The keyword arguments passed to the FastAPI method, e.g. `description`.""" + + function: typing.Callable[..., object] + """The function actually implementing the logic of the endpoint. + + (The "path operation function", in FastAPI terms.) + """