"""S0 FastAPI app foundation (APP-30, APP-45, APP-55, APP-65, SETTINGS-10/25, OPS-25). Base /api/v1; health at /api/health (non-200 on storage not writable). Stdlib JSON logging with request_id from X-Request-Id (generate if absent). Startup guards refuse on bad WOO_BASE_URL or missing SLAB_AES_KEY. No secrets in logs. Minimal, no S1+. """ from __future__ import annotations import json import logging import os import sys import uuid from contextvars import ContextVar from pathlib import Path from typing import Any from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from starlette.middleware.base import BaseHTTPMiddleware from .db import get_connection, init_db, probe_images_writable, probe_writable __version__ = "0.5.0-m3-s0" # --- Structured JSON logging (stdlib only) --- request_id_var: ContextVar[str] = ContextVar("request_id", default="") class _JsonFormatter(logging.Formatter): """JSON lines; redacts any secret-looking content (APP-65).""" def format(self, record: logging.LogRecord) -> str: rid = getattr(record, "request_id", None) or request_id_var.get("") msg = record.getMessage() # Redact secrets: never log creds, keys, aes material lower = msg.lower() if any(k in lower for k in ("password", "api_key", "aes_key", "slab_aes_key", "woo_app_password", "inference_api_key")): msg = "[REDACTED secret material]" data: dict[str, Any] = { "ts": self.formatTime(record, self.datefmt), "level": record.levelname, "logger": record.name, "request_id": rid, "msg": msg, } if record.exc_info: data["exc"] = self.formatException(record.exc_info) return json.dumps(data, ensure_ascii=False, separators=(",", ":")) def _configure_logging() -> None: level = os.getenv("LOG_LEVEL", "INFO").upper() handler = logging.StreamHandler(sys.stdout) handler.setFormatter(_JsonFormatter()) root = logging.getLogger() root.handlers.clear() root.addHandler(handler) root.setLevel(getattr(logging, level, logging.INFO)) # uvicorn access logs also json? for S0 keep simple; they inherit logging.getLogger("uvicorn.access").setLevel(logging.WARNING) _configure_logging() logger = logging.getLogger("slab.backend") # --- Startup guards (refuse with clear message) --- def _enforce_startup_guards() -> None: woo_url = os.getenv("WOO_BASE_URL") if not woo_url: msg = "SETTINGS-10: WOO_BASE_URL is required (https://...)" logger.error(msg) raise RuntimeError(msg) if not woo_url.startswith("https://"): msg = "SETTINGS-10: WOO_BASE_URL must start with https:// (got: " + woo_url[:8] + ")" logger.error(msg) raise RuntimeError(msg) if not os.getenv("SLAB_AES_KEY"): msg = "SETTINGS-25: SLAB_AES_KEY is required (32-byte base64 in env/compose)" logger.error(msg) raise RuntimeError(msg) # db/images envs are required by db.py on use; guards here only per spec _enforce_startup_guards() # --- Request ID middleware (threads X-Request-Id or generates) --- class RequestIdMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): rid = request.headers.get("X-Request-Id") or str(uuid.uuid4()) token = request_id_var.set(rid) try: response = await call_next(request) response.headers["X-Request-Id"] = rid return response finally: request_id_var.reset(token) # --- App --- @asynccontextmanager async def lifespan(app: FastAPI): logger.info("startup: applying schema (idempotent)") try: init_db() logger.info("startup: schema ready; db+images envs present") except Exception: logger.exception("startup failed") raise yield # shutdown: reset conn for test isolation if needed try: from .db import _reset_for_tests _reset_for_tests() except Exception: pass app = FastAPI( title="SlabUploader", version=__version__, docs_url=None, redoc_url=None, lifespan=lifespan, ) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=False, allow_methods=["*"], allow_headers=["*"], ) app.add_middleware(RequestIdMiddleware) @app.get("/api/health") async def health() -> dict[str, Any]: """Health per APP-30/45: non-200 when db or image volume not writable (probe file).""" db_ok = probe_writable() img_ok = probe_images_writable() if not (db_ok and img_ok): logger.warning("health: storage not writable", extra={"request_id": request_id_var.get("")}) raise HTTPException(status_code=503, detail="storage not writable") return { "status": "ok", "version": __version__, "woo_reachable": False, # S0 stub; S4 will implement real check } @app.get("/api/v1/meta") async def meta() -> dict[str, Any]: """Per API.md ยง3.""" return {"version": __version__} # expose for tests __all__ = ["app", "__version__"]