Bart Dorsey

Database & API Endpoints

Database Models

The database stores only the S3 object key (the generated filename) for each uploaded photo — not the image itself. Images live in S3; the database just tracks where they are.

Models are defined with SQLAlchemy’s modern type-annotated style in db_models.py:

backend/db_models.py on GitHub

# db_models.py
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


class DBPhoto(Base):
    __tablename__: str = "photos"

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    photo_name: Mapped[str] = mapped_column()

Mapped[int] and Mapped[str] give you full type safety — SQLAlchemy knows the Python type and the corresponding SQL column type automatically.


Database Operations

All queries live in db.py:

backend/db.py on GitHub

# db.py
from collections.abc import Sequence
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from db_models import Base, DBPhoto
from config import DATABASE_URL

engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(bind=engine)

# Create tables on startup if they don't exist
Base.metadata.create_all(bind=engine)


def get_photos() -> Sequence[DBPhoto]:
    """Return all photos from the database."""
    with SessionLocal() as session:
        return session.execute(select(DBPhoto)).scalars().all()


def add_photo(photo_name: str) -> DBPhoto:
    """Insert a new photo record and return it."""
    with SessionLocal() as session:
        new_photo = DBPhoto(photo_name=photo_name)
        session.add(new_photo)
        session.commit()
        session.refresh(new_photo)
        return new_photo

Base.metadata.create_all() runs on every startup and creates any missing tables. It’s a no-op if the tables already exist, so it’s safe to leave in production.

The with SessionLocal() as session context manager ensures sessions are always closed properly, even if an exception occurs mid-query.


API Endpoints

The FastAPI application is defined in main.py:

backend/main.py on GitHub

# main.py
from __future__ import annotations

import config  # noqa: F401 — must import early to load env vars before anything else
from config import CORS_ORIGINS

from typing import ClassVar

from fastapi import FastAPI, UploadFile
from fastapi.exceptions import HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, ConfigDict

import db
from photos import upload_photo, get_url

app = FastAPI()


class PhotoResponse(BaseModel):
    """Response model for a single photo."""

    id: int
    photo_url: str | None = None

    model_config: ClassVar[ConfigDict] = ConfigDict(from_attributes=True)


app.add_middleware(
    CORSMiddleware,
    allow_origins=CORS_ORIGINS,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.get("/api/photos", response_model=list[PhotoResponse])
def get_photos_endpoint() -> list[PhotoResponse]:
    """Return all photos with fresh pre-signed URLs."""
    return [
        PhotoResponse(id=photo.id, photo_url=get_url(photo.photo_name))
        for photo in db.get_photos()
    ]


@app.post("/api/photos", response_model=PhotoResponse)
def upload_photo_endpoint(photo: UploadFile) -> PhotoResponse:
    """Validate, upload, and record a new photo."""
    photo_name = upload_photo(photo)
    if photo_name is None:
        raise HTTPException(status_code=400, detail="Photo upload failed")
    db_photo = db.add_photo(photo_name)
    return PhotoResponse(id=db_photo.id, photo_url=get_url(db_photo.photo_name))

Why import config first?

The import config at the top (even though nothing from it is used directly) ensures load_dotenv() runs before any other module reads environment variables. If another module imports DATABASE_URL before config is loaded, it would get None.

CORS middleware

The CORSMiddleware is required because the React frontend runs on http://localhost:5173 while the API runs on http://localhost:8000. Without CORS headers, browsers block cross-origin requests. In production, set CORS_ORIGINS to your actual frontend domain.

The response model

PhotoResponse uses from_attributes=True so Pydantic can construct it directly from a SQLAlchemy DBPhoto object. The photo_url field is populated by calling get_url() — generating a fresh pre-signed URL — rather than storing the URL in the database (where it would eventually expire).

Upload flow end-to-end

  1. Client POSTs a multipart/form-data request with a photo field
  2. FastAPI extracts the file into an UploadFile object
  3. upload_photo() validates the file type and size
  4. If valid, the file is streamed to S3 with a UUID-prefixed name
  5. The filename is stored in PostgreSQL
  6. A fresh pre-signed URL is generated and returned to the client