Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Cache pip dependencies
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest

- name: Run tests
env:
PYTHONPATH: ${{ github.workspace }}
run: |
pytest tests/ -v --tb=short

lint:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Install linter
run: |
python -m pip install --upgrade pip
pip install ruff

- name: Run linter
run: |
ruff check . --select=E,F,I --ignore=E501
14 changes: 10 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

FROM python:3.11-slim

WORKDIR /app
Expand All @@ -16,7 +15,14 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .

# Set Python path so imports work correctly
ENV PYTHONPATH=/app/src
ENV PYTHONPATH=/app

# Expose the default port
EXPOSE 8000

# Health check — Docker pings /health every 30s to verify the app is alive
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1

# Keep container running for interactive use
CMD ["tail", "-f", "/dev/null"]
# Start the FastAPI server
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]
7 changes: 4 additions & 3 deletions api/db/models.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
from sqlmodel import SQLModel, Field
from sqlalchemy import Column, JSON
from datetime import datetime
from datetime import datetime, timezone


class Template(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str
fields: dict = Field(sa_column=Column(JSON))
pdf_path: str
created_at: datetime = Field(default_factory=datetime.utcnow)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))


class FormSubmission(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
template_id: int
input_text: str
output_pdf_path: str
created_at: datetime = Field(default_factory=datetime.utcnow)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
5 changes: 4 additions & 1 deletion api/errors/base.py
Original file line number Diff line number Diff line change
@@ -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
self.status_code = status_code
super().__init__(message)
112 changes: 108 additions & 4 deletions api/errors/handlers.py
Original file line number Diff line number Diff line change
@@ -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",
},
},
)
54 changes: 44 additions & 10 deletions api/main.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,60 @@
import os
import logging
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlmodel import SQLModel

from api.db.database import engine
from api.routes import forms, templates
from api.errors.handlers import register_exception_handlers

app = FastAPI()
logger = logging.getLogger("fireform")

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()
]

@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Application lifecycle manager.

Startup: initializes database tables.
Shutdown: logs graceful shutdown.

Using lifespan ensures database initialization only happens
when the server actually starts, not when the module is
imported during testing or linting.
"""
logger.info("Starting FireForm — initializing database tables")
SQLModel.metadata.create_all(engine)
logger.info("Database tables ready")
yield
logger.info("Shutting down FireForm")


app = FastAPI(
title="FireForm API",
description="AI-powered PDF form filling for first responders",
version="0.1.0",
lifespan=lifespan,
)

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.get("/health", tags=["system"])
def health_check():
return {"status": "healthy", "service": "fireform"}
26 changes: 19 additions & 7 deletions api/routes/forms.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
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
Expand All @@ -9,17 +10,28 @@

router = APIRouter(prefix="/forms", tags=["forms"])


@router.post("/fill", response_model=FormFillResponse)
def fill_form(form: FormFill, db: Session = Depends(get_db)):
if not get_template(db, form.template_id):
# Single query instead of the previous duplicate get_template() calls
template = get_template(db, form.template_id)
if not template:
raise AppError("Template not found", status_code=404)

fetched_template = get_template(db, form.template_id)

controller = Controller()
path = controller.fill_form(user_input=form.input_text, fields=fetched_template.fields, pdf_form_path=fetched_template.pdf_path)
try:
path = controller.fill_form(
user_input=form.input_text,
fields=template.fields,
pdf_form_path=template.pdf_path,
)
except AppError:
raise # Re-raise known application errors as-is
except Exception as exc:
raise AppError(
f"Form filling failed: {exc}",
status_code=500,
) from exc

submission = FormSubmission(**form.model_dump(), output_pdf_path=path)
return create_form(db, submission)


return create_form(db, submission)
Loading