From ef385340e8ad1edab19757b038e3544e351506d2 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 08:38:42 -0700 Subject: [PATCH] =?UTF-8?q?feat(slice-1):=20FastAPI=20factory=20+=20/healt?= =?UTF-8?q?hz;=20self-migrate=20at=20startup=20(INV-1/=C2=A76.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/main.py | 50 +++++++++++++++++++++++++++++++++-- backend/app/platform/deps.py | 18 +++++++++++++ backend/tests/test_healthz.py | 24 +++++++++++++++++ 3 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 backend/app/platform/deps.py create mode 100644 backend/tests/test_healthz.py diff --git a/backend/app/main.py b/backend/app/main.py index 987c786..2386454 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,5 +1,51 @@ """ecomm backend — FastAPI app factory + the REST BFF. -Stub in SLICE-1 Task 1 so the top layer of the import-linter contract exists from the -start; the FastAPI factory and /healthz are added in Task 5. +SLICE-1 mounts only /healthz; the auth and storefront endpoints (§6.4) grow here in +later slices. create_app() opens the connection pool and self-migrates the database +at startup (INV-1, INV-7): the app boots against empty persistence and applies its +own schema — there is no seed step. """ +from __future__ import annotations + +from contextlib import asynccontextmanager + +import psycopg +from fastapi import Depends, FastAPI, Response + +from app.platform import config, db +from app.platform.deps import get_conn + + +def create_app(database_url: str | None = None) -> FastAPI: + dsn = database_url or config.database_url() + + @asynccontextmanager + async def lifespan(app: FastAPI): + app.state.pool = db.open_pool(dsn) + with app.state.pool.connection() as conn: + db.migrate(conn) # self-migrate at startup (INV-1, INV-7) + try: + yield + finally: + app.state.pool.close() + + app = FastAPI(title="ecomm", version="0.1", lifespan=lifespan) + + @app.get("/healthz") + def healthz(response: Response, conn: psycopg.Connection = Depends(get_conn)): + """Liveness + readiness: process up, DB reachable, migrations current (§6.4).""" + try: + conn.execute("SELECT 1") + pending = db.pending_migrations(conn) + except Exception: + response.status_code = 503 + return {"status": "unavailable", "reason": "database_unreachable"} + if pending: + response.status_code = 503 + return {"status": "unavailable", "reason": "migrations_pending"} + return {"status": "ok"} + + return app + + +app = create_app() diff --git a/backend/app/platform/deps.py b/backend/app/platform/deps.py new file mode 100644 index 0000000..36cdfca --- /dev/null +++ b/backend/app/platform/deps.py @@ -0,0 +1,18 @@ +"""FastAPI dependencies — per-request wiring (SD-0001 §6.2 platform/deps). + +Yields a pooled connection per request. The pool lives on app.state, built once in +create_app(). Later slices add the current-session and mailer dependencies here. +""" +from __future__ import annotations + +from collections.abc import Iterator + +import psycopg +from fastapi import Request + + +def get_conn(request: Request) -> Iterator[psycopg.Connection]: + """A pooled connection for the duration of one request.""" + pool = request.app.state.pool + with pool.connection() as conn: + yield conn diff --git a/backend/tests/test_healthz.py b/backend/tests/test_healthz.py new file mode 100644 index 0000000..0dc58ba --- /dev/null +++ b/backend/tests/test_healthz.py @@ -0,0 +1,24 @@ +from fastapi.testclient import TestClient + +from app.main import create_app + + +def test_healthz_ok_on_migrated_empty_db(fresh_db_url): + app = create_app(database_url=fresh_db_url) + with TestClient(app) as client: + resp = client.get("/healthz") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + + +def test_startup_migrates_from_empty(fresh_db_url): + # create_app on an empty DB must self-migrate so /healthz reports current. + app = create_app(database_url=fresh_db_url) + with TestClient(app) as client: + assert client.get("/healthz").status_code == 200 + # the schema_migrations row exists -> migrations ran at startup + import psycopg + + with psycopg.connect(fresh_db_url) as conn: + n = conn.execute("SELECT count(*) FROM schema_migrations").fetchone()[0] + assert n >= 1