diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70a47ee..670480f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,4 +31,4 @@ jobs: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/fluentmeet_test REDIS_URL: redis://localhost:6379/1 run: | - pytest --cov=app --cov-fail-under=5 tests/ + pytest --cov=app --cov-fail-under=60 tests/ diff --git a/README.md b/README.md index e2ba2bf..540d114 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,23 @@ pytest tests/ -v --cov=app --cov-report=html --cov-report=term --- +## Linting & Formatting +- **Black**: Enforce consistent code formatting. +```bash +black . +``` +- **isort**: Sort imports for readability. +```bash +isort . +``` +- **ruff**: Linting for code quality and style. +```bash +ruff . +``` +```bash +python -m ruff check . +``` + ## 🤝 Contributing We welcome contributions! Please follow these steps: diff --git a/app/core/config.py b/app/core/config.py index a222a35..1b4f5a4 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,9 +1,21 @@ +import pathlib +import tomllib + from pydantic_settings import BaseSettings, SettingsConfigDict +def get_version() -> str: + pyproject_path = pathlib.Path(__file__).parent.parent.parent / "pyproject.toml" + if pyproject_path.exists(): + with pyproject_path.open("rb") as f: + data = tomllib.load(f) + return str(data.get("project", {}).get("version", "1.0.0")) + return "1.0.0" + + class Settings(BaseSettings): PROJECT_NAME: str = "FluentMeet" - VERSION: str = "1.0.0" + VERSION: str = get_version() API_V1_STR: str = "/api/v1" # Security @@ -31,7 +43,9 @@ class Settings(BaseSettings): VOICE_AI_API_KEY: str | None = None OPENAI_API_KEY: str | None = None - model_config = SettingsConfigDict(env_file=".env", case_sensitive=True) + model_config = SettingsConfigDict( + env_file=".env", case_sensitive=True, extra="ignore" + ) settings = Settings() diff --git a/app/core/error_responses.py b/app/core/error_responses.py new file mode 100644 index 0000000..1e56cc5 --- /dev/null +++ b/app/core/error_responses.py @@ -0,0 +1,50 @@ +from typing import Any + +from fastapi.responses import JSONResponse +from pydantic import BaseModel + + +class ErrorDetail(BaseModel): + field: str | None = None + message: str + + +class ErrorResponse(BaseModel): + status: str = "error" + code: str + message: str + details: list[ErrorDetail] = [] + + +def create_error_response( + status_code: int, + code: str, + message: str, + details: list[dict[str, Any]] | None = None, +) -> JSONResponse: + """ + Helper to create a standardized JSON error response. + """ + error_details = [] + if details: + for detail in details: + error_details.append( + ErrorDetail( + field=detail.get("field"), + message=detail.get("msg") + or detail.get("message") + or "Unknown error", + ) + ) + + response_content = ErrorResponse( + status="error", + code=code, + message=message, + details=error_details, + ) + + return JSONResponse( + status_code=status_code, + content=response_content.model_dump(), + ) diff --git a/app/core/exception_handlers.py b/app/core/exception_handlers.py new file mode 100644 index 0000000..fd2b614 --- /dev/null +++ b/app/core/exception_handlers.py @@ -0,0 +1,80 @@ +import logging +from typing import Any + +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException + +from app.core.error_responses import create_error_response +from app.core.exceptions import FluentMeetException + +logger = logging.getLogger(__name__) + + +async def fluentmeet_exception_handler(_request: Request, exc: Any) -> JSONResponse: + """ + Handler for all custom FluentMeetException exceptions. + """ + return create_error_response( + status_code=exc.status_code, + code=exc.code, + message=exc.message, + details=exc.details, + ) + + +async def validation_exception_handler(_request: Request, exc: Any) -> JSONResponse: + """ + Handler for Pydantic validation errors (422 -> 400). + """ + details = [] + for error in exc.errors(): + details.append( + { + "field": ".".join(str(loc) for loc in error["loc"]), + "msg": error["msg"], + } + ) + + return create_error_response( + status_code=400, + code="VALIDATION_ERROR", + message="Request validation failed", + details=details, + ) + + +async def http_exception_handler(_request: Request, exc: Any) -> JSONResponse: + """ + Handler for Starlette/FastAPI HTTP exceptions. + """ + return create_error_response( + status_code=exc.status_code, + code=getattr(exc, "code", "HTTP_ERROR"), + message=exc.detail, + ) + + +async def unhandled_exception_handler( + _request: Request, exc: Exception +) -> JSONResponse: + """ + Handler for all other unhandled exceptions (500). + """ + logger.exception("Unhandled exception occurred: %s", str(exc)) + return create_error_response( + status_code=500, + code="INTERNAL_SERVER_ERROR", + message="An unexpected server error occurred", + ) + + +def register_exception_handlers(app: FastAPI) -> None: + """ + Register all custom exception handlers to the FastAPI app. + """ + app.add_exception_handler(FluentMeetException, fluentmeet_exception_handler) + app.add_exception_handler(RequestValidationError, validation_exception_handler) + app.add_exception_handler(StarletteHTTPException, http_exception_handler) + app.add_exception_handler(Exception, unhandled_exception_handler) diff --git a/app/core/exceptions.py b/app/core/exceptions.py new file mode 100644 index 0000000..52aec8b --- /dev/null +++ b/app/core/exceptions.py @@ -0,0 +1,80 @@ +from typing import Any + + +class FluentMeetException(Exception): + """ + Base exception for all FluentMeet API errors. + """ + + def __init__( + self, + status_code: int = 500, + code: str = "INTERNAL_SERVER_ERROR", + message: str = "An unexpected error occurred", + details: list[dict[str, Any]] | None = None, + ) -> None: + self.status_code = status_code + self.code = code + self.message = message + self.details = details or [] + super().__init__(self.message) + + +class BadRequestException(FluentMeetException): + def __init__( + self, + message: str = "Bad Request", + code: str = "BAD_REQUEST", + details: list[dict[str, Any]] | None = None, + ) -> None: + super().__init__(400, code, message, details) + + +class UnauthorizedException(FluentMeetException): + def __init__( + self, + message: str = "Unauthorized", + code: str = "UNAUTHORIZED", + details: list[dict[str, Any]] | None = None, + ) -> None: + super().__init__(401, code, message, details) + + +class ForbiddenException(FluentMeetException): + def __init__( + self, + message: str = "Forbidden", + code: str = "FORBIDDEN", + details: list[dict[str, Any]] | None = None, + ) -> None: + super().__init__(403, code, message, details) + + +class NotFoundException(FluentMeetException): + def __init__( + self, + message: str = "Not Found", + code: str = "NOT_FOUND", + details: list[dict[str, Any]] | None = None, + ) -> None: + super().__init__(404, code, message, details) + + +class ConflictException(FluentMeetException): + def __init__( + self, + message: str = "Conflict", + code: str = "CONFLICT", + details: list[dict[str, Any]] | None = None, + ) -> None: + super().__init__(409, code, message, details) + + +class InternalServerException(FluentMeetException): + def __init__( + self, + message: str = "Internal Server Error", + code: str = "INTERNAL_SERVER_ERROR", + details: list[dict[str, Any]] | None = None, + ) -> None: + super().__init__(500, code, message, details) diff --git a/app/main.py b/app/main.py index d1a121c..d7e5ca4 100644 --- a/app/main.py +++ b/app/main.py @@ -1,10 +1,13 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from app.core.config import settings +from app.core.exception_handlers import register_exception_handlers + app = FastAPI( - title="FluentMeet API", + title=settings.PROJECT_NAME, description="Real-time voice translation video conferencing platform API", - version="1.1.0", + version=settings.VERSION, ) # Set all CORS enabled origins @@ -16,10 +19,12 @@ allow_headers=["*"], ) +register_exception_handlers(app) + @app.get("/health", tags=["health"]) async def health_check() -> dict[str, str]: - return {"status": "ok", "version": "1.1.0"} + return {"status": "ok", "version": settings.VERSION} if __name__ == "__main__": diff --git a/docker-compose.yml b/infra/docker-compose.yml similarity index 100% rename from docker-compose.yml rename to infra/docker-compose.yml diff --git a/linting_issue.md b/linting_issue.md deleted file mode 100644 index 4ae748e..0000000 --- a/linting_issue.md +++ /dev/null @@ -1,33 +0,0 @@ -# Issue: Enforce Linting, Type-Checking, and Code Style - -## Problem -The project currently uses `black` and `isort` for formatting, but lacks a comprehensive linter and consistent type-checking. This can lead to subtle bugs, inconsistent coding patterns, and poor maintainability as the codebase grows. There is no automated enforcement of these standards outside of the newly planned CI workflow. - -## Proposed Solution -Standardize the project's code style by adopting a modern linter (e.g., `ruff`) and fully configuring `mypy` for static type analysis. Update `pyproject.toml` to serve as the single source of truth for all linting and formatting configurations. - -## User Stories -- As a developer, I want clear feedback on code quality and style violations as I write code. -- As a reviewer, I want to spend less time on stylistic comments and more time on logic and architecture. - -## Acceptance Criteria -- [ ] `ruff` is added as a development dependency and configured in `pyproject.toml`. -- [ ] `mypy` is added as a development dependency and configured in `pyproject.toml`. -- [ ] Existing code is updated to pass all linting and type-checking rules. -- [ ] A `make lint` or similar command is available for local verification. -- [ ] Documentation is updated to include the coding standards and how to run the tools. - -## Proposed Technical Details -- Use `ruff` to replace multiple tools (flake8, autoflake, etc.) for performance and simplicity. -- Configure `ruff` rules to be strict but pragmatic (e.g., following the `B`, `E`, `F`, and `I` rule sets). -- Set up `mypy` with `strict = true` or a similar high-standard configuration to ensure type safety. -- Update `pyproject.toml` sections for `[tool.ruff]` and `[tool.mypy]`. - -## Tasks -- [ ] Add `ruff` and `mypy` to `requirements.txt` (or a new `requirements-dev.txt`). -- [ ] Configure `ruff` in `pyproject.toml`. -- [ ] Configure `mypy` in `pyproject.toml`. -- [ ] Run `ruff check . --fix` to address auto-fixable issues. -- [ ] Manually fix remaining linting violations. -- [ ] Fix type-checking errors reported by `mypy`. -- [ ] Update `README.md` with instructions for running linting tools. diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py new file mode 100644 index 0000000..63e3323 --- /dev/null +++ b/tests/test_error_handling.py @@ -0,0 +1,135 @@ +from fastapi import HTTPException +from fastapi.testclient import TestClient +from pydantic import BaseModel + +from app.core.config import settings +from app.core.exceptions import ( + BadRequestException, + ConflictException, + ForbiddenException, + NotFoundException, + UnauthorizedException, +) +from app.main import app + +client = TestClient(app, raise_server_exceptions=False) + + +# Mock endpoints to trigger exceptions +@app.get("/test/bad-request") +async def trigger_bad_request(): + raise BadRequestException(message="Custom bad request message") + + +@app.get("/test/unauthorized") +async def trigger_unauthorized(): + raise UnauthorizedException() + + +@app.get("/test/forbidden") +async def trigger_forbidden(): + raise ForbiddenException() + + +@app.get("/test/not-found") +async def trigger_not_found(): + raise NotFoundException() + + +@app.get("/test/conflict") +async def trigger_conflict(): + raise ConflictException() + + +@app.get("/test/http-exception") +async def trigger_http_exception(): + raise HTTPException(status_code=418, detail="I'm a teapot") + + +@app.get("/test/unhandled-exception") +async def trigger_unhandled_exception(): + raise ValueError("Something went wrong internally") + + +class ValidationModel(BaseModel): + name: str + age: int + + +@app.post("/test/validation") +async def trigger_validation_error(data: ValidationModel): + return data + + +def test_bad_request_handler(): + response = client.get("/test/bad-request") + assert response.status_code == 400 + data = response.json() + assert data["status"] == "error" + assert data["code"] == "BAD_REQUEST" + assert data["message"] == "Custom bad request message" + + +def test_unauthorized_handler(): + response = client.get("/test/unauthorized") + assert response.status_code == 401 + data = response.json() + assert data["code"] == "UNAUTHORIZED" + + +def test_forbidden_handler(): + response = client.get("/test/forbidden") + assert response.status_code == 403 + data = response.json() + assert data["code"] == "FORBIDDEN" + + +def test_not_found_handler(): + response = client.get("/test/not-found") + assert response.status_code == 404 + data = response.json() + assert data["code"] == "NOT_FOUND" + + +def test_conflict_handler(): + response = client.get("/test/conflict") + assert response.status_code == 409 + data = response.json() + assert data["code"] == "CONFLICT" + + +def test_http_exception_handler(): + response = client.get("/test/http-exception") + assert response.status_code == 418 + data = response.json() + assert data["status"] == "error" + assert data["message"] == "I'm a teapot" + + +def test_validation_error_handler(): + # Missing required fields + response = client.post("/test/validation", json={"age": "not-an-int"}) + assert response.status_code == 400 + data = response.json() + assert data["status"] == "error" + assert data["code"] == "VALIDATION_ERROR" + assert len(data["details"]) > 0 + # Check if field name is present in details + fields = [d["field"] for d in data["details"]] + assert "body.name" in fields + assert "body.age" in fields + + +def test_unhandled_exception_handler(): + response = client.get("/test/unhandled-exception") + assert response.status_code == 500 + data = response.json() + assert data["status"] == "error" + assert data["code"] == "INTERNAL_SERVER_ERROR" + assert data["message"] == "An unexpected server error occurred" + + +def test_health_check_remains_ok(): + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok", "version": settings.VERSION} diff --git a/tests/test_main.py b/tests/test_main.py index 563bbae..21e5916 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,5 +1,6 @@ from fastapi.testclient import TestClient +from app.core.config import settings from app.main import app client = TestClient(app) @@ -8,4 +9,4 @@ def test_health_check(): response = client.get("/health") assert response.status_code == 200 - assert response.json() == {"status": "ok", "version": "1.0.0"} + assert response.json() == {"status": "ok", "version": settings.VERSION}