Files
Ty 0c00d25cc7 fix(m3): Option A must-fix #79 #82 #83 #84 #89 #98
- #79 publish SKU/create via WooClient (User-Agent + typed 403 unrecoverable)
- #82 stale_field current_options from live taxonomy + owner labels
- #83 create allowlist/extra-key safe; taxonomy+enum 4xx on create
- #84 health woo_reachable real probe (non-failing); version 0.5.0-m3
- #89 review persists assembled title/short after taxonomy lock; UI applies
- #98 live-edge from synced term; attrs resolved by slug then name
2026-09-21 12:55:17 -07:00

303 lines
9.9 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 fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from .db import get_connection, init_db, probe_images_writable, probe_writable
from . import settings as settings_mod
__version__ = "0.5.0-m3"
# --- 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.exception_handler(HTTPException)
async def _unwrap_error(request: Request, exc: HTTPException):
if isinstance(exc.detail, dict) and "error" in exc.detail:
return JSONResponse(status_code=exc.status_code, content=exc.detail)
return JSONResponse(status_code=exc.status_code, content={"error": {"code": "validation" if exc.status_code == 400 else "internal", "message": str(exc.detail), "detail": None}})
def _error(code: str, message: str, detail: Any = None) -> dict:
return {"error": {"code": code, "message": message, "detail": detail}}
@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).
#84: woo_reachable is a real authenticated probe (signal only — store outage
does not fail health).
"""
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": _probe_woo_reachable(),
}
def _probe_woo_reachable() -> bool:
"""Cheap non-failing Woo reachability probe for health (SETTINGS-40 style).
Storage health never depends on this result. Missing creds → False.
"""
try:
base, username, enc_password = _woo_credentials()
if not base or not username or not enc_password:
return False
client = WooClient.from_settings(base, username, enc_password)
# One lightweight authenticated call (categories page 1).
code, _h, _b = client._get("/products/categories?per_page=1&page=1")
return code == 200
except Exception:
return False
@app.get("/api/v1/meta")
async def meta() -> dict[str, Any]:
"""Per API.md §3."""
return {"version": __version__}
@app.get("/api/v1/settings")
async def get_settings() -> dict[str, Any]:
return settings_mod.get_settings()
@app.put("/api/v1/settings")
async def put_settings(payload: dict[str, Any]) -> dict[str, Any]:
return settings_mod.update_settings(payload)
from . import taxonomy as taxonomy_mod
from .woo import (
WooClient,
WooAuthError,
WooConnectionError,
WooCredentialsMissing,
WooError,
WooForbiddenError,
)
def _woo_credentials() -> tuple[str, str | None, str | None]:
conn = get_connection()
base = os.environ.get("WOO_BASE_URL", "")
user_row = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone()
pass_row = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone()
return base, (user_row[0] if user_row else None), (pass_row[0] if pass_row else None)
@app.post("/api/v1/settings/test-woo")
async def test_woo() -> dict[str, Any]:
base, username, enc_password = _woo_credentials()
try:
client = WooClient.from_settings(base, username, enc_password)
except WooCredentialsMissing as e:
return {
"ok": False,
"detail": str(e),
"taxonomy_anchor": None,
"taxonomy_last_sync_at": None,
}
try:
result = taxonomy_mod.sync(client, get_connection())
except WooAuthError as e:
return {"ok": False, "detail": f"auth rejected by store: {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
except WooForbiddenError as e:
return {"ok": False, "detail": f"request blocked by store (403): {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
except WooConnectionError as e:
return {"ok": False, "detail": f"store unreachable: {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
except WooError as e:
return {"ok": False, "detail": str(e), "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
return {
"ok": True,
"detail": "taxonomy synced",
"taxonomy_anchor": result["taxonomy_anchor"],
"taxonomy_last_sync_at": result["taxonomy_last_sync_at"],
}
@app.get("/api/v1/admin/taxonomy")
async def get_taxonomy() -> dict[str, Any]:
return taxonomy_mod.get_cached(get_connection())
@app.post("/api/v1/admin/taxonomy/sync")
async def post_taxonomy_sync() -> dict[str, Any]:
base, username, enc_password = _woo_credentials()
try:
client = WooClient.from_settings(base, username, enc_password)
except WooCredentialsMissing as e:
return JSONResponse(status_code=502, content=_error("woo_unavailable", str(e)))
try:
result = taxonomy_mod.sync(client, get_connection())
except WooError as e:
return JSONResponse(status_code=502, content=_error("woo_unavailable", str(e)))
return {
"ok": True,
"detail": "taxonomy synced",
"taxonomy_anchor": result["taxonomy_anchor"],
"taxonomy_last_sync_at": result["taxonomy_last_sync_at"],
}
# expose for tests
from .slabs import router as slabs_router
app.include_router(slabs_router)
from .photos import router as photos_router
app.include_router(photos_router)
from .pricing import router as pricing_router
app.include_router(pricing_router)
from .review import router as review_router
app.include_router(review_router)
from .publish import router as publish_router
app.include_router(publish_router)
from .inference import router as inference_router
app.include_router(inference_router)
# expose for tests
__all__ = ["app", "__version__"]