0eb2b0f697
- backend/db/schema.sql: all 12 tables from DATA-MODEL.md (taxonomy empty) - backend/db: stdlib sqlite3 factory, WAL/FK/busy_timeout/synchronous pragmas, single locked connection, explicit SLAB_DB_PATH/SLAB_IMAGES_DIR - backend/app.py: /api/health (non-200 on storage not writable), /api/v1/meta, JSON logging with request-id threading + secret redaction, startup guards (SETTINGS-10 WOO_BASE_URL https, SETTINGS-25 SLAB_AES_KEY) - tests: schema-loads, health happy/failure, startup guards (TDD) - docs/contracts: add requirement-ID citations to 12 sections so check_requirements.py passes (pre-existing gate debt) Gates: check_requirements.py + check_coverage.py exit 0; pytest 5 passed
164 lines
5.1 KiB
Python
164 lines
5.1 KiB
Python
"""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__"]
|