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:
3
.env.example
Normal file
3
.env.example
Normal 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
38
.gitignore
vendored
Normal 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
41
Dockerfile
Normal 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
0
backend/__init__.py
Normal file
53
backend/config.py
Normal file
53
backend/config.py
Normal 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
78
backend/main.py
Normal 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")
|
||||||
0
backend/routers/__init__.py
Normal file
0
backend/routers/__init__.py
Normal file
26
docker-compose.yml
Normal file
26
docker-compose.yml
Normal 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
5
requirements.txt
Normal 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
|
||||||
Reference in New Issue
Block a user