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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"])
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Health endpoint — no DB or Redis touch; just liveness."""
|
||||||
|
|
||||||
|
|
||||||
|
async def get_health():
|
||||||
|
return {"status": "ok"}, 200
|
||||||
@@ -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"),
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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"}
|
||||||
Reference in New Issue
Block a user