Scaffold FastAPI backend skeleton with health probes
Phase 0 foundation. uv-managed FastAPI app (package=false, runs from source via uv run). Layered seams in place: app/api for routers, app/core for config (pydantic-settings, fully env-driven) and the async SQLAlchemy engine; service/repository/domain layers land with the data model. Exposes /health (liveness) and /health/ready (Postgres reachability via SELECT 1, 503 on failure) so the deploy wiring is verifiable before any data model exists. Includes a liveness test and the resolved uv.lock. Ignore pytest/ruff/mypy caches. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
"""Liveness and readiness endpoints.
|
||||
|
||||
- ``/health`` — liveness: the process is up. No dependencies touched.
|
||||
- ``/health/ready`` — readiness: dependencies (Postgres) are reachable.
|
||||
|
||||
Orchestrators and Caddy probe these; they are intentionally outside the
|
||||
versioned ``/api`` surface.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Response, status
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.db import get_engine
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health() -> dict:
|
||||
settings = get_settings()
|
||||
return {
|
||||
"status": "ok",
|
||||
"service": settings.app_name,
|
||||
"version": settings.version,
|
||||
"env": settings.app_env,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health/ready")
|
||||
async def ready(response: Response) -> dict:
|
||||
checks: dict[str, str] = {}
|
||||
try:
|
||||
async with get_engine().connect() as conn:
|
||||
await conn.execute(text("SELECT 1"))
|
||||
checks["database"] = "ok"
|
||||
return {"status": "ready", "checks": checks}
|
||||
except Exception as exc: # noqa: BLE001 — surface any failure as "not ready"
|
||||
checks["database"] = "error"
|
||||
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
return {"status": "not ready", "checks": checks, "detail": str(exc)}
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Application configuration.
|
||||
|
||||
Twelve-factor: everything is read from the environment. Defaults are
|
||||
development-friendly; production supplies real values via the compose `.env`.
|
||||
No secrets or endpoints are hard-coded.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
app_name: str = "Provenance"
|
||||
version: str = "0.0.0"
|
||||
app_env: str = Field(default="development", description="development | production")
|
||||
|
||||
# SQLAlchemy async URL, e.g. postgresql+asyncpg://user:pass@host:5432/db
|
||||
database_url: str = Field(
|
||||
default="postgresql+asyncpg://provenance:provenance@localhost:5432/provenance",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Async database engine.
|
||||
|
||||
A single lazily-created async engine for the process. The repository layer
|
||||
(coming with the data model) will build sessions on top of this; for now it
|
||||
backs the readiness probe.
|
||||
"""
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
_engine: AsyncEngine | None = None
|
||||
|
||||
|
||||
def get_engine() -> AsyncEngine:
|
||||
global _engine
|
||||
if _engine is None:
|
||||
_engine = create_async_engine(
|
||||
get_settings().database_url,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
return _engine
|
||||
@@ -0,0 +1,25 @@
|
||||
"""FastAPI application entrypoint.
|
||||
|
||||
Thin by design: wire settings and routers, expose the OpenAPI contract. All
|
||||
domain logic lives in the service layer (added with the data model). The
|
||||
versioned API will mount under ``/api/v1``; health probes stay at the root.
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api.health import router as health_router
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
version=settings.version,
|
||||
description="Provenance API — family and land provenance.",
|
||||
)
|
||||
app.include_router(health_router)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
Reference in New Issue
Block a user