diff --git a/api/errors/base.py b/api/errors/base.py index 1f81a08..ad70344 100644 --- a/api/errors/base.py +++ b/api/errors/base.py @@ -1,4 +1,7 @@ class AppError(Exception): + """Base application error with an HTTP status code.""" + def __init__(self, message: str, status_code: int = 400): self.message = message - self.status_code = status_code \ No newline at end of file + self.status_code = status_code + super().__init__(message) \ No newline at end of file diff --git a/api/errors/handlers.py b/api/errors/handlers.py index 903e744..fb28d02 100644 --- a/api/errors/handlers.py +++ b/api/errors/handlers.py @@ -1,11 +1,115 @@ -from fastapi import Request +""" +Global exception handlers for the FireForm API. + +Ensures every error response returns a uniform JSON envelope matching +the ErrorResponse schema from api.schemas.common, regardless of whether +the error is a validation failure, a known application error, an HTTP +exception, or an unexpected crash. + +Security: unhandled exceptions are logged server-side but never exposed +to the client. +""" + +import logging + +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException + from api.errors.base import AppError -def register_exception_handlers(app): +logger = logging.getLogger("fireform") + + +def register_exception_handlers(app: FastAPI) -> None: + """Attach all global exception handlers to the FastAPI app.""" + @app.exception_handler(AppError) - async def app_error_handler(request: Request, exc: AppError): + async def app_error_handler(request: Request, exc: AppError) -> JSONResponse: + """Handle known application-level errors raised with AppError.""" return JSONResponse( status_code=exc.status_code, - content={"error": exc.message}, + content={ + "success": False, + "error": { + "code": "APPLICATION_ERROR", + "message": exc.message, + }, + }, + ) + + @app.exception_handler(StarletteHTTPException) + async def http_error_handler( + request: Request, exc: StarletteHTTPException + ) -> JSONResponse: + """ + Handle FastAPI/Starlette HTTPExceptions. + + templates.py raises HTTPException while forms.py raises AppError. + This ensures both produce the same response shape for the frontend. + """ + return JSONResponse( + status_code=exc.status_code, + content={ + "success": False, + "error": { + "code": "HTTP_ERROR", + "message": str(exc.detail), + }, + }, + ) + + @app.exception_handler(RequestValidationError) + async def validation_error_handler( + request: Request, exc: RequestValidationError + ) -> JSONResponse: + """ + Handle Pydantic request validation failures. + + Extracts the first validation error and returns a human-readable + message instead of dumping the raw Pydantic error array. + """ + first = exc.errors()[0] if exc.errors() else {} + field = " -> ".join(str(loc) for loc in first.get("loc", [])) + message = first.get("msg", "Validation failed") + detail = f"{field}: {message}" if field else message + + return JSONResponse( + status_code=422, + content={ + "success": False, + "error": { + "code": "VALIDATION_ERROR", + "message": detail, + }, + }, + ) + + @app.exception_handler(Exception) + async def unhandled_error_handler( + request: Request, exc: Exception + ) -> JSONResponse: + """ + Catch-all for unexpected exceptions. + + Logs the full traceback server-side for debugging but returns + only a generic message to the client. This prevents leaking + internal file paths, stack frames, and application state. + """ + logger.exception( + "Unhandled error on %s %s: %s", + request.method, + request.url.path, + str(exc), + ) + return JSONResponse( + status_code=500, + content={ + "success": False, + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error", + }, + }, ) \ No newline at end of file diff --git a/api/main.py b/api/main.py index b6b3fdc..9418c1f 100644 --- a/api/main.py +++ b/api/main.py @@ -7,6 +7,7 @@ from api.errors.handlers import register_exception_handlers from fastapi.middleware.cors import CORSMiddleware from api.routes import forms, templates +from api.errors.handlers import register_exception_handlers @asynccontextmanager async def lifespan(app: FastAPI): @@ -20,20 +21,19 @@ async def lifespan(app: FastAPI): register_exception_handlers(app) -default_origins = "http://127.0.0.1:5173" -allowed_origins = [ - origin.strip() - for origin in os.getenv("FRONTEND_ORIGINS", default_origins).split(",") - if origin.strip() -] +# Register global exception handlers before middleware +register_exception_handlers(app) app.add_middleware( CORSMiddleware, - allow_origins=allowed_origins, - allow_credentials=False, + allow_origins=[ + "http://127.0.0.1:5500", + "http://localhost:5500", + ], + allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.include_router(templates.router) -app.include_router(forms.router) +app.include_router(forms.router) \ No newline at end of file diff --git a/api/routes/forms.py b/api/routes/forms.py index cee5356..7743fec 100644 --- a/api/routes/forms.py +++ b/api/routes/forms.py @@ -1,27 +1,23 @@ -from fastapi import APIRouter, Depends -from sqlmodel import Session -from api.deps import get_db -from api.schemas.forms import FormFill, FormFillResponse -from api.db.repositories import create_form, get_template -from api.db.models import FormSubmission -from api.errors.base import AppError -from src.controller import Controller +from pydantic import BaseModel, Field, field_validator -router = APIRouter(prefix="/forms", tags=["forms"]) +class FormFill(BaseModel): + template_id: int + input_text: str = Field(..., min_length=1, max_length=50000) -@router.post("/fill", response_model=FormFillResponse) -def fill_form(form: FormFill, db: Session = Depends(get_db)): - fetched_template = get_template(db, form.template_id) - if not fetched_template: - raise AppError("Template not found", status_code=404) + @field_validator("input_text") + @classmethod + def validate_input_text(cls, v): + stripped = v.strip() + if not stripped: + raise ValueError("Input text cannot be empty or only whitespace") + return stripped - controller = Controller() - path = controller.fill_form( - user_input=form.input_text, - fields=fetched_template.fields, - pdf_form_path=fetched_template.pdf_path, - ) - submission = FormSubmission(**form.model_dump(), output_pdf_path=path) - return create_form(db, submission) +class FormFillResponse(BaseModel): + id: int + template_id: int + input_text: str + output_pdf_path: str + + model_config = {"from_attributes": True} \ No newline at end of file