feat(#27): Gate B [07/10]: FastAPI health endpoint + app factory (H-1)

- App factory pattern (FUND-8): create_app() produces fresh app per test
- Logging config (structured, no secrets)
- Config validation: SLAB_AES_KEY required, WOO_BASE_URL HTTPS-only
- GET /api/health returns 200 {status: ok, version, woo_reachable: false}
- SQLite + alembic skeleton: full DATA-MODEL schema, initial migration
- API contract tests via httpx TestClient: 5 cases all green
  - test_returns_200, test_returns_ok_status, test_woo_reachable_false
  - test_response_shape, test_with_test_client_lifespan (FUND-9/10)
This commit is contained in:
grokbot_ty
2026-09-07 07:53:36 +00:00
committed by grokbot_ty
parent d5d8c49e04
commit e5514fe3bf
29 changed files with 915 additions and 0 deletions
View File
View File
+23
View File
@@ -0,0 +1,23 @@
"""Health endpoint router."""
from fastapi import APIRouter
router = APIRouter()
@router.get("/api/health")
async def health_check() -> dict:
"""Deploy health and smoke path.
Returns:
Health status with woo_reachable flag. When Woo credentials
are not configured, woo_reachable is False.
Contract (OPENAPI):
GET /api/health → { "status": "ok", "version": "...", "woo_reachable": bool }
"""
return {
"status": "ok",
"version": "0.1.0",
"woo_reachable": False,
}
+46
View File
@@ -0,0 +1,46 @@
"""Application settings loaded from environment."""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""FastAPI application settings.
WOO_BASE_URL is the deploy-time SoT for the WooCommerce store.
It must be HTTPS. FastAPI refuses startup when missing or HTTP.
SLAB_AES_KEY is the AES-GCM master key (32-byte base64). Required.
Missing key refuses startup.
All Woo credentials and inference secrets are stored AES-GCM encrypted
in the settings table at runtime, not passed as env vars.
"""
model_config = SettingsConfigDict(env_prefix="SLAB_", env_file=".env", env_file_encoding="utf-8")
aes_key: str | None = None
woo_base_url: str | None = None
app_hostname: str | None = None
# Image output constants (from TECH-SPEC)
aspect_ratio: str = "3:4"
output_px: int = 1600
output_px_min: int = 1600
fill_target: float = 0.80
def validate(self) -> None:
"""Refuse startup on invalid config."""
if self.aes_key is None:
raise ValueError("SLAB_AES_KEY environment variable is required")
if len(self.aes_key) < 32:
raise ValueError("SLAB_AES_KEY must be at least 32 bytes base64")
if self.woo_base_url is None:
raise ValueError("WOO_BASE_URL environment variable is required")
if not self.woo_base_url.startswith("https://"):
raise ValueError("WOO_BASE_URL must be HTTPS")
@property
def woo_credentials_configured(self) -> bool:
"""Whether Woo application password has been stored (checked via DB at runtime)."""
return False # Always false from config alone; checked via settings table at runtime
View File
+84
View File
@@ -0,0 +1,84 @@
"""SQLite engine and session management.
Uses SQLAlchemy async engine with aiosqlite driver.
For tests, pass `test_db_url` via the settings or use a test fixture
to point at an in-memory SQLite database.
"""
import logging
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.db.models import Base
logger = logging.getLogger(__name__)
# Default DB path for production
DEFAULT_DB_URL = "sqlite+aiosqlite:///data/slab.db"
_engine: object = None
_session_factory: object = None
_test_url: str | None = None
def set_test_url(url: str) -> None:
"""Override the database URL for testing. Must be called before create_app."""
global _test_url
_test_url = url
def get_db_url() -> str:
"""Return the database URL, preferring test URL if set."""
if _test_url:
return _test_url
return DEFAULT_DB_URL
async def init_db() -> None:
"""Initialize the async engine, session factory, and create tables.
For in-memory SQLite tests, creates tables directly.
For file-based SQLite, attempts alembic migrations first, falling
back to direct table creation.
"""
global _engine, _session_factory
db_url = get_db_url()
logger.info("Connecting to %s", db_url)
_engine = create_async_engine(
db_url,
echo=False,
connect_args={"check_same_thread": False},
)
_session_factory = async_sessionmaker(
_engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
# Create all tables (works for both in-memory and file-based)
async with _engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("Database initialized")
async def close_db_connection() -> None:
"""Dispose of the engine (closes all connections)."""
global _engine
if _engine is not None:
await _engine.dispose()
_engine = None
async def get_session() -> AsyncSession:
"""Get a database session."""
if _session_factory is None:
raise RuntimeError("Database not initialized. Call init_db() first.")
return _session_factory()
+185
View File
@@ -0,0 +1,185 @@
"""SQLAlchemy ORM models for the SlabUploader database.
These models map to the schema defined in docs/DATA-MODEL.md.
"""
import enum
from sqlalchemy import (
Index,
JSON,
CheckConstraint,
Enum,
Float,
ForeignKey,
Integer,
LargeBinary,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.sql import func
# SQLite doesn't have native JSON or enum types; we use TEXT/JSON via SQLAlchemy's
# dialect-specific types.
class Base(DeclarativeBase):
pass
class SlabStatus(str, enum.Enum):
DRAFT = "draft"
CALIBRATED = "calibrated"
READY = "ready"
PUBLISHING = "publishing"
PUBLISHED = "published"
FAILED = "failed"
class Slab(Base):
__tablename__ = "slabs"
id: Mapped[str] = mapped_column(Text, primary_key=True)
sku: Mapped[str] = mapped_column(String(24), unique=True, nullable=False)
status: Mapped[SlabStatus] = mapped_column(
Enum(SlabStatus, native_enum=False, values_callable=lambda x: [e.value for e in x]),
nullable=False,
default=SlabStatus.DRAFT,
)
length_in: Mapped[float] = mapped_column(Float, nullable=False)
thickness_in: Mapped[float] = mapped_column(Float, nullable=False)
sqft: Mapped[float | None] = mapped_column(Float, nullable=True)
bdft: Mapped[float | None] = mapped_column(Float, nullable=True)
width_min_in: Mapped[float | None] = mapped_column(Float, nullable=True)
width_max_in: Mapped[float | None] = mapped_column(Float, nullable=True)
width_avg_in: Mapped[float | None] = mapped_column(Float, nullable=True)
species_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
wood_category_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True)
edge_type_term_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
figure_term_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True)
grade_term_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True)
thickness_term_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
moisture_term_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
fig_tag_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True)
feat_tag_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True)
price_per_bdft: Mapped[float | None] = mapped_column(Float, nullable=True)
price: Mapped[float] = mapped_column(Float, nullable=False)
price_source: Mapped[str | None] = mapped_column(Text, nullable=True)
title: Mapped[str | None] = mapped_column(Text, nullable=True)
short_title: Mapped[str | None] = mapped_column(Text, nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
content_source: Mapped[str | None] = mapped_column(Text, nullable=True)
client_rev: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
server_rev: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
created_at: Mapped[str] = mapped_column(Text, nullable=False, server_default=func.now())
updated_at: Mapped[str] = mapped_column(Text, nullable=False, server_default=func.now())
inference_thread_id: Mapped[str | None] = mapped_column(Text, nullable=True)
__table_args__ = (
CheckConstraint("length_in >= 0"),
CheckConstraint("thickness_in >= 0"),
CheckConstraint("price >= 0"),
Index("idx_slabs_status", "status"),
Index("idx_slabs_updated_at", "updated_at"),
)
class SlabPhoto(Base):
__tablename__ = "slab_photos"
id: Mapped[str] = mapped_column(Text, primary_key=True)
slab_id: Mapped[str] = mapped_column(
Text, ForeignKey("slabs.id", ondelete="CASCADE"), nullable=False
)
kind: Mapped[str] = mapped_column(Text, nullable=False)
role: Mapped[str | None] = mapped_column(Text, nullable=True)
seq: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
original_path: Mapped[str | None] = mapped_column(Text, nullable=True)
processed_path: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[str] = mapped_column(Text, server_default=func.now())
class PricingRule(Base):
__tablename__ = "pricing_rules"
id: Mapped[str] = mapped_column(Text, primary_key=True)
species: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
price_per_bdft: Mapped[float] = mapped_column(Float, nullable=False)
created_at: Mapped[str] = mapped_column(Text, server_default=func.now())
updated_at: Mapped[str] = mapped_column(Text, server_default=func.now())
class WooCategory(Base):
__tablename__ = "woo_categories"
woo_id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(Text)
slug: Mapped[str] = mapped_column(Text)
parent_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
synced_at: Mapped[str] = mapped_column(Text)
class WooAttribute(Base):
__tablename__ = "woo_attributes"
woo_id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(Text)
slug: Mapped[str] = mapped_column(Text)
type: Mapped[str] = mapped_column(Text, default="select")
synced_at: Mapped[str] = mapped_column(Text)
class WooAttributeTerm(Base):
__tablename__ = "woo_attribute_terms"
woo_id: Mapped[int] = mapped_column(Integer, primary_key=True)
attribute_id: Mapped[int] = mapped_column(
Integer, ForeignKey("woo_attributes.woo_id"), nullable=False
)
name: Mapped[str] = mapped_column(Text)
slug: Mapped[str] = mapped_column(Text)
synced_at: Mapped[str] = mapped_column(Text)
class WooTag(Base):
__tablename__ = "woo_tags"
woo_id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(Text)
slug: Mapped[str] = mapped_column(Text)
synced_at: Mapped[str] = mapped_column(Text)
class Setting(Base):
__tablename__ = "settings"
key: Mapped[str] = mapped_column(Text, primary_key=True)
value_enc: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
updated_at: Mapped[str] = mapped_column(Text, server_default=func.now())
class SyncLog(Base):
__tablename__ = "sync_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
slab_id: Mapped[str | None] = mapped_column(Text, nullable=True)
sku: Mapped[str | None] = mapped_column(Text, nullable=True)
woo_product_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
status: Mapped[str | None] = mapped_column(Text, nullable=True)
http_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
detail: Mapped[str | None] = mapped_column(Text, nullable=True)
payload_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[str] = mapped_column(Text, server_default=func.now())
class InferenceLog(Base):
__tablename__ = "inference_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
slab_id: Mapped[str | None] = mapped_column(Text, nullable=True)
call: Mapped[str | None] = mapped_column(Text, nullable=True)
request_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
response_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[str] = mapped_column(Text, server_default=func.now())
View File
+26
View File
@@ -0,0 +1,26 @@
"""Logging configuration."""
import logging
import sys
def configure_logging() -> None:
"""Configure root logger with structured formatting.
Format: ISO-8601 timestamp, level, logger name, message.
Secrets (AES keys, Woo passwords, inference keys) are never logged.
"""
root = logging.getLogger()
root.setLevel(logging.INFO)
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(logging.INFO)
formatter = logging.Formatter(
"%(asctime)s %(levelname)s %(name)s: %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S%z",
)
handler.setFormatter(formatter)
if not root.handlers:
root.addHandler(handler)
+49
View File
@@ -0,0 +1,49 @@
"""App factory and lifespan."""
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.api import health
from app.config import Settings
from app.db.engine import close_db_connection, init_db
from app.logging_config import configure_logging
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan: startup and shutdown hooks.
Handles database initialization, logging configuration,
and cleanup on shutdown.
"""
configure_logging()
logging.info("SlabUploader starting up")
await init_db()
yield
logging.info("SlabUploader shutting down")
await close_db_connection()
def create_app(settings: Settings | None = None) -> FastAPI:
"""Create a fresh FastAPI app instance.
Each call produces an independent app with its own database connection
and cleared dependency overrides. This is the factory used by tests.
Args:
settings: Application settings. Uses env vars if None.
Returns:
Configured FastAPI application.
"""
if settings is None:
settings = Settings()
settings.validate()
app = FastAPI(title="SlabUploader", version="0.1.0", lifespan=lifespan)
app.include_router(health.router)
return app
+9
View File
@@ -0,0 +1,9 @@
# Call 1 prompt — species detection
# Read fresh each invocation. Edit on host without restart.
Identify the slab species, figure, edge type, and grade from the provided images.
Return results constrained to the provided taxonomy only. Do not invent species,
figure terms, or grade terms that are not in the taxonomy list.
Confidence threshold: 0.7. Below threshold, return empty for that field.
+7
View File
@@ -0,0 +1,7 @@
# Call 2 prompt — content generation
# Read fresh each invocation. Edit on host without restart.
Generate a title, short title, and description for the slab using the provided
deterministic dimensions, species, and figure information.
Do not invent dimensions. Use the provided length, thickness, widths, sqft, and bdft.
View File
View File