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:
# 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:
# 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
- Client POSTs a
multipart/form-datarequest with aphotofield - FastAPI extracts the file into an
UploadFileobject upload_photo()validates the file type and size- If valid, the file is streamed to S3 with a UUID-prefixed name
- The filename is stored in PostgreSQL
- A fresh pre-signed URL is generated and returned to the client