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:
14
app/__init__.py
Normal file
14
app/__init__.py
Normal 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
149
app/config.py
Normal 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
72
app/logging_config.py
Normal 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
66
app/main.py
Normal 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
7
app/models/__init__.py
Normal 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
8
app/routes/__init__.py
Normal 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
69
app/routes/health.py
Normal 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
7
app/services/__init__.py
Normal 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
0
app/static/.gitkeep
Normal file
0
app/templates/.gitkeep
Normal file
0
app/templates/.gitkeep
Normal file
Reference in New Issue
Block a user