SLICE-1: walking skeleton (SD-0001 §7.2) — scaffold, dev Postgres, migration runner, /healthz #5
+48
-2
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user