|
2 | 2 | Flight routes with dependency injection for improved performance. |
3 | 3 | """ |
4 | 4 |
|
5 | | -from fastapi import APIRouter, Response |
| 5 | +import json |
| 6 | + |
| 7 | +from fastapi import ( |
| 8 | + APIRouter, |
| 9 | + File, |
| 10 | + HTTPException, |
| 11 | + Response, |
| 12 | + UploadFile, |
| 13 | + status, |
| 14 | +) |
6 | 15 | from opentelemetry import trace |
7 | 16 |
|
8 | 17 | from src.views.flight import ( |
9 | 18 | FlightSimulation, |
10 | 19 | FlightCreated, |
11 | 20 | FlightRetrieved, |
| 21 | + FlightImported, |
12 | 22 | ) |
13 | 23 | from src.models.environment import EnvironmentModel |
14 | 24 | from src.models.flight import FlightModel, FlightWithReferencesRequest |
|
27 | 37 |
|
28 | 38 | tracer = trace.get_tracer(__name__) |
29 | 39 |
|
| 40 | +MAX_RPY_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB |
| 41 | + |
30 | 42 |
|
31 | 43 | @router.post("/", status_code=201) |
32 | 44 | async def create_flight( |
@@ -77,6 +89,7 @@ async def read_flight( |
77 | 89 | return await controller.get_flight_by_id(flight_id) |
78 | 90 |
|
79 | 91 |
|
| 92 | + |
80 | 93 | @router.put("/{flight_id}", status_code=204) |
81 | 94 | async def update_flight( |
82 | 95 | flight_id: str, |
@@ -138,33 +151,110 @@ async def delete_flight( |
138 | 151 | "/{flight_id}/rocketpy", |
139 | 152 | responses={ |
140 | 153 | 200: { |
141 | | - "description": "Binary file download", |
142 | | - "content": {"application/octet-stream": {}}, |
| 154 | + "description": "Portable .rpy JSON file download", |
| 155 | + "content": {"application/json": {}}, |
143 | 156 | } |
144 | 157 | }, |
145 | 158 | status_code=200, |
146 | 159 | response_class=Response, |
147 | 160 | ) |
148 | | -async def get_rocketpy_flight_binary( |
| 161 | +async def get_rocketpy_flight_rpy( |
149 | 162 | flight_id: str, |
150 | 163 | controller: FlightControllerDep, |
151 | 164 | ): |
152 | 165 | """ |
153 | | - Loads rocketpy.flight as a dill binary. |
154 | | - Currently only amd64 architecture is supported. |
| 166 | + Export a rocketpy Flight as a portable ``.rpy`` JSON file. |
| 167 | +
|
| 168 | + The ``.rpy`` format is architecture-, OS-, and |
| 169 | + Python-version-agnostic. |
| 170 | +
|
| 171 | + ## Args |
| 172 | + ``` flight_id: str ``` |
| 173 | + """ |
| 174 | + with tracer.start_as_current_span("get_rocketpy_flight_rpy"): |
| 175 | + headers = { |
| 176 | + 'Content-Disposition': ( |
| 177 | + 'attachment; filename=' f'"rocketpy_flight_{flight_id}.rpy"' |
| 178 | + ), |
| 179 | + } |
| 180 | + rpy = await controller.get_rocketpy_flight_rpy(flight_id) |
| 181 | + return Response( |
| 182 | + content=rpy, |
| 183 | + headers=headers, |
| 184 | + media_type="application/json", |
| 185 | + status_code=200, |
| 186 | + ) |
| 187 | + |
| 188 | + |
| 189 | +@router.post( |
| 190 | + "/upload", |
| 191 | + status_code=201, |
| 192 | + responses={ |
| 193 | + 201: {"description": "Flight imported from .rpy file"}, |
| 194 | + 413: {"description": "Uploaded .rpy file exceeds size limit"}, |
| 195 | + 422: {"description": "Invalid .rpy file"}, |
| 196 | + }, |
| 197 | +) |
| 198 | +async def import_flight_from_rpy( |
| 199 | + file: UploadFile = File(...), |
| 200 | + controller: FlightControllerDep = None, # noqa: B008 |
| 201 | +) -> FlightImported: |
| 202 | + """ |
| 203 | + Upload a ``.rpy`` JSON file containing a RocketPy Flight. |
| 204 | +
|
| 205 | + The file is deserialized and decomposed into its |
| 206 | + constituent objects (Environment, Motor, Rocket, Flight). |
| 207 | + Each object is persisted as a normal JSON model and the |
| 208 | + corresponding IDs are returned. Maximum upload size is 10 MB. |
| 209 | +
|
| 210 | + ## Args |
| 211 | + ``` file: .rpy JSON upload ``` |
| 212 | + """ |
| 213 | + with tracer.start_as_current_span("import_flight_from_rpy"): |
| 214 | + content = await file.read(MAX_RPY_UPLOAD_BYTES + 1) |
| 215 | + if len(content) > MAX_RPY_UPLOAD_BYTES: |
| 216 | + raise HTTPException( |
| 217 | + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, |
| 218 | + detail="Uploaded .rpy file exceeds 10 MB limit.", |
| 219 | + ) |
| 220 | + return await controller.import_flight_from_rpy(content) |
| 221 | + |
| 222 | + |
| 223 | +@router.get( |
| 224 | + "/{flight_id}/notebook", |
| 225 | + responses={ |
| 226 | + 200: { |
| 227 | + "description": "Jupyter notebook file download", |
| 228 | + "content": {"application/x-ipynb+json": {}}, |
| 229 | + } |
| 230 | + }, |
| 231 | + status_code=200, |
| 232 | + response_class=Response, |
| 233 | +) |
| 234 | +async def get_flight_notebook( |
| 235 | + flight_id: str, |
| 236 | + controller: FlightControllerDep, |
| 237 | +): |
| 238 | + """ |
| 239 | + Export a flight as a Jupyter notebook (.ipynb). |
| 240 | +
|
| 241 | + The notebook loads the flight's ``.rpy`` file and calls |
| 242 | + ``flight.all_info()`` for interactive exploration. |
155 | 243 |
|
156 | 244 | ## Args |
157 | 245 | ``` flight_id: str ``` |
158 | 246 | """ |
159 | | - with tracer.start_as_current_span("get_rocketpy_flight_binary"): |
| 247 | + with tracer.start_as_current_span("get_flight_notebook"): |
| 248 | + notebook = await controller.get_flight_notebook(flight_id) |
| 249 | + content = json.dumps(notebook, indent=1).encode() |
| 250 | + filename = f"flight_{flight_id}.ipynb" |
160 | 251 | headers = { |
161 | | - 'Content-Disposition': f'attachment; filename="rocketpy_flight_{flight_id}.dill"' |
| 252 | + "Content-Disposition": (f'attachment; filename="{filename}"'), |
162 | 253 | } |
163 | | - binary = await controller.get_rocketpy_flight_binary(flight_id) |
164 | 254 | return Response( |
165 | | - content=binary, |
| 255 | + content=content, |
166 | 256 | headers=headers, |
167 | | - media_type="application/octet-stream", |
| 257 | + media_type="application/x-ipynb+json", |
168 | 258 | status_code=200, |
169 | 259 | ) |
170 | 260 |
|
|
0 commit comments