From b6f1dd8942b1c339dcba6b317e5c40a08dddd923 Mon Sep 17 00:00:00 2001 From: Kai Chappell Date: Thu, 10 Apr 2025 23:30:50 +0100 Subject: [PATCH] feat(backend): add FastAPI app entry point --- backend/src/__init__.py | 0 backend/src/config.py | 27 +++++++++ backend/src/main.py | 124 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 backend/src/__init__.py create mode 100644 backend/src/config.py create mode 100644 backend/src/main.py diff --git a/backend/src/__init__.py b/backend/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/config.py b/backend/src/config.py new file mode 100644 index 0000000..427dd5b --- /dev/null +++ b/backend/src/config.py @@ -0,0 +1,27 @@ +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + ) + + app_name: str = "CodeTutor API" + app_version: str = "0.1.0" + debug: bool = False + + database_url: str + + cors_origins: list[str] = ["http://localhost:3000"] + + default_page_size: int = 20 + max_page_size: int = 100 + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/backend/src/main.py b/backend/src/main.py new file mode 100644 index 0000000..c345e37 --- /dev/null +++ b/backend/src/main.py @@ -0,0 +1,124 @@ +import logging +import time +from collections.abc import Awaitable, Callable + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import Response + +from src.api.routes import categories, patterns, questions, stats +from src.config import get_settings +from src.db.database import async_session_factory + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) +settings = get_settings() + +app = FastAPI( + title=settings.app_name, + version=settings.app_version, + description="API for coding interview preparation with curated educational content", +) + + +class RequestLoggingMiddleware(BaseHTTPMiddleware): + """Middleware to log incoming requests with timing.""" + + async def dispatch( + self, + request: Request, + call_next: Callable[[Request], Awaitable[Response]], + ) -> Response: + """Log request method, path, status, and duration.""" + start_time = time.perf_counter() + response = await call_next(request) + duration_ms = (time.perf_counter() - start_time) * 1000 + + logger.info( + "%s %s - %d (%.2fms)", + request.method, + request.url.path, + response.status_code, + duration_ms, + ) + return response + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + """Middleware to add security headers to all responses.""" + + async def dispatch( + self, + request: Request, + call_next: Callable[[Request], Awaitable[Response]], + ) -> Response: + """Add security headers to response.""" + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Content-Security-Policy"] = "default-src 'self'; frame-ancestors 'none'" + return response + + +app.add_middleware(RequestLoggingMiddleware) +app.add_middleware(SecurityHeadersMiddleware) +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["GET", "HEAD", "OPTIONS", "POST"], + allow_headers=["Content-Type", "Accept"], +) + + +@app.exception_handler(SQLAlchemyError) +async def sqlalchemy_exception_handler( + request: Request, # noqa: ARG001 + exc: SQLAlchemyError, +) -> JSONResponse: + """Handle database errors.""" + logger.exception("Database error: %s", exc) + return JSONResponse( + status_code=500, + content={"detail": "A database error occurred"}, + ) + + +@app.exception_handler(Exception) +async def generic_exception_handler( + request: Request, # noqa: ARG001 + exc: Exception, +) -> JSONResponse: + """Handle unexpected errors.""" + logger.exception("Unexpected error: %s", exc) + return JSONResponse( + status_code=500, + content={"detail": "An unexpected error occurred"}, + ) + + +app.include_router(questions.router, prefix="/api/questions", tags=["questions"]) +app.include_router(categories.router, prefix="/api/categories", tags=["categories"]) +app.include_router(patterns.router, prefix="/api/patterns", tags=["patterns"]) +app.include_router(stats.router, prefix="/api/stats", tags=["stats"]) + + +@app.get("/api/health", response_model=None) +async def health_check() -> dict[str, str] | JSONResponse: + """Health check endpoint with database verification.""" + try: + async with async_session_factory() as session: + await session.execute(text("SELECT 1")) + return {"status": "healthy"} + except Exception: + logger.exception("Health check failed") + return JSONResponse(status_code=503, content={"status": "unhealthy"})