feat: phase 0 foundation — FastAPI scaffold + /healthz

Scaffold app/ package, pinned requirements.txt, multi-stage Dockerfile,
docker-compose.yml, and .env.example. Add typed pydantic-settings loader
with full env contract and a production validator that refuses the
dev-sentinel SECRET_KEY. Wire structlog with an APP_ENV-driven renderer
(console in dev, JSON in prod). Ship a minimal unauthenticated /healthz
returning {status, version, commit_sha} with commit SHA fed through a
GIT_COMMIT_SHA build arg.

Also mark Phase 0 complete in docs/ROADMAP.md.
This commit is contained in:
2026-04-21 14:44:41 -05:00
parent 22f357f3e8
commit 78dd1ac243
19 changed files with 653 additions and 9 deletions

14
app/__init__.py Normal file
View File

@@ -0,0 +1,14 @@
"""Chicken Babies R Us FastAPI application package.
This module intentionally keeps only the package version constant. All
runtime wiring lives in :mod:`app.main`.
"""
from __future__ import annotations
# Semantic version of the application. Bump per release. Surfaced via
# FastAPI's OpenAPI metadata and the /healthz endpoint so ops can verify
# which build is live in a given environment.
__version__: str = "0.1.0"
__all__ = ["__version__"]

149
app/config.py Normal file
View File

@@ -0,0 +1,149 @@
"""Typed application configuration loader.
Loads environment variables (with `.env` support for local development)
into a strongly-typed :class:`Settings` instance via `pydantic-settings`.
The full environment contract lives here so every consumer of configuration
can import :func:`get_settings` and get a validated object. Values that
aren't needed until later roadmap phases are declared as :data:`Optional`
so the app boots cleanly in Phase 0 without mandating yet-unused secrets.
"""
from __future__ import annotations
from functools import lru_cache
from typing import Literal, Optional
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# Sentinel value for the SECRET_KEY in .env.example. The production
# validator below refuses to start the app if this sentinel survives into
# a production deployment. Keeping it as a named constant makes the
# security control explicit and searchable.
_DEV_SECRET_KEY_SENTINEL: str = "dev-insecure-change-me"
class Settings(BaseSettings):
"""Strongly-typed runtime configuration.
Populated from process environment variables and (for local dev) an
optional `.env` file. All fields correspond 1:1 with the env-var
contract documented in ``docs/ROADMAP.md`` and ``.env.example``.
"""
# Pydantic-settings config: read from .env when present, ignore unknown
# keys (so adding new vars to .env ahead of code changes is non-fatal),
# and treat env var names case-insensitively.
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# --- Runtime mode ------------------------------------------------------
app_env: Literal["development", "production"] = Field(
default="development",
description="Runtime mode; gates debug behavior and logging renderer.",
)
# --- Signing / sessions -----------------------------------------------
# Used by itsdangerous for signed cookies and CSRF tokens. The default
# is intentionally a well-known sentinel so local dev just works; the
# model validator below blocks it from reaching production.
secret_key: str = Field(
default=_DEV_SECRET_KEY_SENTINEL,
description="itsdangerous signer key; MUST be overridden in production.",
)
# --- Database ----------------------------------------------------------
database_url: str = Field(
default="sqlite:///data/app.db",
description="SQLAlchemy URL for the application database.",
)
# --- Email (Resend) ----------------------------------------------------
# Optional at Phase 0: contact form and magic-link delivery come online
# in Phases 3 and 5 respectively.
resend_api_key: Optional[str] = Field(default=None)
resend_from: Optional[str] = Field(default=None)
# --- Admin allowlist / contact routing ---------------------------------
# Comma-separated list; stored raw so we preserve exact input for audit,
# and split on demand via the `admin_emails_list` property.
admin_emails: str = Field(
default="",
description="Comma-separated allowlist of admin email addresses.",
)
admin_contact_email: Optional[str] = Field(default=None)
# --- hCaptcha ----------------------------------------------------------
hcaptcha_site_key: Optional[str] = Field(default=None)
hcaptcha_secret: Optional[str] = Field(default=None)
# --- Reverse proxy -----------------------------------------------------
# Only requests whose peer IP is in this list will have their
# X-Forwarded-* headers trusted by Uvicorn's proxy-headers support.
forwarded_allow_ips: str = Field(default="127.0.0.1")
# --- Session / auth tuning --------------------------------------------
session_max_days: int = Field(default=30, ge=1, le=365)
magic_link_ttl_min: int = Field(default=15, ge=1, le=60)
# --- Build metadata ----------------------------------------------------
# Injected at Docker build time via an ARG/ENV. Surfaced via /healthz so
# operators can confirm which build is live.
git_commit_sha: str = Field(default="unknown")
# ----------------------------------------------------------------------
# Derived helpers
# ----------------------------------------------------------------------
@property
def admin_emails_list(self) -> list[str]:
"""Return the admin allowlist as a clean list of lowercase strings.
Splits ``admin_emails`` on commas, strips whitespace, drops empty
tokens, and lowercases — matching typical email canonicalization
for allowlist comparisons. The raw string is preserved on the
instance for audit / diagnostic purposes.
"""
return [
part.strip().lower()
for part in self.admin_emails.split(",")
if part.strip()
]
# ----------------------------------------------------------------------
# Validators
# ----------------------------------------------------------------------
@model_validator(mode="after")
def _refuse_dev_secret_in_production(self) -> "Settings":
"""Fail fast if the dev sentinel SECRET_KEY reaches production.
Security control: prevents accidental deployment with the
publicly-documented placeholder secret, which would make every
signed cookie / CSRF token forgeable by anyone with the source.
"""
if (
self.app_env == "production"
and self.secret_key == _DEV_SECRET_KEY_SENTINEL
):
raise ValueError(
"SECRET_KEY must be overridden in production; "
"the dev sentinel value is not permitted."
)
return self
@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""Return a process-wide cached :class:`Settings` instance.
Using :func:`functools.lru_cache` ensures the environment is parsed
exactly once per process, which is both cheaper and — more importantly
— guarantees every caller sees the same validated values. Tests that
need a fresh parse can call ``get_settings.cache_clear()``.
"""
return Settings()

72
app/logging_config.py Normal file
View File

@@ -0,0 +1,72 @@
"""Structlog initialization.
Single entry point :func:`configure_logging` sets up structlog with an
``APP_ENV``-driven renderer: a pretty console renderer during development
and JSON-lines output in production so logs plug straight into any log
aggregator. Must be called exactly once at app startup, before any
module obtains a logger via ``structlog.get_logger()``.
"""
from __future__ import annotations
import logging
import sys
from typing import Any
import structlog
def configure_logging(app_env: str) -> None:
"""Configure structlog + stdlib logging for the application.
Parameters
----------
app_env:
The resolved ``APP_ENV`` value. ``"development"`` selects a
colorized console renderer; anything else (including
``"production"``) selects the JSON renderer so logs are
machine-parseable.
Notes
-----
This function is idempotent within a process: structlog accepts
repeated calls to :func:`structlog.configure`. It deliberately keeps
the setup small for Phase 0 — a proper ``ProcessorFormatter`` bridge
between stdlib and structlog can be layered in later without touching
call sites.
"""
# Route stdlib logging through a root handler writing to stdout. Our
# own loggers go through structlog's pipeline; third-party libraries
# that use the stdlib logger will at least surface at INFO.
logging.basicConfig(
level=logging.INFO,
stream=sys.stdout,
format="%(message)s",
)
# Processors shared across environments. Order matters: contextvars
# first so bound context is present for every later step; exception
# info extraction before rendering so tracebacks serialize cleanly.
shared_processors: list[Any] = [
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
]
# Environment-specific final renderer. Dev gets human-friendly
# colorized output; everywhere else emits JSON lines.
if app_env == "development":
final_renderer: Any = structlog.dev.ConsoleRenderer(colors=True)
else:
final_renderer = structlog.processors.JSONRenderer()
structlog.configure(
processors=[*shared_processors, final_renderer],
# PrintLoggerFactory writes to stdout without requiring a stdlib
# logger bridge; sufficient for Phase 0.
logger_factory=structlog.PrintLoggerFactory(),
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
cache_logger_on_first_use=True,
)

66
app/main.py Normal file
View File

@@ -0,0 +1,66 @@
"""FastAPI application factory and module-level app instance.
The factory pattern (``create_app``) keeps test setup straightforward and
lets us swap in alternate configurations without module-level side
effects. ``app = create_app()`` at import time is what Uvicorn references
via ``app.main:app``.
"""
from __future__ import annotations
import structlog
from fastapi import FastAPI
from app import __version__
from app.config import get_settings
from app.logging_config import configure_logging
from app.routes.health import router as health_router
def create_app() -> FastAPI:
"""Build and return the FastAPI application.
Responsibilities:
- Load validated configuration via :func:`get_settings`.
- Initialize structured logging *before* any logger is used.
- Instantiate FastAPI with canonical title + version.
- Register routers (Phase 0: only ``/healthz``).
- Emit a single ``app_started`` structured log event.
"""
# Parse + validate configuration first so a bad environment fails fast
# with a clear pydantic error before we touch logging / FastAPI.
settings = get_settings()
# Configure structlog *before* acquiring any logger in the app so the
# very first log line already flows through our processor chain.
configure_logging(settings.app_env)
application = FastAPI(
title="Chicken Babies R Us",
version=__version__,
# Docs stay on for Phase 0; a later phase can gate these behind
# admin auth or disable them entirely in production.
docs_url="/docs",
redoc_url="/redoc",
)
# Register routers. Kept explicit (no dynamic discovery) so the set of
# mounted endpoints is trivially auditable.
application.include_router(health_router)
# Single structured startup event. Do NOT include secret material.
logger = structlog.get_logger(__name__)
logger.info(
"app_started",
app_env=settings.app_env,
version=__version__,
commit_sha=settings.git_commit_sha,
)
return application
# Module-level ASGI handle. Uvicorn / gunicorn import this as
# ``app.main:app``. Building it at import time is intentional: it fails
# loudly at container start if configuration is invalid.
app: FastAPI = create_app()

7
app/models/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
"""Domain models and persistence mappers.
Populated in Phase 2 with dataclasses + SQL schema per
``docs/ROADMAP.md``. Intentionally empty in Phase 0.
"""
from __future__ import annotations

8
app/routes/__init__.py Normal file
View File

@@ -0,0 +1,8 @@
"""HTTP route packages.
Routers live as sibling modules and are wired into the app in
:mod:`app.main`. Phase 0 only exposes ``health``; public, admin, and auth
routers arrive in later phases.
"""
from __future__ import annotations

69
app/routes/health.py Normal file
View File

@@ -0,0 +1,69 @@
"""Liveness / version endpoint.
The ``/healthz`` endpoint is intentionally minimal: it must be safe to
expose unauthenticated (Caddy + uptime checks will hit it), so it leaks
only the app version and the build's git commit SHA — no hostnames,
paths, config values, or environment details.
"""
from __future__ import annotations
from typing import Literal
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from app import __version__
from app.config import Settings, get_settings
class HealthResponse(BaseModel):
"""Response schema for ``GET /healthz``.
Kept as an explicit :class:`pydantic.BaseModel` so FastAPI publishes
the shape in the OpenAPI schema and so any drift is caught at typing
time rather than via a loose ``dict``.
"""
status: Literal["ok"] = Field(
default="ok",
description="Liveness indicator; this endpoint only returns 'ok'.",
)
version: str = Field(description="Application semantic version.")
commit_sha: str = Field(
description="Git commit SHA of the running build ('unknown' in dev).",
)
# Module-level router; mounted by `app.main.create_app`. No prefix and no
# auth dependencies — /healthz is intentionally public.
router: APIRouter = APIRouter(tags=["health"])
@router.get(
"/healthz",
response_model=HealthResponse,
summary="Liveness + version probe",
)
def healthz(settings: Settings = Depends(get_settings)) -> HealthResponse:
"""Return a minimal liveness envelope.
Parameters
----------
settings:
Injected via FastAPI's dependency system so tests can override
configuration cleanly.
Returns
-------
HealthResponse
``status`` is always ``"ok"``; ``version`` and ``commit_sha``
identify the running build.
"""
# Intentionally does not touch the DB, filesystem, or any external
# service. This is liveness, not readiness — a readiness probe with
# deeper checks can be added in a later phase behind a different path.
return HealthResponse(
version=__version__,
commit_sha=settings.git_commit_sha,
)

7
app/services/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
"""Application services (auth, email, cache, markdown, media, hcaptcha).
Populated phase-by-phase per ``docs/ROADMAP.md``. Intentionally empty in
Phase 0.
"""
from __future__ import annotations

0
app/static/.gitkeep Normal file
View File

0
app/templates/.gitkeep Normal file
View File