published_at is now the first-publish time (null for drafts, kept across edits
and unpublish/republish), slugs collide to -2/-3 and regenerate on retitle,
every error is {"error"} with 400/401/403/404/405/409 pinned, timestamps are
RFC 3339 UTC. GUIDELINES pins the contract; each backend passes the same 59-check
end-to-end script.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01128fhuZbgivaSJvtMf4s1G
40 lines
1.4 KiB
Python
40 lines
1.4 KiB
Python
import os
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.responses import JSONResponse, PlainTextResponse
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
|
|
from . import auth, db, posts
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
app.state.client, app.state.db = await db.connect(os.environ["MONGO_URL"], os.environ["MONGO_DB"])
|
|
yield
|
|
await app.state.client.close()
|
|
|
|
|
|
app = FastAPI(title="blog", lifespan=lifespan)
|
|
app.include_router(auth.router)
|
|
app.include_router(posts.router)
|
|
|
|
|
|
@app.get("/health", response_class=PlainTextResponse)
|
|
async def health() -> str:
|
|
return "ok"
|
|
|
|
|
|
# Every error leaves as {"error": "message"}, including framework-raised ones.
|
|
@app.exception_handler(StarletteHTTPException)
|
|
async def http_error(_: Request, exc: StarletteHTTPException) -> JSONResponse:
|
|
return JSONResponse({"error": str(exc.detail)}, status_code=exc.status_code)
|
|
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
async def validation_error(_: Request, exc: RequestValidationError) -> JSONResponse:
|
|
first = exc.errors()[0]
|
|
field = ".".join(str(part) for part in first["loc"] if part != "body")
|
|
message = f"{field}: {first['msg']}" if field else first["msg"]
|
|
return JSONResponse({"error": message}, status_code=400)
|