feat(slice-2): deps — mailer + session dependencies; build mailer at startup
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+111
-6
@@ -1,19 +1,62 @@
|
||||
"""ecomm backend — FastAPI app factory + the REST BFF.
|
||||
|
||||
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.
|
||||
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. storefront is hard-wired null this slice; the
|
||||
storefronts domain (SLICE-3) replaces the _storefront_for seam.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
from app.platform import config, db
|
||||
from app.platform.deps import get_conn
|
||||
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
|
||||
|
||||
|
||||
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).
|
||||
|
||||
Hard-wired null in SLICE-2 (identity only). SLICE-3 replaces this with a storefronts
|
||||
domain call; the verify/me responses already carry the field.
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
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:
|
||||
@@ -24,12 +67,13 @@ def create_app(database_url: str | None = None) -> 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.1", lifespan=lifespan)
|
||||
app = FastAPI(title="ecomm", version="0.2", lifespan=lifespan)
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz(response: Response, conn: psycopg.Connection = Depends(get_conn)):
|
||||
@@ -45,6 +89,67 @@ def create_app(database_url: str | None = None) -> FastAPI:
|
||||
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
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -1,18 +1,38 @@
|
||||
"""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.
|
||||
Yields a pooled connection per request, the process-wide mailer, and the verified session
|
||||
payload (from the signed cookie). The pool and mailer live on app.state, built once in
|
||||
create_app(). The storefronts dependency lands in SLICE-3.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import psycopg
|
||||
from fastapi import Request
|
||||
|
||||
from app.platform import config, session
|
||||
from app.platform.mailer import Mailer
|
||||
|
||||
SESSION_COOKIE = "ecomm_session"
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def get_mailer(request: Request) -> Mailer:
|
||||
"""The process-wide mailer adapter (built from config at startup, INV-8)."""
|
||||
return request.app.state.mailer
|
||||
|
||||
|
||||
def get_session(request: Request) -> dict[str, Any] | None:
|
||||
"""The verified session payload from the cookie, or None if absent/invalid."""
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
if not token:
|
||||
return None
|
||||
return session.verify(token, config.session_secret())
|
||||
|
||||
Reference in New Issue
Block a user