diff --git a/app/api/main.py b/app/api/main.py index d5ce163..d7974a2 100644 --- a/app/api/main.py +++ b/app/api/main.py @@ -1,8 +1,9 @@ from fastapi import APIRouter -from app.api.routes import auth, user +from app.api.routes import auth, questions, user api_router = APIRouter() api_router.include_router(user.router, prefix="/users", tags=["users"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) +api_router.include_router(questions.router, prefix="/questions", tags=["questions"]) diff --git a/app/api/routes/questions.py b/app/api/routes/questions.py new file mode 100644 index 0000000..fbc36df --- /dev/null +++ b/app/api/routes/questions.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter + +from app.settings import settings +from app.utils.http_client import make_request + +router = APIRouter() + + +GENERATOR_SERVICE_URL = settings.GENERATOR_SERVICE_URL + + +@router.get("/daily_questions") +def get_daily_questions(): + """Fetch daily questions from the external generator service.""" + url = f"{GENERATOR_SERVICE_URL}/api/v1/questions/get_daily_question" + return make_request("GET", url) diff --git a/app/main.py b/app/main.py index 4105d7a..f6e9a5e 100644 --- a/app/main.py +++ b/app/main.py @@ -1,13 +1,60 @@ # from functools import lru_cache -from fastapi import FastAPI +import logging + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware from fastapi.openapi.utils import get_openapi +from fastapi.responses import JSONResponse from starlette.middleware.sessions import SessionMiddleware from app.api.main import api_router +from app.schemas.exceptions import ErrorResponse from app.settings import settings +from app.utils.exceptions import AppException + +logger = logging.getLogger(__name__) app = FastAPI() +origins = [ + "http://localhost:5173", # Vite local dev + "http://127.0.0.1:5173", + "http://yourdomain.com", # replace with final domain +] + +app.add_middleware( + CORSMiddleware, + allow_origins=origins, # origins allowed + allow_credentials=True, + allow_methods=["*"], # allow all HTTP methods + allow_headers=["*"], # allow all headers +) + + +@app.exception_handler(AppException) +async def app_exception_handler(_request: Request, exc: AppException): + logger.error("AppException: %s | Context: %s", exc.detail, exc.context) + return JSONResponse( + status_code=exc.status_code, + content=ErrorResponse( + detail=exc.detail, + code=exc.code, + status_code=exc.status_code, + context=exc.context, + ).model_dump(), + ) + + +@app.exception_handler(Exception) +async def unhandled_exception_handler(_request: Request, exc: Exception): + logger.exception("Unhandled Exception occurred : %s", exc) + return JSONResponse( + status_code=500, + content=ErrorResponse( + detail="Internal Server Error", code="internal_error", status_code=500 + ).model_dump(), + ) + def custom_openapi(): if app.openapi_schema: @@ -56,8 +103,5 @@ def health_check(): app.include_router(api_router, prefix=settings.API_V1_STR, tags=["v1"]) # TODO: - -# add middleware for CORS # add middleware for request and response # add basic logging setup -# add exception handling for common errors diff --git a/app/schemas/exceptions.py b/app/schemas/exceptions.py new file mode 100644 index 0000000..1e2383c --- /dev/null +++ b/app/schemas/exceptions.py @@ -0,0 +1,10 @@ +from typing import Optional + +from pydantic import BaseModel + + +class ErrorResponse(BaseModel): + detail: str + code: str + status_code: int + context: Optional[dict] = None diff --git a/app/settings.py b/app/settings.py index 64b9ffb..ad48c51 100644 --- a/app/settings.py +++ b/app/settings.py @@ -39,6 +39,9 @@ class Settings(BaseSettings): GOOGLE_CLIENT_ID: str GOOGLE_CLIENT_SECRET: str + # Services endpoints + GENERATOR_SERVICE_URL: str + class Config: """Configuration for the settings.""" diff --git a/app/utils/exceptions.py b/app/utils/exceptions.py new file mode 100644 index 0000000..9222b78 --- /dev/null +++ b/app/utils/exceptions.py @@ -0,0 +1,49 @@ +class AppException(Exception): + """Base application exception with additional context.""" + + def __init__( + self, + detail: str, + code: str = "app_error", + status_code: int = 400, + context: dict | None = None, + ): + self.detail = detail + self.code = code + self.status_code = status_code + self.context = context or {} + super().__init__(detail) # keeps the built-in Exception message + + +class RequestTimeoutError(AppException): + """Raised when a request times out.""" + + def __init__(self, detail: str = "Request timed out", context: dict | None = None): + super().__init__( + detail=detail, code="timeout_error", status_code=408, context=context + ) + + +class RequestConnectionError(AppException): + """Raised when a connection to the server fails.""" + + def __init__( + self, detail: str = "Failed to connect to server", context: dict | None = None + ): + super().__init__( + detail=detail, code="connection_error", status_code=503, context=context + ) + + +class RequestHTTPError(AppException): + """Raised for non-2xx HTTP responses.""" + + def __init__( + self, + detail: str = "HTTP error occurred", + status_code: int = 500, + context: dict | None = None, + ): + super().__init__( + detail=detail, code="http_error", status_code=status_code, context=context + ) diff --git a/app/utils/http_client.py b/app/utils/http_client.py new file mode 100644 index 0000000..d5c28ba --- /dev/null +++ b/app/utils/http_client.py @@ -0,0 +1,57 @@ +from typing import Any, Dict, Optional + +import requests + +from app.utils.exceptions import (AppException, RequestConnectionError, + RequestHTTPError, RequestTimeoutError) + + +def make_request( + method: str, + url: str, + params: Optional[Dict[str, Any]] = None, + data: Optional[Any] = None, + json: Optional[Any] = None, + headers: Optional[Dict[str, str]] = None, + timeout: int = 60, +) -> Dict[str, Any]: + """ + Generic HTTP request utility. + """ + try: + response = requests.request( + method=method, + url=url, + params=params, + data=data, + json=json, + headers=headers, + timeout=timeout, + ) + response.raise_for_status() + return response.json() + + except requests.Timeout as exc: + raise RequestTimeoutError( + "The request timed out", context={"url": url} + ) from exc + + except requests.ConnectionError as exc: + raise RequestConnectionError( + "Failed to connect to the service", context={"url": url} + ) from exc + + except requests.HTTPError as exc: + status_code = exc.response.status_code if exc.response else 500 + message = exc.response.text if exc.response else str(exc) + raise RequestHTTPError( + detail=message, status_code=status_code, context={"url": url} + ) from exc + + except Exception as exc: + # Catch-all wrapped in your base AppException + raise AppException( + "Unexpected error in HTTP request", + code="unexpected_request_error", + context={"url": url, "error": str(exc)}, + ) from exc diff --git a/entrypoint.sh b/entrypoint.sh index 506653c..2bebe1b 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -20,4 +20,4 @@ echo "Running Alembic migrations..." alembic upgrade head echo "Starting FastAPI app..." -exec uvicorn app.main:app --host 0.0.0.0 --port 8000 +exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload diff --git a/local_setup.md b/local_setup.md index 68f8bed..52bbd14 100644 --- a/local_setup.md +++ b/local_setup.md @@ -36,6 +36,8 @@ POSTGRES_DB=JamAndFlow POSTGRES_HOST=db POSTGRES_PORT=5432 + + GENERATOR_SERVICE_URL=http://generator-service:8000 ``` # TODO: Add docker setup instructions below