2f6335ec26
Prototype routing so 2–3 owners can share one store URL with different WP application passwords. Device creds live in localStorage; server uses request override → Settings fallback and never writes overrides into the Settings row. Inference stays on shared Settings. See PROTOTYPE.md for UAT with two browsers and known LAN forge risk.
304 lines
9.9 KiB
Python
304 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,
|
|
client_for_request,
|
|
settings_woo_creds,
|
|
)
|
|
|
|
|
|
def _woo_credentials() -> tuple[str, str | None, str | None]:
|
|
"""Shop-default Woo creds (Settings). Used by health probe only."""
|
|
return settings_woo_creds(get_connection())
|
|
|
|
|
|
@app.post("/api/v1/settings/test-woo")
|
|
async def test_woo(request: Request) -> dict[str, Any]:
|
|
"""Probe Woo + sync taxonomy. Optional X-Woo-Username / X-Woo-App-Password
|
|
override shop-default Settings for this request only (never persisted).
|
|
"""
|
|
try:
|
|
client = client_for_request(get_connection(), request.headers)
|
|
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(request: Request) -> dict[str, Any]:
|
|
"""Refresh taxonomy cache. Honors X-Woo-* override headers for this request."""
|
|
try:
|
|
client = client_for_request(get_connection(), request.headers)
|
|
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__"]
|