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 target_positions: list[LeafletPoint] | None
footprint: list[LeafletPoly] | None footprint: list[LeafletPoly] | None
class Config:
title = "FrozenCombat"
@staticmethod @staticmethod
def for_combat(combat: FrozenCombat, theater: ConflictTheater) -> FrozenCombatJs: def for_combat(combat: FrozenCombat, theater: ConflictTheater) -> FrozenCombatJs:
if isinstance(combat, AirCombat): if isinstance(combat, AirCombat):

View File

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

View File

@ -1,6 +1,6 @@
from dcs import Point from dcs import Point
from dcs.mapping import LatLng 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 game import Game
from .models import ControlPointJs from .models import ControlPointJs
@ -12,14 +12,18 @@ from ...sim import GameUpdateEvents
router: APIRouter = APIRouter(prefix="/control-points") router: APIRouter = APIRouter(prefix="/control-points")
@router.get("/") @router.get(
"/", operation_id="list_control_points", response_model=list[ControlPointJs]
)
def list_control_points( def list_control_points(
game: Game = Depends(GameContext.require), game: Game = Depends(GameContext.require),
) -> list[ControlPointJs]: ) -> list[ControlPointJs]:
return ControlPointJs.all_in_game(game) 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( def get_control_point(
cp_id: int, game: Game = Depends(GameContext.require) cp_id: int, game: Game = Depends(GameContext.require)
) -> ControlPointJs: ) -> ControlPointJs:
@ -32,7 +36,11 @@ def get_control_point(
return ControlPointJs.for_control_point(cp) 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( def destination_in_range(
cp_id: int, lat: float, lng: float, game: Game = Depends(GameContext.require) cp_id: int, lat: float, lng: float, game: Game = Depends(GameContext.require)
) -> bool: ) -> bool:
@ -47,9 +55,15 @@ def destination_in_range(
return cp.destination_in_range(point) 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( 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: ) -> None:
cp = game.theater.find_control_point_by_id(cp_id) cp = game.theater.find_control_point_by_id(cp_id)
if cp is None: if cp is None:
@ -77,7 +91,11 @@ def set_destination(
EventStream.put_nowait(GameUpdateEvents().update_control_point(cp)) 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: def cancel_travel(cp_id: int, game: Game = Depends(GameContext.require)) -> None:
cp = game.theater.find_control_point_by_id(cp_id) cp = game.theater.find_control_point_by_id(cp_id)
if cp is None: if cp is None:

View File

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

View File

@ -9,19 +9,25 @@ from .models import HoldZonesJs, IpZonesJs, JoinZonesJs
router: APIRouter = APIRouter(prefix="/debug/waypoint-geometries") 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( def hold_zones(
flight_id: UUID, game: Game = Depends(GameContext.require) flight_id: UUID, game: Game = Depends(GameContext.require)
) -> HoldZonesJs: ) -> HoldZonesJs:
return HoldZonesJs.for_flight(game.db.flights.get(flight_id), game) 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: def ip_zones(flight_id: UUID, game: Game = Depends(GameContext.require)) -> IpZonesJs:
return IpZonesJs.for_flight(game.db.flights.get(flight_id), game) 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( def join_zones(
flight_id: UUID, game: Game = Depends(GameContext.require) flight_id: UUID, game: Game = Depends(GameContext.require)
) -> JoinZonesJs: ) -> JoinZonesJs:

View File

@ -22,6 +22,9 @@ class FlightJs(BaseModel):
sidc: str sidc: str
waypoints: list[FlightWaypointJs] | None waypoints: list[FlightWaypointJs] | None
class Config:
title = "Flight"
@staticmethod @staticmethod
def for_flight(flight: Flight, with_waypoints: bool) -> FlightJs: 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 # 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: APIRouter = APIRouter(prefix="/flights")
@router.get("/") @router.get("/", operation_id="list_flights", response_model=list[FlightJs])
def list_flights( def list_flights(
with_waypoints: bool = False, game: Game = Depends(GameContext.require) with_waypoints: bool = False, game: Game = Depends(GameContext.require)
) -> list[FlightJs]: ) -> list[FlightJs]:
return FlightJs.all_in_game(game, with_waypoints) 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( def get_flight(
flight_id: UUID, flight_id: UUID,
with_waypoints: bool = False, with_waypoints: bool = False,
@ -29,7 +29,11 @@ def get_flight(
return FlightJs.for_flight(flight, with_waypoints) 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( def commit_boundary(
flight_id: UUID, game: Game = Depends(GameContext.require) flight_id: UUID, game: Game = Depends(GameContext.require)
) -> LeafletPoly: ) -> LeafletPoly:

View File

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

View File

@ -9,12 +9,14 @@ from ..dependencies import GameContext
router: APIRouter = APIRouter(prefix="/front-lines") 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]: def list_front_lines(game: Game = Depends(GameContext.require)) -> list[FrontLineJs]:
return FrontLineJs.all_in_game(game) 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( def get_front_line(
front_line_id: UUID, game: Game = Depends(GameContext.require) front_line_id: UUID, game: Game = Depends(GameContext.require)
) -> FrontLineJs: ) -> FrontLineJs:

View File

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

View File

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@ from ..leaflet import ShapelyUtil
router: APIRouter = APIRouter(prefix="/map-zones") 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: def get_terrain(game: Game = Depends(GameContext.require)) -> MapZonesJs:
zones = game.theater.landmap zones = game.theater.landmap
if zones is None: 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( def get_unculled_zones(
game: Game = Depends(GameContext.require), game: Game = Depends(GameContext.require),
) -> list[UnculledZoneJs]: ) -> 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( def get_threat_zones(
game: Game = Depends(GameContext.require), game: Game = Depends(GameContext.require),
) -> ThreatZoneContainerJs: ) -> ThreatZoneContainerJs:

View File

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

View File

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

View File

@ -8,7 +8,11 @@ from ..dependencies import GameContext, QtCallbacks, QtContext
router: APIRouter = APIRouter(prefix="/qt") 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( def new_front_line_package(
front_line_id: UUID, front_line_id: UUID,
game: Game = Depends(GameContext.require), 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)) 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( def new_tgo_package(
tgo_id: UUID, tgo_id: UUID,
game: Game = Depends(GameContext.require), game: Game = Depends(GameContext.require),
@ -26,7 +34,11 @@ def new_tgo_package(
qt.create_new_package(game.db.tgos.get(tgo_id)) 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( def show_tgo_info(
tgo_id: UUID, tgo_id: UUID,
game: Game = Depends(GameContext.require), game: Game = Depends(GameContext.require),
@ -35,7 +47,11 @@ def show_tgo_info(
qt.show_tgo_info(game.db.tgos.get(tgo_id)) 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( def new_cp_package(
cp_id: int, cp_id: int,
game: Game = Depends(GameContext.require), game: Game = Depends(GameContext.require),
@ -50,7 +66,11 @@ def new_cp_package(
qt.create_new_package(cp) 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( def show_control_point_info(
cp_id: int, cp_id: int,
game: Game = Depends(GameContext.require), game: Game = Depends(GameContext.require),

View File

@ -68,6 +68,9 @@ class SupplyRouteJs(BaseModel):
blue: bool blue: bool
active_transports: list[str] active_transports: list[str]
class Config:
title = "SupplyRoute"
@staticmethod @staticmethod
def for_link( def for_link(
game: Game, a: ControlPoint, b: ControlPoint, points: list[Point], sea: bool 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: APIRouter = APIRouter(prefix="/supply-routes")
@router.get("/") @router.get("/", operation_id="list_supply_routes", response_model=list[SupplyRouteJs])
def list_supply_routes( def list_supply_routes(
game: Game = Depends(GameContext.require), game: Game = Depends(GameContext.require),
) -> list[SupplyRouteJs]: ) -> list[SupplyRouteJs]:

View File

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

View File

@ -9,11 +9,11 @@ from ..dependencies import GameContext
router: APIRouter = APIRouter(prefix="/tgos") 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]: def list_tgos(game: Game = Depends(GameContext.require)) -> list[TgoJs]:
return TgoJs.all_in_game(game) 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: def get_tgo(tgo_id: UUID, game: Game = Depends(GameContext.require)) -> TgoJs:
return TgoJs.for_tgo(game.db.tgos.get(tgo_id)) return TgoJs.for_tgo(game.db.tgos.get(tgo_id))

View File

@ -34,6 +34,9 @@ class FlightWaypointJs(BaseModel):
include_in_path: bool include_in_path: bool
timing: str timing: str
class Config:
title = "Waypoint"
@staticmethod @staticmethod
def for_waypoint( def for_waypoint(
waypoint: FlightWaypoint, flight: Flight, waypoint_idx: int 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( def all_waypoints_for_flight(
flight_id: UUID, game: Game = Depends(GameContext.require) flight_id: UUID, game: Game = Depends(GameContext.require)
) -> list[FlightWaypointJs]: ) -> list[FlightWaypointJs]:
return waypoints_for_flight(game.db.flights.get(flight_id)) 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( def set_position(
flight_id: UUID, flight_id: UUID,
waypoint_idx: int, waypoint_idx: int,