feat(01-01): Backend FastAPI skeleton + Docker build config

- FastAPI app with SPAStaticFiles catch-all (root_path NOT in constructor)
- config.py with DATA_PATH, PROMPTS_PATH, OUTPUTS_PATH, CAMPAIGNS_PATH, CONFIG_PATH
- Startup lifespan creates data directories automatically
- Health endpoint: GET /api/health -> {"status": "ok"}
- Dockerfile multi-stage: node:22-slim builds React, python:3.12-slim serves API+SPA
- --root-path /postgenerator set in Uvicorn CMD only (avoids Pitfall #4)
- docker-compose.yml: lab-postgenerator-app, proxy_net, named volume for data persistence
- requirements.txt with pinned versions: fastapi[standard]==0.135.1, anthropic==0.84.0
This commit is contained in:
Michele
2026-03-08 01:51:41 +01:00
parent 696b265e4d
commit 50d5708016
9 changed files with 244 additions and 0 deletions

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
ANTHROPIC_API_KEY=your-key-here
LLM_MODEL=claude-sonnet-4-5
DATA_PATH=/app/data

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# Dependencies
node_modules/
__pycache__/
*.pyc
venv/
.venv/
# Environment
.env
.env.local
.env.*.local
# Build
.next/
dist/
build/
out/
frontend/dist/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Data (local dev)
data/
*.db
*.sqlite
backend/data/outputs/

41
Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
# ============================================================
# Stage 1: Build React SPA
# ============================================================
FROM node:22-slim AS frontend-builder
WORKDIR /app/frontend
# Install dependencies first (layer cache optimization)
COPY frontend/package*.json ./
RUN npm ci
# Copy source and build
COPY frontend/ ./
RUN npm run build
# ============================================================
# Stage 2: Python runtime (serves API + built SPA)
# ============================================================
FROM python:3.12-slim AS runtime
WORKDIR /app
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy backend source
COPY backend/ ./backend/
# Copy built React SPA from frontend-builder stage
COPY --from=frontend-builder /app/frontend/dist ./static
# Create data directory structure
RUN mkdir -p /app/data/prompts /app/data/outputs /app/data/campaigns /app/data/config
# Expose port
EXPOSE 8000
# CRITICAL: --root-path /postgenerator is set HERE (Uvicorn), NOT in FastAPI() constructor
# This avoids the double-path bug (Pitfall #4)
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000", "--root-path", "/postgenerator"]

0
backend/__init__.py Normal file
View File

53
backend/config.py Normal file
View File

@@ -0,0 +1,53 @@
"""Centralized configuration and path constants for PostGenerator."""
import os
from pathlib import Path
from dataclasses import dataclass
# ---------------------------------------------------------------------------
# Path constants (read from env, defaulting to ./data for local dev)
# ---------------------------------------------------------------------------
DATA_PATH = Path(os.getenv("DATA_PATH", "./data"))
PROMPTS_PATH = DATA_PATH / "prompts"
OUTPUTS_PATH = DATA_PATH / "outputs"
CAMPAIGNS_PATH = DATA_PATH / "campaigns"
CONFIG_PATH = DATA_PATH / "config"
# ---------------------------------------------------------------------------
# App settings
# ---------------------------------------------------------------------------
@dataclass
class Settings:
anthropic_api_key: str
llm_model: str
data_path: Path
prompts_path: Path
outputs_path: Path
campaigns_path: Path
config_path: Path
def get_settings() -> Settings:
"""Load settings from environment variables."""
api_key = os.getenv("ANTHROPIC_API_KEY", "")
if not api_key:
import warnings
warnings.warn(
"ANTHROPIC_API_KEY not set — generation endpoints will fail",
RuntimeWarning,
stacklevel=2,
)
return Settings(
anthropic_api_key=api_key,
llm_model=os.getenv("LLM_MODEL", "claude-sonnet-4-5"),
data_path=DATA_PATH,
prompts_path=PROMPTS_PATH,
outputs_path=OUTPUTS_PATH,
campaigns_path=CAMPAIGNS_PATH,
config_path=CONFIG_PATH,
)

78
backend/main.py Normal file
View File

@@ -0,0 +1,78 @@
"""PostGenerator FastAPI application.
IMPORTANT: root_path is intentionally NOT set in the FastAPI() constructor.
It is passed only via Uvicorn's --root-path flag at runtime to avoid the
double-path bug (Pitfall #4).
"""
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from backend.config import CAMPAIGNS_PATH, CONFIG_PATH, OUTPUTS_PATH, PROMPTS_PATH
# ---------------------------------------------------------------------------
# SPA static files with catch-all fallback
# ---------------------------------------------------------------------------
class SPAStaticFiles(StaticFiles):
"""Serve a React SPA: return index.html for any 404 so the client-side
router handles unknown paths instead of returning a 404 from the server."""
async def get_response(self, path: str, scope):
try:
return await super().get_response(path, scope)
except Exception:
# Fall back to index.html for client-side routing
return await super().get_response("index.html", scope)
# ---------------------------------------------------------------------------
# Startup / shutdown lifecycle
# ---------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Create required data directories on startup if they do not exist."""
for directory in (PROMPTS_PATH, OUTPUTS_PATH, CAMPAIGNS_PATH, CONFIG_PATH):
directory.mkdir(parents=True, exist_ok=True)
yield
# Nothing to clean up on shutdown
# ---------------------------------------------------------------------------
# Application
# ---------------------------------------------------------------------------
# CRITICAL: Do NOT pass root_path here — use Uvicorn --root-path instead.
app = FastAPI(
title="PostGenerator",
description="Instagram carousel post generator powered by Claude",
version="0.1.0",
lifespan=lifespan,
# root_path is intentionally absent — set via Uvicorn --root-path at runtime
)
# ---------------------------------------------------------------------------
# API routes (must be registered BEFORE the SPA catch-all mount)
# ---------------------------------------------------------------------------
@app.get("/api/health")
async def health() -> dict:
"""Health check endpoint."""
return {"status": "ok"}
# ---------------------------------------------------------------------------
# SPA catch-all mount (MUST be last — catches everything not matched above)
# ---------------------------------------------------------------------------
_STATIC_DIR = Path(__file__).parent.parent / "static"
if _STATIC_DIR.exists():
app.mount("/", SPAStaticFiles(directory=str(_STATIC_DIR), html=True), name="spa")

View File

26
docker-compose.yml Normal file
View File

@@ -0,0 +1,26 @@
services:
app:
container_name: lab-postgenerator-app
build:
context: .
dockerfile: Dockerfile
env_file: .env
volumes:
- postgenerator-data:/app/data
networks:
- proxy_net
# NO exposed ports — traffic routed via lab-router nginx
deploy:
resources:
limits:
memory: 1024M
cpus: '1.0'
restart: unless-stopped
volumes:
postgenerator-data:
name: postgenerator-data
networks:
proxy_net:
external: true

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
fastapi[standard]==0.135.1
anthropic==0.84.0
httpx==0.28.1
python-dotenv==1.2.2
aiofiles==24.1.0