From fe0a311d39a4d085b7f4a226da9d6fb65ab0d098 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 14 May 2026 07:32:17 -0400 Subject: [PATCH] feat: add Quart app factory, config loader, and /api/health endpoint Config reads from env vars (12-factor); .env.example documents defaults. Health endpoint is liveness-only (no DB/Redis touch). Test added for CI. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 24 +++++++++++++++++ backend/app/__init__.py | 19 ++++++++++++++ backend/app/api/__init__.py | 8 ++++++ backend/app/api/health.py | 5 ++++ backend/app/config.py | 52 +++++++++++++++++++++++++++++++++++++ backend/app/extensions.py | 14 ++++++++++ tests/__init__.py | 0 tests/test_health.py | 20 ++++++++++++++ 8 files changed, 142 insertions(+) create mode 100644 .env.example create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/health.py create mode 100644 backend/app/config.py create mode 100644 backend/app/extensions.py create mode 100644 tests/__init__.py create mode 100644 tests/test_health.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d78fd8f --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# Database +DB_USER=fabledcurator +DB_PASSWORD=changeme_use_a_real_password +DB_HOST=postgres +DB_PORT=5432 +DB_NAME=fabledcurator + +# Redis / Celery +CELERY_BROKER_URL=redis://redis:6379/0 +CELERY_RESULT_BACKEND=redis://redis:6379/0 + +# App +# Generate with: openssl rand -hex 32 +SECRET_KEY=changeme_32_byte_hex_secret + +# Extension API key — used in FC-3, lands later but reserved now +# Generate with: openssl rand -hex 32 +EXTENSION_API_KEY= + +# Logging +LOG_LEVEL=INFO + +# Deployment posture: plain HTTP (no TLS in the app; reverse proxy if needed) +# See docs/superpowers/specs/2026-05-13-fabledcurator-merge-design.md §2.1 diff --git a/backend/app/__init__.py b/backend/app/__init__.py index e69de29..d1081c2 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -0,0 +1,19 @@ +"""Quart app factory.""" + +import logging + +from quart import Quart + +from .api import api_bp +from .config import get_config + + +def create_app() -> Quart: + cfg = get_config() + logging.basicConfig(level=cfg.log_level) + + app = Quart(__name__) + app.secret_key = cfg.secret_key + app.register_blueprint(api_bp) + + return app diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..c456a71 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1,8 @@ +"""API blueprint registration.""" + +from quart import Blueprint + +from . import health + +api_bp = Blueprint("api", __name__, url_prefix="/api") +api_bp.add_url_rule("/health", view_func=health.get_health, methods=["GET"]) diff --git a/backend/app/api/health.py b/backend/app/api/health.py new file mode 100644 index 0000000..ab4918e --- /dev/null +++ b/backend/app/api/health.py @@ -0,0 +1,5 @@ +"""Health endpoint — no DB or Redis touch; just liveness.""" + + +async def get_health(): + return {"status": "ok"}, 200 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..2338f25 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,52 @@ +"""Application configuration loaded from environment variables.""" + +import os +from dataclasses import dataclass +from functools import lru_cache + + +@dataclass(frozen=True) +class Config: + db_user: str + db_password: str + db_host: str + db_port: int + db_name: str + + celery_broker_url: str + celery_result_backend: str + + secret_key: str + extension_api_key: str # used by the Firefox extension; lands in FC-3 but read here + log_level: str + + @property + def database_url(self) -> str: + return ( + f"postgresql+asyncpg://{self.db_user}:{self.db_password}" + f"@{self.db_host}:{self.db_port}/{self.db_name}" + ) + + @property + def database_url_sync(self) -> str: + # Alembic uses sync driver + return ( + f"postgresql+psycopg://{self.db_user}:{self.db_password}" + f"@{self.db_host}:{self.db_port}/{self.db_name}" + ) + + +@lru_cache(maxsize=1) +def get_config() -> Config: + return Config( + db_user=os.environ.get("DB_USER", "fabledcurator"), + db_password=os.environ["DB_PASSWORD"], + db_host=os.environ.get("DB_HOST", "postgres"), + db_port=int(os.environ.get("DB_PORT", "5432")), + db_name=os.environ.get("DB_NAME", "fabledcurator"), + celery_broker_url=os.environ.get("CELERY_BROKER_URL", "redis://redis:6379/0"), + celery_result_backend=os.environ.get("CELERY_RESULT_BACKEND", "redis://redis:6379/0"), + secret_key=os.environ["SECRET_KEY"], + extension_api_key=os.environ.get("EXTENSION_API_KEY", ""), + log_level=os.environ.get("LOG_LEVEL", "INFO"), + ) diff --git a/backend/app/extensions.py b/backend/app/extensions.py new file mode 100644 index 0000000..3594b90 --- /dev/null +++ b/backend/app/extensions.py @@ -0,0 +1,14 @@ +"""Singleton extension instances; bound to the app in create_app().""" + +from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine + +from .config import get_config + + +def make_engine() -> AsyncEngine: + cfg = get_config() + return create_async_engine(cfg.database_url, pool_pre_ping=True, future=True) + + +def make_session_factory(engine: AsyncEngine) -> async_sessionmaker: + return async_sessionmaker(engine, expire_on_commit=False) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..d4eb411 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,20 @@ +"""Health endpoint smoke test.""" + +import pytest + +from backend.app import create_app + + +@pytest.fixture +async def client(): + app = create_app() + async with app.test_client() as c: + yield c + + +@pytest.mark.asyncio +async def test_health_returns_ok(client): + response = await client.get("/api/health") + assert response.status_code == 200 + body = await response.get_json() + assert body == {"status": "ok"}