This repository has been archived on 2026-06-09. You can view files and clone it, but cannot push or open issues or pull requests.
wedding-app/apps/api/app/main.py
Claude 2fa17e5feb
feat: rewrite backend in Python (FastAPI + SQLAlchemy + boto3)
Same routes, same Docker topology, same env vars. Frontend untouched.

Backend swap:
- Hono on Node -> FastAPI on Python 3.12, served by uvicorn behind Caddy.
- Drizzle on Node -> SQLAlchemy 2.0 async (asyncpg driver) with declarative
  models that mirror the previous schema 1:1. Migrations are still plain
  SQL files (apps/api/app/migrations/) run idempotently at startup by
  app/db/migrate.py via asyncpg, tracking each file's sha256 in a
  schema_migrations table.
- aws4fetch -> boto3 wrapped in asyncio.to_thread so the async routes
  do not block on MinIO/S3 calls. The presign_put / init_multipart /
  complete_multipart / head / delete contract is unchanged so the React
  client sees the same /api/uploads/* payloads.
- Cookie+JWT admin auth -> pyjwt + FastAPI Cookie() dependency.
  constant-time password compare. Same 7-day HttpOnly cookie shape.
- QR code: qrcode lib + reportlab. /api/qrcode keeps format=png|svg|pdf;
  the PDF is still A6 with the couple's names + 'Aponte a câmera' CTA.
- Pydantic-settings replaces the zod env loader and still fails fast
  on missing required vars.

Routes ported with identical paths and JSON shapes:
- public: /api/event, /api/gallery, /api/qrcode, /api/stats
- uploads: /api/uploads/init, /{id}/confirm, /{id}/abort
- admin: /login, /logout, /me, /event (GET+PATCH), /uploads (GET with
  ?status, ?kind=photo|video, ?q= for search), /uploads/{id} (PATCH
  for author/message edit), /uploads/{id}/{approve,reject,cover},
  /uploads/{id} (DELETE), /event/cover (DELETE), /uploads/bulk, /stats.

Build pipeline:
- Dockerfile: stage 1 builds the React/Vite SPA with pnpm; stage 2 is
  python:3.12-slim that installs deps via uv (fast, reproducible),
  copies the app/, and copies the SPA dist from stage 1 into /app/public
  so the FastAPI process serves both /api/* and the SPA assets.
- docker-compose.yml unchanged: postgres + minio + minio-init + app +
  caddy. Same env vars (DATABASE_URL, S3_*, ADMIN_PASSWORD, SESSION_SECRET,
  ALLOWED_ADMIN_EMAILS, AUTO_MIGRATE).
- Removed apps/api from the pnpm workspace; root package.json now only
  scripts the web. Makefile centralizes dev/build/docker commands so
  the Python side has the same DX as the Node side did.
2026-06-07 22:45:20 +00:00

72 lines
2.0 KiB
Python

import time
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from .config import settings
from .db.base import engine
from .db.migrate import run_migrations
from .routes import admin, public, uploads
@asynccontextmanager
async def lifespan(app: FastAPI):
if settings.AUTO_MIGRATE:
print("[startup] running migrations")
await run_migrations(settings.DATABASE_URL)
print(f"[startup] static dir: {settings.STATIC_DIR}")
yield
await engine.dispose()
app = FastAPI(
lifespan=lifespan,
title="Wedding Photos API",
openapi_url="/api/openapi.json",
docs_url="/api/docs",
redoc_url=None,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/api/health")
async def health():
return {"ok": True, "ts": int(time.time() * 1000)}
app.include_router(public.router, prefix="/api", tags=["public"])
app.include_router(uploads.router, prefix="/api/uploads", tags=["uploads"])
app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
# ----- SPA static asset serving -----
static_dir = Path(settings.STATIC_DIR)
assets_dir = static_dir / "assets"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
@app.get("/{full_path:path}", include_in_schema=False)
async def spa_fallback(full_path: str):
if full_path.startswith("api/") or full_path == "api":
raise HTTPException(404, detail="not_found")
if full_path in {"favicon.ico", "robots.txt"}:
candidate = static_dir / full_path
if candidate.is_file():
return FileResponse(candidate)
index = static_dir / "index.html"
if index.is_file():
return FileResponse(index)
raise HTTPException(404, detail="static_not_found")