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:
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user