diff --git a/Dockerfile b/Dockerfile index 191bffe..2111d50 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,6 @@ RUN apt-get update && \ apt-get purge -y --auto-remove && \ rm -rf /var/lib/apt/lists/* -COPY ./lib /app/lib +COPY ./src /app/src -CMD ["gunicorn", "-c", "lib/settings/gunicorn.py", "-w", "1", "--threads=2", "-k", "uvicorn.workers.UvicornWorker", "lib.api:app", "--log-level", "Debug", "-b", "0.0.0.0:3000", "--timeout", "60"] +CMD ["gunicorn", "-c", "src/settings/gunicorn.py", "-w", "1", "--threads=2", "-k", "uvicorn.workers.UvicornWorker", "src.api:app", "--log-level", "Debug", "-b", "0.0.0.0:3000", "--timeout", "60", "--loop", "uvloop"] diff --git a/Makefile b/Makefile index 815840e..c65bc7f 100644 --- a/Makefile +++ b/Makefile @@ -2,26 +2,26 @@ format: black ruff lint: flake8 pylint black: - black ./lib || true + black ./src || true black ./tests || true flake8: - flake8 --ignore E501,E402,F401,W503,C0414 ./lib || true + flake8 --ignore E501,E402,F401,W503,C0414 ./src || true flake8 --ignore E501,E402,F401,W503,C0414 ./tests || true pylint: - pylint ./lib || true + pylint ./src || true pylint ./tests || true ruff: - ruff check --fix ./lib || true + ruff check --fix ./src || true ruff check --fix ./tests || true test: python3 -m pytest . dev: - python3 -m uvicorn lib:app --reload --port 3000 + python3 -m uvicorn src:app --reload --port 3000 --loop uvloop clean: docker stop infinity-api diff --git a/README.md b/README.md index 7513edb..709dc50 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ $ touch .env && echo MONGODB_CONNECTION_STRING="$ConnectionString" > .env - run docker compose: `docker-compose up --build -d` ### Standalone -- Dev: `python3 -m uvicorn lib:app --reload --port 3000` -- Prod: `gunicorn -k uvicorn.workers.UvicornWorker lib:app -b 0.0.0.0:3000` +- Dev: `python3 -m uvicorn src:app --reload --port 3000` +- Prod: `gunicorn -k uvicorn.workers.UvicornWorker src:app -b 0.0.0.0:3000` ## Project structure ``` @@ -39,6 +39,7 @@ $ touch .env && echo MONGODB_CONNECTION_STRING="$ConnectionString" > .env │ │── secrets.py │   │   │   ├── controllers +│   │   ├── interface.py │   │   ├── environment.py │   │   ├── flight.py │   │   ├── motor.py @@ -57,65 +58,31 @@ $ touch .env && echo MONGODB_CONNECTION_STRING="$ConnectionString" > .env │   │   └── rocket.py │   │   │   ├── repositories -│   │   ├── repo.py +│   │   ├── interface.py │   │   ├── environment.py │   │   ├── flight.py │   │   ├── motor.py │   │   └── rocket.py │   │   │   ├── models -│   │   ├── aerosurfaces.py +│   │   ├── interface.py │   │   ├── environment.py │   │   ├── flight.py │   │   ├── motor.py -│   │   └── rocket.py +│   │   ├── rocket.py +│   │   │   +│   │   └── sub +│   │ ├── aerosurfaces.py +│ │ └── tanks.py │   │   │   └── views +│   ├── interface.py │   ├── environment.py │   ├── flight.py │   ├── motor.py │   └── rocket.py │   └── tests - ├── integration - │   ├── test_environment_integration.py - │   ├── test_motor_integration.py - │   ├── test_rocket_integration.py - │   └── test_flight_integration.py - │   - └── unit - ├── test_secrets.py - ├── test_api.py - │   - ├── test_controllers - │   ├── test_environment_controller.py - │   ├── test_flight_controller.py - │   ├── test_motor_controller.py - │   └── test_rocket_controller.py - │   - ├── test_services - │   ├── test_environment_service.py - │   ├── test_flight_service.py - │   ├── test_motor_service.py - │   └── test_rocket_serice.py - │ - ├── test_repositories - │   ├── test_environment_repo.py - │   ├── test_flight_repo.py - │   ├── test_motor_repo.py - │   └── test_rocket_repo.py - │ - ├── test_models - │   ├── test_environment_model.py - │   ├── test_flight_model.py - │   ├── test_motor_model.py - │   └── test_rocket_model.py - │   - └── test_views -    ├── test_environment_view.py -    ├── test_flight_view.py -    ├── test_motor_view.py -    └── test_rocket_view.py ``` ## DOCS @@ -135,21 +102,21 @@ sequenceDiagram User ->> API: POST /model API ->> MongoDB: Persist API Model as a document MongoDB -->> API: Model ID - API -->> User: ModelCreated View + API -->> User: 201 ModelCreated View User ->> API: GET /model/:id API ->> MongoDB: Read API Model document MongoDB -->> API: API Model document - API -->> User: API ModelView + API -->> User: 200 API ModelView User ->> API: PUT /model/:id API ->> MongoDB: Update API Model document - API -->> User: ModelUpdated View + API -->> User: 204 User ->> API: DELETE /model/:id API ->> MongoDB: Delete API Model document MongoDB -->> API: Deletion Confirmation - API -->> User: ModelDeleted View + API -->> User: 204 ``` @@ -159,19 +126,19 @@ sequenceDiagram participant User participant API participant MongoDB - participant Rocketpy lib + participant RocketPy lib - User ->> API: GET /summary/model/:id - API -->> MongoDB: Retrieve Rocketpy native class - MongoDB -->> API: Rocketpy native class - API ->> Rocketpy lib: Simulate Rocketpy native class - Rocketpy lib -->> API: Simulation Results + User ->> API: GET model/:id/simulate/ + API -->> MongoDB: Retrieve API Model document + MongoDB -->> API: API Model document + API ->> RocketPy: Initialize RocketPy native class and simulate + RocketPy lib -->> API: Simulation Results API -->> User: Simulation Results User ->> API: GET /model/:id/rocketpy - API -->> MongoDB: Retrieve Rocketpy Model - MongoDB -->> API: Rocketpy Model - API ->> Rocketpy lib: Rocketpy Model - Rocketpy lib -->> API: Rocketpy native class - API -->> User: Rocketpy native class as .dill binary + API -->> MongoDB: Retrieve API Model document + MongoDB -->> API: API Model document + API ->> RocketPy: Initialize RocketPy native class + RocketPy lib -->> API: RocketPy native class + API -->> User: RocketPy native class as .dill binary (amd64) ``` diff --git a/lib/controllers/__init__.py b/lib/controllers/__init__.py deleted file mode 100644 index c8add6a..0000000 --- a/lib/controllers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# lib/controllers/__init__.py diff --git a/lib/controllers/environment.py b/lib/controllers/environment.py deleted file mode 100644 index 2da51c2..0000000 --- a/lib/controllers/environment.py +++ /dev/null @@ -1,274 +0,0 @@ -from typing import Union - -from fastapi import HTTPException, status -from pymongo.errors import PyMongoError - -from lib import logger, parse_error -from lib.models.environment import Env -from lib.services.environment import EnvironmentService -from lib.repositories.environment import EnvRepository -from lib.views.environment import ( - EnvSummary, - EnvCreated, - EnvDeleted, - EnvUpdated, -) - - -class EnvController: - """ - Controller for the Environment model. - - Enables: - - Simulation of a RocketPy Environment from models.Env - - CRUD operations over models.Env on the database - """ - - @staticmethod - async def create_env(env: Env) -> Union[EnvCreated, HTTPException]: - """ - Create a env in the database. - - Returns: - views.EnvCreated - """ - env_repo = None - try: - async with EnvRepository(env) as env_repo: - await env_repo.create_env() - except PyMongoError as e: - logger.error( - f"controllers.environment.create_env: PyMongoError {e}" - ) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Failed to create environment in db", - ) from e - except HTTPException as e: - raise e from e - except Exception as e: - exc_str = parse_error(e) - logger.error(f"controllers.environment.create_env: {exc_str}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to create environment: {exc_str}", - ) from e - else: - return EnvCreated(env_id=env_repo.env_id) - finally: - env_id = ( - getattr(env_repo, 'env_id', 'unknown') - if env_repo - else 'unknown' - ) - if env_repo: - logger.info( - f"Call to controllers.environment.create_env completed for Env {env_id}" - ) - - @staticmethod - async def get_env_by_id(env_id: str) -> Union[Env, HTTPException]: - """ - Get a env from the database. - - Args: - env_id: str - - Returns: - models.Env - - Raises: - HTTP 404 Not Found: If the env is not found in the database. - """ - try: - async with EnvRepository() as env_repo: - await env_repo.get_env_by_id(env_id) - env = env_repo.env - except PyMongoError as e: - logger.error( - f"controllers.environment.get_env_by_id: PyMongoError {e}" - ) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Failed to read environment from db", - ) from e - except HTTPException as e: - raise e from e - except Exception as e: - exc_str = parse_error(e) - logger.error(f"controllers.environment.get_env_by_id: {exc_str}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to read environment: {exc_str}", - ) from e - else: - if env: - return env - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Environment not found", - ) - finally: - logger.info( - f"Call to controllers.environment.get_env_by_id completed for Env {env_id}" - ) - - @classmethod - async def get_rocketpy_env_binary( - cls, - env_id: str, - ) -> Union[bytes, HTTPException]: - """ - Get rocketpy.Environmnet dill binary. - - Args: - env_id: str - - Returns: - bytes - - Raises: - HTTP 404 Not Found: If the env is not found in the database. - """ - try: - env = await cls.get_env_by_id(env_id) - env_service = EnvironmentService.from_env_model(env) - except HTTPException as e: - raise e from e - except Exception as e: - exc_str = parse_error(e) - logger.error( - f"controllers.environment.get_rocketpy_env_as_binary: {exc_str}" - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to read environment: {exc_str}", - ) from e - else: - return env_service.get_env_binary() - finally: - logger.info( - f"Call to controllers.environment.get_rocketpy_env_binary completed for Env {env_id}" - ) - - @staticmethod - async def update_env_by_id( - env_id: str, env: Env - ) -> Union[EnvUpdated, HTTPException]: - """ - Update a models.Env in the database. - - Args: - env_id: str - - Returns: - views.EnvUpdated - - Raises: - HTTP 404 Not Found: If the env is not found in the database. - """ - try: - async with EnvRepository(env) as env_repo: - await env_repo.update_env_by_id(env_id) - except PyMongoError as e: - logger.error( - f"controllers.environment.update_env: PyMongoError {e}" - ) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Failed to update environment from db", - ) from e - except HTTPException as e: - raise e from e - except Exception as e: - exc_str = parse_error(e) - logger.error(f"controllers.environment.update_env: {exc_str}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to update environment: {exc_str}", - ) from e - else: - return EnvUpdated(env_id=env_id) - finally: - logger.info( - f"Call to controllers.environment.update_env completed for Env {env_id}" - ) - - @staticmethod - async def delete_env_by_id( - env_id: str, - ) -> Union[EnvDeleted, HTTPException]: - """ - Delete a models.Env from the database. - - Args: - env_id: str - - Returns: - views.EnvDeleted - - Raises: - HTTP 404 Not Found: If the env is not found in the database. - """ - try: - async with EnvRepository() as env_repo: - await env_repo.delete_env_by_id(env_id) - except PyMongoError as e: - logger.error( - f"controllers.environment.delete_env: PyMongoError {e}" - ) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Failed to delete environment from db", - ) from e - except HTTPException as e: - raise e from e - except Exception as e: - exc_str = parse_error(e) - logger.error(f"controllers.environment.delete_env: {exc_str}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to delete environment: {exc_str}", - ) from e - else: - return EnvDeleted(env_id=env_id) - finally: - logger.info( - f"Call to controllers.environment.delete_env completed for Env {env_id}" - ) - - @classmethod - async def simulate_env( - cls, env_id: str - ) -> Union[EnvSummary, HTTPException]: - """ - Simulate a rocket environment. - - Args: - env_id: str. - - Returns: - EnvSummary - - Raises: - HTTP 404 Not Found: If the env does not exist in the database. - """ - try: - env = await cls.get_env_by_id(env_id) - env_service = EnvironmentService.from_env_model(env) - env_summary = env_service.get_env_summary() - except HTTPException as e: - raise e from e - except Exception as e: - exc_str = parse_error(e) - logger.error(f"controllers.environment.simulate: {exc_str}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to simulate environment, parameters may contain data that is not physically coherent: {exc_str}", - ) from e - else: - return env_summary - finally: - logger.info( - f"Call to controllers.environment.simulate completed for Env {env_id}" - ) diff --git a/lib/controllers/flight.py b/lib/controllers/flight.py deleted file mode 100644 index 42fe456..0000000 --- a/lib/controllers/flight.py +++ /dev/null @@ -1,394 +0,0 @@ -from typing import Union -from fastapi import HTTPException, status -from pymongo.errors import PyMongoError - - -from lib import logger, parse_error -from lib.controllers.rocket import RocketController -from lib.models.environment import Env -from lib.models.rocket import Rocket -from lib.models.flight import Flight -from lib.views.motor import MotorView -from lib.views.rocket import RocketView -from lib.views.flight import ( - FlightSummary, - FlightCreated, - FlightUpdated, - FlightDeleted, - FlightView, -) -from lib.repositories.flight import FlightRepository -from lib.services.flight import FlightService - - -class FlightController: - """ - Controller for the Flight model. - - Enables: - - Create a RocketPyFlight object from a Flight model object. - - Generate trajectory simulation from a RocketPyFlight object. - - Create both Flight model and RocketPyFlight objects in the database. - - Update both Flight model and RocketPyFlight objects in the database. - - Delete both Flight model and RocketPyFlight objects from the database. - - Read a Flight model from the database. - - Read a RocketPyFlight object from the database. - - """ - - @staticmethod - def guard(flight: Flight): - RocketController.guard(flight.rocket) - - @classmethod - async def create_flight( - cls, flight: Flight - ) -> Union[FlightCreated, HTTPException]: - """ - Create a flight in the database. - - Returns: - views.FlightCreated - """ - flight_repo = None - try: - cls.guard(flight) - async with FlightRepository(flight) as flight_repo: - await flight_repo.create_flight() - except PyMongoError as e: - logger.error(f"controllers.flight.create_flight: PyMongoError {e}") - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Failed to create flight in db", - ) from e - except HTTPException as e: - raise e from e - except Exception as e: - exc_str = parse_error(e) - logger.error(f"controllers.flight.create_flight: {exc_str}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to create flight: {exc_str}", - ) from e - else: - return FlightCreated(flight_id=flight_repo.flight_id) - finally: - flight_id = ( - getattr(flight_repo, 'flight_id', 'unknown') - if flight_repo - else 'unknown' - ) - logger.info( - f"Call to controllers.flight.create_flight completed for Flight {flight_id}" - ) - - @staticmethod - async def get_flight_by_id( - flight_id: str, - ) -> Union[FlightView, HTTPException]: - """ - Get a flight from the database. - - Args: - flight_id: str - - Returns: - models.Flight - - Raises: - HTTP 404 Not Found: If the flight is not found in the database. - """ - try: - async with FlightRepository() as flight_repo: - await flight_repo.get_flight_by_id(flight_id) - flight = flight_repo.flight - except PyMongoError as e: - logger.error( - f"controllers.flight.get_flight_by_id: PyMongoError {e}" - ) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Failed to read flight from db", - ) from e - except HTTPException as e: - raise e from e - except Exception as e: - exc_str = parse_error(e) - logger.error(f"controllers.flight.get_flight_by_id: {exc_str}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to read flight: {exc_str}", - ) from e - else: - if flight: - motor_view = MotorView( - **flight.rocket.motor.dict(), - selected_motor_kind=flight.rocket.motor.motor_kind, - ) - updated_rocket = flight.rocket.dict() - updated_rocket.update(motor=motor_view) - rocket_view = RocketView( - **updated_rocket, - ) - updated_flight = flight.dict() - updated_flight.update(rocket=rocket_view) - flight_view = FlightView(**updated_flight) - return flight_view - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Flight not found.", - ) - finally: - logger.info( - f"Call to controllers.flight.get_flight_by_id completed for Flight {flight_id}" - ) - - @classmethod - async def get_rocketpy_flight_binary( - cls, - flight_id: str, - ) -> Union[bytes, HTTPException]: - """ - Get rocketpy.flight as dill binary. - - Args: - flight_id: str - - Returns: - bytes - - Raises: - HTTP 404 Not Found: If the flight is not found in the database. - """ - try: - flight = await cls.get_flight_by_id(flight_id) - flight_service = FlightService.from_flight_model(flight) - except HTTPException as e: - raise e from e - except Exception as e: - exc_str = parse_error(e) - logger.error( - f"controllers.flight.get_rocketpy_flight_binary: {exc_str}" - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to read flight: {exc_str}", - ) from e - else: - return flight_service.get_flight_binary() - finally: - logger.info( - f"Call to controllers.flight.get_rocketpy_flight_binary completed for Flight {flight_id}" - ) - - @classmethod - async def update_flight_by_id( - cls, flight_id: str, flight: Flight - ) -> Union[FlightUpdated, HTTPException]: - """ - Update a models.Flight in the database. - - Args: - flight_id: str - - Returns: - views.FlightUpdated - - Raises: - HTTP 404 Not Found: If the flight is not found in the database. - """ - try: - cls.guard(flight) - async with FlightRepository(flight) as flight_repo: - await flight_repo.update_flight_by_id(flight_id) - except PyMongoError as e: - logger.error(f"controllers.flight.update_flight: PyMongoError {e}") - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Failed to update flight in db", - ) from e - except HTTPException as e: - raise e from e - except Exception as e: - exc_str = parse_error(e) - logger.error(f"controllers.flight.update_flight: {exc_str}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to update flight: {exc_str}", - ) from e - else: - return FlightUpdated(flight_id=flight_id) - finally: - logger.info( - f"Call to controllers.flight.update_flight completed for Flight {flight_id}" - ) - - @classmethod - async def update_env_by_flight_id( - cls, flight_id: str, *, env: Env - ) -> Union[FlightUpdated, HTTPException]: - """ - Update a models.Flight.env in the database. - - Args: - flight_id: str - env: models.Env - - Returns: - views.FlightUpdated - - Raises: - HTTP 404 Not Found: If the flight is not found in the database. - """ - try: - flight = await cls.get_flight_by_id(flight_id) - flight.environment = env - async with FlightRepository(flight) as flight_repo: - await flight_repo.update_env_by_flight_id(flight_id) - except PyMongoError as e: - logger.error( - f"controllers.flight.update_env_by_flight_id: PyMongoError {e}" - ) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Failed to update environment from db", - ) from e - except HTTPException as e: - raise e from e - except Exception as e: - exc_str = parse_error(e) - logger.error(f"controllers.flight.update_env: {exc_str}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to update environment: {exc_str}", - ) from e - else: - return FlightUpdated(flight_id=flight_id) - finally: - logger.info( - f"Call to controllers.flight.update_env completed for Flight {flight_id}" - ) - - @classmethod - async def update_rocket_by_flight_id( - cls, flight_id: str, *, rocket: Rocket - ) -> Union[FlightUpdated, HTTPException]: - """ - Update a models.Flight.rocket in the database. - - Args: - flight_id: str - rocket: models.Rocket - - Returns: - views.FlightUpdated - - Raises: - HTTP 404 Not Found: If the flight is not found in the database. - """ - try: - flight = await cls.get_flight_by_id(flight_id) - flight.rocket = rocket - async with FlightRepository(flight) as flight_repo: - await flight_repo.update_rocket_by_flight_id(flight_id) - except PyMongoError as e: - logger.error( - f"controllers.flight.update_rocket_by_flight_id: PyMongoError {e}" - ) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Failed to update rocket from db", - ) from e - except HTTPException as e: - raise e from e - except Exception as e: - exc_str = parse_error(e) - logger.error(f"controllers.flight.update_rocket: {exc_str}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to update rocket: {exc_str}", - ) from e - else: - return FlightUpdated(flight_id=flight_id) - finally: - logger.info( - f"Call to controllers.flight.update_rocket completed for Flight {flight_id}" - ) - - @staticmethod - async def delete_flight_by_id( - flight_id: str, - ) -> Union[FlightDeleted, HTTPException]: - """ - Delete a models.Flight from the database. - - Args: - flight_id: str - - Returns: - views.FlightDeleted - - Raises: - HTTP 404 Not Found: If the flight is not found in the database. - """ - try: - async with FlightRepository() as flight_repo: - await flight_repo.delete_flight_by_id(flight_id) - except PyMongoError as e: - logger.error(f"controllers.flight.delete_flight: PyMongoError {e}") - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Failed to delete flight from db", - ) from e - except HTTPException as e: - raise e from e - except Exception as e: - exc_str = parse_error(e) - logger.error(f"controllers.flight.delete_flight: {exc_str}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to delete flight: {exc_str}", - ) from e - else: - return FlightDeleted(flight_id=flight_id) - finally: - logger.info( - f"Call to controllers.flight.delete_flight completed for Flight {flight_id}" - ) - - @classmethod - async def simulate_flight( - cls, - flight_id: str, - ) -> Union[FlightSummary, HTTPException]: - """ - Simulate a rocket flight. - - Args: - flight_id: str - - Returns: - Flight summary view. - - Raises: - HTTP 404 Not Found: If the flight does not exist in the database. - """ - try: - flight = await cls.get_flight_by_id(flight_id=flight_id) - flight_service = FlightService.from_flight_model(flight) - flight_summary = flight_service.get_flight_summary() - except HTTPException as e: - raise e from e - except Exception as e: - exc_str = parse_error(e) - logger.error(f"controllers.flight.simulate_flight: {exc_str}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to simulate flight, parameters may contain data that is not physically coherent: {exc_str}", - ) from e - else: - return flight_summary - finally: - logger.info( - f"Call to controllers.flight.simulate_flight completed for Flight {flight_id}" - ) diff --git a/lib/controllers/motor.py b/lib/controllers/motor.py deleted file mode 100644 index 81304fe..0000000 --- a/lib/controllers/motor.py +++ /dev/null @@ -1,289 +0,0 @@ -from typing import Union -from fastapi import HTTPException, status -from pymongo.errors import PyMongoError - -from lib import logger, parse_error -from lib.models.motor import Motor, MotorKinds -from lib.services.motor import MotorService -from lib.repositories.motor import MotorRepository -from lib.views.motor import ( - MotorSummary, - MotorCreated, - MotorUpdated, - MotorDeleted, - MotorView, -) - - -class MotorController: - """ - Controller for the motor model. - - Enables: - - Create a rocketpy.Motor object from a Motor model object. - """ - - @staticmethod - def guard(motor: Motor): - if ( - motor.motor_kind not in (MotorKinds.SOLID, MotorKinds.GENERIC) - and motor.tanks is None - ): - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Tanks must be provided for liquid and hybrid motors.", - ) - - # TODO: extend guard to check motor kinds and tank kinds specifics - - @classmethod - async def create_motor( - cls, motor: Motor - ) -> Union[MotorCreated, HTTPException]: - """ - Create a models.Motor in the database. - - Returns: - views.MotorCreated - """ - motor_repo = None - try: - cls.guard(motor) - async with MotorRepository(motor) as motor_repo: - await motor_repo.create_motor() - except PyMongoError as e: - logger.error(f"controllers.motor.create_motor: PyMongoError {e}") - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Failed to create motor in db", - ) from e - except HTTPException as e: - raise e from e - except Exception as e: - exc_str = parse_error(e) - logger.error(f"controllers.motor.create_motor: {exc_str}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to create motor: {exc_str}", - ) from e - else: - return MotorCreated(motor_id=motor_repo.motor_id) - finally: - motor_id = ( - getattr(motor_repo, 'motor_id', 'unknown') - if motor_repo - else 'unknown' - ) - if motor_repo: - logger.info( - f"Call to controllers.motor.create_motor completed for Motor {motor_id}" - ) - - @staticmethod - async def get_motor_by_id( - motor_id: str, - ) -> Union[MotorView, HTTPException]: - """ - Get a models.Motor from the database. - - Args: - motor_id: str - - Returns: - models.Motor - - Raises: - HTTP 404 Not Found: If the motor is not found in the database. - """ - try: - async with MotorRepository() as motor_repo: - await motor_repo.get_motor_by_id(motor_id) - motor = motor_repo.motor - except PyMongoError as e: - logger.error( - f"controllers.motor.get_motor_by_id: PyMongoError {e}" - ) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Failed to read motor from db", - ) from e - except HTTPException as e: - raise e from e - except Exception as e: - exc_str = parse_error(e) - logger.error(f"controllers.motor.get_motor_by_id: {exc_str}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to read motor: {exc_str}", - ) from e - else: - if motor: - motor_view = MotorView( - **motor.dict(), selected_motor_kind=motor.motor_kind - ) - return motor_view - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Motor not found", - ) - finally: - logger.info( - f"Call to controllers.motor.get_motor_by_id completed for Motor {motor_id}" - ) - - @classmethod - async def get_rocketpy_motor_binary( - cls, - motor_id: str, - ) -> Union[bytes, HTTPException]: - """ - Get a rocketpy.Motor object as a dill binary. - - Args: - motor_id: str - - Returns: - bytes - - Raises: - HTTP 404 Not Found: If the motor is not found in the database. - """ - try: - motor = await cls.get_motor_by_id(motor_id) - motor_service = MotorService.from_motor_model(motor) - except HTTPException as e: - raise e from e - except Exception as e: - exc_str = parse_error(e) - logger.error( - f"controllers.motor.get_rocketpy_motor_binary: {exc_str}" - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to read motor: {exc_str}", - ) from e - else: - return motor_service.get_motor_binary() - finally: - logger.info( - f"Call to controllers.motor.get_rocketpy_motor_binary completed for Motor {motor_id}" - ) - - @classmethod - async def update_motor_by_id( - cls, motor_id: str, motor: Motor - ) -> Union[MotorUpdated, HTTPException]: - """ - Update a motor in the database. - - Args: - motor_id: str - - Returns: - views.MotorUpdated - - Raises: - HTTP 404 Not Found: If the motor is not found in the database. - """ - try: - cls.guard(motor) - async with MotorRepository(motor) as motor_repo: - await motor_repo.update_motor_by_id(motor_id) - except PyMongoError as e: - logger.error(f"controllers.motor.update_motor: PyMongoError {e}") - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Failed to update motor in db", - ) from e - except HTTPException as e: - raise e from e - except Exception as e: - exc_str = parse_error(e) - logger.error(f"controllers.motor.update_motor: {exc_str}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to update motor: {exc_str}", - ) from e - else: - return MotorUpdated(motor_id=motor_id) - finally: - logger.info( - f"Call to controllers.motor.update_motor completed for Motor {motor_id}" - ) - - @staticmethod - async def delete_motor_by_id( - motor_id: str, - ) -> Union[MotorDeleted, HTTPException]: - """ - Delete a models.Motor from the database. - - Args: - motor_id: str - - Returns: - views.MotorDeleted - - Raises: - HTTP 404 Not Found: If the motor is not found in the database. - """ - try: - async with MotorRepository() as motor_repo: - await motor_repo.delete_motor_by_id(motor_id) - except PyMongoError as e: - logger.error(f"controllers.motor.delete_motor: PyMongoError {e}") - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Failed to delete motor from db", - ) from e - except HTTPException as e: - raise e from e - except Exception as e: - exc_str = parse_error(e) - logger.error(f"controllers.motor.delete_motor: {exc_str}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to delete motor: {exc_str}", - ) from e - else: - return MotorDeleted(motor_id=motor_id) - finally: - logger.info( - f"Call to controllers.motor.delete_motor completed for Motor {motor_id}" - ) - - @classmethod - async def simulate_motor( - cls, motor_id: str - ) -> Union[MotorSummary, HTTPException]: - """ - Simulate a rocketpy motor. - - Args: - motor_id: str - - Returns: - views.MotorSummary - - Raises: - HTTP 404 Not Found: If the motor does not exist in the database. - """ - try: - motor = await cls.get_motor_by_id(motor_id) - motor_service = MotorService.from_motor_model(motor) - motor_summary = motor_service.get_motor_summary() - except HTTPException as e: - raise e from e - except Exception as e: - exc_str = parse_error(e) - logger.error(f"controllers.motor.simulate_motor: {exc_str}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to simulate motor, parameters may contain data that is not physically coherent: {exc_str}", - ) from e - else: - return motor_summary - finally: - logger.info( - f"Call to controllers.motor.simulate_motor completed for Motor {motor_id}" - ) diff --git a/lib/controllers/rocket.py b/lib/controllers/rocket.py deleted file mode 100644 index 0bfea11..0000000 --- a/lib/controllers/rocket.py +++ /dev/null @@ -1,289 +0,0 @@ -from typing import Union - -from fastapi import HTTPException, status -from pymongo.errors import PyMongoError - -from lib import logger, parse_error -from lib.services.rocket import RocketService -from lib.models.rocket import Rocket -from lib.controllers.motor import MotorController -from lib.repositories.rocket import RocketRepository -from lib.views.motor import MotorView -from lib.views.rocket import ( - RocketSummary, - RocketCreated, - RocketUpdated, - RocketDeleted, - RocketView, -) - - -class RocketController: - """ - Controller for the Rocket model. - - Enables: - - CRUD operations over models.Rocket on the database. - """ - - @staticmethod - def guard(rocket: Rocket): - MotorController.guard(rocket.motor) - - @classmethod - async def create_rocket( - cls, rocket: Rocket - ) -> Union[RocketCreated, HTTPException]: - """ - Create a models.Rocket in the database. - - Returns: - views.RocketCreated - """ - rocket_repo = None - try: - cls.guard(rocket) - async with RocketRepository(rocket) as rocket_repo: - await rocket_repo.create_rocket() - except PyMongoError as e: - logger.error(f"controllers.rocket.create_rocket: PyMongoError {e}") - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Failed to create rocket in the db", - ) from e - except HTTPException as e: - raise e from e - except Exception as e: - exc_str = parse_error(e) - logger.error(f"controllers.rocket.create_rocket: {exc_str}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to create rocket: {exc_str}", - ) from e - else: - return RocketCreated(rocket_id=rocket_repo.rocket_id) - finally: - rocket_id = ( - getattr(rocket_repo, 'rocket_id', 'unknown') - if rocket_repo - else 'unknown' - ) - if rocket_repo: - logger.info( - f"Call to controllers.rocket.create_rocket completed for Rocket {rocket_id}" - ) - - @staticmethod - async def get_rocket_by_id( - rocket_id: str, - ) -> Union[RocketView, HTTPException]: - """ - Get a rocket from the database. - - Args: - rocket_id: str - - Returns: - models.Rocket - - Raises: - HTTP 404 Not Found: If the rocket is not found in the database. - """ - try: - async with RocketRepository() as rocket_repo: - await rocket_repo.get_rocket_by_id(rocket_id) - rocket = rocket_repo.rocket - except PyMongoError as e: - logger.error( - f"controllers.rocket.get_rocket_by_id: PyMongoError {e}" - ) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Failed to read rocket from db", - ) from e - except HTTPException as e: - raise e from e - except Exception as e: - exc_str = parse_error(e) - logger.error(f"controllers.rocket.get_rocket_by_id: {exc_str}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to read rocket: {exc_str}", - ) from e - else: - if rocket: - motor_view = MotorView( - **rocket.motor.dict(), - selected_motor_kind=rocket.motor.motor_kind, - ) - updated_rocket = rocket.dict() - updated_rocket.update(motor=motor_view) - rocket_view = RocketView( - **updated_rocket, - ) - return rocket_view - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Rocket not found", - ) - finally: - logger.info( - f"Call to controllers.rocket.get_rocket_by_id completed for Rocket {rocket_id}" - ) - - @classmethod - async def get_rocketpy_rocket_binary( - cls, rocket_id: str - ) -> Union[bytes, HTTPException]: - """ - Get a rocketpy.Rocket object as dill binary. - - Args: - rocket_id: str - - Returns: - bytes - - Raises: - HTTP 404 Not Found: If the rocket is not found in the database. - """ - try: - rocket = await cls.get_rocket_by_id(rocket_id) - rocket_service = RocketService.from_rocket_model(rocket) - except HTTPException as e: - raise e from e - except Exception as e: - exc_str = parse_error(e) - logger.error( - f"controllers.rocket.get_rocketpy_rocket_binary: {exc_str}" - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to read rocket: {exc_str}", - ) from e - else: - return rocket_service.get_rocket_binary() - finally: - logger.info( - f"Call to controllers.rocket.get_rocketpy_rocket_binary completed for Rocket {rocket_id}" - ) - - @classmethod - async def update_rocket_by_id( - cls, rocket_id: str, rocket: Rocket - ) -> Union[RocketUpdated, HTTPException]: - """ - Update a models.Rocket in the database. - - Args: - rocket_id: str - - Returns: - views.RocketUpdated - - Raises: - HTTP 404 Not Found: If the rocket is not found in the database. - """ - try: - cls.guard(rocket) - async with RocketRepository(rocket) as rocket_repo: - await rocket_repo.update_rocket_by_id(rocket_id) - except PyMongoError as e: - logger.error(f"controllers.rocket.update_rocket: PyMongoError {e}") - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Failed to update rocket in the db", - ) from e - except HTTPException as e: - raise e from e - except Exception as e: - exc_str = parse_error(e) - logger.error(f"controllers.rocket.update_rocket: {exc_str}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to update rocket: {exc_str}", - ) from e - else: - return RocketUpdated(rocket_id=rocket_id) - finally: - logger.info( - f"Call to controllers.rocket.update_rocket completed for Rocket {rocket_id}" - ) - - @staticmethod - async def delete_rocket_by_id( - rocket_id: str, - ) -> Union[RocketDeleted, HTTPException]: - """ - Delete a models.Rocket from the database. - - Args: - rocket_id: str - - Returns: - views.RocketDeleted - - Raises: - HTTP 404 Not Found: If the rocket is not found in the database. - """ - try: - async with RocketRepository() as rocket_repo: - await rocket_repo.delete_rocket_by_id(rocket_id) - except PyMongoError as e: - logger.error(f"controllers.rocket.delete_rocket: PyMongoError {e}") - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Failed to delete rocket from db", - ) from e - except HTTPException as e: - raise e from e - except Exception as e: - exc_str = parse_error(e) - logger.error(f"controllers.rocket.delete_rocket: {exc_str}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to delete rocket: {exc_str}", - ) from e - else: - return RocketDeleted(rocket_id=rocket_id) - finally: - logger.info( - f"Call to controllers.rocket.delete_rocket completed for Rocket {rocket_id}" - ) - - @classmethod - async def simulate_rocket( - cls, - rocket_id: str, - ) -> Union[RocketSummary, HTTPException]: - """ - Simulate a rocketpy rocket. - - Args: - rocket_id: str - - Returns: - views.RocketSummary - - Raises: - HTTP 404 Not Found: If the rocket does not exist in the database. - """ - try: - rocket = await cls.get_rocket_by_id(rocket_id) - rocket_service = RocketService.from_rocket_model(rocket) - rocket_summary = rocket_service.get_rocket_summary() - except HTTPException as e: - raise e from e - except Exception as e: - exc_str = parse_error(e) - logger.error(f"controllers.rocket.simulate: {exc_str}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to simulate rocket, parameters may contain data that is not physically coherent: {exc_str}", - ) from e - else: - return rocket_summary - finally: - logger.info( - f"Call to controllers.rocket.simulate completed for Rocket {rocket_id}" - ) diff --git a/lib/models/environment.py b/lib/models/environment.py deleted file mode 100644 index 3b62940..0000000 --- a/lib/models/environment.py +++ /dev/null @@ -1,28 +0,0 @@ -import datetime -from enum import Enum -from typing import Optional -from pydantic import BaseModel - - -class AtmosphericModelTypes(str, Enum): - STANDARD_ATMOSPHERE: str = "STANDARD_ATMOSPHERE" - CUSTOM_ATMOSPHERE: str = "CUSTOM_ATMOSPHERE" - WYOMING_SOUNDING: str = "WYOMING_SOUNDING" - FORECAST: str = "FORECAST" - REANALYSIS: str = "REANALYSIS" - ENSEMBLE: str = "ENSEMBLE" - - -class Env(BaseModel): - latitude: float - longitude: float - elevation: Optional[int] = 1 - - # Optional parameters - atmospheric_model_type: AtmosphericModelTypes = ( - AtmosphericModelTypes.STANDARD_ATMOSPHERE - ) - atmospheric_model_file: Optional[str] = None - date: Optional[datetime.datetime] = ( - datetime.datetime.today() + datetime.timedelta(days=1) - ) diff --git a/lib/models/flight.py b/lib/models/flight.py deleted file mode 100644 index 7616475..0000000 --- a/lib/models/flight.py +++ /dev/null @@ -1,48 +0,0 @@ -from enum import Enum -from typing import Optional -from pydantic import BaseModel -from lib.models.rocket import Rocket -from lib.models.environment import Env - - -class EquationsOfMotion(str, Enum): - STANDARD: str = "STANDARD" - SOLID_PROPULSION: str = "SOLID_PROPULSION" - - -class Flight(BaseModel): - name: str = "Flight" - environment: Env - rocket: Rocket - rail_length: float = 1 - time_overshoot: bool = True - terminate_on_apogee: bool = True - equations_of_motion: EquationsOfMotion = EquationsOfMotion.STANDARD - - # Optional parameters - inclination: Optional[int] = None - heading: Optional[int] = None - # TODO: implement initial_solution - max_time: Optional[int] = None - max_time_step: Optional[float] = None - min_time_step: Optional[int] = None - rtol: Optional[float] = None - atol: Optional[float] = None - verbose: Optional[bool] = None - - def get_additional_parameters(self): - return { - key: value - for key, value in self.dict().items() - if value is not None - and key - not in [ - "name", - "environment", - "rocket", - "rail_length", - "time_overshoot", - "terminate_on_apogee", - "equations_of_motion", - ] - } diff --git a/lib/models/motor.py b/lib/models/motor.py deleted file mode 100644 index c8121bf..0000000 --- a/lib/models/motor.py +++ /dev/null @@ -1,119 +0,0 @@ -from enum import Enum -from typing import Optional, Tuple, List, Union -from pydantic import BaseModel, PrivateAttr - - -class MotorKinds(str, Enum): - HYBRID: str = "HYBRID" - SOLID: str = "SOLID" - GENERIC: str = "GENERIC" - LIQUID: str = "LIQUID" - - -class TankKinds(str, Enum): - LEVEL: str = "LEVEL" - MASS: str = "MASS" - MASS_FLOW: str = "MASSFLOW" - ULLAGE: str = "ULLAGE" - - -class CoordinateSystemOrientation(str, Enum): - NOZZLE_TO_COMBUSTION_CHAMBER: str = "NOZZLE_TO_COMBUSTION_CHAMBER" - COMBUSTION_CHAMBER_TO_NOZZLE: str = "COMBUSTION_CHAMBER_TO_NOZZLE" - - -class TankFluids(BaseModel): - name: str - density: float - - -class InterpolationMethods(str, Enum): - LINEAR: str = "LINEAR" - SPLINE: str = "SPLINE" - AKIMA: str = "AKIMA" - POLYNOMIAL: str = "POLYNOMIAL" - SHEPARD: str = "SHEPARD" - RBF: str = "RBF" - - -class MotorTank(BaseModel): - # Required parameters - geometry: List[Tuple[Tuple[float, float], float]] - gas: TankFluids - liquid: TankFluids - flux_time: Tuple[float, float] - position: float - discretize: int - - # Level based tank parameters - liquid_height: Optional[float] = None - - # Mass based tank parameters - liquid_mass: Optional[float] = None - gas_mass: Optional[float] = None - - # Mass flow based tank parameters - gas_mass_flow_rate_in: Optional[float] = None - gas_mass_flow_rate_out: Optional[float] = None - liquid_mass_flow_rate_in: Optional[float] = None - liquid_mass_flow_rate_out: Optional[float] = None - initial_liquid_mass: Optional[float] = None - initial_gas_mass: Optional[float] = None - - # Ullage based tank parameters - ullage: Optional[float] = None - - # Optional parameters - name: Optional[str] = None - - # Computed parameters - tank_kind: TankKinds = TankKinds.MASS_FLOW - - -class Motor(BaseModel): - # Required parameters - thrust_source: List[List[float]] - burn_time: float - nozzle_radius: float - dry_mass: float - dry_inertia: Tuple[float, float, float] = (0, 0, 0) - center_of_dry_mass_position: float - - # Generic motor parameters - chamber_radius: Optional[float] = None - chamber_height: Optional[float] = None - chamber_position: Optional[float] = None - propellant_initial_mass: Optional[float] = None - nozzle_position: Optional[float] = None - - # Liquid motor parameters - tanks: Optional[List[MotorTank]] = None - - # Solid motor parameters - grain_number: Optional[int] = None - grain_density: Optional[float] = None - grain_outer_radius: Optional[float] = None - grain_initial_inner_radius: Optional[float] = None - grain_initial_height: Optional[float] = None - grains_center_of_mass_position: Optional[float] = None - grain_separation: Optional[float] = None - - # Hybrid motor parameters - throat_radius: Optional[float] = None - - # Optional parameters - interpolation_method: InterpolationMethods = InterpolationMethods.LINEAR - coordinate_system_orientation: CoordinateSystemOrientation = ( - CoordinateSystemOrientation.NOZZLE_TO_COMBUSTION_CHAMBER - ) - reshape_thrust_curve: Union[bool, tuple] = False - - # Computed parameters - _motor_kind: MotorKinds = PrivateAttr(default=MotorKinds.SOLID) - - @property - def motor_kind(self) -> MotorKinds: - return self._motor_kind - - def set_motor_kind(self, motor_kind: MotorKinds): - self._motor_kind = motor_kind diff --git a/lib/models/rocket.py b/lib/models/rocket.py deleted file mode 100644 index ab45419..0000000 --- a/lib/models/rocket.py +++ /dev/null @@ -1,42 +0,0 @@ -from enum import Enum -from typing import Optional, Tuple, List, Union -from pydantic import BaseModel -from lib.models.motor import Motor -from lib.models.aerosurfaces import ( - Fins, - NoseCone, - Tail, - RailButtons, - Parachute, -) - - -class CoordinateSystemOrientation(str, Enum): - TAIL_TO_NOSE: str = "TAIL_TO_NOSE" - NOSE_TO_TAIL: str = "NOSE_TO_TAIL" - - -class Rocket(BaseModel): - - # Required parameters - motor: Motor - radius: float - mass: float - motor_position: float - center_of_mass_without_motor: int - inertia: Union[ - Tuple[float, float, float], - Tuple[float, float, float, float, float, float], - ] = (0, 0, 0) - power_off_drag: List[Tuple[float, float]] = [(0, 0)] - power_on_drag: List[Tuple[float, float]] = [(0, 0)] - coordinate_system_orientation: CoordinateSystemOrientation = ( - CoordinateSystemOrientation.TAIL_TO_NOSE - ) - nose: NoseCone - fins: List[Fins] - - # Optional parameters - parachutes: Optional[List[Parachute]] = None - rail_buttons: Optional[RailButtons] = None - tail: Optional[Tail] = None diff --git a/lib/repositories/__init__.py b/lib/repositories/__init__.py deleted file mode 100644 index c8add6a..0000000 --- a/lib/repositories/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# lib/controllers/__init__.py diff --git a/lib/repositories/environment.py b/lib/repositories/environment.py deleted file mode 100644 index 56dac18..0000000 --- a/lib/repositories/environment.py +++ /dev/null @@ -1,142 +0,0 @@ -from typing import Self -from bson import ObjectId -from pymongo.errors import PyMongoError -from lib.models.environment import Env -from lib import logger -from lib.repositories.repo import Repository, RepositoryNotInitializedException - - -class EnvRepository(Repository): - """ - Enables database CRUD operations with models.Env - - Init Attributes: - environment: models.Env - """ - - def __init__(self, environment: Env = None): - super().__init__("environments") - self._env = environment - self._env_id = None - - @property - def env(self) -> Env: - return self._env - - @env.setter - def env(self, environment: "Env"): - self._env = environment - - @property - def env_id(self) -> str: - return str(self._env_id) - - @env_id.setter - def env_id(self, env_id: "str"): - self._env_id = env_id - - async def insert_env(self, env_data: dict): - collection = self.get_collection() - result = await collection.insert_one(env_data) - self.env_id = result.inserted_id - return self - - async def update_env(self, env_data: dict, env_id: str): - collection = self.get_collection() - await collection.update_one( - {"_id": ObjectId(env_id)}, {"$set": env_data} - ) - return self - - async def find_env(self, env_id: str): - collection = self.get_collection() - return await collection.find_one({"_id": ObjectId(env_id)}) - - async def delete_env(self, env_id: str): - collection = self.get_collection() - await collection.delete_one({"_id": ObjectId(env_id)}) - return self - - async def create_env(self): - """ - Creates a models.Env in the database - - Returns: - self - """ - try: - environment_to_dict = self.env.dict() - await self.insert_env(environment_to_dict) - except PyMongoError as e: - raise e from e - except RepositoryNotInitializedException as e: - raise e from e - else: - return self - finally: - logger.info( - f"Call to repositories.environment.create_env completed for Env {self.env_id}" - ) - - async def get_env_by_id(self, env_id: str) -> Self: - """ - Gets a models.Env from the database - - Returns: - self - """ - try: - read_env = await self.find_env(env_id) - parsed_env = Env.parse_obj(read_env) if read_env else None - self.env = parsed_env - except PyMongoError as e: - raise e from e - except RepositoryNotInitializedException as e: - raise e from e - else: - return self - finally: - logger.info( - f"Call to repositories.environment.get_env completed for Env {env_id}" - ) - - async def delete_env_by_id(self, env_id: str): - """ - Deletes a models.Env from the database - - Returns: - self - """ - try: - await self.delete_env(env_id) - except PyMongoError as e: - raise e from e - except RepositoryNotInitializedException as e: - raise e from e - else: - return self - finally: - logger.info( - f"Call to repositories.environment.delete_env completed for Env {env_id}" - ) - - async def update_env_by_id(self, env_id: str): - """ - Updates a models.Env in the database - - Returns: - self - """ - try: - environment_to_dict = self.env.dict() - await self.update_env(environment_to_dict, env_id) - except PyMongoError as e: - raise e from e - except RepositoryNotInitializedException as e: - raise e from e - else: - return self - finally: - logger.info( - f"Call to repositories.environment.update_env_by_id completed for Env {env_id}" - ) diff --git a/lib/repositories/flight.py b/lib/repositories/flight.py deleted file mode 100644 index 0c5f1a9..0000000 --- a/lib/repositories/flight.py +++ /dev/null @@ -1,212 +0,0 @@ -from typing import Self -from bson import ObjectId -from pymongo.errors import PyMongoError -from lib import logger -from lib.models.flight import Flight -from lib.models.motor import MotorKinds -from lib.repositories.repo import Repository, RepositoryNotInitializedException - - -class FlightRepository(Repository): - """ - Enables database CRUD operations with models.Flight - - Init Attributes: - flight: models.Flight - """ - - def __init__(self, flight: Flight = None): - super().__init__("flights") - self._flight = flight - self._flight_id = None - - @property - def flight(self) -> Flight: - return self._flight - - @flight.setter - def flight(self, flight: "Flight"): - self._flight = flight - - @property - def flight_id(self) -> str: - return str(self._flight_id) - - @flight_id.setter - def flight_id(self, flight_id: "str"): - self._flight_id = flight_id - - async def insert_flight(self, flight_data: dict): - collection = self.get_collection() - result = await collection.insert_one(flight_data) - self.flight_id = result.inserted_id - return self - - async def update_flight(self, flight_data: dict, flight_id: str): - collection = self.get_collection() - await collection.update_one( - {"_id": ObjectId(flight_id)}, {"$set": flight_data} - ) - return self - - async def update_env(self, env_data: dict, flight_id: str): - collection = self.get_collection() - await collection.update_one( - {"_id": ObjectId(flight_id)}, {"$set": {"environment": env_data}} - ) - return self - - async def update_rocket(self, rocket_data: dict, flight_id: str): - collection = self.get_collection() - await collection.update_one( - {"_id": ObjectId(flight_id)}, {"$set": {"rocket": rocket_data}} - ) - return self - - async def find_flight(self, flight_id: str): - collection = self.get_collection() - return await collection.find_one({"_id": ObjectId(flight_id)}) - - async def delete_flight(self, flight_id: str): - collection = self.get_collection() - await collection.delete_one({"_id": ObjectId(flight_id)}) - return self - - async def create_flight(self): - """ - Creates a models.Flight in the database - - Returns: - self - """ - try: - flight_to_dict = self.flight.dict() - flight_to_dict["rocket"]["motor"][ - "motor_kind" - ] = self.flight.rocket.motor.motor_kind.value - await self.insert_flight(flight_to_dict) - except PyMongoError as e: - raise e from e - except RepositoryNotInitializedException as e: - raise e from e - else: - return self - finally: - logger.info( - f"Call to repositories.flight.create_flight completed for Flight {self.flight_id}" - ) - - async def get_flight_by_id(self, flight_id: str) -> Self: - """ - Gets a models.Flight from the database - - Returns: - self - """ - try: - read_flight = await self.find_flight(flight_id) - if read_flight: - parsed_flight = Flight.parse_obj(read_flight) - parsed_flight.rocket.motor.set_motor_kind( - MotorKinds(read_flight["rocket"]["motor"]["motor_kind"]) - ) - self.flight = parsed_flight - except PyMongoError as e: - raise e from e - except RepositoryNotInitializedException as e: - raise e from e - else: - return self - finally: - logger.info( - f"Call to repositories.flight.get_flight completed for Flight {flight_id}" - ) - - async def delete_flight_by_id(self, flight_id: str): - """ - Deletes a models.Flight from the database - - Returns: - self - """ - try: - await self.delete_flight(flight_id) - except PyMongoError as e: - raise e from e - except RepositoryNotInitializedException as e: - raise e from e - else: - return self - finally: - logger.info( - f"Call to repositories.flight.delete_flight completed for Flight {flight_id}" - ) - - async def update_flight_by_id(self, flight_id: str): - """ - Updates a models.Flight in the database - - Returns: - self - """ - try: - flight_to_dict = self.flight.dict() - flight_to_dict["rocket"]["motor"][ - "motor_kind" - ] = self.flight.rocket.motor.motor_kind.value - await self.update_flight(flight_to_dict, flight_id) - except PyMongoError as e: - raise e from e - except RepositoryNotInitializedException as e: - raise e from e - else: - return self - finally: - logger.info( - f"Call to repositories.flight.update_flight_by_id completed for Flight {flight_id}" - ) - - async def update_env_by_flight_id(self, flight_id: str): - """ - Updates a models.Flight.Env in the database - - Returns: - self - """ - try: - env_to_dict = self.flight.environment.dict() - await self.update_env(env_to_dict, flight_id) - except PyMongoError as e: - raise e from e - except RepositoryNotInitializedException as e: - raise e from e - else: - return self - finally: - logger.info( - f"Call to repositories.flight.update_env_by_flight_id completed for Flight {flight_id}" - ) - - async def update_rocket_by_flight_id(self, flight_id: str): - """ - Updates a models.Flight.Rocket in the database - - Returns: - self - """ - try: - rocket_to_dict = self.flight.rocket.dict() - rocket_to_dict["motor"][ - "motor_kind" - ] = self.flight.rocket.motor.motor_kind.value - await self.update_rocket(rocket_to_dict, flight_id) - except PyMongoError as e: - raise e from e - except RepositoryNotInitializedException as e: - raise e from e - else: - return self - finally: - logger.info( - f"Call to repositories.flight.update_rocket_by_flight_id completed for Flight {flight_id}" - ) diff --git a/lib/repositories/motor.py b/lib/repositories/motor.py deleted file mode 100644 index f63be78..0000000 --- a/lib/repositories/motor.py +++ /dev/null @@ -1,148 +0,0 @@ -from typing import Self -from bson import ObjectId -from pymongo.errors import PyMongoError -from lib import logger -from lib.models.motor import Motor, MotorKinds -from lib.repositories.repo import Repository, RepositoryNotInitializedException - - -class MotorRepository(Repository): - """ - Enables database CRUD operations with models.Motor - - Init Attributes: - motor: models.Motor - """ - - def __init__(self, motor: Motor = None): - super().__init__("motors") - self._motor = motor - self._motor_id = None - - @property - def motor(self) -> Motor: - return self._motor - - @motor.setter - def motor(self, motor: "Motor"): - self._motor = motor - - @property - def motor_id(self) -> str: - return str(self._motor_id) - - @motor_id.setter - def motor_id(self, motor_id: "str"): - self._motor_id = motor_id - - async def insert_motor(self, motor_data: dict): - collection = self.get_collection() - result = await collection.insert_one(motor_data) - self.motor_id = result.inserted_id - return self - - async def update_motor(self, motor_data: dict, motor_id: str): - collection = self.get_collection() - await collection.update_one( - {"_id": ObjectId(motor_id)}, {"$set": motor_data} - ) - return self - - async def find_motor(self, motor_id: str): - collection = self.get_collection() - return await collection.find_one({"_id": ObjectId(motor_id)}) - - async def delete_motor(self, motor_id: str): - collection = self.get_collection() - await collection.delete_one({"_id": ObjectId(motor_id)}) - return self - - async def create_motor(self): - """ - Creates a models.Motor in the database - - Returns: - self - """ - try: - motor_to_dict = self.motor.dict() - motor_to_dict["motor_kind"] = self.motor.motor_kind.value - await self.insert_motor(motor_to_dict) - except PyMongoError as e: - raise e from e - except RepositoryNotInitializedException as e: - raise e from e - else: - return self - finally: - logger.info( - f"Call to repositories.motor.create_motor completed for Motor {self.motor_id}" - ) - - async def get_motor_by_id(self, motor_id: str) -> Self: - """ - Gets a models.Motor from the database - - Returns: - self - """ - try: - read_motor = await self.find_motor(motor_id) - if read_motor: - parsed_motor = Motor.parse_obj(read_motor) - parsed_motor.set_motor_kind( - MotorKinds(read_motor["motor_kind"]) - ) - self.motor = parsed_motor - except PyMongoError as e: - raise e from e - except RepositoryNotInitializedException as e: - raise e from e - else: - return self - finally: - logger.info( - f"Call to repositories.motor.get_motor completed for Motor {motor_id}" - ) - - async def delete_motor_by_id(self, motor_id: str): - """ - Deletes a models.Motor from the database - - Returns: - self - """ - try: - await self.delete_motor(motor_id) - except PyMongoError as e: - raise e from e - except RepositoryNotInitializedException as e: - raise e from e - else: - return self - finally: - logger.info( - f"Call to repositories.motor.delete_motor completed for Motor {motor_id}" - ) - - async def update_motor_by_id(self, motor_id: str): - """ - Updates a models.Motor in the database - - Returns: - self - """ - try: - motor_to_dict = self.motor.dict() - motor_to_dict["motor_kind"] = self.motor.motor_kind.value - await self.update_motor(motor_to_dict, motor_id) - except PyMongoError as e: - raise e from e - except RepositoryNotInitializedException as e: - raise e from e - else: - return self - finally: - logger.info( - f"Call to repositories.motor.update_motor completed for Motor {motor_id}" - ) diff --git a/lib/repositories/rocket.py b/lib/repositories/rocket.py deleted file mode 100644 index 951f9f1..0000000 --- a/lib/repositories/rocket.py +++ /dev/null @@ -1,153 +0,0 @@ -from typing import Self -from bson import ObjectId -from pymongo.errors import PyMongoError -from lib import logger -from lib.models.rocket import Rocket -from lib.models.motor import MotorKinds -from lib.repositories.repo import Repository, RepositoryNotInitializedException - - -class RocketRepository(Repository): - """ - Enables database CRUD operations with models.Rocket - - Init Attributes: - rocket: models.Rocket - """ - - def __init__(self, rocket: Rocket = None): - super().__init__("rockets") - self._rocket_id = None - self._rocket = rocket - - @property - def rocket(self) -> Rocket: - return self._rocket - - @rocket.setter - def rocket(self, rocket: "Rocket"): - self._rocket = rocket - - @property - def rocket_id(self) -> str: - return str(self._rocket_id) - - @rocket_id.setter - def rocket_id(self, rocket_id: "str"): - self._rocket_id = rocket_id - - async def insert_rocket(self, rocket_data: dict): - collection = self.get_collection() - result = await collection.insert_one(rocket_data) - self.rocket_id = result.inserted_id - return self - - async def update_rocket(self, rocket_data: dict, rocket_id: str): - collection = self.get_collection() - await collection.update_one( - {"_id": ObjectId(rocket_id)}, {"$set": rocket_data} - ) - return self - - async def find_rocket(self, rocket_id: str): - collection = self.get_collection() - return await collection.find_one({"_id": ObjectId(rocket_id)}) - - async def delete_rocket(self, rocket_id: str): - collection = self.get_collection() - await collection.delete_one({"_id": ObjectId(rocket_id)}) - return self - - async def create_rocket(self): - """ - Creates a models.Rocket in the database - - Returns: - self - """ - try: - rocket_to_dict = self.rocket.dict() - rocket_to_dict["motor"][ - "motor_kind" - ] = self.rocket.motor.motor_kind.value - await self.insert_rocket(rocket_to_dict) - except PyMongoError as e: - raise e from e - except RepositoryNotInitializedException as e: - raise e from e - else: - return self - finally: - logger.info( - f"Call to repositories.rocket.create_rocket completed for Rocket {self.rocket_id}" - ) - - async def get_rocket_by_id(self, rocket_id: str) -> Self: - """ - Gets a models.Rocket from the database - - Returns: - self - """ - try: - read_rocket = await self.find_rocket(rocket_id) - if read_rocket: - parsed_rocket = Rocket.parse_obj(read_rocket) - parsed_rocket.motor.set_motor_kind( - MotorKinds(read_rocket["motor"]["motor_kind"]) - ) - self.rocket = parsed_rocket - except PyMongoError as e: - raise e from e - except RepositoryNotInitializedException as e: - raise e from e - else: - return self - finally: - logger.info( - f"Call to repositories.rocket.get_rocket completed for Rocket {rocket_id}" - ) - - async def delete_rocket_by_id(self, rocket_id: str): - """ - Deletes a models.Rocket from the database - - Returns: - self - """ - try: - await self.delete_rocket(rocket_id) - except PyMongoError as e: - raise e from e - except RepositoryNotInitializedException as e: - raise e from e - else: - return self - finally: - logger.info( - f"Call to repositories.rocket.delete_rocket completed for Rocket {rocket_id}" - ) - - async def update_rocket_by_id(self, rocket_id: str): - """ - Updates a models.Rocket in the database - - Returns: - self - """ - try: - rocket_to_dict = self.rocket.dict() - rocket_to_dict["motor"][ - "motor_kind" - ] = self.rocket.motor.motor_kind.value - await self.update_rocket(rocket_to_dict, rocket_id) - except PyMongoError as e: - raise e from e - except RepositoryNotInitializedException as e: - raise e from e - else: - return self - finally: - logger.info( - f"Call to repositories.rocket.update_rocket_by_id completed for Rocket {rocket_id}" - ) diff --git a/lib/routes/environment.py b/lib/routes/environment.py deleted file mode 100644 index 6834ed3..0000000 --- a/lib/routes/environment.py +++ /dev/null @@ -1,121 +0,0 @@ -""" -Environment routes -""" - -from fastapi import APIRouter, Response -from opentelemetry import trace - -from lib.views.environment import ( - EnvSummary, - EnvCreated, - EnvUpdated, - EnvDeleted, -) -from lib.models.environment import Env -from lib.controllers.environment import EnvController - -router = APIRouter( - prefix="/environments", - tags=["ENVIRONMENT"], - responses={ - 404: {"description": "Not found"}, - 422: {"description": "Unprocessable Entity"}, - 500: {"description": "Internal Server Error"}, - }, -) - -tracer = trace.get_tracer(__name__) - - -@router.post("/") -async def create_env(env: Env) -> EnvCreated: - """ - Creates a new environment - - ## Args - ``` models.Env JSON ``` - """ - with tracer.start_as_current_span("create_env"): - return await EnvController.create_env(env) - - -@router.get("/{env_id}") -async def read_env(env_id: str) -> Env: - """ - Reads an environment - - ## Args - ``` env_id: str ``` - """ - with tracer.start_as_current_span("read_env"): - return await EnvController.get_env_by_id(env_id) - - -@router.put("/{env_id}") -async def update_env(env_id: str, env: Env) -> EnvUpdated: - """ - Updates an environment - - ## Args - ``` - env_id: str - env: models.Env JSON - ``` - """ - with tracer.start_as_current_span("update_env"): - return await EnvController.update_env_by_id(env_id, env) - - -@router.get( - "/{env_id}/rocketpy", - responses={ - 203: { - "description": "Binary file download", - "content": {"application/octet-stream": {}}, - } - }, - status_code=203, - response_class=Response, -) -async def read_rocketpy_env(env_id: str): - """ - Loads rocketpy.environment as a dill binary - - ## Args - ``` env_id: str ``` - """ - with tracer.start_as_current_span("read_rocketpy_env"): - headers = { - 'Content-Disposition': f'attachment; filename="rocketpy_environment_{env_id}.dill"' - } - binary = await EnvController.get_rocketpy_env_binary(env_id) - return Response( - content=binary, - headers=headers, - media_type="application/octet-stream", - status_code=203, - ) - - -@router.get("/{env_id}/summary") -async def simulate_env(env_id: str) -> EnvSummary: - """ - Loads rocketpy.environment simulation - - ## Args - ``` env_id: str ``` - """ - with tracer.start_as_current_span("simulate_env"): - return await EnvController.simulate_env(env_id) - - -@router.delete("/{env_id}") -async def delete_env(env_id: str) -> EnvDeleted: - """ - Deletes an environment - - ## Args - ``` env_id: str ``` - """ - with tracer.start_as_current_span("delete_env"): - return await EnvController.delete_env_by_id(env_id) diff --git a/lib/routes/flight.py b/lib/routes/flight.py deleted file mode 100644 index e88b87a..0000000 --- a/lib/routes/flight.py +++ /dev/null @@ -1,173 +0,0 @@ -""" -Flight routes -""" - -from fastapi import APIRouter, Response -from opentelemetry import trace - -from lib.views.flight import ( - FlightSummary, - FlightCreated, - FlightUpdated, - FlightDeleted, -) -from lib.models.environment import Env -from lib.models.flight import Flight -from lib.models.rocket import Rocket -from lib.models.motor import MotorKinds -from lib.views.flight import FlightView -from lib.controllers.flight import FlightController - -router = APIRouter( - prefix="/flights", - tags=["FLIGHT"], - responses={ - 404: {"description": "Not found"}, - 422: {"description": "Unprocessable Entity"}, - 500: {"description": "Internal Server Error"}, - }, -) - -tracer = trace.get_tracer(__name__) - - -@router.post("/") -async def create_flight( - flight: Flight, motor_kind: MotorKinds -) -> FlightCreated: - """ - Creates a new flight - - ## Args - ``` Flight object as JSON ``` - """ - with tracer.start_as_current_span("create_flight"): - flight.rocket.motor.set_motor_kind(motor_kind) - return await FlightController.create_flight(flight) - - -@router.get("/{flight_id}") -async def read_flight(flight_id: str) -> FlightView: - """ - Reads a flight - - ## Args - ``` flight_id: Flight ID ``` - """ - with tracer.start_as_current_span("read_flight"): - return await FlightController.get_flight_by_id(flight_id) - - -@router.get( - "/{flight_id}/rocketpy", - responses={ - 203: { - "description": "Binary file download", - "content": {"application/octet-stream": {}}, - } - }, - status_code=203, - response_class=Response, -) -async def read_rocketpy_flight(flight_id: str): - """ - Loads rocketpy.flight as a dill binary - - ## Args - ``` flight_id: str ``` - """ - with tracer.start_as_current_span("read_rocketpy_flight"): - headers = { - 'Content-Disposition': f'attachment; filename="rocketpy_flight_{flight_id}.dill"' - } - binary = await FlightController.get_rocketpy_flight_binary(flight_id) - return Response( - content=binary, - headers=headers, - media_type="application/octet-stream", - status_code=203, - ) - - -@router.put("/{flight_id}/env") -async def update_flight_env(flight_id: str, env: Env) -> FlightUpdated: - """ - Updates flight environment - - ## Args - ``` - flight_id: Flight ID - env: env object as JSON - ``` - """ - with tracer.start_as_current_span("update_flight_env"): - return await FlightController.update_env_by_flight_id( - flight_id, env=env - ) - - -@router.put("/{flight_id}/rocket") -async def update_flight_rocket( - flight_id: str, - rocket: Rocket, - motor_kind: MotorKinds, -) -> FlightUpdated: - """ - Updates flight rocket. - - ## Args - ``` - flight_id: Flight ID - rocket: Rocket object as JSON - ``` - """ - with tracer.start_as_current_span("update_flight_rocket"): - rocket.motor.set_motor_kind(motor_kind) - return await FlightController.update_rocket_by_flight_id( - flight_id, - rocket=rocket, - ) - - -@router.put("/{flight_id}") -async def update_flight( - flight_id: str, - flight: Flight, - motor_kind: MotorKinds, -) -> FlightUpdated: - """ - Updates Flight object - - ## Args - ``` - flight_id: Flight ID - flight: Flight object as JSON - ``` - """ - with tracer.start_as_current_span("update_flight"): - flight.rocket.motor.set_motor_kind(motor_kind) - return await FlightController.update_flight_by_id(flight_id, flight) - - -@router.get("/{flight_id}/summary") -async def simulate_flight(flight_id: str) -> FlightSummary: - """ - Simulates a flight - - ## Args - ``` flight_id: Flight ID ``` - """ - with tracer.start_as_current_span("simulate_flight"): - return await FlightController.simulate_flight(flight_id) - - -@router.delete("/{flight_id}") -async def delete_flight(flight_id: str) -> FlightDeleted: - """ - Deletes a flight - - ## Args - ``` flight_id: Flight ID ``` - """ - with tracer.start_as_current_span("delete_flight"): - return await FlightController.delete_flight_by_id(flight_id) diff --git a/lib/routes/motor.py b/lib/routes/motor.py deleted file mode 100644 index 5a89da7..0000000 --- a/lib/routes/motor.py +++ /dev/null @@ -1,126 +0,0 @@ -""" -Motor routes -""" - -from fastapi import APIRouter, Response -from opentelemetry import trace - -from lib.views.motor import ( - MotorSummary, - MotorCreated, - MotorUpdated, - MotorDeleted, -) -from lib.models.motor import Motor, MotorKinds -from lib.controllers.motor import MotorController -from lib.views.motor import MotorView - -router = APIRouter( - prefix="/motors", - tags=["MOTOR"], - responses={ - 404: {"description": "Not found"}, - 422: {"description": "Unprocessable Entity"}, - 500: {"description": "Internal Server Error"}, - }, -) - -tracer = trace.get_tracer(__name__) - - -@router.post("/") -async def create_motor(motor: Motor, motor_kind: MotorKinds) -> MotorCreated: - """ - Creates a new motor - - ## Args - ``` Motor object as a JSON ``` - """ - with tracer.start_as_current_span("create_motor"): - motor.set_motor_kind(motor_kind) - return await MotorController.create_motor(motor) - - -@router.get("/{motor_id}") -async def read_motor(motor_id: str) -> MotorView: - """ - Reads a motor - - ## Args - ``` motor_id: Motor ID ``` - """ - with tracer.start_as_current_span("read_motor"): - return await MotorController.get_motor_by_id(motor_id) - - -@router.put("/{motor_id}") -async def update_motor( - motor_id: str, motor: Motor, motor_kind: MotorKinds -) -> MotorUpdated: - """ - Updates a motor - - ## Args - ``` - motor_id: Motor ID - motor: Motor object as JSON - ``` - """ - with tracer.start_as_current_span("update_motor"): - motor.set_motor_kind(motor_kind) - return await MotorController.update_motor_by_id(motor_id, motor) - - -@router.get( - "/{motor_id}/rocketpy", - responses={ - 203: { - "description": "Binary file download", - "content": {"application/octet-stream": {}}, - } - }, - status_code=203, - response_class=Response, -) -async def read_rocketpy_motor(motor_id: str): - """ - Loads rocketpy.motor as a dill binary - - ## Args - ``` motor_id: str ``` - """ - with tracer.start_as_current_span("read_rocketpy_motor"): - headers = { - 'Content-Disposition': f'attachment; filename="rocketpy_motor_{motor_id}.dill"' - } - binary = await MotorController.get_rocketpy_motor_binary(motor_id) - return Response( - content=binary, - headers=headers, - media_type="application/octet-stream", - status_code=203, - ) - - -@router.get("/{motor_id}/summary") -async def simulate_motor(motor_id: str) -> MotorSummary: - """ - Simulates a motor - - ## Args - ``` motor_id: Motor ID ``` - """ - with tracer.start_as_current_span("simulate_motor"): - return await MotorController.simulate_motor(motor_id) - - -@router.delete("/{motor_id}") -async def delete_motor(motor_id: str) -> MotorDeleted: - """ - Deletes a motor - - ## Args - ``` motor_id: Motor ID ``` - """ - with tracer.start_as_current_span("delete_motor"): - return await MotorController.delete_motor_by_id(motor_id) diff --git a/lib/routes/rocket.py b/lib/routes/rocket.py deleted file mode 100644 index f847754..0000000 --- a/lib/routes/rocket.py +++ /dev/null @@ -1,131 +0,0 @@ -""" -Rocket routes -""" - -from fastapi import APIRouter, Response -from opentelemetry import trace - -from lib.views.rocket import ( - RocketSummary, - RocketCreated, - RocketUpdated, - RocketDeleted, -) -from lib.models.rocket import Rocket -from lib.models.motor import MotorKinds -from lib.views.rocket import RocketView -from lib.controllers.rocket import RocketController - -router = APIRouter( - prefix="/rockets", - tags=["ROCKET"], - responses={ - 404: {"description": "Not found"}, - 422: {"description": "Unprocessable Entity"}, - 500: {"description": "Internal Server Error"}, - }, -) - -tracer = trace.get_tracer(__name__) - - -@router.post("/") -async def create_rocket( - rocket: Rocket, motor_kind: MotorKinds -) -> RocketCreated: - """ - Creates a new rocket - - ## Args - ``` Rocket object as a JSON ``` - """ - with tracer.start_as_current_span("create_rocket"): - rocket.motor.set_motor_kind(motor_kind) - return await RocketController.create_rocket(rocket) - - -@router.get("/{rocket_id}") -async def read_rocket(rocket_id: str) -> RocketView: - """ - Reads a rocket - - ## Args - ``` rocket_id: Rocket ID ``` - """ - with tracer.start_as_current_span("read_rocket"): - return await RocketController.get_rocket_by_id(rocket_id) - - -@router.put("/{rocket_id}") -async def update_rocket( - rocket_id: str, - rocket: Rocket, - motor_kind: MotorKinds, -) -> RocketUpdated: - """ - Updates a rocket - - ## Args - ``` - rocket_id: Rocket ID - rocket: Rocket object as JSON - ``` - """ - with tracer.start_as_current_span("update_rocket"): - rocket.motor.set_motor_kind(motor_kind) - return await RocketController.update_rocket_by_id(rocket_id, rocket) - - -@router.get( - "/{rocket_id}/rocketpy", - responses={ - 203: { - "description": "Binary file download", - "content": {"application/octet-stream": {}}, - } - }, - status_code=203, - response_class=Response, -) -async def read_rocketpy_rocket(rocket_id: str): - """ - Loads rocketpy.rocket as a dill binary - - ## Args - ``` rocket_id: str ``` - """ - with tracer.start_as_current_span("read_rocketpy_rocket"): - headers = { - 'Content-Disposition': f'attachment; filename="rocketpy_rocket_{rocket_id}.dill"' - } - binary = await RocketController.get_rocketpy_rocket_binary(rocket_id) - return Response( - content=binary, - headers=headers, - media_type="application/octet-stream", - status_code=203, - ) - - -@router.get("/{rocket_id}/summary") -async def simulate_rocket(rocket_id: str) -> RocketSummary: - """ - Simulates a rocket - - ## Args - ``` rocket_id: Rocket ID ``` - """ - with tracer.start_as_current_span("simulate_rocket"): - return await RocketController.simulate_rocket(rocket_id) - - -@router.delete("/{rocket_id}") -async def delete_rocket(rocket_id: str) -> RocketDeleted: - """ - Deletes a rocket - - ## Args - ``` rocket_id: Rocket ID ``` - """ - with tracer.start_as_current_span("delete_rocket"): - return await RocketController.delete_rocket_by_id(rocket_id) diff --git a/lib/views/environment.py b/lib/views/environment.py deleted file mode 100644 index e3fcd31..0000000 --- a/lib/views/environment.py +++ /dev/null @@ -1,67 +0,0 @@ -from typing import Optional, Any -from datetime import datetime, timedelta -from pydantic import BaseModel, ConfigDict -from lib.models.environment import AtmosphericModelTypes -from lib.utils import to_python_primitive - - -class EnvSummary(BaseModel): - latitude: Optional[float] = None - longitude: Optional[float] = None - elevation: Optional[float] = 1 - atmospheric_model_type: Optional[str] = ( - AtmosphericModelTypes.STANDARD_ATMOSPHERE.value - ) - air_gas_constant: Optional[float] = None - standard_g: Optional[float] = None - earth_radius: Optional[float] = None - datum: Optional[str] = None - timezone: Optional[str] = None - initial_utm_zone: Optional[int] = None - initial_utm_letter: Optional[str] = None - initial_north: Optional[float] = None - initial_east: Optional[float] = None - initial_hemisphere: Optional[str] = None - initial_ew: Optional[str] = None - max_expected_height: Optional[int] = None - date: Optional[datetime] = datetime.today() + timedelta(days=1) - local_date: Optional[datetime] = datetime.today() + timedelta(days=1) - datetime_date: Optional[datetime] = datetime.today() + timedelta(days=1) - ellipsoid: Optional[Any] = None - barometric_height: Optional[Any] = None - barometric_height_ISA: Optional[Any] = None - pressure: Optional[Any] = None - pressure_ISA: Optional[Any] = None - temperature: Optional[Any] = None - temperature_ISA: Optional[Any] = None - density: Optional[Any] = None - speed_of_sound: Optional[Any] = None - dynamic_viscosity: Optional[Any] = None - gravity: Optional[Any] = None - somigliana_gravity: Optional[Any] = None - wind_speed: Optional[Any] = None - wind_direction: Optional[Any] = None - wind_heading: Optional[Any] = None - wind_velocity_x: Optional[Any] = None - wind_velocity_y: Optional[Any] = None - calculate_earth_radius: Optional[Any] = None - decimal_degrees_to_arc_seconds: Optional[Any] = None - geodesic_to_utm: Optional[Any] = None - utm_to_geodesic: Optional[Any] = None - - model_config = ConfigDict(json_encoders={Any: to_python_primitive}) - - -class EnvCreated(BaseModel): - env_id: str - message: str = "Environment successfully created" - - -class EnvUpdated(BaseModel): - env_id: str - message: str = "Environment successfully updated" - - -class EnvDeleted(BaseModel): - env_id: str - message: str = "Environment successfully deleted" diff --git a/lib/views/flight.py b/lib/views/flight.py deleted file mode 100644 index f06cae4..0000000 --- a/lib/views/flight.py +++ /dev/null @@ -1,173 +0,0 @@ -from typing import Optional, Any -from pydantic import BaseModel, ConfigDict -from lib.models.flight import Flight -from lib.views.rocket import RocketView, RocketSummary -from lib.views.environment import EnvSummary -from lib.utils import to_python_primitive - - -class FlightSummary(RocketSummary, EnvSummary): - name: Optional[str] = None - max_time: Optional[int] = None - min_time_step: Optional[int] = None - max_time_step: Optional[Any] = None - equations_of_motion: Optional[str] = None - heading: Optional[int] = None - inclination: Optional[int] = None - initial_solution: Optional[list] = None - effective_1rl: Optional[float] = None - effective_2rl: Optional[float] = None - out_of_rail_time: Optional[float] = None - out_of_rail_time_index: Optional[int] = None - parachute_cd_s: Optional[float] = None - rail_length: Optional[float] = None - rtol: Optional[float] = None - t: Optional[float] = None - t_final: Optional[float] = None - t_initial: Optional[int] = None - terminate_on_apogee: Optional[bool] = None - time_overshoot: Optional[bool] = None - latitude: Optional[Any] = None - longitude: Optional[Any] = None - M1: Optional[Any] = None - M2: Optional[Any] = None - M3: Optional[Any] = None - R1: Optional[Any] = None - R2: Optional[Any] = None - R3: Optional[Any] = None - acceleration: Optional[Any] = None - aerodynamic_bending_moment: Optional[Any] = None - aerodynamic_drag: Optional[Any] = None - aerodynamic_lift: Optional[Any] = None - aerodynamic_spin_moment: Optional[Any] = None - alpha1: Optional[Any] = None - alpha2: Optional[Any] = None - alpha3: Optional[Any] = None - altitude: Optional[Any] = None - angle_of_attack: Optional[Any] = None - apogee: Optional[Any] = None - apogee_freestream_speed: Optional[Any] = None - apogee_state: Optional[Any] = None - apogee_time: Optional[Any] = None - apogee_x: Optional[Any] = None - apogee_y: Optional[Any] = None - atol: Optional[Any] = None - attitude_angle: Optional[Any] = None - attitude_frequency_response: Optional[Any] = None - attitude_vector_x: Optional[Any] = None - attitude_vector_y: Optional[Any] = None - attitude_vector_z: Optional[Any] = None - ax: Optional[Any] = None - ay: Optional[Any] = None - az: Optional[Any] = None - bearing: Optional[Any] = None - drag_power: Optional[Any] = None - drift: Optional[Any] = None - dynamic_pressure: Optional[Any] = None - e0: Optional[Any] = None - e1: Optional[Any] = None - e2: Optional[Any] = None - e3: Optional[Any] = None - free_stream_speed: Optional[Any] = None - frontal_surface_wind: Optional[Any] = None - function_evaluations: Optional[Any] = None - function_evaluations_per_time_step: Optional[Any] = None - horizontal_speed: Optional[Any] = None - impact_state: Optional[Any] = None - impact_velocity: Optional[Any] = None - initial_stability_margin: Optional[Any] = None - kinetic_energy: Optional[Any] = None - lateral_attitude_angle: Optional[Any] = None - lateral_surface_wind: Optional[Any] = None - mach_number: Optional[Any] = None - max_acceleration: Optional[Any] = None - max_acceleration_power_off: Optional[Any] = None - max_acceleration_power_off_time: Optional[Any] = None - max_acceleration_power_on: Optional[Any] = None - max_acceleration_power_on_time: Optional[Any] = None - max_acceleration_time: Optional[Any] = None - max_dynamic_pressure: Optional[Any] = None - max_dynamic_pressure_time: Optional[Any] = None - max_mach_number: Optional[Any] = None - max_mach_number_time: Optional[Any] = None - max_rail_button1_normal_force: Optional[Any] = None - max_rail_button1_shear_force: Optional[Any] = None - max_rail_button2_normal_force: Optional[Any] = None - max_rail_button2_shear_force: Optional[Any] = None - max_reynolds_number: Optional[Any] = None - max_reynolds_number_time: Optional[Any] = None - max_speed: Optional[Any] = None - max_speed_time: Optional[Any] = None - max_stability_margin: Optional[Any] = None - max_stability_margin_time: Optional[Any] = None - max_total_pressure: Optional[Any] = None - max_total_pressure_time: Optional[Any] = None - min_stability_margin: Optional[Any] = None - min_stability_margin_time: Optional[Any] = None - omega1_frequency_response: Optional[Any] = None - omega2_frequency_response: Optional[Any] = None - omega3_frequency_response: Optional[Any] = None - out_of_rail_stability_margin: Optional[Any] = None - out_of_rail_state: Optional[Any] = None - out_of_rail_velocity: Optional[Any] = None - parachute_events: Optional[Any] = None - path_angle: Optional[Any] = None - phi: Optional[Any] = None - potential_energy: Optional[Any] = None - psi: Optional[Any] = None - rail_button1_normal_force: Optional[Any] = None - rail_button1_shear_force: Optional[Any] = None - rail_button2_normal_force: Optional[Any] = None - rail_button2_shear_force: Optional[Any] = None - reynolds_number: Optional[Any] = None - rotational_energy: Optional[Any] = None - solution: Optional[Any] = None - solution_array: Optional[Any] = None - speed: Optional[Any] = None - stability_margin: Optional[Any] = None - static_margin: Optional[Any] = None - stream_velocity_x: Optional[Any] = None - stream_velocity_y: Optional[Any] = None - stream_velocity_z: Optional[Any] = None - theta: Optional[Any] = None - thrust_power: Optional[Any] = None - time: Optional[Any] = None - time_steps: Optional[Any] = None - total_energy: Optional[Any] = None - total_pressure: Optional[Any] = None - translational_energy: Optional[Any] = None - vx: Optional[Any] = None - vy: Optional[Any] = None - vz: Optional[Any] = None - w1: Optional[Any] = None - w2: Optional[Any] = None - w3: Optional[Any] = None - x: Optional[Any] = None - x_impact: Optional[Any] = None - y: Optional[Any] = None - y_impact: Optional[Any] = None - y_sol: Optional[Any] = None - z: Optional[Any] = None - z_impact: Optional[Any] = None - flight_phases: Optional[Any] = None - - model_config = ConfigDict(json_encoders={Any: to_python_primitive}) - - -class FlightCreated(BaseModel): - flight_id: str - message: str = "Flight successfully created" - - -class FlightUpdated(BaseModel): - flight_id: str - message: str = "Flight successfully updated" - - -class FlightDeleted(BaseModel): - flight_id: str - message: str = "Flight successfully deleted" - - -class FlightView(Flight): - rocket: RocketView diff --git a/lib/views/motor.py b/lib/views/motor.py deleted file mode 100644 index 858bc0e..0000000 --- a/lib/views/motor.py +++ /dev/null @@ -1,91 +0,0 @@ -from typing import List, Any, Optional -from pydantic import BaseModel, ConfigDict -from lib.models.motor import Motor, MotorKinds, CoordinateSystemOrientation -from lib.utils import to_python_primitive - - -class MotorSummary(BaseModel): - average_thrust: Optional[float] = None - burn_duration: Optional[float] = None - burn_out_time: Optional[float] = None - burn_start_time: Optional[float] = None - center_of_dry_mass_position: Optional[float] = None - coordinate_system_orientation: str = ( - CoordinateSystemOrientation.NOZZLE_TO_COMBUSTION_CHAMBER.value - ) - dry_I_11: Optional[float] = None - dry_I_12: Optional[float] = None - dry_I_13: Optional[float] = None - dry_I_22: Optional[float] = None - dry_I_23: Optional[float] = None - dry_I_33: Optional[float] = None - dry_mass: Optional[float] = None - grain_burn_out: Optional[float] = None - grain_density: Optional[float] = None - grain_initial_height: Optional[float] = None - grain_initial_inner_radius: Optional[float] = None - grain_initial_mass: Optional[float] = None - grain_initial_volume: Optional[float] = None - grain_number: Optional[int] = None - grain_outer_radius: Optional[float] = None - grain_separation: Optional[float] = None - grains_center_of_mass_position: Optional[float] = None - interpolate: Optional[str] = None - max_thrust: Optional[float] = None - max_thrust_time: Optional[float] = None - nozzle_position: Optional[float] = None - nozzle_radius: Optional[float] = None - propellant_initial_mass: Optional[float] = None - throat_area: Optional[float] = None - throat_radius: Optional[float] = None - thrust_source: Optional[List[List[float]]] = None - total_impulse: Optional[float] = None - Kn: Optional[Any] = None - I_11: Optional[Any] = None - I_12: Optional[Any] = None - I_13: Optional[Any] = None - I_22: Optional[Any] = None - I_23: Optional[Any] = None - I_33: Optional[Any] = None - burn_area: Optional[Any] = None - burn_rate: Optional[Any] = None - burn_time: Optional[Any] = None - center_of_mass: Optional[Any] = None - center_of_propellant_mass: Optional[Any] = None - exhaust_velocity: Optional[Any] = None - grain_height: Optional[Any] = None - grain_volume: Optional[Any] = None - grain_inner_radius: Optional[Any] = None - mass_flow_rate: Optional[Any] = None - propellant_I_11: Optional[Any] = None - propellant_I_12: Optional[Any] = None - propellant_I_13: Optional[Any] = None - propellant_I_22: Optional[Any] = None - propellant_I_23: Optional[Any] = None - propellant_I_33: Optional[Any] = None - propellant_mass: Optional[Any] = None - reshape_thrust_curve: Optional[Any] = None - total_mass: Optional[Any] = None - total_mass_flow_rate: Optional[Any] = None - thrust: Optional[Any] = None - - model_config = ConfigDict(json_encoders={Any: to_python_primitive}) - - -class MotorCreated(BaseModel): - motor_id: str - message: str = "Motor successfully created" - - -class MotorUpdated(BaseModel): - motor_id: str - message: str = "Motor successfully updated" - - -class MotorDeleted(BaseModel): - motor_id: str - message: str = "Motor successfully deleted" - - -class MotorView(Motor): - selected_motor_kind: MotorKinds diff --git a/lib/views/rocket.py b/lib/views/rocket.py deleted file mode 100644 index 45664ad..0000000 --- a/lib/views/rocket.py +++ /dev/null @@ -1,60 +0,0 @@ -from typing import Any, Optional -from pydantic import BaseModel, ConfigDict -from lib.models.rocket import Rocket, CoordinateSystemOrientation -from lib.views.motor import MotorView, MotorSummary -from lib.utils import to_python_primitive - - -class RocketSummary(MotorSummary): - area: Optional[float] = None - coordinate_system_orientation: str = ( - CoordinateSystemOrientation.TAIL_TO_NOSE.value - ) - center_of_mass_without_motor: Optional[float] = None - motor_center_of_dry_mass_position: Optional[float] = None - motor_position: Optional[float] = None - nozzle_position: Optional[float] = None - nozzle_to_cdm: Optional[float] = None - cp_eccentricity_x: Optional[float] = None - cp_eccentricity_y: Optional[float] = None - thrust_eccentricity_x: Optional[float] = None - thrust_eccentricity_y: Optional[float] = None - I_11_without_motor: Optional[Any] = None - I_12_without_motor: Optional[Any] = None - I_13_without_motor: Optional[Any] = None - I_22_without_motor: Optional[Any] = None - I_23_without_motor: Optional[Any] = None - I_33_without_motor: Optional[Any] = None - check_parachute_trigger: Optional[Any] = None - com_to_cdm_function: Optional[Any] = None - cp_position: Optional[Any] = None - motor_center_of_mass_position: Optional[Any] = None - nozzle_gyration_tensor: Optional[Any] = None - power_off_drag: Optional[Any] = None - power_on_drag: Optional[Any] = None - reduced_mass: Optional[Any] = None - stability_margin: Optional[Any] = None - static_margin: Optional[Any] = None - thrust_to_weight: Optional[Any] = None - total_lift_coeff_der: Optional[Any] = None - - model_config = ConfigDict(json_encoders={Any: to_python_primitive}) - - -class RocketCreated(BaseModel): - rocket_id: str - message: str = "Rocket successfully created" - - -class RocketUpdated(BaseModel): - rocket_id: str - message: str = "Rocket successfully updated" - - -class RocketDeleted(BaseModel): - rocket_id: str - message: str = "Rocket successfully deleted" - - -class RocketView(Rocket): - motor: MotorView diff --git a/pyproject.toml b/pyproject.toml index eb8954a..a88cbc7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,11 @@ Documentation = "http://api.rocketpy.org/docs" Repository = "https://github.com/RocketPy-Team/infinity-api" "Bug Tracker" = "https://github.com/RocketPy-Team/Infinity-API/issues" +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] +addopts = "--import-mode=importlib" + [tool.black] line-length = 79 include = '\.py$' @@ -47,6 +52,9 @@ disable = """ too-many-arguments, redefined-outer-name, too-many-positional-arguments, + no-member, + protected-access, + import-outside-toplevel, """ [tool.ruff] @@ -55,7 +63,7 @@ target-version = "py312" [tool.ruff.lint] select = ["E", "F", "N", "Q"] -ignore = ["N815", "E501", "Q000", "E402"] +ignore = ["N815", "E501", "Q000", "E402", "N802"] fixable = [ "F401", ] diff --git a/requirements-dev.txt b/requirements-dev.txt index 9b83bee..a20dfb7 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,7 @@ +pytest_asyncio flake8 pylint ruff pytest httpx +black diff --git a/requirements.txt b/requirements.txt index 8d7b739..336559c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ motor dill python-dotenv fastapi +uvloop pydantic numpy==1.26.4 pymongo diff --git a/lib/__init__.py b/src/__init__.py similarity index 89% rename from lib/__init__.py rename to src/__init__.py index 4eecb2e..38835c1 100644 --- a/lib/__init__.py +++ b/src/__init__.py @@ -1,4 +1,4 @@ -# lib/__init__.py +# src.__init__.py import logging import sys @@ -25,6 +25,6 @@ def parse_error(error): return f"{exc_type}: {exc_obj}" -from lib.api import ( # pylint: disable=wrong-import-position,cyclic-import,useless-import-alias +from src.api import ( # pylint: disable=wrong-import-position,cyclic-import,useless-import-alias app as app, ) diff --git a/lib/__main__.py b/src/__main__.py similarity index 77% rename from lib/__main__.py rename to src/__main__.py index 38e9fef..a6937c4 100644 --- a/lib/__main__.py +++ b/src/__main__.py @@ -1,5 +1,5 @@ # __main__.py -from lib.api import app +from src.api import app if __name__ == '__main__': app.run() # pylint: disable=no-member diff --git a/lib/api.py b/src/api.py similarity index 87% rename from lib/api.py rename to src/api.py index 91afdb6..5b97be0 100644 --- a/lib/api.py +++ b/src/api.py @@ -1,19 +1,14 @@ -""" -This is the main API file for the RocketPy API. -""" - from fastapi import FastAPI, Request, status from fastapi.exceptions import RequestValidationError -from fastapi.middleware.cors import CORSMiddleware from fastapi.openapi.utils import get_openapi from fastapi.responses import RedirectResponse, JSONResponse from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor from opentelemetry.instrumentation.requests import RequestsInstrumentor -from lib import logger, parse_error -from lib.routes import flight, environment, motor, rocket -from lib.utils import RocketPyGZipMiddleware +from src import logger, parse_error +from src.routes import flight, environment, motor, rocket +from src.utils import RocketPyGZipMiddleware app = FastAPI( swagger_ui_parameters={ @@ -21,14 +16,6 @@ "syntaxHighlight.theme": "obsidian", } ) - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) app.include_router(flight.router) app.include_router(environment.router) app.include_router(motor.router) @@ -46,7 +33,7 @@ def custom_openapi(): return app.openapi_schema openapi_schema = get_openapi( title="RocketPy Infinity-API", - version="2.2.0", + version="3.0.0", description=( "

RocketPy Infinity-API is a RESTful Open API for RocketPy, a rocket flight simulator.

" "
" @@ -87,7 +74,7 @@ async def __perform_healthcheck(): return {"health": "Everything OK!"} -# Errors +# Global exception handler @app.exception_handler(RequestValidationError) async def validation_exception_handler( request: Request, exc: RequestValidationError diff --git a/src/controllers/__init__.py b/src/controllers/__init__.py new file mode 100644 index 0000000..9eaeadf --- /dev/null +++ b/src/controllers/__init__.py @@ -0,0 +1 @@ +# src.controllers/__init__.py diff --git a/src/controllers/environment.py b/src/controllers/environment.py new file mode 100644 index 0000000..3b5b075 --- /dev/null +++ b/src/controllers/environment.py @@ -0,0 +1,65 @@ +from src.controllers.interface import ( + ControllerBase, + controller_exception_handler, +) +from src.views.environment import EnvironmentSimulation +from src.models.environment import EnvironmentModel +from src.services.environment import EnvironmentService + + +class EnvironmentController(ControllerBase): + """ + Controller for the Environment model. + + Enables: + - Simulation of a RocketPy Environment. + - CRUD for Environment BaseApiModel. + """ + + def __init__(self): + super().__init__(models=[EnvironmentModel]) + + @controller_exception_handler + async def get_rocketpy_environment_binary( + self, + env_id: str, + ) -> bytes: + """ + Get rocketpy.Environmnet dill binary. + + Args: + env_id: str + + Returns: + bytes + + Raises: + HTTP 404 Not Found: If the env is not found in the database. + """ + env_retrieved = await self.get_environment_by_id(env_id) + env_service = EnvironmentService.from_env_model( + env_retrieved.environment + ) + return env_service.get_environment_binary() + + @controller_exception_handler + async def get_environment_simulation( + self, env_id: str + ) -> EnvironmentSimulation: + """ + Simulate a rocket environment. + + Args: + env_id: str. + + Returns: + EnvironmentSimulation + + Raises: + HTTP 404 Not Found: If the env does not exist in the database. + """ + env_retrieved = await self.get_environment_by_id(env_id) + env_service = EnvironmentService.from_env_model( + env_retrieved.environment + ) + return env_service.get_environment_simulation() diff --git a/src/controllers/flight.py b/src/controllers/flight.py new file mode 100644 index 0000000..ac8624a --- /dev/null +++ b/src/controllers/flight.py @@ -0,0 +1,112 @@ +from src.controllers.interface import ( + ControllerBase, + controller_exception_handler, +) +from src.views.flight import FlightSimulation +from src.models.flight import FlightModel +from src.models.environment import EnvironmentModel +from src.models.rocket import RocketModel +from src.services.flight import FlightService + + +class FlightController(ControllerBase): + """ + Controller for the Flight model. + + Enables: + - Simulation of a RocketPy Flight. + - CRUD for Flight BaseApiModel. + """ + + def __init__(self): + super().__init__(models=[FlightModel]) + + @controller_exception_handler + async def update_environment_by_flight_id( + self, flight_id: str, *, environment: EnvironmentModel + ) -> None: + """ + Update a models.Flight.environment in the database. + + Args: + flight_id: str + environment: models.Environment + + Returns: + None + + Raises: + HTTP 404 Not Found: If the flight is not found in the database. + """ + flight = await self.get_flight_by_id(flight_id) + flight.environment = environment + await self.update_flight_by_id(flight_id, flight) + return + + @controller_exception_handler + async def update_rocket_by_flight_id( + self, flight_id: str, *, rocket: RocketModel + ) -> None: + """ + Update a models.Flight.rocket in the database. + + Args: + flight_id: str + rocket: models.Rocket + + Returns: + None + + Raises: + HTTP 404 Not Found: If the flight is not found in the database. + """ + flight = await self.get_flight_by_id(flight_id) + flight.rocket = rocket + await self.update_flight_by_id(flight_id, flight) + return + + @controller_exception_handler + async def get_rocketpy_flight_binary( + self, + flight_id: str, + ) -> bytes: + """ + Get rocketpy.flight as dill binary. + + Args: + flight_id: str + + Returns: + bytes + + Raises: + HTTP 404 Not Found: If the flight is not found in the database. + """ + flight_retrieved = await self.get_flight_by_id(flight_id) + flight_service = FlightService.from_flight_model( + flight_retrieved.flight + ) + return flight_service.get_flight_binary() + + @controller_exception_handler + async def get_flight_simulation( + self, + flight_id: str, + ) -> FlightSimulation: + """ + Simulate a rocket flight. + + Args: + flight_id: str + + Returns: + Flight simulation view. + + Raises: + HTTP 404 Not Found: If the flight does not exist in the database. + """ + flight_retrieved = await self.get_flight_by_id(flight_id=flight_id) + flight_service = FlightService.from_flight_model( + flight_retrieved.flight + ) + return flight_service.get_flight_simulation() diff --git a/src/controllers/interface.py b/src/controllers/interface.py new file mode 100644 index 0000000..a3a6975 --- /dev/null +++ b/src/controllers/interface.py @@ -0,0 +1,137 @@ +import functools +from typing import List +from pymongo.errors import PyMongoError +from fastapi import HTTPException, status + +from src import logger +from src.models.interface import ApiBaseModel +from src.views.interface import ApiBaseView +from src.repositories.interface import RepositoryInterface + + +def controller_exception_handler(method): + @functools.wraps(method) + async def wrapper(self, *args, **kwargs): + try: + return await method(self, *args, **kwargs) + except PyMongoError: + logger.error(f"{method.__name__}: PyMongoError") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Operation failed, please try again later", + ) + except HTTPException as e: + raise e from e + except Exception as e: + logger.exception(f"{method.__name__}: Unexpected error {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Unexpected error", + ) + finally: + logger.info( + f"Call to {method.__name__} completed for {self.__class__.__name__}" + ) + + return wrapper + + +class ControllerBase: + """ + ControllerBase is a base class for all controllers. It provides a set of + methods to handle the CRUD operations for the models provided in the + constructor. CRUD methods are generated dynamically based on the model + methods and the model name. + + The methods are named as follows: + - post_{model_name} for POST method + - get_{model_name}_by_id for GET method + - put_{model_name}_by_id for PUT method + - delete_{model_name}_by_id for DELETE method + + """ + + def __init__(self, models: List[ApiBaseModel]): + self._initialized_models = {} + self._load_models(models) + + def _load_models(self, models: List[ApiBaseModel]): + for model in models: + self._initialized_models[model.NAME] = model + + for action in model.METHODS: + method_name = ( + f"{action.lower()}_{model.NAME}" + if action == "POST" + else f"{action.lower()}_{model.NAME}_by_id" + ) + method = self._generate_method(action.lower(), model) + setattr(self, method_name, method) + + def _generate_method(self, action: str, model: ApiBaseModel): + async def method(*args, **kwargs): + handler = getattr(self, f"_{action}_model", None) + if handler: + model_repo = RepositoryInterface.get_model_repo(model) + return await handler( # pylint: disable=not-callable + model, model_repo, *args, **kwargs + ) + raise NotImplementedError(f"_{action}_model is not implemented") + + return method + + @controller_exception_handler + async def _post_model( + self, + model: ApiBaseModel, + model_repo: RepositoryInterface, + model_instance: ApiBaseModel, + ) -> ApiBaseView: + async with model_repo() as repo: + inserted_id = await getattr(repo, f'create_{model.NAME}')( + model_instance + ) + return model.CREATED(inserted_id) + + @controller_exception_handler + async def _get_model( + self, + model: ApiBaseModel, + model_repo: RepositoryInterface, + model_id: str, + ) -> ApiBaseView: + async with model_repo() as repo: + model_instance = await getattr(repo, f'read_{model.NAME}_by_id')( + model_id + ) + if model_instance: + return model.RETRIEVED(model_instance) + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"{model.NAME} not found", + ) + + @controller_exception_handler + async def _put_model( + self, + model: ApiBaseModel, + model_repo: RepositoryInterface, + model_id: str, + model_instance: ApiBaseModel, + ) -> ApiBaseView: + async with model_repo() as repo: + await getattr(repo, f'update_{model.NAME}_by_id')( + model_id, model_instance + ) + return model.UPDATED() + + @controller_exception_handler + async def _delete_model( + self, + model: ApiBaseModel, + model_repo: RepositoryInterface, + model_id: str, + ) -> ApiBaseView: + async with model_repo() as repo: + await getattr(repo, f'delete_{model.NAME}_by_id')(model_id) + return model.DELETED() diff --git a/src/controllers/motor.py b/src/controllers/motor.py new file mode 100644 index 0000000..e446cef --- /dev/null +++ b/src/controllers/motor.py @@ -0,0 +1,59 @@ +from src.controllers.interface import ( + ControllerBase, + controller_exception_handler, +) +from src.views.motor import MotorSimulation +from src.models.motor import MotorModel +from src.services.motor import MotorService + + +class MotorController(ControllerBase): + """ + Controller for the motor model. + + Enables: + - Simulation of a RocketPy Motor. + - CRUD for Motor BaseApiModel. + """ + + def __init__(self): + super().__init__(models=[MotorModel]) + + @controller_exception_handler + async def get_rocketpy_motor_binary( + self, + motor_id: str, + ) -> bytes: + """ + Get a rocketpy.Motor object as a dill binary. + + Args: + motor_id: str + + Returns: + bytes + + Raises: + HTTP 404 Not Found: If the motor is not found in the database. + """ + motor_retrieved = await self.get_motor_by_id(motor_id) + motor_service = MotorService.from_motor_model(motor_retrieved.motor) + return motor_service.get_motor_binary() + + @controller_exception_handler + async def get_motor_simulation(self, motor_id: str) -> MotorSimulation: + """ + Simulate a rocketpy motor. + + Args: + motor_id: str + + Returns: + views.MotorSimulation + + Raises: + HTTP 404 Not Found: If the motor does not exist in the database. + """ + motor_retrieved = await self.get_motor_by_id(motor_id) + motor_service = MotorService.from_motor_model(motor_retrieved.motor) + return motor_service.get_motor_simulation() diff --git a/src/controllers/rocket.py b/src/controllers/rocket.py new file mode 100644 index 0000000..a7dcb4d --- /dev/null +++ b/src/controllers/rocket.py @@ -0,0 +1,63 @@ +from src.controllers.interface import ( + ControllerBase, + controller_exception_handler, +) +from src.views.rocket import RocketSimulation +from src.models.rocket import RocketModel +from src.services.rocket import RocketService + + +class RocketController(ControllerBase): + """ + Controller for the Rocket model. + + Enables: + - Simulation of a RocketPy Rocket. + - CRUD for Rocket BaseApiModel. + """ + + def __init__(self): + super().__init__(models=[RocketModel]) + + @controller_exception_handler + async def get_rocketpy_rocket_binary(self, rocket_id: str) -> bytes: + """ + Get a rocketpy.Rocket object as dill binary. + + Args: + rocket_id: str + + Returns: + bytes + + Raises: + HTTP 404 Not Found: If the rocket is not found in the database. + """ + rocket_retrieved = await self.get_rocket_by_id(rocket_id) + rocket_service = RocketService.from_rocket_model( + rocket_retrieved.rocket + ) + return rocket_service.get_rocket_binary() + + @controller_exception_handler + async def get_rocket_simulation( + self, + rocket_id: str, + ) -> RocketSimulation: + """ + Simulate a rocketpy rocket. + + Args: + rocket_id: str + + Returns: + views.RocketSimulation + + Raises: + HTTP 404 Not Found: If the rocket does not exist in the database. + """ + rocket_retrieved = await self.get_rocket_by_id(rocket_id) + rocket_service = RocketService.from_rocket_model( + rocket_retrieved.rocket + ) + return rocket_service.get_rocket_simulation() diff --git a/src/models/environment.py b/src/models/environment.py new file mode 100644 index 0000000..89315d2 --- /dev/null +++ b/src/models/environment.py @@ -0,0 +1,50 @@ +import datetime +from typing import Optional, ClassVar, Self, Literal +from src.models.interface import ApiBaseModel + + +class EnvironmentModel(ApiBaseModel): + NAME: ClassVar = 'environment' + METHODS: ClassVar = ('POST', 'GET', 'PUT', 'DELETE') + latitude: float + longitude: float + elevation: Optional[float] = 0.0 + + # Optional parameters + atmospheric_model_type: Literal[ + 'standard_atmosphere', + 'custom_atmosphere', + 'wyoming_sounding', + 'forecast', + 'reanalysis', + 'ensemble', + ] = 'standard_atmosphere' + atmospheric_model_file: Optional[str] = None + date: Optional[datetime.datetime] = ( + datetime.datetime.today() + datetime.timedelta(days=1) + ) + + @staticmethod + def UPDATED(): + return + + @staticmethod + def DELETED(): + return + + @staticmethod + def CREATED(model_id: str): + from src.views.environment import EnvironmentCreated + + return EnvironmentCreated(environment_id=model_id) + + @staticmethod + def RETRIEVED(model_instance: type(Self)): + from src.views.environment import EnvironmentRetrieved, EnvironmentView + + return EnvironmentRetrieved( + environment=EnvironmentView( + environment_id=model_instance.get_id(), + **model_instance.model_dump(), + ) + ) diff --git a/src/models/flight.py b/src/models/flight.py new file mode 100644 index 0000000..44ac16a --- /dev/null +++ b/src/models/flight.py @@ -0,0 +1,70 @@ +from typing import Optional, Self, ClassVar, Literal +from src.models.interface import ApiBaseModel +from src.models.rocket import RocketModel +from src.models.environment import EnvironmentModel + + +class FlightModel(ApiBaseModel): + NAME: ClassVar = "flight" + METHODS: ClassVar = ("POST", "GET", "PUT", "DELETE") + + name: str = "flight" + environment: EnvironmentModel + rocket: RocketModel + rail_length: float = 1 + time_overshoot: bool = True + terminate_on_apogee: bool = False + equations_of_motion: Literal['standard', 'solid_propulsion'] = 'standard' + + # Optional parameters + inclination: float = 90.0 + heading: float = 0.0 + # TODO: implement initial_solution + max_time: Optional[int] = None + max_time_step: Optional[float] = None + min_time_step: Optional[int] = None + rtol: Optional[float] = None + atol: Optional[float] = None + verbose: Optional[bool] = None + + def get_additional_parameters(self): + return { + key: value + for key, value in self.dict().items() + if value is not None + and key + not in [ + "name", + "environment", + "rocket", + "rail_length", + "time_overshoot", + "terminate_on_apogee", + "equations_of_motion", + ] + } + + @staticmethod + def UPDATED(): + return + + @staticmethod + def DELETED(): + return + + @staticmethod + def CREATED(model_id: str): + from src.views.flight import FlightCreated + + return FlightCreated(flight_id=model_id) + + @staticmethod + def RETRIEVED(model_instance: type(Self)): + from src.views.flight import FlightRetrieved, FlightView + + return FlightRetrieved( + flight=FlightView( + flight_id=model_instance.get_id(), + **model_instance.model_dump(), + ) + ) diff --git a/src/models/interface.py b/src/models/interface.py new file mode 100644 index 0000000..bd5b99c --- /dev/null +++ b/src/models/interface.py @@ -0,0 +1,60 @@ +from typing import Self, Optional +from abc import abstractmethod, ABC +from pydantic import ( + BaseModel, + PrivateAttr, + ConfigDict, +) + + +class ApiBaseModel(BaseModel, ABC): + """ + Base class for all models in the API. + + This class is used to define the common attributes and + methods that all models in the API should have. + """ + + _id: Optional[str] = PrivateAttr(default=None) + model_config = ConfigDict( + use_enum_values=True, + validate_default=True, + validate_all_in_root=True, + validate_assignment=True, + ) + + def set_id(self, value): + self._id = value + + def get_id(self): + return self._id + + @property + @abstractmethod + def NAME(): # pylint: disable=invalid-name, no-method-argument + pass + + @property + @abstractmethod + def METHODS(): # pylint: disable=invalid-name, no-method-argument + pass + + @staticmethod + @abstractmethod + def UPDATED(): # pylint: disable=invalid-name + pass + + @staticmethod + @abstractmethod + def DELETED(): # pylint: disable=invalid-name + pass + + @staticmethod + @abstractmethod + def CREATED(model_id: str): # pylint: disable=invalid-name + pass + + @staticmethod + @abstractmethod + def RETRIEVED(model_instance: type(Self)): # pylint: disable=invalid-name + pass diff --git a/src/models/motor.py b/src/models/motor.py new file mode 100644 index 0000000..4d1cdf5 --- /dev/null +++ b/src/models/motor.py @@ -0,0 +1,95 @@ +from enum import Enum +from typing import Optional, Tuple, List, Union, Self, ClassVar, Literal +from pydantic import model_validator + +from src.models.interface import ApiBaseModel +from src.models.sub.tanks import MotorTank + + +class MotorKinds(str, Enum): + HYBRID: str = "HYBRID" + SOLID: str = "SOLID" + GENERIC: str = "GENERIC" + LIQUID: str = "LIQUID" + + +class MotorModel(ApiBaseModel): + NAME: ClassVar = 'motor' + METHODS: ClassVar = ('POST', 'GET', 'PUT', 'DELETE') + + # Required parameters + thrust_source: List[List[float]] + burn_time: float + nozzle_radius: float + dry_mass: float + dry_inertia: Tuple[float, float, float] = (0, 0, 0) + center_of_dry_mass_position: float + motor_kind: MotorKinds + + # Generic motor parameters + chamber_radius: Optional[float] = None + chamber_height: Optional[float] = None + chamber_position: Optional[float] = None + propellant_initial_mass: Optional[float] = None + nozzle_position: Optional[float] = None + + # Liquid motor parameters + tanks: Optional[List[MotorTank]] = None + + # Solid motor parameters + grain_number: Optional[int] = None + grain_density: Optional[float] = None + grain_outer_radius: Optional[float] = None + grain_initial_inner_radius: Optional[float] = None + grain_initial_height: Optional[float] = None + grains_center_of_mass_position: Optional[float] = None + grain_separation: Optional[float] = None + + # Hybrid motor parameters + throat_radius: Optional[float] = None + + # Optional parameters + interpolation_method: Literal[ + 'linear', 'spline', 'akima', 'polynomial', 'shepard', 'rbf' + ] = 'linear' + coordinate_system_orientation: Literal[ + 'nozzle_to_combustion_chamber', 'combustion_chamber_to_nozzle' + ] = 'nozzle_to_combustion_chamber' + reshape_thrust_curve: Union[bool, tuple] = False + + @model_validator(mode='after') + # TODO: extend guard to check motor kinds and tank kinds specifics + def validate_motor_kind(self): + if ( + self.motor_kind not in (MotorKinds.SOLID, MotorKinds.GENERIC) + and self.tanks is None + ): + raise ValueError( + "Tanks must be provided for liquid and hybrid motors." + ) + return self + + @staticmethod + def UPDATED(): + return + + @staticmethod + def DELETED(): + return + + @staticmethod + def CREATED(model_id: str): + from src.views.motor import MotorCreated + + return MotorCreated(motor_id=model_id) + + @staticmethod + def RETRIEVED(model_instance: type(Self)): + from src.views.motor import MotorRetrieved, MotorView + + return MotorRetrieved( + motor=MotorView( + motor_id=model_instance.get_id(), + **model_instance.model_dump(), + ) + ) diff --git a/src/models/rocket.py b/src/models/rocket.py new file mode 100644 index 0000000..c2f53a3 --- /dev/null +++ b/src/models/rocket.py @@ -0,0 +1,63 @@ +from typing import Optional, Tuple, List, Union, Self, ClassVar, Literal +from src.models.interface import ApiBaseModel +from src.models.motor import MotorModel +from src.models.sub.aerosurfaces import ( + Fins, + NoseCone, + Tail, + RailButtons, + Parachute, +) + + +class RocketModel(ApiBaseModel): + NAME: ClassVar = "rocket" + METHODS: ClassVar = ("POST", "GET", "PUT", "DELETE") + + # Required parameters + motor: MotorModel + radius: float + mass: float + motor_position: float + center_of_mass_without_motor: int + inertia: Union[ + Tuple[float, float, float], + Tuple[float, float, float, float, float, float], + ] = (0, 0, 0) + power_off_drag: List[Tuple[float, float]] = [(0, 0)] + power_on_drag: List[Tuple[float, float]] = [(0, 0)] + coordinate_system_orientation: Literal['tail_to_nose', 'nose_to_tail'] = ( + 'tail_to_nose' + ) + nose: NoseCone + fins: List[Fins] + + # Optional parameters + parachutes: Optional[List[Parachute]] = None + rail_buttons: Optional[RailButtons] = None + tail: Optional[Tail] = None + + @staticmethod + def UPDATED(): + return + + @staticmethod + def DELETED(): + return + + @staticmethod + def CREATED(model_id: str): + from src.views.rocket import RocketCreated + + return RocketCreated(rocket_id=model_id) + + @staticmethod + def RETRIEVED(model_instance: type(Self)): + from src.views.rocket import RocketRetrieved, RocketView + + return RocketRetrieved( + rocket=RocketView( + rocket_id=model_instance.get_id(), + **model_instance.model_dump(), + ) + ) diff --git a/lib/models/aerosurfaces.py b/src/models/sub/aerosurfaces.py similarity index 79% rename from lib/models/aerosurfaces.py rename to src/models/sub/aerosurfaces.py index 68d8e41..966770e 100644 --- a/lib/models/aerosurfaces.py +++ b/src/models/sub/aerosurfaces.py @@ -1,5 +1,4 @@ -from enum import Enum -from typing import Optional, Tuple, List, Union +from typing import Optional, Tuple, List, Union, Literal from pydantic import BaseModel, Field @@ -28,18 +27,8 @@ class NoseCone(BaseModel): rocket_radius: float -class FinsKinds(str, Enum): - TRAPEZOIDAL: str = "TRAPEZOIDAL" - ELLIPTICAL: str = "ELLIPTICAL" - - -class AngleUnit(str, Enum): - RADIANS: str = "RADIANS" - DEGREES: str = "DEGREES" - - class Fins(BaseModel): - fins_kind: FinsKinds + fins_kind: Literal['trapezoidal', 'elliptical'] name: str n: int root_chord: float @@ -50,7 +39,9 @@ class Fins(BaseModel): tip_chord: Optional[float] = None cant_angle: Optional[float] = None rocket_radius: Optional[float] = None - airfoil: Optional[Tuple[List[Tuple[float, float]], AngleUnit]] = None + airfoil: Optional[ + Tuple[List[Tuple[float, float]], Literal['radians', 'degrees']] + ] = None def get_additional_parameters(self): return { diff --git a/src/models/sub/tanks.py b/src/models/sub/tanks.py new file mode 100644 index 0000000..0873264 --- /dev/null +++ b/src/models/sub/tanks.py @@ -0,0 +1,49 @@ +from enum import Enum +from typing import Optional, Tuple, List +from pydantic import BaseModel + + +class TankKinds(str, Enum): + LEVEL: str = "LEVEL" + MASS: str = "MASS" + MASS_FLOW: str = "MASS_FLOW" + ULLAGE: str = "ULLAGE" + + +class TankFluids(BaseModel): + name: str + density: float + + +class MotorTank(BaseModel): + # Required parameters + geometry: List[Tuple[Tuple[float, float], float]] + gas: TankFluids + liquid: TankFluids + flux_time: Tuple[float, float] + position: float + discretize: int + + # Level based tank parameters + liquid_height: Optional[float] = None + + # Mass based tank parameters + liquid_mass: Optional[float] = None + gas_mass: Optional[float] = None + + # Mass flow based tank parameters + gas_mass_flow_rate_in: Optional[float] = None + gas_mass_flow_rate_out: Optional[float] = None + liquid_mass_flow_rate_in: Optional[float] = None + liquid_mass_flow_rate_out: Optional[float] = None + initial_liquid_mass: Optional[float] = None + initial_gas_mass: Optional[float] = None + + # Ullage based tank parameters + ullage: Optional[float] = None + + # Optional parameters + name: Optional[str] = None + + # Computed parameters + tank_kind: TankKinds = TankKinds.MASS_FLOW diff --git a/src/repositories/__init__.py b/src/repositories/__init__.py new file mode 100644 index 0000000..41576ce --- /dev/null +++ b/src/repositories/__init__.py @@ -0,0 +1 @@ +# src/controllers/__init__.py diff --git a/src/repositories/environment.py b/src/repositories/environment.py new file mode 100644 index 0000000..6f483b3 --- /dev/null +++ b/src/repositories/environment.py @@ -0,0 +1,40 @@ +from typing import Optional +from src.models.environment import EnvironmentModel +from src.repositories.interface import ( + RepositoryInterface, + repository_exception_handler, +) + + +class EnvironmentRepository(RepositoryInterface): + """ + Enables database CRUD operations with models.Environment + + Init Attributes: + environment: models.EnvironmentModel + """ + + def __init__(self): + super().__init__(EnvironmentModel) + + @repository_exception_handler + async def create_environment(self, environment: EnvironmentModel) -> str: + return await self.insert(environment.model_dump()) + + @repository_exception_handler + async def read_environment_by_id( + self, environment_id: str + ) -> Optional[EnvironmentModel]: + return await self.find_by_id(data_id=environment_id) + + @repository_exception_handler + async def update_environment_by_id( + self, environment_id: str, environment: EnvironmentModel + ): + await self.update_by_id( + environment.model_dump(), data_id=environment_id + ) + + @repository_exception_handler + async def delete_environment_by_id(self, environment_id: str): + await self.delete_by_id(data_id=environment_id) diff --git a/src/repositories/flight.py b/src/repositories/flight.py new file mode 100644 index 0000000..3b3448b --- /dev/null +++ b/src/repositories/flight.py @@ -0,0 +1,34 @@ +from typing import Optional +from src.models.flight import FlightModel +from src.repositories.interface import ( + RepositoryInterface, + repository_exception_handler, +) + + +class FlightRepository(RepositoryInterface): + """ + Enables database CRUD operations with models.Flight + + Init Attributes: + flight: models.FlightModel + """ + + def __init__(self): + super().__init__(FlightModel) + + @repository_exception_handler + async def create_flight(self, flight: FlightModel) -> str: + return await self.insert(flight.model_dump()) + + @repository_exception_handler + async def read_flight_by_id(self, flight_id: str) -> Optional[FlightModel]: + return await self.find_by_id(data_id=flight_id) + + @repository_exception_handler + async def update_flight_by_id(self, flight_id: str, flight: FlightModel): + await self.update_by_id(flight.model_dump(), data_id=flight_id) + + @repository_exception_handler + async def delete_flight_by_id(self, flight_id: str): + await self.delete_by_id(data_id=flight_id) diff --git a/lib/repositories/repo.py b/src/repositories/interface.py similarity index 54% rename from lib/repositories/repo.py rename to src/repositories/interface.py index 9a9f7ae..f5dcc22 100644 --- a/lib/repositories/repo.py +++ b/src/repositories/interface.py @@ -1,18 +1,28 @@ +import importlib import asyncio import threading +import functools +from typing import Self from tenacity import ( stop_after_attempt, wait_fixed, retry, ) -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError +from pymongo.errors import PyMongoError from pymongo.server_api import ServerApi from motor.motor_asyncio import AsyncIOMotorClient from fastapi import HTTPException, status +from bson import ObjectId -from lib import logger -from lib.secrets import Secrets +from src import logger +from src.secrets import Secrets +from src.models.interface import ApiBaseModel + + +def not_implemented(*args, **kwargs): + raise NotImplementedError("Method not implemented.") class RepositoryNotInitializedException(HTTPException): @@ -23,6 +33,35 @@ def __init__(self): ) +def repository_exception_handler(method): + @functools.wraps(method) + async def wrapper(self, *args, **kwargs): + try: + return await method(self, *args, **kwargs) + except PyMongoError as e: + logger.exception(f"{method.__name__} - caught PyMongoError: {e}") + raise + except RepositoryNotInitializedException as e: + logger.exception( + f"{method.__name__} - Repository not initialized: {e}" + ) + raise + except Exception as e: + logger.exception( + f"{method.__name__} - caught unexpected error: {e}" + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Unexpected error ocurred', + ) from e + finally: + logger.info( + f"Call to repositories.{self.model.NAME}.{method.__name__} completed for {kwargs}" + ) + + return wrapper + + class RepoInstances(BaseModel): instance: object prospecting: int = 0 @@ -40,9 +79,18 @@ def get_instance(self): return self.instance -class Repository: +class RepositoryInterface: """ - Base class for all repositories (singleton) + Interface class for all repositories (singleton) + + This class is used to define the common attributes and + methods that all repositories should have. + + The class is a singleton, meaning that only one instance + of the class is created and shared among all instances + of the class. This is done to ensure that only one + connection per collection in the database is created + and shared among all repositories. """ _global_instances = {} @@ -54,7 +102,7 @@ def __new__(cls, *args, **kwargs): cls._global_thread_lock ): # Ensure thread safety for singleton instance creation if cls not in cls._global_instances: - instance = super(Repository, cls).__new__(cls) + instance = super(RepositoryInterface, cls).__new__(cls) cls._global_instances[cls] = RepoInstances(instance=instance) else: cls._global_instances[cls].add_prospecting() @@ -71,10 +119,10 @@ def _get_instance_prospecting(cls): return cls._global_instances[cls].get_prospecting() return 0 - def __init__(self, collection_name: str, *, max_pool_size: int = 10): + def __init__(self, model: ApiBaseModel, *, max_pool_size: int = 3): if not getattr(self, '_initialized', False): + self.model = model self._max_pool_size = max_pool_size - self._collection_name = collection_name self._initialized_event = asyncio.Event() self._initialize() @@ -128,11 +176,11 @@ def _initialize_connection(self): self._connection_string, server_api=ServerApi("1"), maxIdleTimeMS=30000, - minPoolSize=10, + minPoolSize=1, maxPoolSize=self._max_pool_size, serverSelectionTimeoutMS=60000, ) - self._collection = self._client.rocketpy[self._collection_name] + self._collection = self._client.rocketpy[self.model.NAME] logger.info("MongoDB client initialized for %s", self.__class__) except Exception as e: logger.error( @@ -164,3 +212,54 @@ def get_collection(self): if not getattr(self, '_initialized', False): raise RepositoryNotInitializedException() return self._collection + + @classmethod + def get_model_repo(cls, model: ApiBaseModel) -> Self: + repo_path = cls.__module__.replace('interface', f'{model.NAME}') + return getattr( + importlib.import_module(repo_path), + f"{model.NAME.capitalize()}Repository", + ) + + @repository_exception_handler + async def insert(self, data: dict): + collection = self.get_collection() + try: + self.model.model_validate(data) + except ValidationError as e: + raise HTTPException(status_code=422, detail=str(e)) + result = await collection.insert_one(data) + return str(result.inserted_id) + + @repository_exception_handler + async def update_by_id(self, data: dict, *, data_id: str): + collection = self.get_collection() + assert self.model.model_validate(data) + await collection.update_one({"_id": ObjectId(data_id)}, {"$set": data}) + return self + + @repository_exception_handler + async def find_by_id(self, *, data_id: str): + collection = self.get_collection() + read_data = await collection.find_one({"_id": ObjectId(data_id)}) + if read_data: + parsed_model = self.model.model_validate(read_data) + parsed_model.set_id(str(read_data["_id"])) + return parsed_model + return None + + @repository_exception_handler + async def delete_by_id(self, *, data_id: str): + collection = self.get_collection() + await collection.delete_one({"_id": ObjectId(data_id)}) + return self + + @repository_exception_handler + async def find_by_query(self, query: dict): + collection = self.get_collection() + parsed_models = [] + async for read_data in collection.find(query): + parsed_model = self.model.model_validate(read_data) + parsed_model.set_id(str(read_data["_id"])) + parsed_models.append(parsed_model) + return parsed_models diff --git a/src/repositories/motor.py b/src/repositories/motor.py new file mode 100644 index 0000000..026655f --- /dev/null +++ b/src/repositories/motor.py @@ -0,0 +1,34 @@ +from typing import Optional +from src.models.motor import MotorModel +from src.repositories.interface import ( + RepositoryInterface, + repository_exception_handler, +) + + +class MotorRepository(RepositoryInterface): + """ + Enables database CRUD operations with models.Motor + + Init Attributes: + motor: models.MotorModel + """ + + def __init__(self): + super().__init__(MotorModel) + + @repository_exception_handler + async def create_motor(self, motor: MotorModel) -> str: + return await self.insert(motor.model_dump()) + + @repository_exception_handler + async def read_motor_by_id(self, motor_id: str) -> Optional[MotorModel]: + return await self.find_by_id(data_id=motor_id) + + @repository_exception_handler + async def update_motor_by_id(self, motor_id: str, motor: MotorModel): + await self.update_by_id(motor.model_dump(), data_id=motor_id) + + @repository_exception_handler + async def delete_motor_by_id(self, motor_id: str): + await self.delete_by_id(data_id=motor_id) diff --git a/src/repositories/rocket.py b/src/repositories/rocket.py new file mode 100644 index 0000000..f4b6f38 --- /dev/null +++ b/src/repositories/rocket.py @@ -0,0 +1,34 @@ +from typing import Optional +from src.models.rocket import RocketModel +from src.repositories.interface import ( + RepositoryInterface, + repository_exception_handler, +) + + +class RocketRepository(RepositoryInterface): + """ + Enables database CRUD operations with models.Rocket + + Init Attributes: + rocket: models.RocketModel + """ + + def __init__(self): + super().__init__(RocketModel) + + @repository_exception_handler + async def create_rocket(self, rocket: RocketModel) -> str: + return await self.insert(rocket.model_dump()) + + @repository_exception_handler + async def read_rocket_by_id(self, rocket_id: str) -> Optional[RocketModel]: + return await self.find_by_id(data_id=rocket_id) + + @repository_exception_handler + async def update_rocket_by_id(self, rocket_id: str, rocket: RocketModel): + await self.update_by_id(rocket.model_dump(), data_id=rocket_id) + + @repository_exception_handler + async def delete_rocket_by_id(self, rocket_id: str): + await self.delete_by_id(data_id=rocket_id) diff --git a/src/routes/environment.py b/src/routes/environment.py new file mode 100644 index 0000000..6c5e8b2 --- /dev/null +++ b/src/routes/environment.py @@ -0,0 +1,137 @@ +""" +Environment routes +""" + +from fastapi import APIRouter, Response +from opentelemetry import trace + +from src.views.environment import ( + EnvironmentSimulation, + EnvironmentCreated, + EnvironmentRetrieved, +) +from src.models.environment import EnvironmentModel +from src.controllers.environment import EnvironmentController + +router = APIRouter( + prefix="/environments", + tags=["ENVIRONMENT"], + responses={ + 404: {"description": "Not found"}, + 422: {"description": "Unprocessable Entity"}, + 500: {"description": "Internal Server Error"}, + }, +) + +tracer = trace.get_tracer(__name__) + + +@router.post("/", status_code=201) +async def create_environment( + environment: EnvironmentModel, +) -> EnvironmentCreated: + """ + Creates a new environment + + ## Args + ``` models.Environment JSON ``` + """ + with tracer.start_as_current_span("create_environment"): + controller = EnvironmentController() + return await controller.post_environment(environment) + + +@router.get("/{environment_id}") +async def read_environment(environment_id: str) -> EnvironmentRetrieved: + """ + Reads an existing environment + + ## Args + ``` environment_id: str ``` + """ + with tracer.start_as_current_span("read_environment"): + controller = EnvironmentController() + return await controller.get_environment_by_id(environment_id) + + +@router.put("/{environment_id}", status_code=204) +async def update_environment( + environment_id: str, environment: EnvironmentModel +) -> None: + """ + Updates an existing environment + + ## Args + ``` + environment_id: str + environment: models.Environment JSON + ``` + """ + with tracer.start_as_current_span("update_environment"): + controller = EnvironmentController() + return await controller.put_environment_by_id( + environment_id, environment + ) + + +@router.delete("/{environment_id}", status_code=204) +async def delete_environment(environment_id: str) -> None: + """ + Deletes an existing environment + + ## Args + ``` environment_id: str ``` + """ + with tracer.start_as_current_span("delete_environment"): + controller = EnvironmentController() + return await controller.delete_environment_by_id(environment_id) + + +@router.get( + "/{environment_id}/rocketpy", + responses={ + 200: { + "description": "Binary file download", + "content": {"application/octet-stream": {}}, + } + }, + status_code=200, + response_class=Response, +) +async def get_rocketpy_environment_binary(environment_id: str): + """ + Loads rocketpy.environment as a dill binary. + Currently only amd64 architecture is supported. + + ## Args + ``` environment_id: str ``` + """ + with tracer.start_as_current_span("get_rocketpy_environment_binary"): + headers = { + 'Content-Disposition': f'attachment; filename="rocketpy_environment_{environment_id}.dill"' + } + controller = EnvironmentController() + binary = await controller.get_rocketpy_environment_binary( + environment_id + ) + return Response( + content=binary, + headers=headers, + media_type="application/octet-stream", + status_code=200, + ) + + +@router.get("/{environment_id}/simulate") +async def get_environment_simulation( + environment_id: str, +) -> EnvironmentSimulation: + """ + Simulates an environment + + ## Args + ``` environment_id: Environment ID``` + """ + with tracer.start_as_current_span("get_environment_simulation"): + controller = EnvironmentController() + return await controller.get_environment_simulation(environment_id) diff --git a/src/routes/flight.py b/src/routes/flight.py new file mode 100644 index 0000000..9aff0db --- /dev/null +++ b/src/routes/flight.py @@ -0,0 +1,168 @@ +""" +Flight routes +""" + +from fastapi import APIRouter, Response +from opentelemetry import trace + +from src.views.flight import ( + FlightSimulation, + FlightCreated, + FlightRetrieved, +) +from src.models.environment import EnvironmentModel +from src.models.flight import FlightModel +from src.models.rocket import RocketModel +from src.controllers.flight import FlightController + +router = APIRouter( + prefix="/flights", + tags=["FLIGHT"], + responses={ + 404: {"description": "Not found"}, + 422: {"description": "Unprocessable Entity"}, + 500: {"description": "Internal Server Error"}, + }, +) + +tracer = trace.get_tracer(__name__) + + +@router.post("/", status_code=201) +async def create_flight(flight: FlightModel) -> FlightCreated: + """ + Creates a new flight + + ## Args + ``` models.Flight JSON ``` + """ + with tracer.start_as_current_span("create_flight"): + controller = FlightController() + return await controller.post_flight(flight) + + +@router.get("/{flight_id}") +async def read_flight(flight_id: str) -> FlightRetrieved: + """ + Reads an existing flight + + ## Args + ``` flight_id: str ``` + """ + with tracer.start_as_current_span("read_flight"): + controller = FlightController() + return await controller.get_flight_by_id(flight_id) + + +@router.put("/{flight_id}", status_code=204) +async def update_flight(flight_id: str, flight: FlightModel) -> None: + """ + Updates an existing flight + + ## Args + ``` + flight_id: str + flight: models.flight JSON + ``` + """ + with tracer.start_as_current_span("update_flight"): + controller = FlightController() + return await controller.put_flight_by_id(flight_id, flight) + + +@router.delete("/{flight_id}", status_code=204) +async def delete_flight(flight_id: str) -> None: + """ + Deletes an existing flight + + ## Args + ``` flight_id: str ``` + """ + with tracer.start_as_current_span("delete_flight"): + controller = FlightController() + return await controller.delete_flight_by_id(flight_id) + + +@router.get( + "/{flight_id}/rocketpy", + responses={ + 200: { + "description": "Binary file download", + "content": {"application/octet-stream": {}}, + } + }, + status_code=200, + response_class=Response, +) +async def get_rocketpy_flight_binary(flight_id: str): + """ + Loads rocketpy.flight as a dill binary. + Currently only amd64 architecture is supported. + + ## Args + ``` flight_id: str ``` + """ + with tracer.start_as_current_span("get_rocketpy_flight_binary"): + controller = FlightController() + headers = { + 'Content-Disposition': f'attachment; filename="rocketpy_flight_{flight_id}.dill"' + } + binary = await controller.get_rocketpy_flight_binary(flight_id) + return Response( + content=binary, + headers=headers, + media_type="application/octet-stream", + status_code=200, + ) + + +@router.put("/{flight_id}/environment", status_code=204) +async def update_flight_environment( + flight_id: str, environment: EnvironmentModel +) -> None: + """ + Updates flight environment + + ## Args + ``` + flight_id: Flight ID + environment: env object as JSON + ``` + """ + with tracer.start_as_current_span("update_flight_environment"): + controller = FlightController() + return await controller.update_environment_by_flight_id( + flight_id, environment=environment + ) + + +@router.put("/{flight_id}/rocket", status_code=204) +async def update_flight_rocket(flight_id: str, rocket: RocketModel) -> None: + """ + Updates flight rocket. + + ## Args + ``` + flight_id: Flight ID + rocket: RocketModel object as JSON + ``` + """ + with tracer.start_as_current_span("update_flight_rocket"): + controller = FlightController() + return await controller.update_rocket_by_flight_id( + flight_id, + rocket=rocket, + ) + + +@router.get("/{flight_id}/simulate") +async def get_flight_simulation(flight_id: str) -> FlightSimulation: + """ + Simulates a flight + + ## Args + ``` flight_id: Flight ID ``` + """ + with tracer.start_as_current_span("get_flight_simulation"): + controller = FlightController() + return await controller.get_flight_simulation(flight_id) diff --git a/src/routes/motor.py b/src/routes/motor.py new file mode 100644 index 0000000..3143c26 --- /dev/null +++ b/src/routes/motor.py @@ -0,0 +1,127 @@ +""" +Motor routes +""" + +from fastapi import APIRouter, Response +from opentelemetry import trace + +from src.views.motor import ( + MotorSimulation, + MotorCreated, + MotorRetrieved, +) +from src.models.motor import MotorModel +from src.controllers.motor import MotorController + +router = APIRouter( + prefix="/motors", + tags=["MOTOR"], + responses={ + 404: {"description": "Not found"}, + 422: {"description": "Unprocessable Entity"}, + 500: {"description": "Internal Server Error"}, + }, +) + +tracer = trace.get_tracer(__name__) + + +@router.post("/", status_code=201) +async def create_motor(motor: MotorModel) -> MotorCreated: + """ + Creates a new motor + + ## Args + ``` models.Motor JSON ``` + """ + with tracer.start_as_current_span("create_motor"): + controller = MotorController() + return await controller.post_motor(motor) + + +@router.get("/{motor_id}") +async def read_motor(motor_id: str) -> MotorRetrieved: + """ + Reads an existing motor + + ## Args + ``` motor_id: str ``` + """ + with tracer.start_as_current_span("read_motor"): + controller = MotorController() + return await controller.get_motor_by_id(motor_id) + + +@router.put("/{motor_id}", status_code=204) +async def update_motor(motor_id: str, motor: MotorModel) -> None: + """ + Updates an existing motor + + ## Args + ``` + motor_id: str + motor: models.motor JSON + ``` + """ + with tracer.start_as_current_span("update_motor"): + controller = MotorController() + return await controller.put_motor_by_id(motor_id, motor) + + +@router.delete("/{motor_id}", status_code=204) +async def delete_motor(motor_id: str) -> None: + """ + Deletes an existing motor + + ## Args + ``` motor_id: str ``` + """ + with tracer.start_as_current_span("delete_motor"): + controller = MotorController() + return await controller.delete_motor_by_id(motor_id) + + +@router.get( + "/{motor_id}/rocketpy", + responses={ + 200: { + "description": "Binary file download", + "content": {"application/octet-stream": {}}, + } + }, + status_code=200, + response_class=Response, +) +async def get_rocketpy_motor_binary(motor_id: str): + """ + Loads rocketpy.motor as a dill binary. + Currently only amd64 architecture is supported. + + ## Args + ``` motor_id: str ``` + """ + with tracer.start_as_current_span("get_rocketpy_motor_binary"): + headers = { + 'Content-Disposition': f'attachment; filename="rocketpy_motor_{motor_id}.dill"' + } + controller = MotorController() + binary = await controller.get_rocketpy_motor_binary(motor_id) + return Response( + content=binary, + headers=headers, + media_type="application/octet-stream", + status_code=200, + ) + + +@router.get("/{motor_id}/simulate") +async def get_motor_simulation(motor_id: str) -> MotorSimulation: + """ + Simulates a motor + + ## Args + ``` motor_id: Motor ID ``` + """ + with tracer.start_as_current_span("get_motor_simulation"): + controller = MotorController() + return await controller.get_motor_simulation(motor_id) diff --git a/src/routes/rocket.py b/src/routes/rocket.py new file mode 100644 index 0000000..5346d9e --- /dev/null +++ b/src/routes/rocket.py @@ -0,0 +1,127 @@ +""" +Rocket routes +""" + +from fastapi import APIRouter, Response +from opentelemetry import trace + +from src.views.rocket import ( + RocketSimulation, + RocketCreated, + RocketRetrieved, +) +from src.models.rocket import RocketModel +from src.controllers.rocket import RocketController + +router = APIRouter( + prefix="/rockets", + tags=["ROCKET"], + responses={ + 404: {"description": "Not found"}, + 422: {"description": "Unprocessable Entity"}, + 500: {"description": "Internal Server Error"}, + }, +) + +tracer = trace.get_tracer(__name__) + + +@router.post("/", status_code=201) +async def create_rocket(rocket: RocketModel) -> RocketCreated: + """ + Creates a new rocket + + ## Args + ``` models.Rocket JSON ``` + """ + with tracer.start_as_current_span("create_rocket"): + controller = RocketController() + return await controller.post_rocket(rocket) + + +@router.get("/{rocket_id}") +async def read_rocket(rocket_id: str) -> RocketRetrieved: + """ + Reads an existing rocket + + ## Args + ``` rocket_id: str ``` + """ + with tracer.start_as_current_span("read_rocket"): + controller = RocketController() + return await controller.get_rocket_by_id(rocket_id) + + +@router.put("/{rocket_id}", status_code=204) +async def update_rocket(rocket_id: str, rocket: RocketModel) -> None: + """ + Updates an existing rocket + + ## Args + ``` + rocket_id: str + rocket: models.rocket JSON + ``` + """ + with tracer.start_as_current_span("update_rocket"): + controller = RocketController() + return await controller.put_rocket_by_id(rocket_id, rocket) + + +@router.delete("/{rocket_id}", status_code=204) +async def delete_rocket(rocket_id: str) -> None: + """ + Deletes an existing rocket + + ## Args + ``` rocket_id: str ``` + """ + with tracer.start_as_current_span("delete_rocket"): + controller = RocketController() + return await controller.delete_rocket_by_id(rocket_id) + + +@router.get( + "/{rocket_id}/rocketpy", + responses={ + 200: { + "description": "Binary file download", + "content": {"application/octet-stream": {}}, + } + }, + status_code=200, + response_class=Response, +) +async def get_rocketpy_rocket_binary(rocket_id: str): + """ + Loads rocketpy.rocket as a dill binary. + Currently only amd64 architecture is supported. + + ## Args + ``` rocket_id: str ``` + """ + with tracer.start_as_current_span("get_rocketpy_rocket_binary"): + headers = { + 'Content-Disposition': f'attachment; filename="rocketpy_rocket_{rocket_id}.dill"' + } + controller = RocketController() + binary = await controller.get_rocketpy_rocket_binary(rocket_id) + return Response( + content=binary, + headers=headers, + media_type="application/octet-stream", + status_code=200, + ) + + +@router.get("/{rocket_id}/simulate") +async def simulate_rocket(rocket_id: str) -> RocketSimulation: + """ + Simulates a rocket + + ## Args + ``` rocket_id: Rocket ID ``` + """ + with tracer.start_as_current_span("get_rocket_simulation"): + controller = RocketController() + return await controller.get_rocket_simulation(rocket_id) diff --git a/lib/secrets.py b/src/secrets.py similarity index 100% rename from lib/secrets.py rename to src/secrets.py diff --git a/lib/services/environment.py b/src/services/environment.py similarity index 72% rename from lib/services/environment.py rename to src/services/environment.py index f98ea84..4f1137c 100644 --- a/lib/services/environment.py +++ b/src/services/environment.py @@ -4,8 +4,8 @@ from rocketpy.environment.environment import Environment as RocketPyEnvironment from rocketpy.utilities import get_instance_attributes -from lib.models.environment import Env -from lib.views.environment import EnvSummary +from src.models.environment import EnvironmentModel +from src.views.environment import EnvironmentSimulation class EnvironmentService: @@ -15,7 +15,7 @@ def __init__(self, environment: RocketPyEnvironment = None): self._environment = environment @classmethod - def from_env_model(cls, env: Env) -> Self: + def from_env_model(cls, env: EnvironmentModel) -> Self: """ Get the rocketpy env object. @@ -29,7 +29,7 @@ def from_env_model(cls, env: Env) -> Self: date=env.date, ) rocketpy_env.set_atmospheric_model( - type=env.atmospheric_model_type.value.lower(), + type=env.atmospheric_model_type, file=env.atmospheric_model_file, ) return cls(environment=rocketpy_env) @@ -42,19 +42,19 @@ def environment(self) -> RocketPyEnvironment: def environment(self, environment: RocketPyEnvironment): self._environment = environment - def get_env_summary(self) -> EnvSummary: + def get_environment_simulation(self) -> EnvironmentSimulation: """ - Get the summary of the environment. + Get the simulation of the environment. Returns: - EnvSummary + EnvironmentSimulation """ attributes = get_instance_attributes(self.environment) - env_summary = EnvSummary(**attributes) - return env_summary + env_simulation = EnvironmentSimulation(**attributes) + return env_simulation - def get_env_binary(self) -> bytes: + def get_environment_binary(self) -> bytes: """ Get the binary representation of the environment. diff --git a/lib/services/flight.py b/src/services/flight.py similarity index 73% rename from lib/services/flight.py rename to src/services/flight.py index 169b9fa..fdce768 100644 --- a/lib/services/flight.py +++ b/src/services/flight.py @@ -5,9 +5,10 @@ from rocketpy.simulation.flight import Flight as RocketPyFlight from rocketpy.utilities import get_instance_attributes -from lib.services.environment import EnvironmentService -from lib.services.rocket import RocketService -from lib.views.flight import FlightSummary, FlightView +from src.services.environment import EnvironmentService +from src.services.rocket import RocketService +from src.models.flight import FlightModel +from src.views.flight import FlightSimulation class FlightService: @@ -17,7 +18,7 @@ def __init__(self, flight: RocketPyFlight = None): self._flight = flight @classmethod - def from_flight_model(cls, flight: FlightView) -> Self: + def from_flight_model(cls, flight: FlightModel) -> Self: """ Get the rocketpy flight object. @@ -34,7 +35,7 @@ def from_flight_model(cls, flight: FlightView) -> Self: rail_length=flight.rail_length, terminate_on_apogee=flight.terminate_on_apogee, time_overshoot=flight.time_overshoot, - equations_of_motion=flight.equations_of_motion.value.lower(), + equations_of_motion=flight.equations_of_motion, **flight.get_additional_parameters(), ) return cls(flight=rocketpy_flight) @@ -47,16 +48,16 @@ def flight(self) -> RocketPyFlight: def flight(self, flight: RocketPyFlight): self._flight = flight - def get_flight_summary(self) -> FlightSummary: + def get_flight_simulation(self) -> FlightSimulation: """ - Get the summary of the flight. + Get the simulation of the flight. Returns: - FlightSummary + FlightSimulation """ attributes = get_instance_attributes(self.flight) - flight_summary = FlightSummary(**attributes) - return flight_summary + flight_simulation = FlightSimulation(**attributes) + return flight_simulation def get_flight_binary(self) -> bytes: """ diff --git a/lib/services/motor.py b/src/services/motor.py similarity index 91% rename from lib/services/motor.py rename to src/services/motor.py index 4bf4b35..5b2acde 100644 --- a/lib/services/motor.py +++ b/src/services/motor.py @@ -15,8 +15,9 @@ TankGeometry, ) -from lib.models.motor import MotorKinds, TankKinds -from lib.views.motor import MotorSummary, MotorView +from src.models.sub.tanks import TankKinds +from src.models.motor import MotorKinds, MotorModel +from src.views.motor import MotorSimulation class MotorService: @@ -26,7 +27,7 @@ def __init__(self, motor: RocketPyMotor = None): self._motor = motor @classmethod - def from_motor_model(cls, motor: MotorView) -> Self: + def from_motor_model(cls, motor: MotorModel) -> Self: """ Get the rocketpy motor object. @@ -41,12 +42,12 @@ def from_motor_model(cls, motor: MotorView) -> Self: "dry_mass": motor.dry_mass, "dry_inertia": motor.dry_inertia, "center_of_dry_mass_position": motor.center_of_dry_mass_position, - "coordinate_system_orientation": motor.coordinate_system_orientation.value.lower(), - "interpolation_method": motor.interpolation_method.value.lower(), + "coordinate_system_orientation": motor.coordinate_system_orientation, + "interpolation_method": motor.interpolation_method, "reshape_thrust_curve": False or motor.reshape_thrust_curve, } - match MotorKinds(motor.selected_motor_kind): + match MotorKinds(motor.motor_kind): case MotorKinds.LIQUID: rocketpy_motor = LiquidMotor(**motor_core) case MotorKinds.HYBRID: @@ -132,16 +133,16 @@ def motor(self) -> RocketPyMotor: def motor(self, motor: RocketPyMotor): self._motor = motor - def get_motor_summary(self) -> MotorSummary: + def get_motor_simulation(self) -> MotorSimulation: """ - Get the summary of the motor. + Get the simulation of the motor. Returns: - MotorSummary + MotorSimulation """ attributes = get_instance_attributes(self.motor) - motor_summary = MotorSummary(**attributes) - return motor_summary + motor_simulation = MotorSimulation(**attributes) + return motor_simulation def get_motor_binary(self) -> bytes: """ diff --git a/lib/services/rocket.py b/src/services/rocket.py similarity index 91% rename from lib/services/rocket.py rename to src/services/rocket.py index 85db0e8..65bbfe3 100644 --- a/lib/services/rocket.py +++ b/src/services/rocket.py @@ -13,11 +13,11 @@ ) from rocketpy.utilities import get_instance_attributes -from lib import logger -from lib.models.rocket import Parachute -from lib.models.aerosurfaces import NoseCone, Tail, Fins -from lib.services.motor import MotorService -from lib.views.rocket import RocketView, RocketSummary +from src import logger +from src.models.rocket import RocketModel, Parachute +from src.models.sub.aerosurfaces import NoseCone, Tail, Fins +from src.services.motor import MotorService +from src.views.rocket import RocketSimulation class RocketService: @@ -27,7 +27,7 @@ def __init__(self, rocket: RocketPyRocket = None): self._rocket = rocket @classmethod - def from_rocket_model(cls, rocket: RocketView) -> Self: + def from_rocket_model(cls, rocket: RocketModel) -> Self: """ Get the rocketpy rocket object. @@ -43,7 +43,7 @@ def from_rocket_model(cls, rocket: RocketView) -> Self: power_off_drag=rocket.power_off_drag, power_on_drag=rocket.power_on_drag, center_of_mass_without_motor=rocket.center_of_mass_without_motor, - coordinate_system_orientation=rocket.coordinate_system_orientation.value.lower(), + coordinate_system_orientation=rocket.coordinate_system_orientation, ) rocketpy_rocket.add_motor( MotorService.from_motor_model(rocket.motor).motor, @@ -100,16 +100,16 @@ def rocket(self) -> RocketPyRocket: def rocket(self, rocket: RocketPyRocket): self._rocket = rocket - def get_rocket_summary(self) -> RocketSummary: + def get_rocket_simulation(self) -> RocketSimulation: """ - Get the summary of the rocket. + Get the simulation of the rocket. Returns: - RocketSummary + RocketSimulation """ attributes = get_instance_attributes(self.rocket) - rocket_summary = RocketSummary(**attributes) - return rocket_summary + rocket_simulation = RocketSimulation(**attributes) + return rocket_simulation def get_rocket_binary(self) -> bytes: """ @@ -157,7 +157,7 @@ def get_rocketpy_finset(fins: Fins, kind: str) -> RocketPyFins: RocketPyEllipticalFins """ match kind: - case "TRAPEZOIDAL": + case "trapezoidal": rocketpy_finset = RocketPyTrapezoidalFins( n=fins.n, name=fins.name, @@ -165,7 +165,7 @@ def get_rocketpy_finset(fins: Fins, kind: str) -> RocketPyFins: span=fins.span, **fins.get_additional_parameters(), ) - case "ELLIPTICAL": + case "elliptical": rocketpy_finset = RocketPyEllipticalFins( n=fins.n, name=fins.name, diff --git a/lib/settings/gunicorn.py b/src/settings/gunicorn.py similarity index 82% rename from lib/settings/gunicorn.py rename to src/settings/gunicorn.py index a563567..f2b92cd 100644 --- a/lib/settings/gunicorn.py +++ b/src/settings/gunicorn.py @@ -1,6 +1,6 @@ import uptrace from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor -from lib.secrets import Secrets +from src.secrets import Secrets def post_fork(server, worker): # pylint: disable=unused-argument @@ -10,7 +10,7 @@ def post_fork(server, worker): # pylint: disable=unused-argument service_version="2.2.0", deployment_environment="production", ) - from lib.api import ( # pylint: disable=import-outside-toplevel + from src.api import ( # pylint: disable=import-outside-toplevel app as fastapi_server, ) diff --git a/lib/utils.py b/src/utils.py similarity index 85% rename from lib/utils.py rename to src/utils.py index db3077c..0a8ba45 100644 --- a/lib/utils.py +++ b/src/utils.py @@ -1,14 +1,43 @@ # fork of https://github.com/encode/starlette/blob/master/starlette/middleware/gzip.py import gzip import io -import typing +from typing import Annotated, NoReturn, Any import numpy as np +from pydantic import PlainSerializer from starlette.datastructures import Headers, MutableHeaders from starlette.types import ASGIApp, Message, Receive, Scope, Send +def to_python_primitive(v: Any) -> Any: + """ + Convert complex types to Python primitives. + + Args: + v: Any value, particularly those with a 'source' attribute + containing numpy arrays or generic types. + + Returns: + The primitive representation of the input value. + """ + if hasattr(v, "source"): + if isinstance(v.source, np.ndarray): + return v.source.tolist() + + if isinstance(v.source, (np.generic,)): + return v.source.item() + + return str(v.source) + return str(v) + + +AnyToPrimitive = Annotated[ + Any, + PlainSerializer(to_python_primitive), +] + + class RocketPyGZipMiddleware: def __init__( self, app: ASGIApp, minimum_size: int = 500, compresslevel: int = 9 @@ -74,13 +103,13 @@ async def send_with_gzip(self, message: Message) -> None: self.started = True body = message.get("body", b"") more_body = message.get("more_body", False) - if len(body) < (self.minimum_size and not more_body) or any( + if ((len(body) < self.minimum_size) and not more_body) or any( value == b'application/octet-stream' for header, value in self.initial_message["headers"] ): # Don't apply GZip to small outgoing responses or octet-streams. await self.send(self.initial_message) - await self.send(message) + await self.send(message) # pylint: disable=unreachable elif not more_body: # Standard GZip response. self.gzip_file.write(body) @@ -94,7 +123,7 @@ async def send_with_gzip(self, message: Message) -> None: message["body"] = body await self.send(self.initial_message) - await self.send(message) + await self.send(message) # pylint: disable=unreachable else: # Initial body in streaming GZip response. headers = MutableHeaders(raw=self.initial_message["headers"]) @@ -108,7 +137,7 @@ async def send_with_gzip(self, message: Message) -> None: self.gzip_buffer.truncate() await self.send(self.initial_message) - await self.send(message) + await self.send(message) # pylint: disable=unreachable elif message_type == "http.response.body": # Remaining body in streaming GZip response. @@ -126,18 +155,5 @@ async def send_with_gzip(self, message: Message) -> None: await self.send(message) -async def unattached_send(message: Message) -> typing.NoReturn: +async def unattached_send(message: Message) -> NoReturn: raise RuntimeError("send awaitable not set") # pragma: no cover - - -def to_python_primitive(v): - if hasattr(v, "source"): - if isinstance(v.source, np.ndarray): - return v.source.tolist() - - if isinstance(v.source, (np.generic,)): - return v.source.item() - - return str(v.source) - - return str(v) diff --git a/src/views/environment.py b/src/views/environment.py new file mode 100644 index 0000000..4283d52 --- /dev/null +++ b/src/views/environment.py @@ -0,0 +1,63 @@ +from typing import Optional +from datetime import datetime, timedelta +from src.views.interface import ApiBaseView +from src.models.environment import EnvironmentModel +from src.utils import AnyToPrimitive + + +class EnvironmentSimulation(ApiBaseView): + message: str = "Environment successfully simulated" + latitude: Optional[float] = None + longitude: Optional[float] = None + elevation: Optional[float] = 1 + atmospheric_model_type: Optional[str] = None + air_gas_constant: Optional[float] = None + standard_g: Optional[float] = None + earth_radius: Optional[float] = None + datum: Optional[str] = None + timezone: Optional[str] = None + initial_utm_zone: Optional[int] = None + initial_utm_letter: Optional[str] = None + initial_north: Optional[float] = None + initial_east: Optional[float] = None + initial_hemisphere: Optional[str] = None + initial_ew: Optional[str] = None + max_expected_height: Optional[int] = None + date: Optional[datetime] = datetime.today() + timedelta(days=1) + local_date: Optional[datetime] = datetime.today() + timedelta(days=1) + datetime_date: Optional[datetime] = datetime.today() + timedelta(days=1) + ellipsoid: Optional[AnyToPrimitive] = None + barometric_height: Optional[AnyToPrimitive] = None + barometric_height_ISA: Optional[AnyToPrimitive] = None + pressure: Optional[AnyToPrimitive] = None + pressure_ISA: Optional[AnyToPrimitive] = None + temperature: Optional[AnyToPrimitive] = None + temperature_ISA: Optional[AnyToPrimitive] = None + density: Optional[AnyToPrimitive] = None + speed_of_sound: Optional[AnyToPrimitive] = None + dynamic_viscosity: Optional[AnyToPrimitive] = None + gravity: Optional[AnyToPrimitive] = None + somigliana_gravity: Optional[AnyToPrimitive] = None + wind_speed: Optional[AnyToPrimitive] = None + wind_direction: Optional[AnyToPrimitive] = None + wind_heading: Optional[AnyToPrimitive] = None + wind_velocity_x: Optional[AnyToPrimitive] = None + wind_velocity_y: Optional[AnyToPrimitive] = None + calculate_earth_radius: Optional[AnyToPrimitive] = None + decimal_degrees_to_arc_seconds: Optional[AnyToPrimitive] = None + geodesic_to_utm: Optional[AnyToPrimitive] = None + utm_to_geodesic: Optional[AnyToPrimitive] = None + + +class EnvironmentView(EnvironmentModel): + environment_id: Optional[str] = None + + +class EnvironmentCreated(ApiBaseView): + message: str = "Environment successfully created" + environment_id: str + + +class EnvironmentRetrieved(ApiBaseView): + message: str = "Environment successfully retrieved" + environment: EnvironmentView diff --git a/src/views/flight.py b/src/views/flight.py new file mode 100644 index 0000000..1a82f98 --- /dev/null +++ b/src/views/flight.py @@ -0,0 +1,168 @@ +from typing import Optional +from src.models.flight import FlightModel +from src.views.interface import ApiBaseView +from src.views.rocket import RocketView, RocketSimulation +from src.views.environment import EnvironmentSimulation +from src.utils import AnyToPrimitive + + +class FlightSimulation(RocketSimulation, EnvironmentSimulation): + message: str = "Flight successfully simulated" + name: Optional[str] = None + max_time: Optional[int] = None + min_time_step: Optional[int] = None + max_time_step: Optional[AnyToPrimitive] = None + equations_of_motion: Optional[str] = None + heading: Optional[int] = None + inclination: Optional[int] = None + initial_solution: Optional[list] = None + effective_1rl: Optional[float] = None + effective_2rl: Optional[float] = None + out_of_rail_time: Optional[float] = None + out_of_rail_time_index: Optional[int] = None + parachute_cd_s: Optional[float] = None + rail_length: Optional[float] = None + rtol: Optional[float] = None + t: Optional[float] = None + t_final: Optional[float] = None + t_initial: Optional[int] = None + terminate_on_apogee: Optional[bool] = None + time_overshoot: Optional[bool] = None + latitude: Optional[AnyToPrimitive] = None + longitude: Optional[AnyToPrimitive] = None + M1: Optional[AnyToPrimitive] = None + M2: Optional[AnyToPrimitive] = None + M3: Optional[AnyToPrimitive] = None + R1: Optional[AnyToPrimitive] = None + R2: Optional[AnyToPrimitive] = None + R3: Optional[AnyToPrimitive] = None + acceleration: Optional[AnyToPrimitive] = None + aerodynamic_bending_moment: Optional[AnyToPrimitive] = None + aerodynamic_drag: Optional[AnyToPrimitive] = None + aerodynamic_lift: Optional[AnyToPrimitive] = None + aerodynamic_spin_moment: Optional[AnyToPrimitive] = None + alpha1: Optional[AnyToPrimitive] = None + alpha2: Optional[AnyToPrimitive] = None + alpha3: Optional[AnyToPrimitive] = None + altitude: Optional[AnyToPrimitive] = None + angle_of_attack: Optional[AnyToPrimitive] = None + apogee: Optional[AnyToPrimitive] = None + apogee_freestream_speed: Optional[AnyToPrimitive] = None + apogee_state: Optional[AnyToPrimitive] = None + apogee_time: Optional[AnyToPrimitive] = None + apogee_x: Optional[AnyToPrimitive] = None + apogee_y: Optional[AnyToPrimitive] = None + atol: Optional[AnyToPrimitive] = None + attitude_angle: Optional[AnyToPrimitive] = None + attitude_frequency_response: Optional[AnyToPrimitive] = None + attitude_vector_x: Optional[AnyToPrimitive] = None + attitude_vector_y: Optional[AnyToPrimitive] = None + attitude_vector_z: Optional[AnyToPrimitive] = None + ax: Optional[AnyToPrimitive] = None + ay: Optional[AnyToPrimitive] = None + az: Optional[AnyToPrimitive] = None + bearing: Optional[AnyToPrimitive] = None + drag_power: Optional[AnyToPrimitive] = None + drift: Optional[AnyToPrimitive] = None + dynamic_pressure: Optional[AnyToPrimitive] = None + e0: Optional[AnyToPrimitive] = None + e1: Optional[AnyToPrimitive] = None + e2: Optional[AnyToPrimitive] = None + e3: Optional[AnyToPrimitive] = None + free_stream_speed: Optional[AnyToPrimitive] = None + frontal_surface_wind: Optional[AnyToPrimitive] = None + function_evaluations: Optional[AnyToPrimitive] = None + function_evaluations_per_time_step: Optional[AnyToPrimitive] = None + horizontal_speed: Optional[AnyToPrimitive] = None + impact_state: Optional[AnyToPrimitive] = None + impact_velocity: Optional[AnyToPrimitive] = None + initial_stability_margin: Optional[AnyToPrimitive] = None + kinetic_energy: Optional[AnyToPrimitive] = None + lateral_attitude_angle: Optional[AnyToPrimitive] = None + lateral_surface_wind: Optional[AnyToPrimitive] = None + mach_number: Optional[AnyToPrimitive] = None + max_acceleration: Optional[AnyToPrimitive] = None + max_acceleration_power_off: Optional[AnyToPrimitive] = None + max_acceleration_power_off_time: Optional[AnyToPrimitive] = None + max_acceleration_power_on: Optional[AnyToPrimitive] = None + max_acceleration_power_on_time: Optional[AnyToPrimitive] = None + max_acceleration_time: Optional[AnyToPrimitive] = None + max_dynamic_pressure: Optional[AnyToPrimitive] = None + max_dynamic_pressure_time: Optional[AnyToPrimitive] = None + max_mach_number: Optional[AnyToPrimitive] = None + max_mach_number_time: Optional[AnyToPrimitive] = None + max_rail_button1_normal_force: Optional[AnyToPrimitive] = None + max_rail_button1_shear_force: Optional[AnyToPrimitive] = None + max_rail_button2_normal_force: Optional[AnyToPrimitive] = None + max_rail_button2_shear_force: Optional[AnyToPrimitive] = None + max_reynolds_number: Optional[AnyToPrimitive] = None + max_reynolds_number_time: Optional[AnyToPrimitive] = None + max_speed: Optional[AnyToPrimitive] = None + max_speed_time: Optional[AnyToPrimitive] = None + max_stability_margin: Optional[AnyToPrimitive] = None + max_stability_margin_time: Optional[AnyToPrimitive] = None + max_total_pressure: Optional[AnyToPrimitive] = None + max_total_pressure_time: Optional[AnyToPrimitive] = None + min_stability_margin: Optional[AnyToPrimitive] = None + min_stability_margin_time: Optional[AnyToPrimitive] = None + omega1_frequency_response: Optional[AnyToPrimitive] = None + omega2_frequency_response: Optional[AnyToPrimitive] = None + omega3_frequency_response: Optional[AnyToPrimitive] = None + out_of_rail_stability_margin: Optional[AnyToPrimitive] = None + out_of_rail_state: Optional[AnyToPrimitive] = None + out_of_rail_velocity: Optional[AnyToPrimitive] = None + parachute_events: Optional[AnyToPrimitive] = None + path_angle: Optional[AnyToPrimitive] = None + phi: Optional[AnyToPrimitive] = None + potential_energy: Optional[AnyToPrimitive] = None + psi: Optional[AnyToPrimitive] = None + rail_button1_normal_force: Optional[AnyToPrimitive] = None + rail_button1_shear_force: Optional[AnyToPrimitive] = None + rail_button2_normal_force: Optional[AnyToPrimitive] = None + rail_button2_shear_force: Optional[AnyToPrimitive] = None + reynolds_number: Optional[AnyToPrimitive] = None + rotational_energy: Optional[AnyToPrimitive] = None + solution: Optional[AnyToPrimitive] = None + solution_array: Optional[AnyToPrimitive] = None + speed: Optional[AnyToPrimitive] = None + stability_margin: Optional[AnyToPrimitive] = None + static_margin: Optional[AnyToPrimitive] = None + stream_velocity_x: Optional[AnyToPrimitive] = None + stream_velocity_y: Optional[AnyToPrimitive] = None + stream_velocity_z: Optional[AnyToPrimitive] = None + theta: Optional[AnyToPrimitive] = None + thrust_power: Optional[AnyToPrimitive] = None + time: Optional[AnyToPrimitive] = None + time_steps: Optional[AnyToPrimitive] = None + total_energy: Optional[AnyToPrimitive] = None + total_pressure: Optional[AnyToPrimitive] = None + translational_energy: Optional[AnyToPrimitive] = None + vx: Optional[AnyToPrimitive] = None + vy: Optional[AnyToPrimitive] = None + vz: Optional[AnyToPrimitive] = None + w1: Optional[AnyToPrimitive] = None + w2: Optional[AnyToPrimitive] = None + w3: Optional[AnyToPrimitive] = None + x: Optional[AnyToPrimitive] = None + x_impact: Optional[AnyToPrimitive] = None + y: Optional[AnyToPrimitive] = None + y_impact: Optional[AnyToPrimitive] = None + y_sol: Optional[AnyToPrimitive] = None + z: Optional[AnyToPrimitive] = None + z_impact: Optional[AnyToPrimitive] = None + flight_phases: Optional[AnyToPrimitive] = None + + +class FlightView(FlightModel): + flight_id: str + rocket: RocketView + + +class FlightCreated(ApiBaseView): + message: str = "Flight successfully created" + flight_id: str + + +class FlightRetrieved(ApiBaseView): + message: str = "Flight successfully retrieved" + flight: FlightView diff --git a/src/views/interface.py b/src/views/interface.py new file mode 100644 index 0000000..ef58c5a --- /dev/null +++ b/src/views/interface.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class ApiBaseView(BaseModel): + message: str = 'View not implemented' diff --git a/src/views/motor.py b/src/views/motor.py new file mode 100644 index 0000000..9f73a17 --- /dev/null +++ b/src/views/motor.py @@ -0,0 +1,84 @@ +from typing import List, Optional +from pydantic import BaseModel +from src.views.interface import ApiBaseView +from src.models.motor import MotorModel +from src.utils import AnyToPrimitive + + +class MotorSimulation(BaseModel): + message: str = "Motor successfully simulated" + average_thrust: Optional[float] = None + burn_duration: Optional[float] = None + burn_out_time: Optional[float] = None + burn_start_time: Optional[float] = None + center_of_dry_mass_position: Optional[float] = None + coordinate_system_orientation: str = 'nozzle_to_combustion_chamber' + dry_I_11: Optional[float] = None + dry_I_12: Optional[float] = None + dry_I_13: Optional[float] = None + dry_I_22: Optional[float] = None + dry_I_23: Optional[float] = None + dry_I_33: Optional[float] = None + dry_mass: Optional[float] = None + grain_burn_out: Optional[float] = None + grain_density: Optional[float] = None + grain_initial_height: Optional[float] = None + grain_initial_inner_radius: Optional[float] = None + grain_initial_mass: Optional[float] = None + grain_initial_volume: Optional[float] = None + grain_number: Optional[int] = None + grain_outer_radius: Optional[float] = None + grain_separation: Optional[float] = None + grains_center_of_mass_position: Optional[float] = None + interpolate: Optional[str] = None + max_thrust: Optional[float] = None + max_thrust_time: Optional[float] = None + nozzle_position: Optional[float] = None + nozzle_radius: Optional[float] = None + propellant_initial_mass: Optional[float] = None + throat_area: Optional[float] = None + throat_radius: Optional[float] = None + thrust_source: Optional[List[List[float]]] = None + total_impulse: Optional[float] = None + Kn: Optional[AnyToPrimitive] = None + I_11: Optional[AnyToPrimitive] = None + I_12: Optional[AnyToPrimitive] = None + I_13: Optional[AnyToPrimitive] = None + I_22: Optional[AnyToPrimitive] = None + I_23: Optional[AnyToPrimitive] = None + I_33: Optional[AnyToPrimitive] = None + burn_area: Optional[AnyToPrimitive] = None + burn_rate: Optional[AnyToPrimitive] = None + burn_time: Optional[AnyToPrimitive] = None + center_of_mass: Optional[AnyToPrimitive] = None + center_of_propellant_mass: Optional[AnyToPrimitive] = None + exhaust_velocity: Optional[AnyToPrimitive] = None + grain_height: Optional[AnyToPrimitive] = None + grain_volume: Optional[AnyToPrimitive] = None + grain_inner_radius: Optional[AnyToPrimitive] = None + mass_flow_rate: Optional[AnyToPrimitive] = None + propellant_I_11: Optional[AnyToPrimitive] = None + propellant_I_12: Optional[AnyToPrimitive] = None + propellant_I_13: Optional[AnyToPrimitive] = None + propellant_I_22: Optional[AnyToPrimitive] = None + propellant_I_23: Optional[AnyToPrimitive] = None + propellant_I_33: Optional[AnyToPrimitive] = None + propellant_mass: Optional[AnyToPrimitive] = None + reshape_thrust_curve: Optional[AnyToPrimitive] = None + total_mass: Optional[AnyToPrimitive] = None + total_mass_flow_rate: Optional[AnyToPrimitive] = None + thrust: Optional[AnyToPrimitive] = None + + +class MotorView(MotorModel): + motor_id: Optional[str] = None + + +class MotorCreated(ApiBaseView): + message: str = "Motor successfully created" + motor_id: str + + +class MotorRetrieved(ApiBaseView): + message: str = "Motor successfully retrieved" + motor: MotorView diff --git a/src/views/rocket.py b/src/views/rocket.py new file mode 100644 index 0000000..becee4a --- /dev/null +++ b/src/views/rocket.py @@ -0,0 +1,53 @@ +from typing import Optional +from src.models.rocket import RocketModel +from src.views.interface import ApiBaseView +from src.views.motor import MotorView, MotorSimulation +from src.utils import AnyToPrimitive + + +class RocketSimulation(MotorSimulation): + message: str = "Rocket successfully simulated" + area: Optional[float] = None + coordinate_system_orientation: str = 'tail_to_nose' + center_of_mass_without_motor: Optional[float] = None + motor_center_of_dry_mass_position: Optional[float] = None + motor_position: Optional[float] = None + nozzle_position: Optional[float] = None + nozzle_to_cdm: Optional[float] = None + cp_eccentricity_x: Optional[float] = None + cp_eccentricity_y: Optional[float] = None + thrust_eccentricity_x: Optional[float] = None + thrust_eccentricity_y: Optional[float] = None + I_11_without_motor: Optional[AnyToPrimitive] = None + I_12_without_motor: Optional[AnyToPrimitive] = None + I_13_without_motor: Optional[AnyToPrimitive] = None + I_22_without_motor: Optional[AnyToPrimitive] = None + I_23_without_motor: Optional[AnyToPrimitive] = None + I_33_without_motor: Optional[AnyToPrimitive] = None + check_parachute_trigger: Optional[AnyToPrimitive] = None + com_to_cdm_function: Optional[AnyToPrimitive] = None + cp_position: Optional[AnyToPrimitive] = None + motor_center_of_mass_position: Optional[AnyToPrimitive] = None + nozzle_gyration_tensor: Optional[AnyToPrimitive] = None + power_off_drag: Optional[AnyToPrimitive] = None + power_on_drag: Optional[AnyToPrimitive] = None + reduced_mass: Optional[AnyToPrimitive] = None + stability_margin: Optional[AnyToPrimitive] = None + static_margin: Optional[AnyToPrimitive] = None + thrust_to_weight: Optional[AnyToPrimitive] = None + total_lift_coeff_der: Optional[AnyToPrimitive] = None + + +class RocketView(RocketModel): + rocket_id: Optional[str] = None + motor: MotorView + + +class RocketCreated(ApiBaseView): + message: str = "Rocket successfully created" + rocket_id: str + + +class RocketRetrieved(ApiBaseView): + message: str = "Rocket successfully retrieved" + rocket: RocketView diff --git a/tests/test_routes/test_environments_route.py b/tests/test_routes/test_environments_route.py deleted file mode 100644 index ceea53a..0000000 --- a/tests/test_routes/test_environments_route.py +++ /dev/null @@ -1,259 +0,0 @@ -from unittest.mock import patch -import json -import pytest -from fastapi.testclient import TestClient -from fastapi import HTTPException, status -from lib.models.environment import Env -from lib.controllers.environment import EnvController -from lib.views.environment import ( - EnvCreated, - EnvUpdated, - EnvDeleted, - EnvSummary, -) -from lib import app - -client = TestClient(app) - - -@pytest.fixture -def stub_env_summary(): - env_summary = EnvSummary() - env_summary_json = env_summary.model_dump_json() - return json.loads(env_summary_json) - - -def test_create_env(stub_env): - with patch.object( - EnvController, 'create_env', return_value=EnvCreated(env_id='123') - ) as mock_create_env: - response = client.post('/environments/', json=stub_env) - assert response.status_code == 200 - assert response.json() == { - 'env_id': '123', - 'message': 'Environment successfully created', - } - mock_create_env.assert_called_once_with(Env(**stub_env)) - - -def test_create_env_optional_params(): - test_object = { - 'latitude': 0, - 'longitude': 0, - 'elevation': 1, - 'atmospheric_model_type': 'STANDARD_ATMOSPHERE', - 'atmospheric_model_file': None, - 'date': '2021-01-01T00:00:00', - } - with patch.object( - EnvController, 'create_env', return_value=EnvCreated(env_id='123') - ) as mock_create_env: - response = client.post('/environments/', json=test_object) - assert response.status_code == 200 - assert response.json() == { - 'env_id': '123', - 'message': 'Environment successfully created', - } - mock_create_env.assert_called_once_with(Env(**test_object)) - - -def test_create_env_invalid_input(): - response = client.post( - '/environments/', json={'latitude': 'foo', 'longitude': 'bar'} - ) - assert response.status_code == 422 - - -def test_create_env_server_error(stub_env): - with patch.object( - EnvController, - 'create_env', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR - ), - ): - response = client.post('/environments/', json=stub_env) - assert response.status_code == 500 - assert response.json() == {'detail': 'Internal Server Error'} - - -def test_read_env(stub_env): - with patch.object( - EnvController, 'get_env_by_id', return_value=Env(**stub_env) - ) as mock_read_env: - response = client.get('/environments/123') - assert response.status_code == 200 - assert response.json() == stub_env - mock_read_env.assert_called_once_with('123') - - -def test_read_env_not_found(): - with patch.object( - EnvController, - 'get_env_by_id', - side_effect=HTTPException(status_code=status.HTTP_404_NOT_FOUND), - ) as mock_read_env: - response = client.get('/environments/123') - assert response.status_code == 404 - assert response.json() == {'detail': 'Not Found'} - mock_read_env.assert_called_once_with('123') - - -def test_read_env_server_error(): - with patch.object( - EnvController, - 'get_env_by_id', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR - ), - ): - response = client.get('/environments/123') - assert response.status_code == 500 - assert response.json() == {'detail': 'Internal Server Error'} - - -def test_update_env(stub_env): - with patch.object( - EnvController, - 'update_env_by_id', - return_value=EnvUpdated(env_id='123'), - ) as mock_update_env: - response = client.put('/environments/123', json=stub_env) - assert response.status_code == 200 - assert response.json() == { - 'env_id': '123', - 'message': 'Environment successfully updated', - } - mock_update_env.assert_called_once_with('123', Env(**stub_env)) - - -def test_update_env_invalid_input(): - response = client.put( - '/environments/123', json={'latitude': 'foo', 'longitude': 'bar'} - ) - assert response.status_code == 422 - - -def test_update_env_not_found(stub_env): - with patch.object( - EnvController, - 'update_env_by_id', - side_effect=HTTPException(status_code=status.HTTP_404_NOT_FOUND), - ): - response = client.put('/environments/123', json=stub_env) - assert response.status_code == 404 - assert response.json() == {'detail': 'Not Found'} - - -def test_update_env_server_error(stub_env): - with patch.object( - EnvController, - 'update_env_by_id', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR - ), - ): - response = client.put('/environments/123', json=stub_env) - assert response.status_code == 500 - assert response.json() == {'detail': 'Internal Server Error'} - - -def test_delete_env(): - with patch.object( - EnvController, - 'delete_env_by_id', - return_value=EnvDeleted(env_id='123'), - ) as mock_delete_env: - response = client.delete('/environments/123') - assert response.status_code == 200 - assert response.json() == { - 'env_id': '123', - 'message': 'Environment successfully deleted', - } - mock_delete_env.assert_called_once_with('123') - - -def test_delete_env_server_error(): - with patch.object( - EnvController, - 'delete_env_by_id', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR - ), - ): - response = client.delete('/environments/123') - assert response.status_code == 500 - assert response.json() == {'detail': 'Internal Server Error'} - - -def test_simulate_env(stub_env_summary): - with patch.object( - EnvController, - 'simulate_env', - return_value=EnvSummary(**stub_env_summary), - ) as mock_simulate_env: - response = client.get('/environments/123/summary') - assert response.status_code == 200 - assert response.json() == stub_env_summary - mock_simulate_env.assert_called_once_with('123') - - -def test_simulate_env_not_found(): - with patch.object( - EnvController, - 'simulate_env', - side_effect=HTTPException(status_code=status.HTTP_404_NOT_FOUND), - ) as mock_simulate_env: - response = client.get('/environments/123/summary') - assert response.status_code == 404 - assert response.json() == {'detail': 'Not Found'} - mock_simulate_env.assert_called_once_with('123') - - -def test_simulate_env_server_error(): - with patch.object( - EnvController, - 'simulate_env', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR - ), - ): - response = client.get('/environments/123/summary') - assert response.status_code == 500 - assert response.json() == {'detail': 'Internal Server Error'} - - -def test_read_rocketpy_env(): - with patch.object( - EnvController, 'get_rocketpy_env_binary', return_value=b'rocketpy' - ) as mock_read_rocketpy_env: - response = client.get('/environments/123/rocketpy') - assert response.status_code == 203 - assert response.content == b'rocketpy' - assert response.headers['content-type'] == 'application/octet-stream' - mock_read_rocketpy_env.assert_called_once_with('123') - - -def test_read_rocketpy_env_not_found(): - with patch.object( - EnvController, - 'get_rocketpy_env_binary', - side_effect=HTTPException(status_code=status.HTTP_404_NOT_FOUND), - ) as mock_read_rocketpy_env: - response = client.get('/environments/123/rocketpy') - assert response.status_code == 404 - assert response.json() == {'detail': 'Not Found'} - mock_read_rocketpy_env.assert_called_once_with('123') - - -def test_read_rocketpy_env_server_error(): - with patch.object( - EnvController, - 'get_rocketpy_env_binary', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR - ), - ): - response = client.get('/environments/123/rocketpy') - assert response.status_code == 500 - assert response.json() == {'detail': 'Internal Server Error'} diff --git a/tests/test_routes/test_flights_route.py b/tests/test_routes/test_flights_route.py deleted file mode 100644 index 695fb95..0000000 --- a/tests/test_routes/test_flights_route.py +++ /dev/null @@ -1,372 +0,0 @@ -from unittest.mock import patch -import json -import pytest -from fastapi.testclient import TestClient -from fastapi import HTTPException, status -from lib.models.environment import Env -from lib.models.flight import Flight -from lib.models.motor import Motor, MotorKinds -from lib.models.rocket import Rocket -from lib.controllers.flight import FlightController -from lib.views.motor import MotorView -from lib.views.rocket import RocketView -from lib.views.flight import ( - FlightCreated, - FlightUpdated, - FlightDeleted, - FlightSummary, - FlightView, -) -from lib import app - -client = TestClient(app) - - -@pytest.fixture -def stub_flight(stub_env, stub_rocket): - flight = { - 'name': 'Test Flight', - 'environment': stub_env, - 'rocket': stub_rocket, - 'rail_length': 1, - 'time_overshoot': True, - 'terminate_on_apogee': True, - 'equations_of_motion': 'STANDARD', - } - return flight - - -@pytest.fixture -def stub_flight_summary(): - flight_summary = FlightSummary() - flight_summary_json = flight_summary.model_dump_json() - return json.loads(flight_summary_json) - - -def test_create_flight(stub_flight): - with patch.object( - FlightController, - 'create_flight', - return_value=FlightCreated(flight_id='123'), - ) as mock_create_flight: - with patch.object( - Motor, 'set_motor_kind', side_effect=None - ) as mock_set_motor_kind: - response = client.post( - '/flights/', json=stub_flight, params={'motor_kind': 'HYBRID'} - ) - assert response.status_code == 200 - assert response.json() == { - 'flight_id': '123', - 'message': 'Flight successfully created', - } - mock_set_motor_kind.assert_called_once_with(MotorKinds.HYBRID) - mock_create_flight.assert_called_once_with(Flight(**stub_flight)) - - -def test_create_flight_optional_params(stub_flight): - stub_flight.update( - { - 'inclination': 0, - 'heading': 0, - 'max_time': 1, - 'max_time_step': 1.0, - 'min_time_step': 1, - 'rtol': 1.0, - 'atol': 1.0, - 'verbose': True, - } - ) - with patch.object( - FlightController, - 'create_flight', - return_value=FlightCreated(flight_id='123'), - ) as mock_create_flight: - with patch.object( - Motor, 'set_motor_kind', side_effect=None - ) as mock_set_motor_kind: - response = client.post( - '/flights/', json=stub_flight, params={'motor_kind': 'HYBRID'} - ) - assert response.status_code == 200 - assert response.json() == { - 'flight_id': '123', - 'message': 'Flight successfully created', - } - mock_set_motor_kind.assert_called_once_with(MotorKinds.HYBRID) - mock_create_flight.assert_called_once_with(Flight(**stub_flight)) - - -def test_create_flight_invalid_input(): - response = client.post( - '/flights/', json={'environment': 'foo', 'rocket': 'bar'} - ) - assert response.status_code == 422 - - -def test_create_flight_server_error(stub_flight): - with patch.object( - FlightController, - 'create_flight', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR - ), - ): - response = client.post( - '/flights/', json=stub_flight, params={'motor_kind': 'HYBRID'} - ) - assert response.status_code == 500 - assert response.json() == {'detail': 'Internal Server Error'} - - -def test_read_flight(stub_flight, stub_rocket, stub_motor): - del stub_rocket['motor'] - del stub_flight['rocket'] - motor_view = MotorView(**stub_motor, selected_motor_kind=MotorKinds.HYBRID) - rocket_view = RocketView(**stub_rocket, motor=motor_view) - flight_view = FlightView(**stub_flight, rocket=rocket_view) - with patch.object( - FlightController, - 'get_flight_by_id', - return_value=flight_view, - ) as mock_read_flight: - response = client.get('/flights/123') - assert response.status_code == 200 - assert response.json() == json.loads(flight_view.model_dump_json()) - mock_read_flight.assert_called_once_with('123') - - -def test_read_flight_not_found(): - with patch.object( - FlightController, - 'get_flight_by_id', - side_effect=HTTPException(status_code=status.HTTP_404_NOT_FOUND), - ) as mock_read_flight: - response = client.get('/flights/123') - assert response.status_code == 404 - assert response.json() == {'detail': 'Not Found'} - mock_read_flight.assert_called_once_with('123') - - -def test_read_flight_server_error(): - with patch.object( - FlightController, - 'get_flight_by_id', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR - ), - ): - response = client.get('/flights/123') - assert response.status_code == 500 - assert response.json() == {'detail': 'Internal Server Error'} - - -def test_update_flight(stub_flight): - with patch.object( - FlightController, - 'update_flight_by_id', - return_value=FlightUpdated(flight_id='123'), - ) as mock_update_flight: - with patch.object( - Motor, 'set_motor_kind', side_effect=None - ) as mock_set_motor_kind: - response = client.put( - '/flights/123', - json=stub_flight, - params={'motor_kind': 'GENERIC'}, - ) - assert response.status_code == 200 - assert response.json() == { - 'flight_id': '123', - 'message': 'Flight successfully updated', - } - mock_update_flight.assert_called_once_with( - '123', Flight(**stub_flight) - ) - mock_set_motor_kind.assert_called_once_with(MotorKinds.GENERIC) - - -def test_update_env_by_flight_id(stub_env): - with patch.object( - FlightController, - 'update_env_by_flight_id', - return_value=FlightUpdated(flight_id='123'), - ) as mock_update_flight: - response = client.put('/flights/123/env', json=stub_env) - assert response.status_code == 200 - assert response.json() == { - 'flight_id': '123', - 'message': 'Flight successfully updated', - } - mock_update_flight.assert_called_once_with('123', env=Env(**stub_env)) - - -def test_update_rocket_by_flight_id(stub_rocket): - with patch.object( - FlightController, - 'update_rocket_by_flight_id', - return_value=FlightUpdated(flight_id='123'), - ) as mock_update_flight: - response = client.put( - '/flights/123/rocket', - json=stub_rocket, - params={'motor_kind': 'GENERIC'}, - ) - assert response.status_code == 200 - assert response.json() == { - 'flight_id': '123', - 'message': 'Flight successfully updated', - } - assert mock_update_flight.call_count == 1 - assert mock_update_flight.call_args[0][0] == '123' - assert mock_update_flight.call_args[1]['rocket'].model_dump() == Rocket(**stub_rocket).model_dump() - - -def test_update_env_by_flight_id_invalid_input(): - response = client.put('/flights/123', json={'environment': 'foo'}) - assert response.status_code == 422 - - -def test_update_rocket_by_flight_id_invalid_input(): - response = client.put('/flights/123', json={'rocket': 'bar'}) - assert response.status_code == 422 - - -def test_update_flight_invalid_input(): - response = client.put( - '/flights/123', - json={'environment': 'foo', 'rocket': 'bar'}, - params={'motor_kind': 'GENERIC'}, - ) - assert response.status_code == 422 - - -def test_update_flight_not_found(stub_flight): - with patch.object( - FlightController, - 'update_flight_by_id', - side_effect=HTTPException(status_code=status.HTTP_404_NOT_FOUND), - ): - response = client.put( - '/flights/123', json=stub_flight, params={'motor_kind': 'HYBRID'} - ) - assert response.status_code == 404 - assert response.json() == {'detail': 'Not Found'} - - -def test_update_flight_server_error(stub_flight): - with patch.object( - FlightController, - 'update_flight_by_id', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR - ), - ): - response = client.put( - '/flights/123', json=stub_flight, params={'motor_kind': 'HYBRID'} - ) - assert response.status_code == 500 - assert response.json() == {'detail': 'Internal Server Error'} - - -def test_delete_flight(): - with patch.object( - FlightController, - 'delete_flight_by_id', - return_value=FlightDeleted(flight_id='123'), - ) as mock_delete_flight: - response = client.delete('/flights/123') - assert response.status_code == 200 - assert response.json() == { - 'flight_id': '123', - 'message': 'Flight successfully deleted', - } - mock_delete_flight.assert_called_once_with('123') - - -def test_delete_flight_server_error(): - with patch.object( - FlightController, - 'delete_flight_by_id', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR - ), - ): - response = client.delete('/flights/123') - assert response.status_code == 500 - assert response.json() == {'detail': 'Internal Server Error'} - - -def test_simulate_flight(stub_flight_summary): - with patch.object( - FlightController, - 'simulate_flight', - return_value=FlightSummary(**stub_flight_summary), - ) as mock_simulate_flight: - response = client.get('/flights/123/summary') - assert response.status_code == 200 - assert response.json() == stub_flight_summary - mock_simulate_flight.assert_called_once_with('123') - - -def test_simulate_flight_not_found(): - with patch.object( - FlightController, - 'simulate_flight', - side_effect=HTTPException(status_code=status.HTTP_404_NOT_FOUND), - ) as mock_simulate_flight: - response = client.get('/flights/123/summary') - assert response.status_code == 404 - assert response.json() == {'detail': 'Not Found'} - mock_simulate_flight.assert_called_once_with('123') - - -def test_simulate_flight_server_error(): - with patch.object( - FlightController, - 'simulate_flight', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR - ), - ): - response = client.get('/flights/123/summary') - assert response.status_code == 500 - assert response.json() == {'detail': 'Internal Server Error'} - - -def test_read_rocketpy_flight(): - with patch.object( - FlightController, - 'get_rocketpy_flight_binary', - return_value=b'rocketpy', - ) as mock_read_rocketpy_flight: - response = client.get('/flights/123/rocketpy') - assert response.status_code == 203 - assert response.content == b'rocketpy' - assert response.headers['content-type'] == 'application/octet-stream' - mock_read_rocketpy_flight.assert_called_once_with('123') - - -def test_read_rocketpy_flight_not_found(): - with patch.object( - FlightController, - 'get_rocketpy_flight_binary', - side_effect=HTTPException(status_code=status.HTTP_404_NOT_FOUND), - ) as mock_read_rocketpy_flight: - response = client.get('/flights/123/rocketpy') - assert response.status_code == 404 - assert response.json() == {'detail': 'Not Found'} - mock_read_rocketpy_flight.assert_called_once_with('123') - - -def test_read_rocketpy_flight_server_error(): - with patch.object( - FlightController, - 'get_rocketpy_flight_binary', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR - ), - ): - response = client.get('/flights/123/rocketpy') - assert response.status_code == 500 - assert response.json() == {'detail': 'Internal Server Error'} diff --git a/tests/test_routes/test_motors_route.py b/tests/test_routes/test_motors_route.py deleted file mode 100644 index 9f32a7b..0000000 --- a/tests/test_routes/test_motors_route.py +++ /dev/null @@ -1,481 +0,0 @@ -from unittest.mock import patch -import json -import pytest -from fastapi.testclient import TestClient -from fastapi import HTTPException, status -from lib.models.motor import ( - Motor, - MotorKinds, -) -from lib.controllers.motor import MotorController -from lib.views.motor import ( - MotorCreated, - MotorUpdated, - MotorDeleted, - MotorSummary, - MotorView, -) -from lib import app - -client = TestClient(app) - - -@pytest.fixture -def stub_motor_summary(): - motor_summary = MotorSummary() - motor_summary_json = motor_summary.model_dump_json() - return json.loads(motor_summary_json) - - -def test_create_motor(stub_motor): - with patch.object( - MotorController, - 'create_motor', - return_value=MotorCreated(motor_id='123'), - ) as mock_create_motor: - with patch.object( - Motor, 'set_motor_kind', side_effect=None - ) as mock_set_motor_kind: - response = client.post( - '/motors/', json=stub_motor, params={'motor_kind': 'HYBRID'} - ) - assert response.status_code == 200 - assert response.json() == { - 'motor_id': '123', - 'message': 'Motor successfully created', - } - mock_set_motor_kind.assert_called_once_with(MotorKinds.HYBRID) - mock_create_motor.assert_called_once_with(Motor(**stub_motor)) - - -def test_create_motor_optional_params(stub_motor): - stub_motor.update( - { - 'interpolation_method': 'LINEAR', - 'coordinate_system_orientation': 'NOZZLE_TO_COMBUSTION_CHAMBER', - 'reshape_thrust_curve': False, - } - ) - with patch.object( - MotorController, - 'create_motor', - return_value=MotorCreated(motor_id='123'), - ) as mock_create_motor: - with patch.object( - Motor, 'set_motor_kind', side_effect=None - ) as mock_set_motor_kind: - response = client.post( - '/motors/', json=stub_motor, params={'motor_kind': 'HYBRID'} - ) - assert response.status_code == 200 - assert response.json() == { - 'motor_id': '123', - 'message': 'Motor successfully created', - } - mock_set_motor_kind.assert_called_once_with(MotorKinds.HYBRID) - mock_create_motor.assert_called_once_with(Motor(**stub_motor)) - - -def test_create_generic_motor(stub_motor): - stub_motor.update( - { - 'chamber_radius': 0, - 'chamber_height': 0, - 'chamber_position': 0, - 'propellant_initial_mass': 0, - 'nozzle_position': 0, - } - ) - with patch.object( - MotorController, - 'create_motor', - return_value=MotorCreated(motor_id='123'), - ) as mock_create_motor: - with patch.object( - Motor, 'set_motor_kind', side_effect=None - ) as mock_set_motor_kind: - response = client.post( - '/motors/', json=stub_motor, params={'motor_kind': 'GENERIC'} - ) - assert response.status_code == 200 - assert response.json() == { - 'motor_id': '123', - 'message': 'Motor successfully created', - } - mock_set_motor_kind.assert_called_once_with(MotorKinds.GENERIC) - mock_create_motor.assert_called_once_with(Motor(**stub_motor)) - - -def test_create_liquid_motor_level_tank(stub_motor, stub_level_tank): - stub_motor.update({'tanks': [stub_level_tank]}) - with patch.object( - MotorController, - 'create_motor', - return_value=MotorCreated(motor_id='123'), - ) as mock_create_motor: - with patch.object( - Motor, 'set_motor_kind', side_effect=None - ) as mock_set_motor_kind: - response = client.post( - '/motors/', json=stub_motor, params={'motor_kind': 'LIQUID'} - ) - assert response.status_code == 200 - assert response.json() == { - 'motor_id': '123', - 'message': 'Motor successfully created', - } - mock_set_motor_kind.assert_called_once_with(MotorKinds.LIQUID) - mock_create_motor.assert_called_once_with(Motor(**stub_motor)) - - -def test_create_liquid_motor_mass_flow_tank(stub_motor, stub_mass_flow_tank): - stub_motor.update({'tanks': [stub_mass_flow_tank]}) - with patch.object( - MotorController, - 'create_motor', - return_value=MotorCreated(motor_id='123'), - ) as mock_create_motor: - with patch.object( - Motor, 'set_motor_kind', side_effect=None - ) as mock_set_motor_kind: - response = client.post( - '/motors/', json=stub_motor, params={'motor_kind': 'LIQUID'} - ) - assert response.status_code == 200 - assert response.json() == { - 'motor_id': '123', - 'message': 'Motor successfully created', - } - mock_set_motor_kind.assert_called_once_with(MotorKinds.LIQUID) - mock_create_motor.assert_called_once_with(Motor(**stub_motor)) - - -def test_create_liquid_motor_ullage_tank(stub_motor, stub_ullage_tank): - stub_motor.update({'tanks': [stub_ullage_tank]}) - with patch.object( - MotorController, - 'create_motor', - return_value=MotorCreated(motor_id='123'), - ) as mock_create_motor: - with patch.object( - Motor, 'set_motor_kind', side_effect=None - ) as mock_set_motor_kind: - response = client.post( - '/motors/', json=stub_motor, params={'motor_kind': 'LIQUID'} - ) - assert response.status_code == 200 - assert response.json() == { - 'motor_id': '123', - 'message': 'Motor successfully created', - } - mock_set_motor_kind.assert_called_once_with(MotorKinds.LIQUID) - mock_create_motor.assert_called_once_with(Motor(**stub_motor)) - - -def test_create_liquid_motor_mass_tank(stub_motor, stub_mass_tank): - stub_motor.update({'tanks': [stub_mass_tank]}) - with patch.object( - MotorController, - 'create_motor', - return_value=MotorCreated(motor_id='123'), - ) as mock_create_motor: - with patch.object( - Motor, 'set_motor_kind', side_effect=None - ) as mock_set_motor_kind: - response = client.post( - '/motors/', json=stub_motor, params={'motor_kind': 'LIQUID'} - ) - assert response.status_code == 200 - assert response.json() == { - 'motor_id': '123', - 'message': 'Motor successfully created', - } - mock_set_motor_kind.assert_called_once_with(MotorKinds.LIQUID) - mock_create_motor.assert_called_once_with(Motor(**stub_motor)) - - -def test_create_hybrid_motor(stub_motor, stub_level_tank): - stub_motor.update( - { - 'grain_number': 0, - 'grain_density': 0, - 'grain_outer_radius': 0, - 'grain_initial_inner_radius': 0, - 'grain_initial_height': 0, - 'grains_center_of_mass_position': 0, - 'grain_separation': 0, - 'throat_radius': 0, - 'tanks': [stub_level_tank], - } - ) - with patch.object( - MotorController, - 'create_motor', - return_value=MotorCreated(motor_id='123'), - ) as mock_create_motor: - with patch.object( - Motor, 'set_motor_kind', side_effect=None - ) as mock_set_motor_kind: - response = client.post( - '/motors/', json=stub_motor, params={'motor_kind': 'HYBRID'} - ) - assert response.status_code == 200 - assert response.json() == { - 'motor_id': '123', - 'message': 'Motor successfully created', - } - mock_set_motor_kind.assert_called_once_with(MotorKinds.HYBRID) - mock_create_motor.assert_called_once_with(Motor(**stub_motor)) - - -def test_create_solid_motor(stub_motor): - stub_motor.update( - { - 'grain_number': 0, - 'grain_density': 0, - 'grain_outer_radius': 0, - 'grain_initial_inner_radius': 0, - 'grain_initial_height': 0, - 'grains_center_of_mass_position': 0, - 'grain_separation': 0, - } - ) - with patch.object( - MotorController, - 'create_motor', - return_value=MotorCreated(motor_id='123'), - ) as mock_create_motor: - with patch.object( - Motor, 'set_motor_kind', side_effect=None - ) as mock_set_motor_kind: - response = client.post( - '/motors/', json=stub_motor, params={'motor_kind': 'SOLID'} - ) - assert response.status_code == 200 - assert response.json() == { - 'motor_id': '123', - 'message': 'Motor successfully created', - } - mock_set_motor_kind.assert_called_once_with(MotorKinds.SOLID) - mock_create_motor.assert_called_once_with(Motor(**stub_motor)) - - -def test_create_motor_invalid_input(): - response = client.post( - '/motors/', json={'burn_time': 'foo', 'nozzle_radius': 'bar'} - ) - assert response.status_code == 422 - - -def test_create_motor_server_error(stub_motor): - with patch.object( - MotorController, - 'create_motor', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR - ), - ): - response = client.post( - '/motors/', json=stub_motor, params={'motor_kind': 'HYBRID'} - ) - assert response.status_code == 500 - assert response.json() == {'detail': 'Internal Server Error'} - - -def test_read_motor(stub_motor): - stub_motor.update({'selected_motor_kind': 'HYBRID'}) - with patch.object( - MotorController, - 'get_motor_by_id', - return_value=MotorView(**stub_motor), - ) as mock_read_motor: - response = client.get('/motors/123') - assert response.status_code == 200 - assert response.json() == stub_motor - mock_read_motor.assert_called_once_with('123') - - -def test_read_motor_not_found(): - with patch.object( - MotorController, - 'get_motor_by_id', - side_effect=HTTPException(status_code=status.HTTP_404_NOT_FOUND), - ) as mock_read_motor: - response = client.get('/motors/123') - assert response.status_code == 404 - assert response.json() == {'detail': 'Not Found'} - mock_read_motor.assert_called_once_with('123') - - -def test_read_motor_server_error(): - with patch.object( - MotorController, - 'get_motor_by_id', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR - ), - ): - response = client.get('/motors/123') - assert response.status_code == 500 - assert response.json() == {'detail': 'Internal Server Error'} - - -def test_update_motor(stub_motor): - with patch.object( - MotorController, - 'update_motor_by_id', - return_value=MotorUpdated(motor_id='123'), - ) as mock_update_motor: - with patch.object( - Motor, 'set_motor_kind', side_effect=None - ) as mock_set_motor_kind: - response = client.put( - '/motors/123', json=stub_motor, params={'motor_kind': 'HYBRID'} - ) - assert response.status_code == 200 - assert response.json() == { - 'motor_id': '123', - 'message': 'Motor successfully updated', - } - mock_update_motor.assert_called_once_with( - '123', Motor(**stub_motor) - ) - mock_set_motor_kind.assert_called_once_with(MotorKinds.HYBRID) - - -def test_update_motor_invalid_input(): - response = client.put( - '/motors/123', - json={'burn_time': 'foo', 'nozzle_radius': 'bar'}, - params={'motor_kind': 'HYBRID'}, - ) - assert response.status_code == 422 - - -def test_update_motor_not_found(stub_motor): - with patch.object( - MotorController, - 'update_motor_by_id', - side_effect=HTTPException(status_code=status.HTTP_404_NOT_FOUND), - ): - response = client.put( - '/motors/123', json=stub_motor, params={'motor_kind': 'HYBRID'} - ) - assert response.status_code == 404 - assert response.json() == {'detail': 'Not Found'} - - -def test_update_motor_server_error(stub_motor): - with patch.object( - MotorController, - 'update_motor_by_id', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR - ), - ): - response = client.put( - '/motors/123', json=stub_motor, params={'motor_kind': 'HYBRID'} - ) - assert response.status_code == 500 - assert response.json() == {'detail': 'Internal Server Error'} - - -def test_delete_motor(): - with patch.object( - MotorController, - 'delete_motor_by_id', - return_value=MotorDeleted(motor_id='123'), - ) as mock_delete_motor: - response = client.delete('/motors/123') - assert response.status_code == 200 - assert response.json() == { - 'motor_id': '123', - 'message': 'Motor successfully deleted', - } - mock_delete_motor.assert_called_once_with('123') - - -def test_delete_motor_server_error(): - with patch.object( - MotorController, - 'delete_motor_by_id', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR - ), - ): - response = client.delete('/motors/123') - assert response.status_code == 500 - assert response.json() == {'detail': 'Internal Server Error'} - - -def test_simulate_motor(stub_motor_summary): - with patch.object( - MotorController, - 'simulate_motor', - return_value=MotorSummary(**stub_motor_summary), - ) as mock_simulate_motor: - response = client.get('/motors/123/summary') - assert response.status_code == 200 - assert response.json() == stub_motor_summary - mock_simulate_motor.assert_called_once_with('123') - - -def test_simulate_motor_not_found(): - with patch.object( - MotorController, - 'simulate_motor', - side_effect=HTTPException(status_code=status.HTTP_404_NOT_FOUND), - ) as mock_simulate_motor: - response = client.get('/motors/123/summary') - assert response.status_code == 404 - assert response.json() == {'detail': 'Not Found'} - mock_simulate_motor.assert_called_once_with('123') - - -def test_simulate_motor_server_error(): - with patch.object( - MotorController, - 'simulate_motor', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR - ), - ): - response = client.get('/motors/123/summary') - assert response.status_code == 500 - assert response.json() == {'detail': 'Internal Server Error'} - - -def test_read_rocketpy_motor(): - with patch.object( - MotorController, 'get_rocketpy_motor_binary', return_value=b'rocketpy' - ) as mock_read_rocketpy_motor: - response = client.get('/motors/123/rocketpy') - assert response.status_code == 203 - assert response.content == b'rocketpy' - assert response.headers['content-type'] == 'application/octet-stream' - mock_read_rocketpy_motor.assert_called_once_with('123') - - -def test_read_rocketpy_motor_not_found(): - with patch.object( - MotorController, - 'get_rocketpy_motor_binary', - side_effect=HTTPException(status_code=status.HTTP_404_NOT_FOUND), - ) as mock_read_rocketpy_motor: - response = client.get('/motors/123/rocketpy') - assert response.status_code == 404 - assert response.json() == {'detail': 'Not Found'} - mock_read_rocketpy_motor.assert_called_once_with('123') - - -def test_read_rocketpy_motor_server_error(): - with patch.object( - MotorController, - 'get_rocketpy_motor_binary', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR - ), - ): - response = client.get('/motors/123/rocketpy') - assert response.status_code == 500 - assert response.json() == {'detail': 'Internal Server Error'} diff --git a/tests/test_routes/test_rockets_route.py b/tests/test_routes/test_rockets_route.py deleted file mode 100644 index f689b98..0000000 --- a/tests/test_routes/test_rockets_route.py +++ /dev/null @@ -1,551 +0,0 @@ -from unittest.mock import patch -import json -import pytest -from fastapi.testclient import TestClient -from fastapi import HTTPException, status -from lib.models.aerosurfaces import ( - Tail, - RailButtons, - Parachute, -) -from lib.models.rocket import Rocket -from lib.models.motor import ( - Motor, - MotorKinds, -) -from lib.controllers.rocket import RocketController -from lib.views.motor import MotorView -from lib.views.rocket import ( - RocketCreated, - RocketUpdated, - RocketDeleted, - RocketSummary, - RocketView, -) -from lib import app - -client = TestClient(app) - - -@pytest.fixture -def stub_rocket_summary(): - rocket_summary = RocketSummary() - rocket_summary_json = rocket_summary.model_dump_json() - return json.loads(rocket_summary_json) - - -@pytest.fixture -def stub_tail(): - tail = Tail( - name='tail', - top_radius=0, - bottom_radius=0, - length=0, - position=0, - radius=0, - ) - tail_json = tail.model_dump_json() - return json.loads(tail_json) - - -@pytest.fixture -def stub_rail_buttons(): - rail_buttons = RailButtons( - upper_button_position=0, - lower_button_position=0, - angular_position=0, - ) - rail_buttons_json = rail_buttons.model_dump_json() - return json.loads(rail_buttons_json) - - -@pytest.fixture -def stub_parachute(): - parachute = Parachute( - name='parachute', - cd_s=0, - sampling_rate=1, - lag=0, - trigger='trigger', - noise=(0, 0, 0), - ) - parachute_json = parachute.model_dump_json() - return json.loads(parachute_json) - - -def test_create_rocket(stub_rocket): - with patch.object( - RocketController, - 'create_rocket', - return_value=RocketCreated(rocket_id='123'), - ) as mock_create_rocket: - with patch.object( - Motor, 'set_motor_kind', side_effect=None - ) as mock_set_motor_kind: - response = client.post( - '/rockets/', json=stub_rocket, params={'motor_kind': 'HYBRID'} - ) - assert response.status_code == 200 - assert response.json() == { - 'rocket_id': '123', - 'message': 'Rocket successfully created', - } - mock_set_motor_kind.assert_called_once_with(MotorKinds.HYBRID) - mock_create_rocket.assert_called_once_with(Rocket(**stub_rocket)) - - -def test_create_rocket_optional_params( - stub_rocket, - stub_tail, - stub_rail_buttons, - stub_parachute, -): - stub_rocket.update( - { - 'parachutes': [stub_parachute], - 'rail_buttons': stub_rail_buttons, - 'tail': stub_tail, - } - ) - with patch.object( - RocketController, - 'create_rocket', - return_value=RocketCreated(rocket_id='123'), - ) as mock_create_rocket: - with patch.object( - Motor, 'set_motor_kind', side_effect=None - ) as mock_set_motor_kind: - response = client.post( - '/rockets/', json=stub_rocket, params={'motor_kind': 'HYBRID'} - ) - assert response.status_code == 200 - assert response.json() == { - 'rocket_id': '123', - 'message': 'Rocket successfully created', - } - mock_set_motor_kind.assert_called_once_with(MotorKinds.HYBRID) - mock_create_rocket.assert_called_once_with(Rocket(**stub_rocket)) - - -def test_create_generic_motor_rocket(stub_rocket, stub_motor): - stub_motor.update( - { - 'chamber_radius': 0, - 'chamber_height': 0, - 'chamber_position': 0, - 'propellant_initial_mass': 0, - 'nozzle_position': 0, - } - ) - stub_rocket.update({'motor': stub_motor}) - with patch.object( - RocketController, - 'create_rocket', - return_value=RocketCreated(rocket_id='123'), - ) as mock_create_rocket: - with patch.object( - Motor, 'set_motor_kind', side_effect=None - ) as mock_set_motor_kind: - response = client.post( - '/rockets/', json=stub_rocket, params={'motor_kind': 'GENERIC'} - ) - assert response.status_code == 200 - assert response.json() == { - 'rocket_id': '123', - 'message': 'Rocket successfully created', - } - mock_set_motor_kind.assert_called_once_with(MotorKinds.GENERIC) - mock_create_rocket.assert_called_once_with(Rocket(**stub_rocket)) - - -def test_create_liquid_motor_level_tank_rocket( - stub_rocket, stub_motor, stub_level_tank -): - stub_motor.update({'tanks': [stub_level_tank]}) - stub_rocket.update({'motor': stub_motor}) - with patch.object( - RocketController, - 'create_rocket', - return_value=RocketCreated(rocket_id='123'), - ) as mock_create_rocket: - with patch.object( - Motor, 'set_motor_kind', side_effect=None - ) as mock_set_motor_kind: - response = client.post( - '/rockets/', json=stub_rocket, params={'motor_kind': 'LIQUID'} - ) - assert response.status_code == 200 - assert response.json() == { - 'rocket_id': '123', - 'message': 'Rocket successfully created', - } - mock_set_motor_kind.assert_called_once_with(MotorKinds.LIQUID) - mock_create_rocket.assert_called_once_with(Rocket(**stub_rocket)) - - -def test_create_liquid_motor_mass_flow_tank_rocket( - stub_rocket, stub_motor, stub_mass_flow_tank -): - stub_motor.update({'tanks': [stub_mass_flow_tank]}) - stub_rocket.update({'motor': stub_motor}) - with patch.object( - RocketController, - 'create_rocket', - return_value=RocketCreated(rocket_id='123'), - ) as mock_create_rocket: - with patch.object( - Motor, 'set_motor_kind', side_effect=None - ) as mock_set_motor_kind: - response = client.post( - '/rockets/', json=stub_rocket, params={'motor_kind': 'LIQUID'} - ) - assert response.status_code == 200 - assert response.json() == { - 'rocket_id': '123', - 'message': 'Rocket successfully created', - } - mock_set_motor_kind.assert_called_once_with(MotorKinds.LIQUID) - mock_create_rocket.assert_called_once_with(Rocket(**stub_rocket)) - - -def test_create_liquid_motor_ullage_tank_rocket( - stub_rocket, stub_motor, stub_ullage_tank -): - stub_motor.update({'tanks': [stub_ullage_tank]}) - stub_rocket.update({'motor': stub_motor}) - with patch.object( - RocketController, - 'create_rocket', - return_value=RocketCreated(rocket_id='123'), - ) as mock_create_rocket: - with patch.object( - Motor, 'set_motor_kind', side_effect=None - ) as mock_set_motor_kind: - response = client.post( - '/rockets/', json=stub_rocket, params={'motor_kind': 'LIQUID'} - ) - assert response.status_code == 200 - assert response.json() == { - 'rocket_id': '123', - 'message': 'Rocket successfully created', - } - mock_set_motor_kind.assert_called_once_with(MotorKinds.LIQUID) - mock_create_rocket.assert_called_once_with(Rocket(**stub_rocket)) - - -def test_create_liquid_motor_mass_tank_rocket( - stub_rocket, stub_motor, stub_mass_tank -): - stub_motor.update({'tanks': [stub_mass_tank]}) - stub_rocket.update({'motor': stub_motor}) - with patch.object( - RocketController, - 'create_rocket', - return_value=RocketCreated(rocket_id='123'), - ) as mock_create_rocket: - with patch.object( - Motor, 'set_motor_kind', side_effect=None - ) as mock_set_motor_kind: - response = client.post( - '/rockets/', json=stub_rocket, params={'motor_kind': 'LIQUID'} - ) - assert response.status_code == 200 - assert response.json() == { - 'rocket_id': '123', - 'message': 'Rocket successfully created', - } - mock_set_motor_kind.assert_called_once_with(MotorKinds.LIQUID) - mock_create_rocket.assert_called_once_with(Rocket(**stub_rocket)) - - -def test_create_hybrid_motor_rocket(stub_rocket, stub_motor, stub_level_tank): - stub_motor.update( - { - 'grain_number': 0, - 'grain_density': 0, - 'grain_outer_radius': 0, - 'grain_initial_inner_radius': 0, - 'grain_initial_height': 0, - 'grains_center_of_mass_position': 0, - 'grain_separation': 0, - 'throat_radius': 0, - 'tanks': [stub_level_tank], - } - ) - stub_rocket.update({'motor': stub_motor}) - with patch.object( - RocketController, - 'create_rocket', - return_value=RocketCreated(rocket_id='123'), - ) as mock_create_rocket: - with patch.object( - Motor, 'set_motor_kind', side_effect=None - ) as mock_set_motor_kind: - response = client.post( - '/rockets/', json=stub_rocket, params={'motor_kind': 'HYBRID'} - ) - assert response.status_code == 200 - assert response.json() == { - 'rocket_id': '123', - 'message': 'Rocket successfully created', - } - mock_set_motor_kind.assert_called_once_with(MotorKinds.HYBRID) - mock_create_rocket.assert_called_once_with(Rocket(**stub_rocket)) - - -def test_create_solid_motor_rocket(stub_rocket, stub_motor): - stub_motor.update( - { - 'grain_number': 0, - 'grain_density': 0, - 'grain_outer_radius': 0, - 'grain_initial_inner_radius': 0, - 'grain_initial_height': 0, - 'grains_center_of_mass_position': 0, - 'grain_separation': 0, - } - ) - stub_rocket.update({'motor': stub_motor}) - with patch.object( - RocketController, - 'create_rocket', - return_value=RocketCreated(rocket_id='123'), - ) as mock_create_rocket: - with patch.object( - Motor, 'set_motor_kind', side_effect=None - ) as mock_set_motor_kind: - response = client.post( - '/rockets/', json=stub_rocket, params={'motor_kind': 'SOLID'} - ) - assert response.status_code == 200 - assert response.json() == { - 'rocket_id': '123', - 'message': 'Rocket successfully created', - } - mock_set_motor_kind.assert_called_once_with(MotorKinds.SOLID) - mock_create_rocket.assert_called_once_with(Rocket(**stub_rocket)) - - -def test_create_rocket_invalid_input(): - response = client.post('/rockets/', json={'radius': 'foo', 'mass': 'bar'}) - assert response.status_code == 422 - - -def test_create_rocket_server_error(stub_rocket): - with patch.object( - RocketController, - 'create_rocket', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR - ), - ): - response = client.post( - '/rockets/', json=stub_rocket, params={'motor_kind': 'HYBRID'} - ) - assert response.status_code == 500 - assert response.json() == {'detail': 'Internal Server Error'} - - -def test_read_rocket(stub_rocket, stub_motor): - del stub_rocket['motor'] - motor_view = MotorView(**stub_motor, selected_motor_kind=MotorKinds.HYBRID) - rocket_view = RocketView(**stub_rocket, motor=motor_view) - with patch.object( - RocketController, - 'get_rocket_by_id', - return_value=rocket_view, - ) as mock_read_rocket: - response = client.get('/rockets/123') - assert response.status_code == 200 - assert response.json() == json.loads(rocket_view.model_dump_json()) - mock_read_rocket.assert_called_once_with('123') - - -def test_read_rocket_not_found(): - with patch.object( - RocketController, - 'get_rocket_by_id', - side_effect=HTTPException(status_code=status.HTTP_404_NOT_FOUND), - ) as mock_read_rocket: - response = client.get('/rockets/123') - assert response.status_code == 404 - assert response.json() == {'detail': 'Not Found'} - mock_read_rocket.assert_called_once_with('123') - - -def test_read_rocket_server_error(): - with patch.object( - RocketController, - 'get_rocket_by_id', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR - ), - ): - response = client.get('/rockets/123') - assert response.status_code == 500 - assert response.json() == {'detail': 'Internal Server Error'} - - -def test_update_rocket(stub_rocket): - with patch.object( - RocketController, - 'update_rocket_by_id', - return_value=RocketUpdated(rocket_id='123'), - ) as mock_update_rocket: - with patch.object( - Motor, 'set_motor_kind', side_effect=None - ) as mock_set_motor_kind: - response = client.put( - '/rockets/123', - json=stub_rocket, - params={'motor_kind': 'GENERIC'}, - ) - assert response.status_code == 200 - assert response.json() == { - 'rocket_id': '123', - 'message': 'Rocket successfully updated', - } - mock_update_rocket.assert_called_once_with( - '123', Rocket(**stub_rocket) - ) - mock_set_motor_kind.assert_called_once_with(MotorKinds.GENERIC) - - -def test_update_rocket_invalid_input(): - response = client.put( - '/rockets/123', - json={'mass': 'foo', 'radius': 'bar'}, - params={'motor_kind': 'GENERIC'}, - ) - assert response.status_code == 422 - - -def test_update_rocket_not_found(stub_rocket): - with patch.object( - RocketController, - 'update_rocket_by_id', - side_effect=HTTPException(status_code=status.HTTP_404_NOT_FOUND), - ): - response = client.put( - '/rockets/123', json=stub_rocket, params={'motor_kind': 'HYBRID'} - ) - assert response.status_code == 404 - assert response.json() == {'detail': 'Not Found'} - - -def test_update_rocket_server_error(stub_rocket): - with patch.object( - RocketController, - 'update_rocket_by_id', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR - ), - ): - response = client.put( - '/rockets/123', json=stub_rocket, params={'motor_kind': 'HYBRID'} - ) - assert response.status_code == 500 - assert response.json() == {'detail': 'Internal Server Error'} - - -def test_delete_rocket(): - with patch.object( - RocketController, - 'delete_rocket_by_id', - return_value=RocketDeleted(rocket_id='123'), - ) as mock_delete_rocket: - response = client.delete('/rockets/123') - assert response.status_code == 200 - assert response.json() == { - 'rocket_id': '123', - 'message': 'Rocket successfully deleted', - } - mock_delete_rocket.assert_called_once_with('123') - - -def test_delete_rocket_server_error(): - with patch.object( - RocketController, - 'delete_rocket_by_id', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR - ), - ): - response = client.delete('/rockets/123') - assert response.status_code == 500 - assert response.json() == {'detail': 'Internal Server Error'} - - -def test_simulate_rocket(stub_rocket_summary): - with patch.object( - RocketController, - 'simulate_rocket', - return_value=RocketSummary(**stub_rocket_summary), - ) as mock_simulate_rocket: - response = client.get('/rockets/123/summary') - assert response.status_code == 200 - assert response.json() == stub_rocket_summary - mock_simulate_rocket.assert_called_once_with('123') - - -def test_simulate_rocket_not_found(): - with patch.object( - RocketController, - 'simulate_rocket', - side_effect=HTTPException(status_code=status.HTTP_404_NOT_FOUND), - ) as mock_simulate_rocket: - response = client.get('/rockets/123/summary') - assert response.status_code == 404 - assert response.json() == {'detail': 'Not Found'} - mock_simulate_rocket.assert_called_once_with('123') - - -def test_simulate_rocket_server_error(): - with patch.object( - RocketController, - 'simulate_rocket', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR - ), - ): - response = client.get('/rockets/123/summary') - assert response.status_code == 500 - assert response.json() == {'detail': 'Internal Server Error'} - - -def test_read_rocketpy_rocket(): - with patch.object( - RocketController, - 'get_rocketpy_rocket_binary', - return_value=b'rocketpy', - ) as mock_read_rocketpy_rocket: - response = client.get('/rockets/123/rocketpy') - assert response.status_code == 203 - assert response.content == b'rocketpy' - assert response.headers['content-type'] == 'application/octet-stream' - mock_read_rocketpy_rocket.assert_called_once_with('123') - - -def test_read_rocketpy_rocket_not_found(): - with patch.object( - RocketController, - 'get_rocketpy_rocket_binary', - side_effect=HTTPException(status_code=status.HTTP_404_NOT_FOUND), - ) as mock_read_rocketpy_rocket: - response = client.get('/rockets/123/rocketpy') - assert response.status_code == 404 - assert response.json() == {'detail': 'Not Found'} - mock_read_rocketpy_rocket.assert_called_once_with('123') - - -def test_read_rocketpy_rocket_server_error(): - with patch.object( - RocketController, - 'get_rocketpy_rocket_binary', - side_effect=HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR - ), - ): - response = client.get('/rockets/123/rocketpy') - assert response.status_code == 500 - assert response.json() == {'detail': 'Internal Server Error'} diff --git a/tests/unit/test_controllers/test_controller_interface.py b/tests/unit/test_controllers/test_controller_interface.py new file mode 100644 index 0000000..9cfec3f --- /dev/null +++ b/tests/unit/test_controllers/test_controller_interface.py @@ -0,0 +1,227 @@ +from unittest.mock import patch, Mock +import pytest +from pymongo.errors import PyMongoError +from fastapi import HTTPException, status +from src.controllers.interface import ( + ControllerBase, + controller_exception_handler, +) + + +@pytest.fixture +def stub_model(): + model = Mock() + model.NAME = 'test_model' + model.METHODS = ('GET', 'POST', 'PUT', 'DELETE') + model.UPDATED = lambda: 'Updated' + model.DELETED = lambda: 'Deleted' + model.CREATED = lambda arg: 'Created' + model.RETRIEVED = lambda arg: 'Retrieved' + return model + + +@pytest.fixture +def stub_controller(stub_model): + return ControllerBase([stub_model]) + + +@pytest.mark.asyncio +async def test_controller_exception_handler_no_exception(stub_model): + async def method( + self, model, *args, **kwargs + ): # pylint: disable=unused-argument + return stub_model, args, kwargs + + test_controller = Mock(method=method) + method = test_controller.method + mock_kwargs = {'foo': 'bar'} + mock_args = ('foo', 'bar') + wrapped_method = controller_exception_handler(method) + with patch('src.controllers.interface.logger') as mock_logger: + assert await wrapped_method( + test_controller, stub_model, *mock_args, **mock_kwargs + ) == (stub_model, mock_args, mock_kwargs) + mock_logger.error.assert_not_called() + mock_logger.info.assert_called_once_with( + "Call to method completed for Mock" + ) + + +@pytest.mark.asyncio +async def test_controller_exception_handler_db_exception(stub_model): + async def method(self, model, *args, **kwargs): + raise PyMongoError + + wrapped_method = controller_exception_handler(method) + with patch('src.controllers.interface.logger') as mock_logger: + with pytest.raises(HTTPException) as exc: + await wrapped_method(None, stub_model) + assert exc.value.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + assert exc.value.detail == "Operation failed, please try again later" + mock_logger.error.assert_called_once_with( + f"{method.__name__}: PyMongoError" + ) + + +@pytest.mark.asyncio +async def test_controller_exception_handler_http_exception(stub_model): + async def method(self, model, *args, **kwargs): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail='Not Found' + ) + + wrapped_method = controller_exception_handler(method) + with patch('src.controllers.interface.logger') as mock_logger: + with pytest.raises(HTTPException) as exc: + await wrapped_method(None, stub_model) + assert exc.value.status_code == status.HTTP_404_NOT_FOUND + assert exc.value.detail == 'Not Found' + mock_logger.error.assert_not_called() + + +@pytest.mark.asyncio +async def test_controller_exception_handler_unexpected_exception(stub_model): + async def method(self, model, *args, **kwargs): + raise ValueError('Test Error') + + wrapped_method = controller_exception_handler(method) + with patch('src.controllers.interface.logger') as mock_logger: + with pytest.raises(HTTPException) as exc: + await wrapped_method(None, stub_model) + assert exc.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert exc.value.detail == 'Unexpected error' + mock_logger.exception.assert_called_once_with( + f"{method.__name__}: Unexpected error Test Error" + ) + + +def test_controller_interface_init(stub_model): + with patch( + 'src.controllers.interface.ControllerBase._generate_method' + ) as mock_gen: + mock_gen.return_value = lambda *args, **kwargs: True + stub_controller = ControllerBase([stub_model]) + assert stub_controller._initialized_models == { + 'test_model': stub_model + } + assert hasattr(stub_controller, 'post_test_model') + assert hasattr(stub_controller, 'get_test_model_by_id') + assert hasattr(stub_controller, 'put_test_model_by_id') + assert hasattr(stub_controller, 'delete_test_model_by_id') + assert getattr(stub_controller, 'post_test_model')() is True + assert getattr(stub_controller, 'get_test_model_by_id')() is True + assert getattr(stub_controller, 'put_test_model_by_id')() is True + assert getattr(stub_controller, 'delete_test_model_by_id')() is True + + +@pytest.mark.asyncio +async def test_controller_interface_generate_available_method( + stub_controller, stub_model +): + with patch('src.controllers.interface.RepositoryInterface') as mock_repo: + with patch( + 'src.controllers.interface.ControllerBase._get_model' + ) as mock_get: + mock_get.return_value = stub_model + method = stub_controller._generate_method('get', stub_model) + assert ( + await method(None, stub_model, mock_repo, 'arg', key='bar') + == stub_model + ) + + +@pytest.mark.asyncio +async def test_controller_interface_generate_unavailable_method( + stub_controller, stub_model +): + with patch('src.controllers.interface.RepositoryInterface') as mock_repo: + with pytest.raises(NotImplementedError): + method = stub_controller._generate_method('foo', stub_model) + await method(None, stub_model, mock_repo, 'arg', key='bar') + + +@pytest.mark.asyncio +async def test_controller_interface_post_model(stub_controller, stub_model): + with patch('src.controllers.interface.RepositoryInterface') as mock_repo: + with patch( + 'src.controllers.interface.RepositoryInterface.get_model_repo' + ) as mock_get_repo: + mock_get_repo.return_value = mock_repo + assert ( + await stub_controller._post_model( + stub_model, mock_repo, stub_model + ) + == 'Created' + ) + + +@pytest.mark.asyncio +async def test_controller_interface_get_model_found( + stub_controller, stub_model +): + with patch('src.controllers.interface.RepositoryInterface') as mock_repo: + with patch( + 'src.controllers.interface.RepositoryInterface.get_model_repo' + ) as mock_get_repo: + mock_get_repo.return_value = mock_repo + mock_repo.return_value.__aenter__.return_value.read_test_model_by_id.return_value = ( + stub_model + ) + assert ( + await stub_controller._get_model(stub_model, mock_repo, '123') + == 'Retrieved' + ) + + +@pytest.mark.asyncio +async def test_controller_interface_get_model_not_found( + stub_controller, stub_model +): + with patch('src.controllers.interface.RepositoryInterface') as mock_repo: + with patch( + 'src.controllers.interface.RepositoryInterface.get_model_repo' + ) as mock_get_repo: + mock_get_repo.return_value = mock_repo + mock_repo.return_value.__aenter__.return_value.read_test_model_by_id.return_value = ( + None + ) + with pytest.raises(HTTPException) as exc: + await stub_controller._get_model(stub_model, mock_repo, '123') + assert exc.value.status_code == status.HTTP_404_NOT_FOUND + assert exc.value.detail == 'test_model not found' + + +@pytest.mark.asyncio +async def test_controller_interface_update_model(stub_controller, stub_model): + with patch('src.controllers.interface.RepositoryInterface') as mock_repo: + with patch( + 'src.controllers.interface.RepositoryInterface.get_model_repo' + ) as mock_get_repo: + mock_get_repo.return_value = mock_repo + mock_repo.return_value.__aenter__.return_value.update_test_model_by_id.return_value = ( + None + ) + assert ( + await stub_controller._put_model( + stub_model, mock_repo, '123', stub_model + ) + == 'Updated' + ) + + +@pytest.mark.asyncio +async def test_controller_interface_delete_model(stub_controller, stub_model): + with patch('src.controllers.interface.RepositoryInterface') as mock_repo: + with patch( + 'src.controllers.interface.RepositoryInterface.get_model_repo' + ) as mock_get_repo: + mock_get_repo.return_value = mock_repo + mock_repo.return_value.__aenter__.return_value.delete_test_model_by_id.return_value = ( + None + ) + assert ( + await stub_controller._delete_model( + stub_model, mock_repo, '123' + ) + == 'Deleted' + ) diff --git a/tests/unit/test_repositories/test_repository_interface.py b/tests/unit/test_repositories/test_repository_interface.py new file mode 100644 index 0000000..f4989dc --- /dev/null +++ b/tests/unit/test_repositories/test_repository_interface.py @@ -0,0 +1,286 @@ +from unittest.mock import patch, Mock, AsyncMock +import pytest +import pytest_asyncio +from pymongo.errors import PyMongoError +from fastapi import HTTPException, status +from src.repositories.interface import ( + RepositoryInterface, + repository_exception_handler, + RepositoryNotInitializedException, +) + + +@pytest.fixture +def stub_loaded_model(): + return {'_id': 'mock_id'} + + +@pytest.fixture +def mock_model(): + return Mock(set_id=Mock()) + + +@pytest_asyncio.fixture +def mock_db_interface(stub_loaded_model): + async def async_gen(*args, **kwargs): # pylint: disable=unused-argument + yield stub_loaded_model + yield stub_loaded_model + + mock_db_interface = AsyncMock() + mock_db_interface.find = Mock(return_value=async_gen()) + mock_db_interface.insert_one = AsyncMock( + return_value=Mock(inserted_id='mock_id') + ) + mock_db_interface.update_one = AsyncMock(return_value=None) + mock_db_interface.find_one = AsyncMock(return_value=stub_loaded_model) + mock_db_interface.delete_one = AsyncMock(return_value=None) + return mock_db_interface + + +@pytest_asyncio.fixture +def mock_db_interface_empty_find(): + async def async_gen(*args, **kwargs): # pylint: disable=unused-argument + if False: # pylint: disable=using-constant-test + yield + + mock_db_interface = AsyncMock() + mock_db_interface.find = Mock(return_value=async_gen()) + return mock_db_interface + + +@pytest_asyncio.fixture +def stub_repository(mock_model): + with patch.object(RepositoryInterface, "_initialize", return_value=None): + stub_model = Mock( + NAME='mock_model', + model_validate=mock_model, + ) + repo = RepositoryInterface(stub_model) + repo._initialized = True + yield repo + + +@pytest_asyncio.fixture +def stub_repository_invalid_model(): + with patch.object(RepositoryInterface, "_initialize", return_value=None): + stub_model = Mock( + NAME='mock_model', + model_validate=Mock(return_value=False), + ) + repo = RepositoryInterface(stub_model) + repo._initialized = True + yield repo + + +@pytest.mark.asyncio +async def test_repository_exception_handler_no_exception(): + async def method(self, *args, **kwargs): # pylint: disable=unused-argument + return args, kwargs + + mock_kwargs = {'foo': 'bar'} + mock_args = ('foo', 'bar') + mock_repo = Mock(model=Mock(NAME='mock_model')) + wrapped_method = repository_exception_handler(method) + with patch('src.repositories.interface.logger') as mock_logger: + assert await wrapped_method(mock_repo, *mock_args, **mock_kwargs) == ( + mock_args, + mock_kwargs, + ) + mock_logger.error.assert_not_called() + mock_logger.info.assert_called_once_with( + f"Call to repositories.{mock_repo.model.NAME}.{method.__name__} completed for {mock_kwargs}" + ) + + +@pytest.mark.asyncio +async def test_repository_exception_handler_db_exception(): + async def method(self, *args, **kwargs): + raise PyMongoError + + wrapped_method = repository_exception_handler(method) + with pytest.raises(PyMongoError): + await wrapped_method(Mock(model=Mock(NAME='mock_model'))) + + +@pytest.mark.asyncio +async def test_repository_exception_not_initialized_exception(): + async def method(self, *args, **kwargs): + raise RepositoryNotInitializedException + + wrapped_method = repository_exception_handler(method) + with pytest.raises(HTTPException) as exc: + await wrapped_method(Mock(model=Mock(NAME='mock_model'))) + assert exc.value.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + assert ( + exc.value.detail + == "Repository not initialized. Please try again later." + ) + + +@pytest.mark.asyncio +async def test_repository_exception_handler_unexpected_exception(): + async def method(self, *args, **kwargs): + raise Exception # pylint: disable=broad-exception-raised + + wrapped_method = repository_exception_handler(method) + with pytest.raises(HTTPException) as exc: + await wrapped_method(Mock(model=Mock(NAME='mock_model'))) + assert exc.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert exc.value.detail == 'Unexpected error ocurred' + + +@pytest.mark.asyncio +async def test_repository_interface_init(stub_repository): + assert stub_repository.model.NAME == 'mock_model' + + +def test_get_model_repo(stub_repository): + with patch( + 'src.repositories.interface.importlib.import_module' + ) as mock_import_module: + mock_import_module.return_value = Mock(MockmodelRepository='mock_repo') + assert ( + stub_repository.get_model_repo(Mock(NAME='mockmodel')) + == 'mock_repo' + ) + + +@pytest.mark.asyncio +async def test_repository_insert_data(stub_repository, mock_db_interface): + with patch( + 'src.repositories.interface.RepositoryInterface.get_collection', + return_value=mock_db_interface, + ): + assert await stub_repository.insert('mock_data') == 'mock_id' + mock_db_interface.insert_one.assert_called_once_with('mock_data') + + +@pytest.mark.asyncio +async def test_repository_insert_invalid_data(stub_repository_invalid_model): + with patch( + 'src.repositories.interface.RepositoryInterface.get_collection', + return_value=mock_db_interface, + ): + with pytest.raises(HTTPException): + await stub_repository_invalid_model.insert('invalid_model_data') + + +@pytest.mark.asyncio +async def test_repository_update_data(stub_repository, mock_db_interface): + with patch( + 'src.repositories.interface.RepositoryInterface.get_collection', + return_value=mock_db_interface, + ): + with patch( + 'src.repositories.interface.ObjectId', side_effect=lambda x: x + ): + assert ( + await stub_repository.update_by_id( + 'mock_data', data_id='mock_id' + ) + is stub_repository + ) + mock_db_interface.update_one.assert_called_once_with( + {'_id': 'mock_id'}, {'$set': 'mock_data'} + ) + + +@pytest.mark.asyncio +async def test_repository_update_invalid_data(stub_repository_invalid_model): + with patch( + 'src.repositories.interface.RepositoryInterface.get_collection', + return_value=mock_db_interface, + ): + with pytest.raises(HTTPException): + await stub_repository_invalid_model.update_by_id( + data_id='mock_id', data='invalid_model_data' + ) + + +@pytest.mark.asyncio +async def test_repository_find_data_found( + stub_repository, mock_db_interface, stub_loaded_model +): + with patch( + 'src.repositories.interface.RepositoryInterface.get_collection', + return_value=mock_db_interface, + ): + with patch( + 'src.repositories.interface.ObjectId', side_effect=lambda x: x + ): + assert ( + await stub_repository.find_by_id(data_id='mock_id') + == stub_repository.model.model_validate.return_value + ) + mock_db_interface.find_one.assert_called_once_with( + {'_id': 'mock_id'} + ) + stub_repository.model.model_validate.return_value.set_id.assert_called_once_with( + stub_loaded_model['_id'] + ) + + +@pytest.mark.asyncio +async def test_repository_find_data_not_found( + stub_repository, mock_db_interface +): + mock_db_interface.find_one.return_value = None + with patch( + 'src.repositories.interface.RepositoryInterface.get_collection', + return_value=mock_db_interface, + ): + with patch( + 'src.repositories.interface.ObjectId', side_effect=lambda x: x + ): + assert await stub_repository.find_by_id(data_id='mock_id') is None + mock_db_interface.find_one.assert_called_once_with( + {'_id': 'mock_id'} + ) + + +@pytest.mark.asyncio +async def test_repository_delete_data(stub_repository, mock_db_interface): + with patch( + 'src.repositories.interface.RepositoryInterface.get_collection', + return_value=mock_db_interface, + ): + with patch( + 'src.repositories.interface.ObjectId', side_effect=lambda x: x + ): + assert ( + await stub_repository.delete_by_id(data_id='mock_id') + is stub_repository + ) + mock_db_interface.delete_one.assert_called_once_with( + {'_id': 'mock_id'} + ) + + +@pytest.mark.asyncio +async def test_repository_find_by_query_found( + stub_repository, mock_db_interface, stub_loaded_model +): + with patch( + 'src.repositories.interface.RepositoryInterface.get_collection', + return_value=mock_db_interface, + ): + assert await stub_repository.find_by_query('mock_query') == [ + stub_repository.model.model_validate.return_value, + stub_repository.model.model_validate.return_value, + ] + mock_db_interface.find.assert_called_once_with('mock_query') + stub_repository.model.model_validate.return_value.set_id.assert_called_with( + stub_loaded_model['_id'] + ) + + +@pytest.mark.asyncio +async def test_repository_find_by_query_not_found( + stub_repository, mock_db_interface_empty_find +): + with patch( + 'src.repositories.interface.RepositoryInterface.get_collection', + return_value=mock_db_interface_empty_find, + ): + assert await stub_repository.find_by_query('mock_query') == [] + mock_db_interface_empty_find.find.assert_called_once_with('mock_query') diff --git a/tests/test_routes/conftest.py b/tests/unit/test_routes/conftest.py similarity index 60% rename from tests/test_routes/conftest.py rename to tests/unit/test_routes/conftest.py index e8f6fb1..74c63f9 100644 --- a/tests/test_routes/conftest.py +++ b/tests/unit/test_routes/conftest.py @@ -1,35 +1,37 @@ import json import pytest -from lib.models.rocket import Rocket -from lib.models.motor import Motor, MotorTank, TankFluids, TankKinds -from lib.models.environment import Env -from lib.models.aerosurfaces import Fins, NoseCone +from src.models.rocket import RocketModel +from src.models.sub.tanks import MotorTank, TankFluids, TankKinds +from src.models.motor import MotorModel +from src.models.environment import EnvironmentModel +from src.models.sub.aerosurfaces import Fins, NoseCone @pytest.fixture -def stub_env(): - env = Env(latitude=0, longitude=0) +def stub_environment_dump(): + env = EnvironmentModel(latitude=0, longitude=0) env_json = env.model_dump_json() return json.loads(env_json) @pytest.fixture -def stub_motor(): - motor = Motor( +def stub_motor_dump(): + motor = MotorModel( thrust_source=[[0, 0]], burn_time=0, nozzle_radius=0, dry_mass=0, dry_inertia=[0, 0, 0], center_of_dry_mass_position=0, + motor_kind='GENERIC', ) motor_json = motor.model_dump_json() return json.loads(motor_json) @pytest.fixture -def stub_tank(): +def stub_tank_dump(): tank = MotorTank( geometry=[[(0, 0), 0]], gas=TankFluids(name='gas', density=0), @@ -44,14 +46,14 @@ def stub_tank(): @pytest.fixture -def stub_level_tank(stub_tank): - stub_tank.update({'tank_kind': TankKinds.LEVEL, 'liquid_height': 0}) - return stub_tank +def stub_level_tank_dump(stub_tank_dump): + stub_tank_dump.update({'tank_kind': TankKinds.LEVEL, 'liquid_height': 0}) + return stub_tank_dump @pytest.fixture -def stub_mass_flow_tank(stub_tank): - stub_tank.update( +def stub_mass_flow_tank_dump(stub_tank_dump): + stub_tank_dump.update( { 'tank_kind': TankKinds.MASS_FLOW, 'gas_mass_flow_rate_in': 0, @@ -62,25 +64,25 @@ def stub_mass_flow_tank(stub_tank): 'initial_gas_mass': 0, } ) - return stub_tank + return stub_tank_dump @pytest.fixture -def stub_ullage_tank(stub_tank): - stub_tank.update({'tank_kind': TankKinds.ULLAGE, 'ullage': 0}) - return stub_tank +def stub_ullage_tank_dump(stub_tank_dump): + stub_tank_dump.update({'tank_kind': TankKinds.ULLAGE, 'ullage': 0}) + return stub_tank_dump @pytest.fixture -def stub_mass_tank(stub_tank): - stub_tank.update( +def stub_mass_tank_dump(stub_tank_dump): + stub_tank_dump.update( {'tank_kind': TankKinds.MASS, 'liquid_mass': 0, 'gas_mass': 0} ) - return stub_tank + return stub_tank_dump @pytest.fixture -def stub_nose_cone(): +def stub_nose_cone_dump(): nose_cone = NoseCone( name='nose', length=0, @@ -94,9 +96,9 @@ def stub_nose_cone(): @pytest.fixture -def stub_fins(): +def stub_fins_dump(): fins = Fins( - fins_kind='TRAPEZOIDAL', + fins_kind='trapezoidal', name='fins', n=0, root_chord=0, @@ -108,9 +110,9 @@ def stub_fins(): @pytest.fixture -def stub_rocket(stub_motor, stub_nose_cone, stub_fins): - rocket = Rocket( - motor=stub_motor, +def stub_rocket_dump(stub_motor_dump, stub_nose_cone_dump, stub_fins_dump): + rocket = RocketModel( + motor=stub_motor_dump, radius=0, mass=0, motor_position=0, @@ -118,9 +120,9 @@ def stub_rocket(stub_motor, stub_nose_cone, stub_fins): inertia=[0, 0, 0], power_off_drag=[(0, 0)], power_on_drag=[(0, 0)], - nose=stub_nose_cone, - fins=[stub_fins], - coordinate_system_orientation='TAIL_TO_NOSE', + nose=stub_nose_cone_dump, + fins=[stub_fins_dump], + coordinate_system_orientation='tail_to_nose', ) rocket_json = rocket.model_dump_json() return json.loads(rocket_json) diff --git a/tests/unit/test_routes/test_environments_route.py b/tests/unit/test_routes/test_environments_route.py new file mode 100644 index 0000000..93ff4c3 --- /dev/null +++ b/tests/unit/test_routes/test_environments_route.py @@ -0,0 +1,258 @@ +from unittest.mock import patch, Mock, AsyncMock +import json +import pytest +from fastapi.testclient import TestClient +from fastapi import HTTPException +from src.models.environment import EnvironmentModel +from src.views.environment import ( + EnvironmentView, + EnvironmentCreated, + EnvironmentRetrieved, + EnvironmentSimulation, +) +from src import app + +client = TestClient(app) + + +@pytest.fixture +def stub_environment_simulation_dump(): + env_simulation = EnvironmentSimulation() + env_simulation_json = env_simulation.model_dump_json() + return json.loads(env_simulation_json) + + +@pytest.fixture(autouse=True) +def mock_controller_instance(): + with patch( + "src.routes.environment.EnvironmentController", autospec=True + ) as mock_controller: + mock_controller_instance = mock_controller.return_value + mock_controller_instance.post_environment = Mock() + mock_controller_instance.get_environment_by_id = Mock() + mock_controller_instance.put_environment_by_id = Mock() + mock_controller_instance.delete_environment_by_id = Mock() + mock_controller_instance.get_environment_simulation = Mock() + mock_controller_instance.get_rocketpy_environment_binary = Mock() + yield mock_controller_instance + + +def test_create_environment(stub_environment_dump, mock_controller_instance): + mock_response = AsyncMock( + return_value=EnvironmentCreated(environment_id='123') + ) + mock_controller_instance.post_environment = mock_response + response = client.post('/environments/', json=stub_environment_dump) + assert response.status_code == 201 + assert response.json() == { + 'environment_id': '123', + 'message': 'Environment successfully created', + } + mock_controller_instance.post_environment.assert_called_once_with( + EnvironmentModel(**stub_environment_dump) + ) + + +def test_create_environment_optional_params( + stub_environment_dump, mock_controller_instance +): + stub_environment_dump.update( + { + 'latitude': 0, + 'longitude': 0, + 'elevation': 1, + 'atmospheric_model_type': 'standard_atmosphere', + 'atmospheric_model_file': None, + 'date': '2021-01-01T00:00:00', + } + ) + mock_response = AsyncMock( + return_value=EnvironmentCreated(environment_id='123') + ) + mock_controller_instance.post_environment = mock_response + response = client.post('/environments/', json=stub_environment_dump) + assert response.status_code == 201 + assert response.json() == { + 'environment_id': '123', + 'message': 'Environment successfully created', + } + + +def test_create_environment_invalid_input(): + response = client.post( + '/environments/', json={'latitude': 'foo', 'longitude': 'bar'} + ) + assert response.status_code == 422 + + +def test_create_environment_server_error( + stub_environment_dump, mock_controller_instance +): + mock_response = AsyncMock(side_effect=HTTPException(status_code=500)) + mock_controller_instance.post_environment = mock_response + response = client.post('/environments/', json=stub_environment_dump) + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + + +def test_read_environment(stub_environment_dump, mock_controller_instance): + environment_view = EnvironmentView( + environment_id='123', **stub_environment_dump + ) + mock_response = AsyncMock( + return_value=EnvironmentRetrieved(environment=environment_view) + ) + mock_controller_instance.get_environment_by_id = mock_response + response = client.get('/environments/123') + assert response.status_code == 200 + assert response.json() == { + 'message': 'Environment successfully retrieved', + 'environment': json.loads(environment_view.model_dump_json()), + } + mock_controller_instance.get_environment_by_id.assert_called_once_with( + '123' + ) + + +def test_read_environment_not_found(mock_controller_instance): + mock_response = AsyncMock(side_effect=HTTPException(status_code=404)) + mock_controller_instance.get_environment_by_id = mock_response + response = client.get('/environments/123') + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + mock_controller_instance.get_environment_by_id.assert_called_once_with( + '123' + ) + + +def test_read_environment_server_error(mock_controller_instance): + mock_response = AsyncMock(side_effect=HTTPException(status_code=500)) + mock_controller_instance.get_environment_by_id = mock_response + response = client.get('/environments/123') + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + + +def test_update_environment(stub_environment_dump, mock_controller_instance): + mock_reponse = AsyncMock(return_value=None) + mock_controller_instance.put_environment_by_id = mock_reponse + response = client.put('/environments/123', json=stub_environment_dump) + assert response.status_code == 204 + mock_controller_instance.put_environment_by_id.assert_called_once_with( + '123', EnvironmentModel(**stub_environment_dump) + ) + + +def test_update_environment_invalid_input(): + response = client.put( + '/environments/123', json={'consignment': 'foo', 'delivery': 'bar'} + ) + assert response.status_code == 422 + + +def test_update_environment_not_found( + stub_environment_dump, mock_controller_instance +): + mock_reponse = AsyncMock(side_effect=HTTPException(status_code=404)) + mock_controller_instance.put_environment_by_id = mock_reponse + response = client.put('/environments/123', json=stub_environment_dump) + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + mock_controller_instance.put_environment_by_id.assert_called_once_with( + '123', EnvironmentModel(**stub_environment_dump) + ) + + +def test_update_environment_server_error( + stub_environment_dump, mock_controller_instance +): + mock_response = AsyncMock(side_effect=HTTPException(status_code=500)) + mock_controller_instance.put_environment_by_id = mock_response + response = client.put('/environments/123', json=stub_environment_dump) + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + + +def test_delete_environment(mock_controller_instance): + mock_reponse = AsyncMock(return_value=None) + mock_controller_instance.delete_environment_by_id = mock_reponse + response = client.delete('/environments/123') + assert response.status_code == 204 + mock_controller_instance.delete_environment_by_id.assert_called_once_with( + '123' + ) + + +def test_delete_environment_server_error(mock_controller_instance): + mock_response = AsyncMock(side_effect=HTTPException(status_code=500)) + mock_controller_instance.delete_environment_by_id = mock_response + response = client.delete('/environments/123') + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + + +def test_get_environment_simulation_success( + stub_environment_simulation_dump, mock_controller_instance +): + mock_reponse = AsyncMock( + return_value=EnvironmentSimulation(**stub_environment_simulation_dump) + ) + mock_controller_instance.get_environment_simulation = mock_reponse + response = client.get('/environments/123/simulate') + assert response.status_code == 200 + assert response.json() == stub_environment_simulation_dump + mock_controller_instance.get_environment_simulation.assert_called_once_with( + '123' + ) + + +def test_get_environment_simulation_not_found(mock_controller_instance): + mock_response = AsyncMock(side_effect=HTTPException(status_code=404)) + mock_controller_instance.get_environment_simulation = mock_response + response = client.get('/environments/123/simulate') + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + mock_controller_instance.get_environment_simulation.assert_called_once_with( + '123' + ) + + +def test_get_environment_simulation_server_error(mock_controller_instance): + mock_response = AsyncMock(side_effect=HTTPException(status_code=500)) + mock_controller_instance.get_environment_simulation = mock_response + response = client.get('/environments/123/simulate') + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + + +def test_read_rocketpy_environment_binary(mock_controller_instance): + mock_response = AsyncMock(return_value=b'rocketpy') + mock_controller_instance.get_rocketpy_environment_binary = mock_response + response = client.get('/environments/123/rocketpy') + assert response.status_code == 200 + assert response.content == b'rocketpy' + assert response.headers['content-type'] == 'application/octet-stream' + mock_controller_instance.get_rocketpy_environment_binary.assert_called_once_with( + '123' + ) + + +def test_read_rocketpy_environment_binary_not_found(mock_controller_instance): + mock_response = AsyncMock(side_effect=HTTPException(status_code=404)) + mock_controller_instance.get_rocketpy_environment_binary = mock_response + response = client.get('/environments/123/rocketpy') + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + mock_controller_instance.get_rocketpy_environment_binary.assert_called_once_with( + '123' + ) + + +def test_read_rocketpy_environment_binary_server_error( + mock_controller_instance, +): + mock_response = AsyncMock(side_effect=HTTPException(status_code=500)) + mock_controller_instance.get_rocketpy_environment_binary = mock_response + response = client.get('/environments/123/rocketpy') + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} diff --git a/tests/unit/test_routes/test_flights_route.py b/tests/unit/test_routes/test_flights_route.py new file mode 100644 index 0000000..0b8ac30 --- /dev/null +++ b/tests/unit/test_routes/test_flights_route.py @@ -0,0 +1,365 @@ +from unittest.mock import patch, Mock, AsyncMock +import json +import pytest +from fastapi.testclient import TestClient +from fastapi import HTTPException, status +from src.models.environment import EnvironmentModel +from src.models.flight import FlightModel +from src.models.rocket import RocketModel +from src.views.motor import MotorView +from src.views.rocket import RocketView +from src.views.flight import ( + FlightCreated, + FlightRetrieved, + FlightSimulation, + FlightView, +) +from src import app + +client = TestClient(app) + + +@pytest.fixture +def stub_flight_dump(stub_environment_dump, stub_rocket_dump): + flight = { + 'name': 'Test Flight', + 'environment': stub_environment_dump, + 'rocket': stub_rocket_dump, + 'rail_length': 1, + 'time_overshoot': True, + 'terminate_on_apogee': True, + 'equations_of_motion': 'standard', + } + return flight + + +@pytest.fixture +def stub_flight_simulate_dump(): + flight_simulate = FlightSimulation() + flight_simulate_json = flight_simulate.model_dump_json() + return json.loads(flight_simulate_json) + + +@pytest.fixture(autouse=True) +def mock_controller_instance(): + with patch( + "src.routes.flight.FlightController", autospec=True + ) as mock_controller: + mock_controller_instance = mock_controller.return_value + mock_controller_instance.post_flight = Mock() + mock_controller_instance.get_flight_by_id = Mock() + mock_controller_instance.put_flight_by_id = Mock() + mock_controller_instance.delete_flight_by_id = Mock() + mock_controller_instance.get_flight_simulation = Mock() + mock_controller_instance.get_rocketpy_flight_binary = Mock() + mock_controller_instance.update_environment_by_flight_id = Mock() + mock_controller_instance.update_rocket_by_flight_id = Mock() + yield mock_controller_instance + + +def test_create_flight(stub_flight_dump, mock_controller_instance): + mock_response = AsyncMock(return_value=FlightCreated(flight_id='123')) + mock_controller_instance.post_flight = mock_response + response = client.post('/flights/', json=stub_flight_dump) + assert response.status_code == 201 + assert response.json() == { + 'flight_id': '123', + 'message': 'Flight successfully created', + } + mock_controller_instance.post_flight.assert_called_once_with( + FlightModel(**stub_flight_dump) + ) + + +def test_create_flight_optional_params( + stub_flight_dump, mock_controller_instance +): + stub_flight_dump.update( + { + 'inclination': 0, + 'heading': 0, + 'max_time': 1, + 'max_time_step': 1.0, + 'min_time_step': 1, + 'rtol': 1.0, + 'atol': 1.0, + 'verbose': True, + } + ) + mock_response = AsyncMock(return_value=FlightCreated(flight_id='123')) + mock_controller_instance.post_flight = mock_response + response = client.post('/flights/', json=stub_flight_dump) + assert response.status_code == 201 + assert response.json() == { + 'flight_id': '123', + 'message': 'Flight successfully created', + } + mock_controller_instance.post_flight.assert_called_once_with( + FlightModel(**stub_flight_dump) + ) + + +def test_create_flight_invalid_input(): + response = client.post( + '/flights/', json={'environment': 'foo', 'rocket': 'bar'} + ) + assert response.status_code == 422 + + +def test_create_flight_server_error( + stub_flight_dump, mock_controller_instance +): + mock_response = AsyncMock( + side_effect=HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + ) + mock_controller_instance.post_flight = mock_response + response = client.post('/flights/', json=stub_flight_dump) + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + + +def test_read_flight( + stub_flight_dump, + stub_rocket_dump, + stub_motor_dump, + mock_controller_instance, +): + del stub_rocket_dump['motor'] + del stub_flight_dump['rocket'] + stub_motor_dump.update({'motor_kind': 'SOLID'}) + motor_view = MotorView(**stub_motor_dump) + rocket_view = RocketView(**stub_rocket_dump, motor=motor_view) + flight_view = FlightView( + **stub_flight_dump, flight_id='123', rocket=rocket_view + ) + mock_response = AsyncMock(return_value=FlightRetrieved(flight=flight_view)) + mock_controller_instance.get_flight_by_id = mock_response + response = client.get('/flights/123') + assert response.status_code == 200 + assert response.json() == { + 'message': 'Flight successfully retrieved', + 'flight': json.loads(flight_view.model_dump_json()), + } + mock_controller_instance.get_flight_by_id.assert_called_once_with('123') + + +def test_read_flight_not_found(mock_controller_instance): + mock_controller_instance.get_flight_by_id.side_effect = HTTPException( + status_code=status.HTTP_404_NOT_FOUND + ) + response = client.get('/flights/123') + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + + +def test_read_flight_server_error(mock_controller_instance): + mock_controller_instance.get_flight_by_id.side_effect = HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + response = client.get('/flights/123') + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + + +def test_update_flight_by_id(stub_flight_dump, mock_controller_instance): + mock_response = AsyncMock(return_value=None) + mock_controller_instance.put_flight_by_id = mock_response + response = client.put('/flights/123', json=stub_flight_dump) + assert response.status_code == 204 + mock_controller_instance.put_flight_by_id.assert_called_once_with( + '123', FlightModel(**stub_flight_dump) + ) + + +def test_update_environment_by_flight_id( + stub_environment_dump, mock_controller_instance +): + mock_response = AsyncMock(return_value=None) + mock_controller_instance.update_environment_by_flight_id = mock_response + response = client.put( + '/flights/123/environment', json=stub_environment_dump + ) + assert response.status_code == 204 + mock_controller_instance.update_environment_by_flight_id.assert_called_once_with( + '123', environment=EnvironmentModel(**stub_environment_dump) + ) + + +def test_update_rocket_by_flight_id( + stub_rocket_dump, mock_controller_instance +): + mock_response = AsyncMock(return_value=None) + mock_controller_instance.update_rocket_by_flight_id = mock_response + response = client.put('/flights/123/rocket', json=stub_rocket_dump) + assert response.status_code == 204 + mock_controller_instance.update_rocket_by_flight_id.assert_called_once_with( + '123', rocket=RocketModel(**stub_rocket_dump) + ) + + +def test_update_environment_by_flight_id_invalid_input(): + response = client.put('/flights/123', json={'environment': 'foo'}) + assert response.status_code == 422 + + +def test_update_rocket_by_flight_id_invalid_input(): + response = client.put('/flights/123', json={'rocket': 'bar'}) + assert response.status_code == 422 + + +def test_update_flight_invalid_input(): + response = client.put( + '/flights/123', json={'environment': 'foo', 'rocket': 'bar'} + ) + assert response.status_code == 422 + + +def test_update_flight_not_found(stub_flight_dump, mock_controller_instance): + mock_controller_instance.put_flight_by_id.side_effect = HTTPException( + status_code=status.HTTP_404_NOT_FOUND + ) + response = client.put('/flights/123', json=stub_flight_dump) + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + + +def test_update_flight_server_error( + stub_flight_dump, mock_controller_instance +): + mock_controller_instance.put_flight_by_id.side_effect = HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + response = client.put('/flights/123', json=stub_flight_dump) + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + + +def test_update_environment_by_flight_id_not_found( + stub_environment_dump, mock_controller_instance +): + mock_controller_instance.update_environment_by_flight_id.side_effect = ( + HTTPException(status_code=status.HTTP_404_NOT_FOUND) + ) + response = client.put( + '/flights/123/environment', json=stub_environment_dump + ) + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + + +def test_update_environment_by_flight_id_server_error( + stub_environment_dump, mock_controller_instance +): + mock_controller_instance.update_environment_by_flight_id.side_effect = ( + HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) + ) + response = client.put( + '/flights/123/environment', json=stub_environment_dump + ) + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + + +def test_update_rocket_by_flight_id_not_found( + stub_rocket_dump, mock_controller_instance +): + mock_controller_instance.update_rocket_by_flight_id.side_effect = ( + HTTPException(status_code=status.HTTP_404_NOT_FOUND) + ) + response = client.put('/flights/123/rocket', json=stub_rocket_dump) + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + + +def test_update_rocket_by_flight_id_server_error( + stub_rocket_dump, mock_controller_instance +): + mock_controller_instance.update_rocket_by_flight_id.side_effect = ( + HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) + ) + response = client.put('/flights/123/rocket', json=stub_rocket_dump) + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + + +def test_delete_flight(mock_controller_instance): + mock_response = AsyncMock(return_value=None) + mock_controller_instance.delete_flight_by_id = mock_response + response = client.delete('/flights/123') + assert response.status_code == 204 + mock_controller_instance.delete_flight_by_id.assert_called_once_with('123') + + +def test_delete_flight_server_error(mock_controller_instance): + mock_controller_instance.delete_flight_by_id.side_effect = HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + response = client.delete('/flights/123') + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + + +def test_get_flight_simulation( + stub_flight_simulate_dump, mock_controller_instance +): + mock_response = AsyncMock( + return_value=FlightSimulation(**stub_flight_simulate_dump) + ) + mock_controller_instance.get_flight_simulation = mock_response + response = client.get('/flights/123/simulate') + assert response.status_code == 200 + assert response.json() == stub_flight_simulate_dump + mock_controller_instance.get_flight_simulation.assert_called_once_with( + '123' + ) + + +def test_get_flight_simulation_not_found(mock_controller_instance): + mock_controller_instance.get_flight_simulation.side_effect = HTTPException( + status_code=status.HTTP_404_NOT_FOUND + ) + response = client.get('/flights/123/simulate') + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + + +def test_get_flight_simulation_server_error(mock_controller_instance): + mock_controller_instance.get_flight_simulation.side_effect = HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + response = client.get('/flights/123/simulate') + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + + +def test_read_rocketpy_flight_binary(mock_controller_instance): + mock_controller_instance.get_rocketpy_flight_binary = AsyncMock( + return_value=b'rocketpy' + ) + response = client.get('/flights/123/rocketpy') + assert response.status_code == 200 + assert response.content == b'rocketpy' + assert response.headers['content-type'] == 'application/octet-stream' + mock_controller_instance.get_rocketpy_flight_binary.assert_called_once_with( + '123' + ) + + +def test_read_rocketpy_flight_binary_not_found(mock_controller_instance): + mock_controller_instance.get_rocketpy_flight_binary.side_effect = ( + HTTPException(status_code=status.HTTP_404_NOT_FOUND) + ) + response = client.get('/flights/123/rocketpy') + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + + +def test_read_rocketpy_flight_binary_server_error(mock_controller_instance): + mock_controller_instance.get_rocketpy_flight_binary.side_effect = ( + HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) + ) + response = client.get('/flights/123/rocketpy') + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} diff --git a/tests/unit/test_routes/test_motors_route.py b/tests/unit/test_routes/test_motors_route.py new file mode 100644 index 0000000..e55c976 --- /dev/null +++ b/tests/unit/test_routes/test_motors_route.py @@ -0,0 +1,415 @@ +from unittest.mock import patch, AsyncMock, Mock +import json +import pytest +from fastapi.testclient import TestClient +from fastapi import HTTPException +from src.models.motor import MotorModel +from src.views.motor import ( + MotorCreated, + MotorRetrieved, + MotorSimulation, + MotorView, +) +from src import app + +client = TestClient(app) + + +@pytest.fixture +def stub_motor_dump_simulation(): + motor_simulation = MotorSimulation() + motor_simulation_json = motor_simulation.model_dump_json() + return json.loads(motor_simulation_json) + + +@pytest.fixture(autouse=True) +def mock_controller_instance(): + with patch( + "src.routes.motor.MotorController", autospec=True + ) as mock_controller: + mock_controller_instance = mock_controller.return_value + mock_controller_instance.post_motor = Mock() + mock_controller_instance.get_motor_by_id = Mock() + mock_controller_instance.put_motor_by_id = Mock() + mock_controller_instance.delete_motor_by_id = Mock() + mock_controller_instance.get_motor_simulation = Mock() + mock_controller_instance.get_rocketpy_motor_binary = Mock() + yield mock_controller_instance + + +def test_create_motor(stub_motor_dump, mock_controller_instance): + mock_response = AsyncMock(return_value=MotorCreated(motor_id='123')) + mock_controller_instance.post_motor = mock_response + response = client.post('/motors/', json=stub_motor_dump) + assert response.status_code == 201 + assert response.json() == { + 'motor_id': '123', + 'message': 'Motor successfully created', + } + mock_controller_instance.post_motor.assert_called_once_with( + MotorModel(**stub_motor_dump) + ) + + +def test_create_motor_optional_params( + stub_motor_dump, mock_controller_instance +): + stub_motor_dump.update( + { + 'interpolation_method': 'linear', + 'coordinate_system_orientation': 'nozzle_to_combustion_chamber', + 'reshape_thrust_curve': False, + } + ) + mock_response = AsyncMock(return_value=MotorCreated(motor_id='123')) + mock_controller_instance.post_motor = mock_response + response = client.post('/motors/', json=stub_motor_dump) + assert response.status_code == 201 + assert response.json() == { + 'motor_id': '123', + 'message': 'Motor successfully created', + } + mock_controller_instance.post_motor.assert_called_once_with( + MotorModel(**stub_motor_dump) + ) + + +def test_create_generic_motor(stub_motor_dump, mock_controller_instance): + stub_motor_dump.update( + { + 'chamber_radius': 0, + 'chamber_height': 0, + 'chamber_position': 0, + 'propellant_initial_mass': 0, + 'nozzle_position': 0, + 'motor_kind': 'GENERIC', + } + ) + mock_response = AsyncMock(return_value=MotorCreated(motor_id='123')) + mock_controller_instance.post_motor = mock_response + response = client.post('/motors/', json=stub_motor_dump) + assert response.status_code == 201 + assert response.json() == { + 'motor_id': '123', + 'message': 'Motor successfully created', + } + mock_controller_instance.post_motor.assert_called_once_with( + MotorModel(**stub_motor_dump) + ) + + +def test_create_liquid_motor_level_tank( + stub_motor_dump, stub_level_tank_dump, mock_controller_instance +): + stub_motor_dump.update( + {'tanks': [stub_level_tank_dump], 'motor_kind': 'LIQUID'} + ) + mock_response = AsyncMock(return_value=MotorCreated(motor_id='123')) + mock_controller_instance.post_motor = mock_response + response = client.post('/motors/', json=stub_motor_dump) + assert response.status_code == 201 + assert response.json() == { + 'motor_id': '123', + 'message': 'Motor successfully created', + } + mock_controller_instance.post_motor.assert_called_once_with( + MotorModel(**stub_motor_dump) + ) + + +def test_create_liquid_motor_mass_flow_tank( + stub_motor_dump, stub_mass_flow_tank_dump, mock_controller_instance +): + stub_motor_dump.update( + {'tanks': [stub_mass_flow_tank_dump], 'motor_kind': 'LIQUID'} + ) + mock_response = AsyncMock(return_value=MotorCreated(motor_id='123')) + mock_controller_instance.post_motor = mock_response + response = client.post('/motors/', json=stub_motor_dump) + assert response.status_code == 201 + assert response.json() == { + 'motor_id': '123', + 'message': 'Motor successfully created', + } + mock_controller_instance.post_motor.assert_called_once_with( + MotorModel(**stub_motor_dump) + ) + + +def test_create_liquid_motor_ullage_tank( + stub_motor_dump, stub_ullage_tank_dump, mock_controller_instance +): + stub_motor_dump.update( + {'tanks': [stub_ullage_tank_dump], 'motor_kind': 'LIQUID'} + ) + mock_response = AsyncMock(return_value=MotorCreated(motor_id='123')) + mock_controller_instance.post_motor = mock_response + response = client.post('/motors/', json=stub_motor_dump) + assert response.status_code == 201 + assert response.json() == { + 'motor_id': '123', + 'message': 'Motor successfully created', + } + mock_controller_instance.post_motor.assert_called_once_with( + MotorModel(**stub_motor_dump) + ) + + +def test_create_liquid_motor_mass_tank( + stub_motor_dump, stub_mass_tank_dump, mock_controller_instance +): + stub_motor_dump.update( + {'tanks': [stub_mass_tank_dump], 'motor_kind': 'LIQUID'} + ) + mock_response = AsyncMock(return_value=MotorCreated(motor_id='123')) + mock_controller_instance.post_motor = mock_response + response = client.post('/motors/', json=stub_motor_dump) + assert response.status_code == 201 + assert response.json() == { + 'motor_id': '123', + 'message': 'Motor successfully created', + } + mock_controller_instance.post_motor.assert_called_once_with( + MotorModel(**stub_motor_dump) + ) + + +def test_create_hybrid_motor( + stub_motor_dump, stub_level_tank_dump, mock_controller_instance +): + stub_motor_dump.update( + { + 'grain_number': 0, + 'grain_density': 0, + 'grain_outer_radius': 0, + 'grain_initial_inner_radius': 0, + 'grain_initial_height': 0, + 'grains_center_of_mass_position': 0, + 'grain_separation': 0, + 'throat_radius': 0, + 'motor_kind': 'HYBRID', + 'tanks': [stub_level_tank_dump], + } + ) + mock_response = AsyncMock(return_value=MotorCreated(motor_id='123')) + mock_controller_instance.post_motor = mock_response + response = client.post('/motors/', json=stub_motor_dump) + assert response.status_code == 201 + assert response.json() == { + 'motor_id': '123', + 'message': 'Motor successfully created', + } + mock_controller_instance.post_motor.assert_called_once_with( + MotorModel(**stub_motor_dump) + ) + + +def test_create_solid_motor(stub_motor_dump, mock_controller_instance): + stub_motor_dump.update( + { + 'grain_number': 0, + 'grain_density': 0, + 'grain_outer_radius': 0, + 'grain_initial_inner_radius': 0, + 'grain_initial_height': 0, + 'grains_center_of_mass_position': 0, + 'grain_separation': 0, + 'motor_kind': 'SOLID', + } + ) + mock_response = AsyncMock(return_value=MotorCreated(motor_id='123')) + mock_controller_instance.post_motor = mock_response + response = client.post('/motors/', json=stub_motor_dump) + assert response.status_code == 201 + assert response.json() == { + 'motor_id': '123', + 'message': 'Motor successfully created', + } + mock_controller_instance.post_motor.assert_called_once_with( + MotorModel(**stub_motor_dump) + ) + + +def test_create_motor_invalid_input(): + response = client.post( + '/motors/', json={'burn_time': 'foo', 'nozzle_radius': 'bar'} + ) + assert response.status_code == 422 + + +def test_create_motor_invalid_input_post_instantiation(stub_motor_dump): + stub_motor_dump.update( + { + 'motor_kind': 'HYBRID', + } + ) + response = client.post('/motors/', json=stub_motor_dump) + assert response.status_code == 422 + + +def test_create_motor_server_error(stub_motor_dump, mock_controller_instance): + mock_response = AsyncMock(side_effect=HTTPException(status_code=500)) + mock_controller_instance.post_motor = mock_response + response = client.post('/motors/', json=stub_motor_dump) + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + mock_controller_instance.post_motor.assert_called_once_with( + MotorModel(**stub_motor_dump) + ) + + +def test_read_motor(stub_motor_dump, mock_controller_instance): + motor_view = MotorView(motor_id='123', **stub_motor_dump) + mock_response = AsyncMock(return_value=MotorRetrieved(motor=motor_view)) + mock_controller_instance.get_motor_by_id = mock_response + response = client.get('/motors/123') + assert response.status_code == 200 + assert response.json() == { + 'message': 'Motor successfully retrieved', + 'motor': json.loads(motor_view.model_dump_json()), + } + mock_controller_instance.get_motor_by_id.assert_called_once_with('123') + + +def test_read_motor_not_found(mock_controller_instance): + mock_response = AsyncMock(side_effect=HTTPException(status_code=404)) + mock_controller_instance.get_motor_by_id = mock_response + response = client.get('/motors/123') + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + mock_controller_instance.get_motor_by_id.assert_called_once_with('123') + + +def test_read_motor_server_error(mock_controller_instance): + mock_response = AsyncMock(side_effect=HTTPException(status_code=500)) + mock_controller_instance.get_motor_by_id = mock_response + response = client.get('/motors/123') + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + + +def test_update_motor(stub_motor_dump, mock_controller_instance): + mock_response = AsyncMock(return_value=None) + mock_controller_instance.put_motor_by_id = mock_response + response = client.put('/motors/123', json=stub_motor_dump) + assert response.status_code == 204 + mock_controller_instance.put_motor_by_id.assert_called_once_with( + '123', MotorModel(**stub_motor_dump) + ) + + +def test_update_motor_invalid_input(): + response = client.put( + '/motors/123', json={'burn_time': 'foo', 'nozzle_radius': 'bar'} + ) + assert response.status_code == 422 + + +def test_update_motor_not_found(stub_motor_dump, mock_controller_instance): + mock_response = AsyncMock(side_effect=HTTPException(status_code=404)) + mock_controller_instance.put_motor_by_id = mock_response + response = client.put('/motors/123', json=stub_motor_dump) + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + mock_controller_instance.put_motor_by_id.assert_called_once_with( + '123', MotorModel(**stub_motor_dump) + ) + + +def test_update_motor_server_error(stub_motor_dump, mock_controller_instance): + mock_response = AsyncMock(side_effect=HTTPException(status_code=500)) + mock_controller_instance.put_motor_by_id = mock_response + response = client.put('/motors/123', json=stub_motor_dump) + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + mock_controller_instance.put_motor_by_id.assert_called_once_with( + '123', MotorModel(**stub_motor_dump) + ) + + +def test_delete_motor(mock_controller_instance): + mock_response = AsyncMock(return_value=None) + mock_controller_instance.delete_motor_by_id = mock_response + response = client.delete('/motors/123') + assert response.status_code == 204 + mock_controller_instance.delete_motor_by_id.assert_called_once_with('123') + + +def test_delete_motor_server_error(mock_controller_instance): + mock_response = AsyncMock(side_effect=HTTPException(status_code=500)) + mock_controller_instance.delete_motor_by_id = mock_response + response = client.delete('/motors/123') + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + mock_controller_instance.delete_motor_by_id.assert_called_once_with('123') + + +def test_get_motor_simulation( + mock_controller_instance, stub_motor_dump_simulation +): + mock_response = AsyncMock( + return_value=MotorSimulation(**stub_motor_dump_simulation) + ) + mock_controller_instance.get_motor_simulation = mock_response + response = client.get('/motors/123/simulate') + assert response.status_code == 200 + assert response.json() == stub_motor_dump_simulation + mock_controller_instance.get_motor_simulation.assert_called_once_with( + '123' + ) + + +def test_get_motor_simulation_not_found(mock_controller_instance): + mock_response = AsyncMock(side_effect=HTTPException(status_code=404)) + mock_controller_instance.get_motor_simulation = mock_response + response = client.get('/motors/123/simulate') + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + mock_controller_instance.get_motor_simulation.assert_called_once_with( + '123' + ) + + +def test_get_motor_simulation_server_error(mock_controller_instance): + mock_response = AsyncMock(side_effect=HTTPException(status_code=500)) + mock_controller_instance.get_motor_simulation = mock_response + response = client.get('/motors/123/simulate') + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + mock_controller_instance.get_motor_simulation.assert_called_once_with( + '123' + ) + + +def test_read_rocketpy_motor_binary(mock_controller_instance): + mock_response = AsyncMock(return_value=b'rocketpy') + mock_controller_instance.get_rocketpy_motor_binary = mock_response + response = client.get('/motors/123/rocketpy') + assert response.status_code == 200 + assert response.content == b'rocketpy' + assert response.headers['content-type'] == 'application/octet-stream' + mock_controller_instance.get_rocketpy_motor_binary.assert_called_once_with( + '123' + ) + + +def test_read_rocketpy_motor_binary_not_found(mock_controller_instance): + mock_response = AsyncMock(side_effect=HTTPException(status_code=404)) + mock_controller_instance.get_rocketpy_motor_binary = mock_response + response = client.get('/motors/123/rocketpy') + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + mock_controller_instance.get_rocketpy_motor_binary.assert_called_once_with( + '123' + ) + + +def test_read_rocketpy_motor_binary_server_error(mock_controller_instance): + mock_response = AsyncMock(side_effect=HTTPException(status_code=500)) + mock_controller_instance.get_rocketpy_motor_binary = mock_response + response = client.get('/motors/123/rocketpy') + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + mock_controller_instance.get_rocketpy_motor_binary.assert_called_once_with( + '123' + ) diff --git a/tests/unit/test_routes/test_rockets_route.py b/tests/unit/test_routes/test_rockets_route.py new file mode 100644 index 0000000..6b73d8c --- /dev/null +++ b/tests/unit/test_routes/test_rockets_route.py @@ -0,0 +1,472 @@ +from unittest.mock import patch, Mock, AsyncMock +import json +import pytest +from fastapi.testclient import TestClient +from fastapi import HTTPException, status +from src.models.sub.aerosurfaces import ( + Tail, + RailButtons, + Parachute, +) +from src.models.rocket import RocketModel +from src.views.rocket import ( + RocketCreated, + RocketRetrieved, + RocketSimulation, + RocketView, +) +from src import app + +client = TestClient(app) + + +@pytest.fixture +def stub_rocket_simulation_dump(): + rocket_simulation = RocketSimulation() + rocket_simulation_json = rocket_simulation.model_dump_json() + return json.loads(rocket_simulation_json) + + +@pytest.fixture +def stub_tail_dump(): + tail = Tail( + name='tail', + top_radius=0, + bottom_radius=0, + length=0, + position=0, + radius=0, + ) + tail_json = tail.model_dump_json() + return json.loads(tail_json) + + +@pytest.fixture +def stub_rail_buttons_dump(): + rail_buttons = RailButtons( + upper_button_position=0, + lower_button_position=0, + angular_position=0, + ) + rail_buttons_json = rail_buttons.model_dump_json() + return json.loads(rail_buttons_json) + + +@pytest.fixture +def stub_parachute_dump(): + parachute = Parachute( + name='parachute', + cd_s=0, + sampling_rate=1, + lag=0, + trigger='trigger', + noise=(0, 0, 0), + ) + parachute_json = parachute.model_dump_json() + return json.loads(parachute_json) + + +@pytest.fixture(autouse=True) +def mock_controller_instance(): + with patch( + "src.routes.rocket.RocketController", autospec=True + ) as mock_controller: + mock_controller_instance = mock_controller.return_value + mock_controller_instance.post_rocket = Mock() + mock_controller_instance.get_rocket_by_id = Mock() + mock_controller_instance.put_rocket_by_id = Mock() + mock_controller_instance.delete_rocket_by_id = Mock() + mock_controller_instance.get_rocket_simulation = Mock() + mock_controller_instance.get_rocketpy_rocket_binary = Mock() + yield mock_controller_instance + + +def test_create_rocket(stub_rocket_dump, mock_controller_instance): + mock_response = AsyncMock(return_value=RocketCreated(rocket_id='123')) + mock_controller_instance.post_rocket = mock_response + response = client.post('/rockets/', json=stub_rocket_dump) + assert response.status_code == 201 + assert response.json() == { + 'rocket_id': '123', + 'message': 'Rocket successfully created', + } + mock_controller_instance.post_rocket.assert_called_once_with( + RocketModel(**stub_rocket_dump) + ) + + +def test_create_rocket_optional_params( + stub_rocket_dump, + stub_tail_dump, + stub_rail_buttons_dump, + stub_parachute_dump, + mock_controller_instance, +): + stub_rocket_dump.update( + { + 'parachutes': [stub_parachute_dump], + 'rail_buttons': stub_rail_buttons_dump, + 'tail': stub_tail_dump, + } + ) + mock_response = AsyncMock(return_value=RocketCreated(rocket_id='123')) + mock_controller_instance.post_rocket = mock_response + response = client.post('/rockets/', json=stub_rocket_dump) + assert response.status_code == 201 + assert response.json() == { + 'rocket_id': '123', + 'message': 'Rocket successfully created', + } + mock_controller_instance.post_rocket.assert_called_once_with( + RocketModel(**stub_rocket_dump) + ) + + +def test_create_generic_motor_rocket( + stub_rocket_dump, stub_motor_dump, mock_controller_instance +): + stub_motor_dump.update( + { + 'chamber_radius': 0, + 'chamber_height': 0, + 'chamber_position': 0, + 'propellant_initial_mass': 0, + 'nozzle_position': 0, + 'motor_kind': 'GENERIC', + } + ) + stub_rocket_dump.update({'motor': stub_motor_dump}) + mock_response = AsyncMock(return_value=RocketCreated(rocket_id='123')) + mock_controller_instance.post_rocket = mock_response + response = client.post('/rockets/', json=stub_rocket_dump) + assert response.status_code == 201 + assert response.json() == { + 'rocket_id': '123', + 'message': 'Rocket successfully created', + } + mock_controller_instance.post_rocket.assert_called_once_with( + RocketModel(**stub_rocket_dump) + ) + + +def test_create_liquid_motor_level_tank_rocket( + stub_rocket_dump, + stub_motor_dump, + stub_level_tank_dump, + mock_controller_instance, +): + stub_motor_dump.update( + {'tanks': [stub_level_tank_dump], 'motor_kind': 'LIQUID'} + ) + stub_rocket_dump.update({'motor': stub_motor_dump}) + mock_response = AsyncMock(return_value=RocketCreated(rocket_id='123')) + mock_controller_instance.post_rocket = mock_response + response = client.post('/rockets/', json=stub_rocket_dump) + assert response.status_code == 201 + assert response.json() == { + 'rocket_id': '123', + 'message': 'Rocket successfully created', + } + mock_controller_instance.post_rocket.assert_called_once_with( + RocketModel(**stub_rocket_dump) + ) + + +def test_create_liquid_motor_mass_flow_tank_rocket( + stub_rocket_dump, + stub_motor_dump, + stub_mass_flow_tank_dump, + mock_controller_instance, +): + stub_motor_dump.update( + {'tanks': [stub_mass_flow_tank_dump], 'motor_kind': 'LIQUID'} + ) + stub_rocket_dump.update({'motor': stub_motor_dump}) + mock_response = AsyncMock(return_value=RocketCreated(rocket_id='123')) + mock_controller_instance.post_rocket = mock_response + response = client.post('/rockets/', json=stub_rocket_dump) + assert response.status_code == 201 + assert response.json() == { + 'rocket_id': '123', + 'message': 'Rocket successfully created', + } + mock_controller_instance.post_rocket.assert_called_once_with( + RocketModel(**stub_rocket_dump) + ) + + +def test_create_liquid_motor_ullage_tank_rocket( + stub_rocket_dump, + stub_motor_dump, + stub_ullage_tank_dump, + mock_controller_instance, +): + stub_motor_dump.update( + {'tanks': [stub_ullage_tank_dump], 'motor_kind': 'LIQUID'} + ) + stub_rocket_dump.update({'motor': stub_motor_dump}) + mock_response = AsyncMock(return_value=RocketCreated(rocket_id='123')) + mock_controller_instance.post_rocket = mock_response + response = client.post('/rockets/', json=stub_rocket_dump) + assert response.status_code == 201 + assert response.json() == { + 'rocket_id': '123', + 'message': 'Rocket successfully created', + } + mock_controller_instance.post_rocket.assert_called_once_with( + RocketModel(**stub_rocket_dump) + ) + + +def test_create_liquid_motor_mass_tank_rocket( + stub_rocket_dump, + stub_motor_dump, + stub_mass_tank_dump, + mock_controller_instance, +): + stub_motor_dump.update( + {'tanks': [stub_mass_tank_dump], 'motor_kind': 'LIQUID'} + ) + stub_rocket_dump.update({'motor': stub_motor_dump}) + mock_response = AsyncMock(return_value=RocketCreated(rocket_id='123')) + mock_controller_instance.post_rocket = mock_response + response = client.post('/rockets/', json=stub_rocket_dump) + assert response.status_code == 201 + assert response.json() == { + 'rocket_id': '123', + 'message': 'Rocket successfully created', + } + mock_controller_instance.post_rocket.assert_called_once_with( + RocketModel(**stub_rocket_dump) + ) + + +def test_create_hybrid_motor_rocket( + stub_rocket_dump, + stub_motor_dump, + stub_level_tank_dump, + mock_controller_instance, +): + stub_motor_dump.update( + { + 'grain_number': 0, + 'grain_density': 0, + 'grain_outer_radius': 0, + 'grain_initial_inner_radius': 0, + 'grain_initial_height': 0, + 'grains_center_of_mass_position': 0, + 'grain_separation': 0, + 'throat_radius': 0, + 'motor_kind': 'HYBRID', + 'tanks': [stub_level_tank_dump], + } + ) + stub_rocket_dump.update({'motor': stub_motor_dump}) + mock_response = AsyncMock(return_value=RocketCreated(rocket_id='123')) + mock_controller_instance.post_rocket = mock_response + response = client.post('/rockets/', json=stub_rocket_dump) + assert response.status_code == 201 + assert response.json() == { + 'rocket_id': '123', + 'message': 'Rocket successfully created', + } + mock_controller_instance.post_rocket.assert_called_once_with( + RocketModel(**stub_rocket_dump) + ) + + +def test_create_solid_motor_rocket( + stub_rocket_dump, stub_motor_dump, mock_controller_instance +): + stub_motor_dump.update( + { + 'grain_number': 0, + 'grain_density': 0, + 'grain_outer_radius': 0, + 'grain_initial_inner_radius': 0, + 'grain_initial_height': 0, + 'grains_center_of_mass_position': 0, + 'grain_separation': 0, + 'motor_kind': 'SOLID', + } + ) + stub_rocket_dump.update({'motor': stub_motor_dump}) + mock_response = AsyncMock(return_value=RocketCreated(rocket_id='123')) + mock_controller_instance.post_rocket = mock_response + response = client.post('/rockets/', json=stub_rocket_dump) + assert response.status_code == 201 + assert response.json() == { + 'rocket_id': '123', + 'message': 'Rocket successfully created', + } + mock_controller_instance.post_rocket.assert_called_once_with( + RocketModel(**stub_rocket_dump) + ) + + +def test_create_rocket_invalid_input(): + response = client.post('/rockets/', json={'radius': 'foo', 'mass': 'bar'}) + assert response.status_code == 422 + + +def test_create_rocket_server_error( + stub_rocket_dump, mock_controller_instance +): + mock_controller_instance.post_rocket.side_effect = HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + response = client.post('/rockets/', json=stub_rocket_dump) + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + + +def test_read_rocket( + stub_rocket_dump, stub_motor_dump, mock_controller_instance +): + stub_rocket_dump.update({'motor': stub_motor_dump}) + rocket_view = RocketView(rocket_id='123', **stub_rocket_dump) + mock_response = AsyncMock(return_value=RocketRetrieved(rocket=rocket_view)) + mock_controller_instance.get_rocket_by_id = mock_response + response = client.get('/rockets/123') + assert response.status_code == 200 + assert response.json() == { + 'message': 'Rocket successfully retrieved', + 'rocket': json.loads(rocket_view.model_dump_json()), + } + mock_controller_instance.get_rocket_by_id.assert_called_once_with('123') + + +def test_read_rocket_not_found(mock_controller_instance): + mock_controller_instance.get_rocket_by_id.side_effect = HTTPException( + status_code=status.HTTP_404_NOT_FOUND + ) + response = client.get('/rockets/123') + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + + +def test_read_rocket_server_error(mock_controller_instance): + mock_controller_instance.get_rocket_by_id.side_effect = HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + response = client.get('/rockets/123') + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + + +def test_update_rocket(stub_rocket_dump, mock_controller_instance): + stub_rocket_dump['motor']['motor_kind'] = 'SOLID' + mock_response = AsyncMock(return_value=None) + mock_controller_instance.put_rocket_by_id = mock_response + response = client.put('/rockets/123', json=stub_rocket_dump) + assert response.status_code == 204 + mock_controller_instance.put_rocket_by_id.assert_called_once_with( + '123', RocketModel(**stub_rocket_dump) + ) + + +def test_update_rocket_invalid_input(): + response = client.put( + '/rockets/123', json={'mass': 'foo', 'radius': 'bar'} + ) + assert response.status_code == 422 + + +def test_update_rocket_not_found(stub_rocket_dump, mock_controller_instance): + mock_controller_instance.put_rocket_by_id.side_effect = HTTPException( + status_code=status.HTTP_404_NOT_FOUND + ) + response = client.put('/rockets/123', json=stub_rocket_dump) + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + + +def test_update_rocket_server_error( + stub_rocket_dump, mock_controller_instance +): + mock_controller_instance.put_rocket_by_id.side_effect = HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + response = client.put('/rockets/123', json=stub_rocket_dump) + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + + +def test_delete_rocket(mock_controller_instance): + mock_response = AsyncMock(return_value=None) + mock_controller_instance.delete_rocket_by_id = mock_response + response = client.delete('/rockets/123') + assert response.status_code == 204 + mock_controller_instance.delete_rocket_by_id.assert_called_once_with('123') + + +def test_delete_rocket_server_error(mock_controller_instance): + mock_controller_instance.delete_rocket_by_id.side_effect = HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + response = client.delete('/rockets/123') + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + + +def test_get_rocket_simulation( + stub_rocket_simulation_dump, mock_controller_instance +): + mock_response = AsyncMock( + return_value=RocketSimulation(**stub_rocket_simulation_dump) + ) + mock_controller_instance.get_rocket_simulation = mock_response + response = client.get('/rockets/123/simulate') + assert response.status_code == 200 + assert response.json() == stub_rocket_simulation_dump + mock_controller_instance.get_rocket_simulation.assert_called_once_with( + '123' + ) + + +def test_get_rocket_simulation_not_found(mock_controller_instance): + mock_controller_instance.get_rocket_simulation.side_effect = HTTPException( + status_code=status.HTTP_404_NOT_FOUND + ) + response = client.get('/rockets/123/simulate') + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + + +def test_get_rocket_simulation_server_error(mock_controller_instance): + mock_controller_instance.get_rocket_simulation.side_effect = HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + response = client.get('/rockets/123/simulate') + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + + +def test_read_rocketpy_rocket_binary(mock_controller_instance): + mock_response = AsyncMock(return_value=b'rocketpy') + mock_controller_instance.get_rocketpy_rocket_binary = mock_response + response = client.get('/rockets/123/rocketpy') + assert response.status_code == 200 + assert response.content == b'rocketpy' + assert response.headers['content-type'] == 'application/octet-stream' + mock_controller_instance.get_rocketpy_rocket_binary.assert_called_once_with( + '123' + ) + + +def test_read_rocketpy_rocket_binary_not_found(mock_controller_instance): + mock_controller_instance.get_rocketpy_rocket_binary.side_effect = ( + HTTPException(status_code=status.HTTP_404_NOT_FOUND) + ) + response = client.get('/rockets/123/rocketpy') + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + + +def test_read_rocketpy_rocket_binary_server_error(mock_controller_instance): + mock_controller_instance.get_rocketpy_rocket_binary.side_effect = ( + HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) + ) + response = client.get('/rockets/123/rocketpy') + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'}