Files
wiggleverse-ecomm/backend/app/main.py
T

199 lines
7.8 KiB
Python

"""ecomm backend — FastAPI app factory + the REST BFF.
SLICE-1 mounted /healthz; SLICE-2 adds the /api/auth/* identity endpoints (§6.4). The BFF
translates HTTP <-> domain calls and owns no business logic (INV-6): every rule lives in
the accounts domain. create_app() opens the pool, self-migrates (INV-1, INV-7), and builds
the configured mailer (INV-8) at startup. SLICE-3 adds POST /api/storefronts and feeds the
_storefront_for seam from the storefronts domain.
"""
from __future__ import annotations
import logging
import sys
from contextlib import asynccontextmanager
from typing import Any
import psycopg
from fastapi import Depends, FastAPI, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from app.domains import accounts, storefronts
from app.platform import config, db
from app.platform import mailer as mailer_mod
from app.platform.deps import SESSION_COOKIE, get_conn, get_mailer, get_session
from app.platform.mailer import Mailer
from app.platform import session as session_mod
class RequestCodeBody(BaseModel):
email: str
class VerifyBody(BaseModel):
email: str
code: str
class CreateStorefrontBody(BaseModel):
name: str | None = None
def _error(status: int, code: str, message: str, **extra: Any) -> JSONResponse:
"""The shared §6.4 error envelope: {"error": {"code", "message", ...}}."""
return JSONResponse(status_code=status, content={"error": {"code": code, "message": message, **extra}})
def _storefront_for(conn: psycopg.Connection, account: accounts.Account) -> dict | None:
"""The entry-routing answer: which storefront, if any, this account has (§6.5)."""
sf = storefronts.storefront_for(conn, account.id)
return {"id": sf.id, "name": sf.name} if sf else None
def _ensure_app_logging() -> None:
"""Surface the app's own `ecomm.*` INFO logs on stderr (idempotent).
uvicorn configures only its own loggers, leaving the root with no INFO handler — so
without this the `ecomm.mailer` line (PUC-10's local dev channel: the one-time code in
the backend log) would be swallowed. A dedicated handler with propagate=False keeps it
out of uvicorn's stream and avoids double-logging.
"""
lg = logging.getLogger("ecomm")
if not lg.handlers:
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s: %(message)s"))
lg.addHandler(handler)
lg.setLevel(logging.INFO)
lg.propagate = False
def _set_session_cookie(response: Response, account: accounts.Account) -> None:
token = session_mod.sign({"account_id": account.id, "email": account.email}, config.session_secret())
response.set_cookie(
SESSION_COOKIE,
token,
httponly=True,
samesite="lax",
secure=config.cookie_secure(),
path="/",
)
def create_app(database_url: str | None = None) -> FastAPI:
_ensure_app_logging()
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)
app.state.mailer = mailer_mod.build_mailer(config.mailer_kind()) # INV-8
try:
yield
finally:
app.state.pool.close()
app = FastAPI(title="ecomm", version="0.3", 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"}
@app.post("/api/auth/request-code")
def request_code(
body: RequestCodeBody,
conn: psycopg.Connection = Depends(get_conn),
mailer: Mailer = Depends(get_mailer),
):
"""Issue + dispatch a one-time code. Uniform for new/known emails (§6.6)."""
try:
accounts.request_code(conn, mailer, body.email)
except accounts.InvalidEmail:
return _error(400, "invalid_email", "That doesn't look like an email address.")
except accounts.ResendCooldown as exc:
return _error(
429, "resend_cooldown",
f"Please wait {exc.retry_after_s}s before requesting another code.",
retry_after_s=exc.retry_after_s,
)
return Response(status_code=204)
@app.post("/api/auth/verify")
def verify(body: VerifyBody, conn: psycopg.Connection = Depends(get_conn)):
"""Verify a code, start a session, and answer entry routing (§6.4/§6.5)."""
try:
account, created = accounts.verify(conn, body.email, body.code)
except accounts.InvalidEmail:
return _error(400, "invalid_email", "That doesn't look like an email address.")
except accounts.CodeMismatch as exc:
return _error(
400, "code_mismatch", "That code didn't match.",
attempts_remaining=exc.attempts_remaining,
)
except accounts.CodeExpired:
return _error(400, "code_expired", "That code expired — request a fresh one.")
except accounts.CodeExhausted:
return _error(400, "code_exhausted", "Too many attempts — request a fresh code.")
payload = {
"account": {"email": account.email},
"storefront": _storefront_for(conn, account),
"created": created,
}
resp = JSONResponse(status_code=200, content=payload)
_set_session_cookie(resp, account)
return resp
@app.get("/api/auth/me")
def me(conn: psycopg.Connection = Depends(get_conn), sess: dict | None = Depends(get_session)):
"""The signed-in account + entry-routing answer, or 401 (§6.4/§6.5)."""
if sess is None:
return _error(401, "unauthenticated", "You are not signed in.")
account = accounts.get_account(conn, sess["account_id"])
if account is None:
return _error(401, "unauthenticated", "You are not signed in.")
return {"account": {"email": account.email}, "storefront": _storefront_for(conn, account)}
@app.post("/api/auth/logout")
def logout():
"""End the session by clearing the cookie. Idempotent (PUC-9; no server state)."""
resp = Response(status_code=204)
resp.delete_cookie(SESSION_COOKIE, path="/")
return resp
@app.post("/api/storefronts")
def create_storefront(
body: CreateStorefrontBody,
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""Create the account's one storefront (§6.4; PUC-4, INV-4/PUC-7 on refusal)."""
if sess is None:
return _error(401, "unauthenticated", "You are not signed in.")
account = accounts.get_account(conn, sess["account_id"])
if account is None:
return _error(401, "unauthenticated", "You are not signed in.")
try:
sf = storefronts.create_storefront(conn, account.id, account.email, body.name)
except storefronts.AlreadyOwnsStorefront:
return _error(
409, "already_owns_storefront",
"Your account already has its storefront — ecomm is one storefront per account today.",
)
return JSONResponse(status_code=201, content={"id": sf.id, "name": sf.name})
return app
app = create_app()