Skip to content
Merged
24 changes: 24 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Repository Guidelines

## Project Structure & Module Organization
Source code lives in `src/`, with FastAPI entrypoints in `src/api.py` and domain layers grouped by concern (`controllers/`, `services/`, `repositories/`, `models/`, `routes/`, `views/`). Shared settings and helpers sit in `src/settings/` and `src/utils.py`. Tests mirror this layout under `tests/unit/` and `tests/integration/` for focused and end-to-end coverage. Deployment assets stay at the root (`Dockerfile`, `compose.yaml`, `Makefile`), while virtualenv tooling is kept in `infinity_env/`.

## Build, Test, and Development Commands
- `python3 -m pip install -r requirements.txt` prepares runtime dependencies; add `requirements-dev.txt` for local tooling.
- `make format` runs Black and Ruff autofixes across `src/` and `tests/`.
- `make lint` executes Flake8 and Pylint with the repository presets.
- `make test` wraps `pytest` with the configured `tests/` path.
- `make dev` starts Uvicorn on `http://localhost:3000` with hot reload.
- `docker-compose up --build -d` (from `compose.yaml`) builds and runs the API plus MongoDB in containers; use `make clean` to prune stacks.

## Coding Style & Naming Conventions
Target Python 3.12 and a 79-character line length (Black, Ruff, and Pylint enforce this). Prefer module-level functions in `snake_case`, classes in `PascalCase`, and constants in `UPPER_SNAKE_CASE`. Keep FastAPI routers named `<feature>_router` inside `src/routes/`, and align Pydantic models with `CamelCase` class names under `src/models/`. Run `make format` before opening a PR to avoid stylistic churn.

## Testing Guidelines
Pytest drives both unit and integration suites; name files `test_<feature>.py` and group fixtures near usage. Place pure-function tests in `tests/unit/` and API or database flows in `tests/integration/`. Execute `pytest tests/integration/test_environment.py -k simulate` to target scenarios while keeping `--import-mode=importlib` behavior intact. New features should include happy-path and failure-case coverage, plus integration smoke tests when touching MongoDB writes.

## Commit & Pull Request Guidelines
Git history favors concise, uppercase prefixes (`BUG:`, `ENH:`, `MNT:`) followed by a short imperative summary and optional issue reference, e.g. `ENH: streamline rocket encoders (#58)`. Squash commits that fix review feedback before merging. Pull requests should describe intent, list API or schema changes, link to tracking issues, and attach screenshots or sample responses when observable behavior shifts.

## Security & Configuration Tips
Never commit `.env` or credentials; instead, document required keys such as `MONGODB_CONNECTION_STRING` in the PR. Use `src/secrets.py` helpers for secret access rather than inlining values, and prefer Docker secrets or environment variables when deploying.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ $ touch .env && echo MONGODB_CONNECTION_STRING="$ConnectionString" > .env
- Dev: `python3 -m uvicorn src:app --reload --port 3000`
- Prod: `gunicorn -k uvicorn.workers.UvicornWorker src:app -b 0.0.0.0:3000`

## MCP Server
- The MCP bridge is mounted directly on the FastAPI app and is available at `/mcp` alongside the REST API.
- No extra process is required: `uvicorn src:app` serves both the REST routes and the MCP transport.

## Project structure
```
├── README.md # this file
Expand Down
4 changes: 2 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
motor
dill
python-dotenv
fastapi
uvloop
pydantic
numpy==1.26.4
pymongo
pymongo>=4.15
jsonpickle
gunicorn
uvicorn
Expand All @@ -16,3 +15,4 @@ opentelemetry.instrumentation.requests
opentelemetry-api
opentelemetry-sdk
tenacity
fastmcp
89 changes: 66 additions & 23 deletions src/api.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,49 @@
from __future__ import annotations

import logging

from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.openapi.utils import get_openapi
from fastapi.responses import RedirectResponse, JSONResponse
from fastapi.responses import JSONResponse, RedirectResponse

from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor

from src import logger, parse_error
from src.routes import flight, environment, motor, rocket
from src.mcp.server import build_mcp
from src.routes import environment, flight, motor, rocket
from src.utils import RocketPyGZipMiddleware

app = FastAPI(
log = logging.getLogger(__name__)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated


# --- REST application -------------------------------------------------------

rest_app = FastAPI(
title="Infinity API",
swagger_ui_parameters={
"defaultModelsExpandDepth": 0,
"syntaxHighlight.theme": "obsidian",
}
},
)
app.include_router(flight.router)
app.include_router(environment.router)
app.include_router(motor.router)
app.include_router(rocket.router)

FastAPIInstrumentor.instrument_app(app)
rest_app.include_router(flight.router)
rest_app.include_router(environment.router)
rest_app.include_router(motor.router)
rest_app.include_router(rocket.router)

FastAPIInstrumentor.instrument_app(rest_app)
RequestsInstrumentor().instrument()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

# Compress responses above 1KB
app.add_middleware(RocketPyGZipMiddleware, minimum_size=1000)
rest_app.add_middleware(RocketPyGZipMiddleware, minimum_size=1000)


def custom_openapi():
if app.openapi_schema:
return app.openapi_schema
if rest_app.openapi_schema:
return rest_app.openapi_schema
openapi_schema = get_openapi(
title="RocketPy Infinity-API",
version="3.0.0",
Expand All @@ -47,40 +60,70 @@ def custom_openapi():
"<p>Create, manage, and simulate rocket flights, environments, rockets, and motors.</p>"
"<p>Please report any bugs at <a href='https://github.com/RocketPy-Team/infinity-api/issues/new/choose' style='text-decoration: none; color: #008CBA;'>GitHub Issues</a></p>"
),
routes=app.routes,
routes=rest_app.routes,
)
openapi_schema["info"]["x-logo"] = {
"url": "https://raw.githubusercontent.com/RocketPy-Team/RocketPy/master/docs/static/RocketPy_Logo_black.png"
}
app.openapi_schema = openapi_schema
return app.openapi_schema
rest_app.openapi_schema = openapi_schema
return rest_app.openapi_schema


app.openapi = custom_openapi
rest_app.openapi = custom_openapi


# Main
@app.get("/", include_in_schema=False)
@rest_app.get("/", include_in_schema=False)
async def main_page():
"""
Redirects to API docs.
"""
"""Redirect to API docs."""
return RedirectResponse(url="/redoc")


# Additional routes
@app.get("/health", status_code=status.HTTP_200_OK, include_in_schema=False)
@rest_app.get(
"/health", status_code=status.HTTP_200_OK, include_in_schema=False
)
async def __perform_healthcheck():
return {"health": "Everything OK!"}


# Global exception handler
@app.exception_handler(RequestValidationError)
@rest_app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request, exc: RequestValidationError
):
exc_str = parse_error(exc)
logger.error(f"{request}: {exc_str}")
logger.error("%s: %s", request, exc_str)
return JSONResponse(
content=exc_str, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY
)


# --- MCP server mounted under /mcp ------------------------------------------
mcp_app = build_mcp(rest_app).http_app(path="/")


def _combine_lifespans(rest_lifespan, mcp_lifespan):
"""Combine FastAPI and MCP lifespans."""

@asynccontextmanager
async def lifespan(app: FastAPI):
async with rest_lifespan(app):
async with mcp_lifespan(app):
yield

return lifespan


app = FastAPI(
docs_url=None,
redoc_url=None,
openapi_url=None,
lifespan=_combine_lifespans(
rest_app.router.lifespan_context, mcp_app.lifespan
),
)
app.mount("/mcp", mcp_app)
app.mount("/", rest_app)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
GabrielBarberini marked this conversation as resolved.

__all__ = ["app", "rest_app"]
Empty file added src/mcp/__init__.py
Empty file.
21 changes: 21 additions & 0 deletions src/mcp/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""FastMCP integration helpers for Infinity API."""

from __future__ import annotations

from fastapi import FastAPI
from fastmcp import FastMCP, settings


def build_mcp(app: FastAPI) -> FastMCP:
"""
Create (or return cached) FastMCP server
that mirrors the FastAPI app.
"""
Comment thread
GabrielBarberini marked this conversation as resolved.

if hasattr(app.state, 'mcp'):
return app.state.mcp # type: ignore[attr-defined]

settings.experimental.enable_new_openapi_parser = True
Comment thread
GabrielBarberini marked this conversation as resolved.
mcp = FastMCP.from_fastapi(app, name=app.title)
app.state.mcp = mcp # type: ignore[attr-defined]
return mcp
Comment thread
GabrielBarberini marked this conversation as resolved.
103 changes: 35 additions & 68 deletions src/repositories/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@
wait_fixed,
retry,
)
from pydantic import BaseModel, ValidationError
from pydantic import ValidationError
from pymongo.errors import PyMongoError
from pymongo.server_api import ServerApi
from motor.motor_asyncio import AsyncIOMotorClient
from pymongo import AsyncMongoClient

from fastapi import HTTPException, status
from bson import ObjectId

Expand Down Expand Up @@ -62,23 +63,6 @@ async def wrapper(self, *args, **kwargs):
return wrapper


class RepoInstances(BaseModel):
instance: object
prospecting: int = 0

def add_prospecting(self):
self.prospecting += 1

def remove_prospecting(self):
self.prospecting -= 1

def get_prospecting(self):
return self.prospecting

def get_instance(self):
return self.instance


class RepositoryInterface:
"""
Interface class for all repositories (singleton)
Expand All @@ -94,30 +78,14 @@ class RepositoryInterface:
"""

_global_instances = {}
_global_thread_lock = threading.RLock()
_global_async_lock = asyncio.Lock()
_global_thread_lock = threading.Lock()

def __new__(cls, *args, **kwargs):
with (
cls._global_thread_lock
): # Ensure thread safety for singleton instance creation
with cls._global_thread_lock:
if cls not in cls._global_instances:
instance = super(RepositoryInterface, cls).__new__(cls)
cls._global_instances[cls] = RepoInstances(instance=instance)
else:
cls._global_instances[cls].add_prospecting()
return cls._global_instances[cls].get_instance()

@classmethod
def _stop_prospecting(cls):
if cls in cls._global_instances:
cls._global_instances[cls].remove_prospecting()

@classmethod
def _get_instance_prospecting(cls):
if cls in cls._global_instances:
return cls._global_instances[cls].get_prospecting()
return 0
instance = super().__new__(cls)
cls._global_instances[cls] = instance
return cls._global_instances[cls]

def __init__(self, model: ApiBaseModel, *, max_pool_size: int = 3):
if not getattr(self, '_initialized', False):
Expand All @@ -128,37 +96,33 @@ def __init__(self, model: ApiBaseModel, *, max_pool_size: int = 3):

@retry(stop=stop_after_attempt(5), wait=wait_fixed(0.2))
async def _async_init(self):
async with (
self._global_async_lock
): # Hybrid safe locks for initialization
with self._global_thread_lock:
try:
self._initialize_connection()
self._initialized = True
except Exception as e:
logger.error("Initialization failed: %s", e, exc_info=True)
self._initialized = False

def _on_init_done(self, future):
try:
future.result()
finally:
if getattr(self, '_initialized', False):
return

if not hasattr(self, '_init_lock'):
self._init_lock = asyncio.Lock()
Comment thread
GabrielBarberini marked this conversation as resolved.

async with self._init_lock:
if getattr(self, '_initialized', False):
return

try:
self._initialize_connection()
except Exception as e:
logger.error("Initialization failed: %s", e, exc_info=True)
self._initialized = False
raise

self._initialized = True
self._initialized_event.set()

def _initialize(self):
if not asyncio.get_event_loop().is_running():
try:
loop = asyncio.get_running_loop()
except RuntimeError:
asyncio.run(self._async_init())
else:
loop = asyncio.get_event_loop()
loop.create_task(self._async_init()).add_done_callback(
self._on_init_done
)

def __del__(self):
with self._global_thread_lock:
self._global_instances.pop(self.__class__, None)
self._initialized = False
self._stop_prospecting()
loop.create_task(self._async_init())

async def __aenter__(self):
await self._initialized_event.wait() # Ensure initialization is complete
Expand All @@ -172,7 +136,7 @@ def _initialize_connection(self):
self._connection_string = Secrets.get_secret(
"MONGODB_CONNECTION_STRING"
)
self._client = AsyncIOMotorClient(
self._client = AsyncMongoClient(
self._connection_string,
server_api=ServerApi("1"),
maxIdleTimeMS=30000,
Expand All @@ -181,7 +145,10 @@ def _initialize_connection(self):
serverSelectionTimeoutMS=60000,
)
self._collection = self._client.rocketpy[self.model.NAME]
logger.info("MongoDB client initialized for %s", self.__class__)
logger.info(
"AsyncMongoClient initialized for %s",
self.__class__,
)
except Exception as e:
logger.error(
f"Failed to initialize MongoDB client: {e}", exc_info=True
Expand Down
Loading