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/infra/wedding_photo/apps/api/app/main.py

72 lines
2.0 KiB
Python
Raw Normal View History

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 19:45:20 -03:00
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")