Skip to content

Commit 60b4fe4

Browse files
Improve api granularity to empower MCP server (#63)
1 parent c7bb348 commit 60b4fe4

File tree

12 files changed

+802
-40
lines changed

12 files changed

+802
-40
lines changed

.github/workflows/pylint.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,4 @@ jobs:
2121
pip install -r requirements.txt
2222
- name: Analysing the code with pylint
2323
run: |
24-
pylint -d C0415,C0200,C0301,C0114,R0903,C0115,W0246,R0914,C0209,E1121,C0103,C2801,R0801,E1101,E0401,E0611,R0911,C0116,W0212,W0719,W0601,W1203,W0123,W0511,W0621,R0913,R0917 $(git ls-files '*.py')
24+
pylint -d R0912,C0415,C0200,C0301,C0114,R0903,C0115,W0246,R0914,C0209,E1121,C0103,C2801,R0801,E1101,E0401,E0611,R0911,C0116,W0212,W0719,W0601,W1203,W0123,W0511,W0621,R0913,R0917 $(git ls-files '*.py')

src/controllers/flight.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
1+
from fastapi import HTTPException, status
2+
13
from src.controllers.interface import (
24
ControllerBase,
35
controller_exception_handler,
46
)
5-
from src.views.flight import FlightSimulation
6-
from src.models.flight import FlightModel
7+
from src.views.flight import FlightSimulation, FlightCreated
8+
from src.models.flight import (
9+
FlightModel,
10+
FlightWithReferencesRequest,
11+
)
712
from src.models.environment import EnvironmentModel
813
from src.models.rocket import RocketModel
14+
from src.repositories.interface import RepositoryInterface
915
from src.services.flight import FlightService
1016

1117

@@ -21,6 +27,56 @@ class FlightController(ControllerBase):
2127
def __init__(self):
2228
super().__init__(models=[FlightModel])
2329

30+
async def _load_environment(self, environment_id: str) -> EnvironmentModel:
31+
repo_cls = RepositoryInterface.get_model_repo(EnvironmentModel)
32+
async with repo_cls() as repo:
33+
environment = await repo.read_environment_by_id(environment_id)
34+
if environment is None:
35+
raise HTTPException(
36+
status_code=status.HTTP_404_NOT_FOUND,
37+
detail="Environment not found",
38+
)
39+
return environment
40+
41+
async def _load_rocket(self, rocket_id: str) -> RocketModel:
42+
repo_cls = RepositoryInterface.get_model_repo(RocketModel)
43+
async with repo_cls() as repo:
44+
rocket = await repo.read_rocket_by_id(rocket_id)
45+
if rocket is None:
46+
raise HTTPException(
47+
status_code=status.HTTP_404_NOT_FOUND,
48+
detail="Rocket not found",
49+
)
50+
return rocket
51+
52+
@controller_exception_handler
53+
async def create_flight_from_references(
54+
self, payload: FlightWithReferencesRequest
55+
) -> FlightCreated:
56+
environment = await self._load_environment(payload.environment_id)
57+
rocket = await self._load_rocket(payload.rocket_id)
58+
flight_model = payload.flight.assemble(
59+
environment=environment,
60+
rocket=rocket,
61+
)
62+
return await self.post_flight(flight_model)
63+
64+
@controller_exception_handler
65+
async def update_flight_from_references(
66+
self,
67+
flight_id: str,
68+
payload: FlightWithReferencesRequest,
69+
) -> None:
70+
environment = await self._load_environment(payload.environment_id)
71+
rocket = await self._load_rocket(payload.rocket_id)
72+
flight_model = payload.flight.assemble(
73+
environment=environment,
74+
rocket=rocket,
75+
)
76+
flight_model.set_id(flight_id)
77+
await self.put_flight_by_id(flight_id, flight_model)
78+
return
79+
2480
@controller_exception_handler
2581
async def update_environment_by_flight_id(
2682
self, flight_id: str, *, environment: EnvironmentModel

src/controllers/rocket.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1+
from fastapi import HTTPException, status
2+
13
from src.controllers.interface import (
24
ControllerBase,
35
controller_exception_handler,
46
)
5-
from src.views.rocket import RocketSimulation
6-
from src.models.rocket import RocketModel
7+
from src.views.rocket import RocketSimulation, RocketCreated
8+
from src.models.motor import MotorModel
9+
from src.models.rocket import (
10+
RocketModel,
11+
RocketWithMotorReferenceRequest,
12+
)
13+
from src.repositories.interface import RepositoryInterface
714
from src.services.rocket import RocketService
815

916

@@ -19,6 +26,37 @@ class RocketController(ControllerBase):
1926
def __init__(self):
2027
super().__init__(models=[RocketModel])
2128

29+
async def _load_motor(self, motor_id: str) -> MotorModel:
30+
repo_cls = RepositoryInterface.get_model_repo(MotorModel)
31+
async with repo_cls() as repo:
32+
motor = await repo.read_motor_by_id(motor_id)
33+
if motor is None:
34+
raise HTTPException(
35+
status_code=status.HTTP_404_NOT_FOUND,
36+
detail="Motor not found",
37+
)
38+
return motor
39+
40+
@controller_exception_handler
41+
async def create_rocket_from_motor_reference(
42+
self, payload: RocketWithMotorReferenceRequest
43+
) -> RocketCreated:
44+
motor = await self._load_motor(payload.motor_id)
45+
rocket_model = payload.rocket.assemble(motor)
46+
return await self.post_rocket(rocket_model)
47+
48+
@controller_exception_handler
49+
async def update_rocket_from_motor_reference(
50+
self,
51+
rocket_id: str,
52+
payload: RocketWithMotorReferenceRequest,
53+
) -> None:
54+
motor = await self._load_motor(payload.motor_id)
55+
rocket_model = payload.rocket.assemble(motor)
56+
rocket_model.set_id(rocket_id)
57+
await self.put_rocket_by_id(rocket_id, rocket_model)
58+
return
59+
2260
@controller_exception_handler
2361
async def get_rocketpy_rocket_binary(self, rocket_id: str) -> bytes:
2462
"""

src/models/flight.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import json
12
from typing import Optional, Self, ClassVar, Literal
3+
4+
from pydantic import BaseModel, Field, field_validator
25
from src.models.interface import ApiBaseModel
36
from src.models.rocket import RocketModel
47
from src.models.environment import EnvironmentModel
@@ -69,3 +72,76 @@ def RETRIEVED(model_instance: type(Self)):
6972
**model_instance.model_dump(),
7073
)
7174
)
75+
76+
@field_validator('environment', mode='before')
77+
@classmethod
78+
def _coerce_environment(cls, value):
79+
if isinstance(value, str):
80+
try:
81+
return json.loads(value)
82+
except json.JSONDecodeError as exc:
83+
raise ValueError(
84+
'Invalid JSON for environment payload'
85+
) from exc
86+
return value
87+
88+
@field_validator('rocket', mode='before')
89+
@classmethod
90+
def _coerce_rocket(cls, value):
91+
if isinstance(value, str):
92+
try:
93+
return json.loads(value)
94+
except json.JSONDecodeError as exc:
95+
raise ValueError('Invalid JSON for rocket payload') from exc
96+
return value
97+
98+
99+
class FlightPartialModel(BaseModel):
100+
"""Flight attributes required when rocket/environment are referenced."""
101+
102+
name: str = Field(default="flight")
103+
rail_length: float = 1
104+
time_overshoot: bool = True
105+
terminate_on_apogee: bool = False
106+
equations_of_motion: Literal['standard', 'solid_propulsion'] = 'standard'
107+
inclination: float = 90.0
108+
heading: float = 0.0
109+
max_time: Optional[int] = None
110+
max_time_step: Optional[float] = None
111+
min_time_step: Optional[int] = None
112+
rtol: Optional[float] = None
113+
atol: Optional[float] = None
114+
verbose: Optional[bool] = None
115+
116+
def assemble(
117+
self,
118+
*,
119+
environment: EnvironmentModel,
120+
rocket: RocketModel,
121+
) -> FlightModel:
122+
"""Compose a full flight model using referenced resources."""
123+
124+
flight_data = self.model_dump(exclude_none=True)
125+
return FlightModel(
126+
environment=environment,
127+
rocket=rocket,
128+
**flight_data,
129+
)
130+
131+
132+
class FlightWithReferencesRequest(BaseModel):
133+
"""Payload for creating or updating flights via component references."""
134+
135+
environment_id: str
136+
rocket_id: str
137+
flight: FlightPartialModel
138+
139+
@field_validator('flight', mode='before')
140+
@classmethod
141+
def _coerce_flight(cls, value):
142+
if isinstance(value, str):
143+
try:
144+
value = json.loads(value)
145+
except json.JSONDecodeError as exc:
146+
raise ValueError('Invalid JSON for flight payload') from exc
147+
return value

src/models/motor.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import json
12
from enum import Enum
23
from typing import Optional, Tuple, List, Union, Self, ClassVar, Literal
3-
from pydantic import model_validator
4+
from pydantic import model_validator, field_validator
45

56
from src.models.interface import ApiBaseModel
67
from src.models.sub.tanks import MotorTank
@@ -57,6 +58,18 @@ class MotorModel(ApiBaseModel):
5758
] = 'nozzle_to_combustion_chamber'
5859
reshape_thrust_curve: Union[bool, tuple] = False
5960

61+
@field_validator('tanks', mode='before')
62+
@classmethod
63+
def _coerce_tanks(cls, value):
64+
if isinstance(value, str):
65+
try:
66+
value = json.loads(value)
67+
except json.JSONDecodeError as exc:
68+
raise ValueError('Invalid JSON for tanks payload') from exc
69+
if isinstance(value, dict):
70+
value = [value]
71+
return value
72+
6073
@model_validator(mode='after')
6174
# TODO: extend guard to check motor kinds and tank kinds specifics
6275
def validate_motor_kind(self):

src/models/rocket.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import json
12
from typing import Optional, Tuple, List, Union, Self, ClassVar, Literal
3+
4+
from pydantic import BaseModel, Field, field_validator
25
from src.models.interface import ApiBaseModel
36
from src.models.motor import MotorModel
47
from src.models.sub.aerosurfaces import (
@@ -10,6 +13,15 @@
1013
)
1114

1215

16+
def _maybe_parse_json(value):
17+
if isinstance(value, str):
18+
try:
19+
return json.loads(value)
20+
except json.JSONDecodeError as exc:
21+
raise ValueError('Invalid JSON payload') from exc
22+
return value
23+
24+
1325
class RocketModel(ApiBaseModel):
1426
NAME: ClassVar = "rocket"
1527
METHODS: ClassVar = ("POST", "GET", "PUT", "DELETE")
@@ -37,6 +49,42 @@ class RocketModel(ApiBaseModel):
3749
rail_buttons: Optional[RailButtons] = None
3850
tail: Optional[Tail] = None
3951

52+
@field_validator('motor', mode='before')
53+
@classmethod
54+
def _coerce_motor(cls, value):
55+
return _maybe_parse_json(value)
56+
57+
@field_validator('nose', mode='before')
58+
@classmethod
59+
def _coerce_nose(cls, value):
60+
return _maybe_parse_json(value)
61+
62+
@field_validator('fins', mode='before')
63+
@classmethod
64+
def _coerce_fins(cls, value):
65+
value = _maybe_parse_json(value)
66+
if isinstance(value, dict):
67+
value = [value]
68+
return value
69+
70+
@field_validator('parachutes', mode='before')
71+
@classmethod
72+
def _coerce_parachutes(cls, value):
73+
value = _maybe_parse_json(value)
74+
if isinstance(value, dict):
75+
value = [value]
76+
return value
77+
78+
@field_validator('rail_buttons', mode='before')
79+
@classmethod
80+
def _coerce_rail_buttons(cls, value):
81+
return _maybe_parse_json(value)
82+
83+
@field_validator('tail', mode='before')
84+
@classmethod
85+
def _coerce_tail(cls, value):
86+
return _maybe_parse_json(value)
87+
4088
@staticmethod
4189
def UPDATED():
4290
return
@@ -61,3 +109,53 @@ def RETRIEVED(model_instance: type(Self)):
61109
**model_instance.model_dump(),
62110
)
63111
)
112+
113+
114+
class RocketPartialModel(BaseModel):
115+
"""Rocket attributes required when a motor is supplied by reference."""
116+
117+
radius: float
118+
mass: float
119+
motor_position: float
120+
center_of_mass_without_motor: float
121+
inertia: Union[
122+
Tuple[float, float, float],
123+
Tuple[float, float, float, float, float, float],
124+
] = (0, 0, 0)
125+
power_off_drag: List[Tuple[float, float]] = Field(
126+
default_factory=lambda: [(0, 0)]
127+
)
128+
power_on_drag: List[Tuple[float, float]] = Field(
129+
default_factory=lambda: [(0, 0)]
130+
)
131+
coordinate_system_orientation: Literal['tail_to_nose', 'nose_to_tail'] = (
132+
'tail_to_nose'
133+
)
134+
nose: NoseCone
135+
fins: List[Fins]
136+
parachutes: Optional[List[Parachute]] = None
137+
rail_buttons: Optional[RailButtons] = None
138+
tail: Optional[Tail] = None
139+
140+
def assemble(self, motor: MotorModel) -> RocketModel:
141+
"""Compose a full rocket model using the referenced motor."""
142+
143+
rocket_data = self.model_dump(exclude_none=True)
144+
return RocketModel(motor=motor, **rocket_data)
145+
146+
147+
class RocketWithMotorReferenceRequest(BaseModel):
148+
"""Payload for creating or updating rockets via motor reference."""
149+
150+
motor_id: str
151+
rocket: RocketPartialModel
152+
153+
@field_validator('rocket', mode='before')
154+
@classmethod
155+
def _coerce_rocket(cls, value):
156+
if isinstance(value, str):
157+
try:
158+
value = json.loads(value)
159+
except json.JSONDecodeError as exc:
160+
raise ValueError('Invalid JSON for rocket payload') from exc
161+
return value

0 commit comments

Comments
 (0)