Add metadata to FastAPI endpoints for OpenAPI.

operation_ids give us better function names when generating the
typescript API from the openapi.json. BaseModel.Config.title does the
same for type names. Response models (or 204 status codes) need to be
explicit or the API will be declared as returning any.
This commit is contained in:
Dan Albert 2022-03-06 17:12:00 -08:00
parent 4053356e13
commit b7439cbd17
23 changed files with 142 additions and 30 deletions

View File

@ -18,6 +18,9 @@ class FrozenCombatJs(BaseModel):
target_positions: list[LeafletPoint] | None
footprint: list[LeafletPoly] | None
class Config:
title = "FrozenCombat"
@staticmethod
def for_combat(combat: FrozenCombat, theater: ConflictTheater) -> FrozenCombatJs:
if isinstance(combat, AirCombat):

View File

@ -20,6 +20,9 @@ class ControlPointJs(BaseModel):
destination: LeafletPoint | None
sidc: str
class Config:
title = "ControlPoint"
@staticmethod
def for_control_point(control_point: ControlPoint) -> ControlPointJs:
destination = None

View File

@ -1,6 +1,6 @@
from dcs import Point
from dcs.mapping import LatLng
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Body, Depends, HTTPException, status
from game import Game
from .models import ControlPointJs
@ -12,14 +12,18 @@ from ...sim import GameUpdateEvents
router: APIRouter = APIRouter(prefix="/control-points")
@router.get("/")
@router.get(
"/", operation_id="list_control_points", response_model=list[ControlPointJs]
)
def list_control_points(
game: Game = Depends(GameContext.require),
) -> list[ControlPointJs]:
return ControlPointJs.all_in_game(game)
@router.get("/{cp_id}")
@router.get(
"/{cp_id}", operation_id="get_control_point_by_id", response_model=ControlPointJs
)
def get_control_point(
cp_id: int, game: Game = Depends(GameContext.require)
) -> ControlPointJs:
@ -32,7 +36,11 @@ def get_control_point(
return ControlPointJs.for_control_point(cp)
@router.get("/{cp_id}/destination-in-range")
@router.get(
"/{cp_id}/destination-in-range",
operation_id="control_point_destination_in_range",
response_model=bool,
)
def destination_in_range(
cp_id: int, lat: float, lng: float, game: Game = Depends(GameContext.require)
) -> bool:
@ -47,9 +55,15 @@ def destination_in_range(
return cp.destination_in_range(point)
@router.put("/{cp_id}/destination")
@router.put(
"/{cp_id}/destination",
operation_id="set_control_point_destination",
status_code=status.HTTP_204_NO_CONTENT,
)
def set_destination(
cp_id: int, destination: LeafletPoint, game: Game = Depends(GameContext.require)
cp_id: int,
destination: LeafletPoint = Body(..., title="destination"),
game: Game = Depends(GameContext.require),
) -> None:
cp = game.theater.find_control_point_by_id(cp_id)
if cp is None:
@ -77,7 +91,11 @@ def set_destination(
EventStream.put_nowait(GameUpdateEvents().update_control_point(cp))
@router.put("/{cp_id}/cancel-travel")
@router.put(
"/{cp_id}/cancel-travel",
operation_id="clear_control_point_destination",
status_code=status.HTTP_204_NO_CONTENT,
)
def cancel_travel(cp_id: int, game: Game = Depends(GameContext.require)) -> None:
cp = game.theater.find_control_point_by_id(cp_id)
if cp is None:

View File

@ -16,6 +16,9 @@ class HoldZonesJs(BaseModel):
permissible_zones: list[LeafletPoly] = Field(alias="permissibleZones")
preferred_lines: list[LeafletPoly] = Field(alias="preferredLines")
class Config:
title = "HoldZones"
@classmethod
def empty(cls) -> HoldZonesJs:
return HoldZonesJs(
@ -62,6 +65,9 @@ class IpZonesJs(BaseModel):
permissibleZone: LeafletPoly = Field(alias="permissibleZone")
safeZones: list[LeafletPoly] = Field(alias="safeZones")
class Config:
title = "IpZones"
@classmethod
def empty(cls) -> IpZonesJs:
return IpZonesJs(homeBubble=[], ipBubble=[], permissibleZone=[], safeZones=[])
@ -89,6 +95,9 @@ class JoinZonesJs(BaseModel):
permissible_zones: list[LeafletPoly] = Field(alias="permissibleZones")
preferred_lines: list[LeafletPoly] = Field(alias="preferredLines")
class Config:
title = "JoinZones"
@classmethod
def empty(cls) -> JoinZonesJs:
return JoinZonesJs(

View File

@ -9,19 +9,25 @@ from .models import HoldZonesJs, IpZonesJs, JoinZonesJs
router: APIRouter = APIRouter(prefix="/debug/waypoint-geometries")
@router.get("/hold/{flight_id}")
@router.get(
"/hold/{flight_id}", operation_id="get_debug_hold_zones", response_model=HoldZonesJs
)
def hold_zones(
flight_id: UUID, game: Game = Depends(GameContext.require)
) -> HoldZonesJs:
return HoldZonesJs.for_flight(game.db.flights.get(flight_id), game)
@router.get("/ip/{flight_id}")
@router.get(
"/ip/{flight_id}", operation_id="get_debug_ip_zones", response_model=IpZonesJs
)
def ip_zones(flight_id: UUID, game: Game = Depends(GameContext.require)) -> IpZonesJs:
return IpZonesJs.for_flight(game.db.flights.get(flight_id), game)
@router.get("/join/{flight_id}")
@router.get(
"/join/{flight_id}", operation_id="get_debug_join_zones", response_model=JoinZonesJs
)
def join_zones(
flight_id: UUID, game: Game = Depends(GameContext.require)
) -> JoinZonesJs:

View File

@ -22,6 +22,9 @@ class FlightJs(BaseModel):
sidc: str
waypoints: list[FlightWaypointJs] | None
class Config:
title = "Flight"
@staticmethod
def for_flight(flight: Flight, with_waypoints: bool) -> FlightJs:
# Don't provide a location for aircraft that aren't in the air. Later we can

View File

@ -12,14 +12,14 @@ from game.server.leaflet import LeafletPoly, ShapelyUtil
router: APIRouter = APIRouter(prefix="/flights")
@router.get("/")
@router.get("/", operation_id="list_flights", response_model=list[FlightJs])
def list_flights(
with_waypoints: bool = False, game: Game = Depends(GameContext.require)
) -> list[FlightJs]:
return FlightJs.all_in_game(game, with_waypoints)
@router.get("/{flight_id}")
@router.get("/{flight_id}", operation_id="get_flight_by_id", response_model=FlightJs)
def get_flight(
flight_id: UUID,
with_waypoints: bool = False,
@ -29,7 +29,11 @@ def get_flight(
return FlightJs.for_flight(flight, with_waypoints)
@router.get("/{flight_id}/commit-boundary")
@router.get(
"/{flight_id}/commit-boundary",
operation_id="get_commit_boundary_for_flight",
response_model=LeafletPoly,
)
def commit_boundary(
flight_id: UUID, game: Game = Depends(GameContext.require)
) -> LeafletPoly:

View File

@ -17,6 +17,9 @@ class FrontLineJs(BaseModel):
id: UUID
extents: list[LeafletPoint]
class Config:
title = "FrontLine"
@staticmethod
def for_front_line(front_line: FrontLine) -> FrontLineJs:
a = front_line.position.point_from_heading(

View File

@ -9,12 +9,14 @@ from ..dependencies import GameContext
router: APIRouter = APIRouter(prefix="/front-lines")
@router.get("/")
@router.get("/", operation_id="list_front_lines", response_model=list[FrontLineJs])
def list_front_lines(game: Game = Depends(GameContext.require)) -> list[FrontLineJs]:
return FrontLineJs.all_in_game(game)
@router.get("/{front_line_id}")
@router.get(
"/{front_line_id}", operation_id="get_front_line_by_id", response_model=FrontLineJs
)
def get_front_line(
front_line_id: UUID, game: Game = Depends(GameContext.require)
) -> FrontLineJs:

View File

@ -23,6 +23,9 @@ class GameJs(BaseModel):
flights: list[FlightJs]
map_center: LeafletPoint
class Config:
title = "Game"
@staticmethod
def from_game(game: Game) -> GameJs:
return GameJs(

View File

@ -7,7 +7,7 @@ from .models import GameJs
router: APIRouter = APIRouter(prefix="/game")
@router.get("/")
@router.get("/", operation_id="get_game_state", response_model=GameJs)
def game_state(game: Game | None = Depends(GameContext.get)) -> GameJs | None:
if game is None:
return None

View File

@ -19,6 +19,8 @@ class LeafletPoint(BaseModel):
class Config:
orm_mode = True
title = "LatLng"
class ShapelyUtil:
@staticmethod

View File

@ -12,11 +12,17 @@ class MapZonesJs(BaseModel):
exclusion: list[LeafletPoly]
sea: list[LeafletPoly]
class Config:
title = "MapZones"
class UnculledZoneJs(BaseModel):
position: LeafletPoint
radius: float
class Config:
title = "UnculledZone"
class ThreatZonesJs(BaseModel):
full: list[LeafletPoly]
@ -24,6 +30,9 @@ class ThreatZonesJs(BaseModel):
air_defenses: list[LeafletPoly]
radar_sams: list[LeafletPoly]
class Config:
title = "ThreatZones"
@classmethod
def from_zones(cls, zones: ThreatZones, theater: ConflictTheater) -> ThreatZonesJs:
return ThreatZonesJs(
@ -37,3 +46,6 @@ class ThreatZonesJs(BaseModel):
class ThreatZoneContainerJs(BaseModel):
blue: ThreatZonesJs
red: ThreatZonesJs
class Config:
title = "ThreatZoneContainer"

View File

@ -8,7 +8,7 @@ from ..leaflet import ShapelyUtil
router: APIRouter = APIRouter(prefix="/map-zones")
@router.get("/terrain")
@router.get("/terrain", operation_id="get_terrain_zones", response_model=MapZonesJs)
def get_terrain(game: Game = Depends(GameContext.require)) -> MapZonesJs:
zones = game.theater.landmap
if zones is None:
@ -21,7 +21,9 @@ def get_terrain(game: Game = Depends(GameContext.require)) -> MapZonesJs:
)
@router.get("/unculled")
@router.get(
"/unculled", operation_id="list_unculled_zones", response_model=list[UnculledZoneJs]
)
def get_unculled_zones(
game: Game = Depends(GameContext.require),
) -> list[UnculledZoneJs]:
@ -33,7 +35,9 @@ def get_unculled_zones(
]
@router.get("/threats")
@router.get(
"/threats", operation_id="get_threat_zones", response_model=ThreatZoneContainerJs
)
def get_threat_zones(
game: Game = Depends(GameContext.require),
) -> ThreatZoneContainerJs:

View File

@ -8,3 +8,6 @@ from game.server.leaflet import LeafletPoly
class NavMeshPolyJs(BaseModel):
poly: LeafletPoly
threatened: bool
class Config:
title = "NavMeshPoly"

View File

@ -8,7 +8,7 @@ from ..leaflet import ShapelyUtil
router: APIRouter = APIRouter(prefix="/navmesh")
@router.get("/", response_model=list[NavMeshPolyJs])
@router.get("/", operation_id="get_navmesh", response_model=list[NavMeshPolyJs])
def get(
for_player: bool, game: Game = Depends(GameContext.require)
) -> list[NavMeshPolyJs]:

View File

@ -8,7 +8,11 @@ from ..dependencies import GameContext, QtCallbacks, QtContext
router: APIRouter = APIRouter(prefix="/qt")
@router.post("/create-package/front-line/{front_line_id}")
@router.post(
"/create-package/front-line/{front_line_id}",
operation_id="open_new_front_line_package_dialog",
status_code=status.HTTP_204_NO_CONTENT,
)
def new_front_line_package(
front_line_id: UUID,
game: Game = Depends(GameContext.require),
@ -17,7 +21,11 @@ def new_front_line_package(
qt.create_new_package(game.db.front_lines.get(front_line_id))
@router.post("/create-package/tgo/{tgo_id}")
@router.post(
"/create-package/tgo/{tgo_id}",
operation_id="open_new_tgo_package_dialog",
status_code=status.HTTP_204_NO_CONTENT,
)
def new_tgo_package(
tgo_id: UUID,
game: Game = Depends(GameContext.require),
@ -26,7 +34,11 @@ def new_tgo_package(
qt.create_new_package(game.db.tgos.get(tgo_id))
@router.post("/info/tgo/{tgo_id}")
@router.post(
"/info/tgo/{tgo_id}",
operation_id="open_tgo_info_dialog",
status_code=status.HTTP_204_NO_CONTENT,
)
def show_tgo_info(
tgo_id: UUID,
game: Game = Depends(GameContext.require),
@ -35,7 +47,11 @@ def show_tgo_info(
qt.show_tgo_info(game.db.tgos.get(tgo_id))
@router.post("/create-package/control-point/{cp_id}")
@router.post(
"/create-package/control-point/{cp_id}",
operation_id="open_new_control_point_package_dialog",
status_code=status.HTTP_204_NO_CONTENT,
)
def new_cp_package(
cp_id: int,
game: Game = Depends(GameContext.require),
@ -50,7 +66,11 @@ def new_cp_package(
qt.create_new_package(cp)
@router.post("/info/control-point/{cp_id}")
@router.post(
"/info/control-point/{cp_id}",
operation_id="open_control_point_info_dialog",
status_code=status.HTTP_204_NO_CONTENT,
)
def show_control_point_info(
cp_id: int,
game: Game = Depends(GameContext.require),

View File

@ -68,6 +68,9 @@ class SupplyRouteJs(BaseModel):
blue: bool
active_transports: list[str]
class Config:
title = "SupplyRoute"
@staticmethod
def for_link(
game: Game, a: ControlPoint, b: ControlPoint, points: list[Point], sea: bool

View File

@ -7,7 +7,7 @@ from ..dependencies import GameContext
router: APIRouter = APIRouter(prefix="/supply-routes")
@router.get("/")
@router.get("/", operation_id="list_supply_routes", response_model=list[SupplyRouteJs])
def list_supply_routes(
game: Game = Depends(GameContext.require),
) -> list[SupplyRouteJs]:

View File

@ -25,6 +25,9 @@ class TgoJs(BaseModel):
dead: bool # TODO: Event stream
sidc: str # TODO: Event stream
class Config:
title = "Tgo"
@staticmethod
def for_tgo(tgo: TheaterGroundObject) -> TgoJs:
if not tgo.might_have_aa:

View File

@ -9,11 +9,11 @@ from ..dependencies import GameContext
router: APIRouter = APIRouter(prefix="/tgos")
@router.get("/")
@router.get("/", operation_id="list_tgos", response_model=list[TgoJs])
def list_tgos(game: Game = Depends(GameContext.require)) -> list[TgoJs]:
return TgoJs.all_in_game(game)
@router.get("/{tgo_id}")
@router.get("/{tgo_id}", operation_id="get_tgo_by_id", response_model=TgoJs)
def get_tgo(tgo_id: UUID, game: Game = Depends(GameContext.require)) -> TgoJs:
return TgoJs.for_tgo(game.db.tgos.get(tgo_id))

View File

@ -34,6 +34,9 @@ class FlightWaypointJs(BaseModel):
include_in_path: bool
timing: str
class Config:
title = "Waypoint"
@staticmethod
def for_waypoint(
waypoint: FlightWaypoint, flight: Flight, waypoint_idx: int

View File

@ -34,14 +34,22 @@ def waypoints_for_flight(flight: Flight) -> list[FlightWaypointJs]:
]
@router.get("/{flight_id}", response_model=list[FlightWaypointJs])
@router.get(
"/{flight_id}",
operation_id="list_all_waypoints_for_flight",
response_model=list[FlightWaypointJs],
)
def all_waypoints_for_flight(
flight_id: UUID, game: Game = Depends(GameContext.require)
) -> list[FlightWaypointJs]:
return waypoints_for_flight(game.db.flights.get(flight_id))
@router.post("/{flight_id}/{waypoint_idx}/position")
@router.post(
"/{flight_id}/{waypoint_idx}/position",
operation_id="set_waypoint_position",
status_code=status.HTTP_204_NO_CONTENT,
)
def set_position(
flight_id: UUID,
waypoint_idx: int,