From 50d570801686e60c733596a3f01817497e0ea49b Mon Sep 17 00:00:00 2001 From: Michele Date: Sun, 8 Mar 2026 01:51:41 +0100 Subject: [PATCH] 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 --- .env.example | 3 ++ .gitignore | 38 ++++++++++++++++++ Dockerfile | 41 +++++++++++++++++++ backend/__init__.py | 0 backend/config.py | 53 +++++++++++++++++++++++++ backend/main.py | 78 +++++++++++++++++++++++++++++++++++++ backend/routers/__init__.py | 0 docker-compose.yml | 26 +++++++++++++ requirements.txt | 5 +++ 9 files changed, 244 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 backend/__init__.py create mode 100644 backend/config.py create mode 100644 backend/main.py create mode 100644 backend/routers/__init__.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6183f47 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +ANTHROPIC_API_KEY=your-key-here +LLM_MODEL=claude-sonnet-4-5 +DATA_PATH=/app/data diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aebf8aa --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f66a706 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..fc252c2 --- /dev/null +++ b/backend/config.py @@ -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, + ) diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..46e4550 --- /dev/null +++ b/backend/main.py @@ -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") diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a3b01de --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..050ec0e --- /dev/null +++ b/requirements.txt @@ -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