Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
dill
python-dotenv
python-multipart
fastapi
uvloop
pydantic
Expand Down
83 changes: 77 additions & 6 deletions src/controllers/flight.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@
ControllerBase,
controller_exception_handler,
)
from src.views.flight import FlightSimulation, FlightCreated
from src.views.flight import FlightSimulation, FlightCreated, FlightImported
from src.models.flight import (
FlightModel,
FlightWithReferencesRequest,
)
from src.models.environment import EnvironmentModel
from src.models.motor import MotorModel
from src.models.rocket import RocketModel
from src.repositories.interface import RepositoryInterface
from src.services.flight import FlightService
Expand All @@ -22,6 +23,7 @@ class FlightController(ControllerBase):
Enables:
- Simulation of a RocketPy Flight.
- CRUD for Flight BaseApiModel.
- Import/export as portable .rpy files and Jupyter notebooks.
"""

def __init__(self):
Expand Down Expand Up @@ -122,25 +124,26 @@ async def update_rocket_by_flight_id(
return

@controller_exception_handler
async def get_rocketpy_flight_binary(
async def get_rocketpy_flight_rpy(
self,
flight_id: str,
) -> bytes:
"""
Get rocketpy.flight as dill binary.
Get rocketpy.flight as a portable ``.rpy`` JSON file.

Args:
flight_id: str

Returns:
bytes
bytes (UTF-8 encoded JSON)

Raises:
HTTP 404 Not Found: If the flight is not found in the database.
HTTP 404 Not Found: If the flight is not found
in the database.
"""
flight = await self.get_flight_by_id(flight_id)
flight_service = FlightService.from_flight_model(flight.flight)
return flight_service.get_flight_binary()
return flight_service.get_flight_rpy()

@controller_exception_handler
async def get_flight_simulation(
Expand All @@ -162,3 +165,71 @@ async def get_flight_simulation(
flight = await self.get_flight_by_id(flight_id)
flight_service = FlightService.from_flight_model(flight.flight)
return flight_service.get_flight_simulation()

async def _persist_model(self, model_cls, model_instance) -> str:
repo_cls = RepositoryInterface.get_model_repo(model_cls)
async with repo_cls() as repo:
creator = getattr(repo, f"create_{model_cls.NAME}")
return await creator(model_instance)

@controller_exception_handler
async def import_flight_from_rpy(
self,
content: bytes,
) -> FlightImported:
"""
Import a ``.rpy`` JSON file: decompose the RocketPy Flight
into Environment, Motor, Rocket and Flight models, persist
each one via the normal CRUD pipeline, and return all IDs.

Args:
content: raw bytes of a ``.rpy`` JSON file.

Returns:
FlightImported with environment_id, motor_id,
rocket_id, and flight_id.

Raises:
HTTP 422: If the file is not a valid ``.rpy`` Flight.
"""
try:
flight_service = FlightService.from_rpy(content)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid .rpy file: {exc}",
) from exc

env, motor, rocket, flight = flight_service.extract_models()

env_id = await self._persist_model(EnvironmentModel, env)
motor_id = await self._persist_model(MotorModel, motor)
rocket_id = await self._persist_model(RocketModel, rocket)
flight_id = await self._persist_model(FlightModel, flight)

return FlightImported(
flight_id=flight_id,
rocket_id=rocket_id,
motor_id=motor_id,
environment_id=env_id,
)

@controller_exception_handler
async def get_flight_notebook(
self,
flight_id: str,
) -> dict:
"""
Generate a Jupyter notebook for a persisted flight.

Args:
flight_id: str

Returns:
dict representing a valid .ipynb.

Raises:
HTTP 404 Not Found: If the flight does not exist.
"""
await self.get_flight_by_id(flight_id)
return FlightService.generate_notebook(flight_id)
20 changes: 13 additions & 7 deletions src/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@
from src.controllers.environment import EnvironmentController
from src.controllers.flight import FlightController


@cache
def get_rocket_controller() -> RocketController:
"""
Provides a singleton RocketController instance.

The controller is stateless and can be safely reused across requests.
Using functools.cache memoizes this function so a single instance is reused per process; it does not by itself guarantee thread-safe initialization in multi-threaded setups.

Returns:
RocketController: Shared controller instance for rocket operations.
"""
Expand All @@ -26,7 +27,7 @@ def get_rocket_controller() -> RocketController:
def get_motor_controller() -> MotorController:
"""
Provides a singleton MotorController instance.

Returns:
MotorController: Shared controller instance for motor operations.
"""
Expand All @@ -37,7 +38,7 @@ def get_motor_controller() -> MotorController:
def get_environment_controller() -> EnvironmentController:
"""
Provides a singleton EnvironmentController instance.

Returns:
EnvironmentController: Shared controller instance for environment operations.
"""
Expand All @@ -48,15 +49,20 @@ def get_environment_controller() -> EnvironmentController:
def get_flight_controller() -> FlightController:
"""
Provides a singleton FlightController instance.

Returns:
FlightController: Shared controller instance for flight operations.
"""
return FlightController()

RocketControllerDep = Annotated[RocketController, Depends(get_rocket_controller)]

RocketControllerDep = Annotated[
RocketController, Depends(get_rocket_controller)
]
MotorControllerDep = Annotated[MotorController, Depends(get_motor_controller)]
EnvironmentControllerDep = Annotated[
EnvironmentController, Depends(get_environment_controller)
]
FlightControllerDep = Annotated[FlightController, Depends(get_flight_controller)]
FlightControllerDep = Annotated[
FlightController, Depends(get_flight_controller)
]
114 changes: 102 additions & 12 deletions src/routes/flight.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,23 @@
Flight routes with dependency injection for improved performance.
"""

from fastapi import APIRouter, Response
import json

from fastapi import (
APIRouter,
File,
HTTPException,
Response,
UploadFile,
status,
)
from opentelemetry import trace

from src.views.flight import (
FlightSimulation,
FlightCreated,
FlightRetrieved,
FlightImported,
)
from src.models.environment import EnvironmentModel
from src.models.flight import FlightModel, FlightWithReferencesRequest
Expand All @@ -27,6 +37,8 @@

tracer = trace.get_tracer(__name__)

MAX_RPY_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB


@router.post("/", status_code=201)
async def create_flight(
Expand Down Expand Up @@ -76,6 +88,7 @@ async def read_flight(
with tracer.start_as_current_span("read_flight"):
return await controller.get_flight_by_id(flight_id)


@router.put("/{flight_id}", status_code=204)
async def update_flight(
flight_id: str,
Expand Down Expand Up @@ -117,6 +130,7 @@ async def update_flight_from_references(
flight_id, payload
)


@router.delete("/{flight_id}", status_code=204)
async def delete_flight(
flight_id: str,
Expand All @@ -136,34 +150,109 @@ async def delete_flight(
"/{flight_id}/rocketpy",
responses={
200: {
"description": "Binary file download",
"content": {"application/octet-stream": {}},
"description": "Portable .rpy JSON file download",
"content": {"application/json": {}},
}
},
status_code=200,
response_class=Response,
)
async def get_rocketpy_flight_rpy(
flight_id: str,
controller: FlightControllerDep,
):
"""
Export a rocketpy Flight as a portable ``.rpy`` JSON file.

The ``.rpy`` format is architecture-, OS-, and
Python-version-agnostic.

## Args
``` flight_id: str ```
"""
with tracer.start_as_current_span("get_rocketpy_flight_rpy"):
headers = {
'Content-Disposition': (
'attachment; filename=' f'"rocketpy_flight_{flight_id}.rpy"'
),
}
rpy = await controller.get_rocketpy_flight_rpy(flight_id)
return Response(
content=rpy,
headers=headers,
media_type="application/json",
status_code=200,
)


@router.post(
"/upload",
status_code=201,
responses={
201: {"description": "Flight imported from .rpy file"},
422: {"description": "Invalid .rpy file"},
},
Comment on lines +191 to +194
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add HTTP 413 to the route response schema.

The handler returns 413 on oversized uploads, but the decorator’s responses map doesn’t declare it.

💡 Proposed fix
 `@router.post`(
     "/upload",
     status_code=201,
     responses={
+        413: {"description": "Uploaded .rpy file exceeds 10 MB limit"},
     },
 )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/flight.py` around lines 191 - 194, The route decorator's responses
map currently lists 201 and 422 but omits HTTP 413 which the handler returns for
oversized uploads; update the decorator's responses dict (the same one shown
with keys 201 and 422) to include a 413 entry (e.g. 413: {"description":
"Uploaded file too large" or "Payload too large"}) so the OpenAPI/schema matches
the handler behavior. Ensure the new 413 entry is added alongside the existing
201 and 422 keys in the responses mapping in src/routes/flight.py.

)
async def import_flight_from_rpy(
file: UploadFile = File(...),
controller: FlightControllerDep = None, # noqa: B008
) -> FlightImported:
"""
Upload a ``.rpy`` JSON file containing a RocketPy Flight.

The file is deserialized and decomposed into its
constituent objects (Environment, Motor, Rocket, Flight).
Each object is persisted as a normal JSON model and the
corresponding IDs are returned. Maximum upload size is 10 MB.

## Args
``` file: .rpy JSON upload ```
"""
with tracer.start_as_current_span("import_flight_from_rpy"):
content = await file.read(MAX_RPY_UPLOAD_BYTES + 1)
if len(content) > MAX_RPY_UPLOAD_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="Uploaded .rpy file exceeds 10 MB limit.",
)
return await controller.import_flight_from_rpy(content)


async def get_rocketpy_flight_binary(
@router.get(
"/{flight_id}/notebook",
responses={
200: {
"description": "Jupyter notebook file download",
"content": {"application/x-ipynb+json": {}},
}
},
status_code=200,
response_class=Response,
)
async def get_flight_notebook(
flight_id: str,
controller: FlightControllerDep,
):
"""
Loads rocketpy.flight as a dill binary.
Currently only amd64 architecture is supported.
Export a flight as a Jupyter notebook (.ipynb).

The notebook loads the flight's ``.rpy`` file and calls
``flight.all_info()`` for interactive exploration.

## Args
``` flight_id: str ```
"""
with tracer.start_as_current_span("get_rocketpy_flight_binary"):
with tracer.start_as_current_span("get_flight_notebook"):
notebook = await controller.get_flight_notebook(flight_id)
content = json.dumps(notebook, indent=1).encode()
filename = f"flight_{flight_id}.ipynb"
headers = {
'Content-Disposition': f'attachment; filename="rocketpy_flight_{flight_id}.dill"'
"Content-Disposition": (f'attachment; filename="{filename}"'),
}
binary = await controller.get_rocketpy_flight_binary(flight_id)
return Response(
content=binary,
content=content,
headers=headers,
media_type="application/octet-stream",
media_type="application/x-ipynb+json",
status_code=200,
)

Expand Down Expand Up @@ -210,6 +299,7 @@ async def update_flight_rocket(
rocket=rocket,
)


@router.get("/{flight_id}/simulate")
async def get_flight_simulation(
flight_id: str,
Expand All @@ -222,4 +312,4 @@ async def get_flight_simulation(
``` flight_id: Flight ID ```
"""
with tracer.start_as_current_span("get_flight_simulation"):
return await controller.get_flight_simulation(flight_id)
return await controller.get_flight_simulation(flight_id)
4 changes: 4 additions & 0 deletions src/routes/rocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ async def create_rocket(
"""
with tracer.start_as_current_span("create_rocket"):
return await controller.post_rocket(rocket)


@router.post("/from-motor-reference", status_code=201)
async def create_rocket_from_motor_reference(
payload: RocketWithMotorReferenceRequest,
Expand Down Expand Up @@ -114,6 +116,8 @@ async def update_rocket_from_motor_reference(
return await controller.update_rocket_from_motor_reference(
rocket_id, payload
)


@router.delete("/{rocket_id}", status_code=204)
async def delete_rocket(
rocket_id: str,
Expand Down
Loading