OpenAPI type codegen with CI drift gate (plan 3.4, ARCH-16, FE-14)

Every dict-returning route the SPA consumes now declares a response
model (auth, bulk, sync-status, notifications). api-types.gen.ts is
generated from app.openapi() via openapi-typescript, and the 22 hand-
written read interfaces in api.ts are now aliases onto the generated
schemas — backend schema drift becomes a typecheck failure, and CI
regenerates the file and fails the deploy if it's stale. The aliasing
immediately caught two real drifts: RecurringItemType was missing
SAVINGS, and raw_charges (untyped JSON column) is now explicitly
shaped at the boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-06-12 07:53:00 -06:00
parent 3ea1ef0fa0
commit 7dfe2da2a9
11 changed files with 4347 additions and 251 deletions

View File

@@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from fastapi.security import OAuth2PasswordRequestForm
from app.auth import (
@@ -11,7 +12,16 @@ from app.auth import (
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login")
class TokenResponse(BaseModel):
access_token: str
token_type: str
class MeResponse(BaseModel):
username: str
@router.post("/login", response_model=TokenResponse)
def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
check_login_rate_limit(request)
verify_admin_credentials(form_data.username, form_data.password)
@@ -19,6 +29,6 @@ def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
return {"access_token": token, "token_type": "bearer"}
@router.get("/me")
@router.get("/me", response_model=MeResponse)
def me(username: str = Depends(get_current_user_cookie_or_bearer)):
return {"username": username}

View File

@@ -1,6 +1,7 @@
import json
import logging
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException
from pywebpush import WebPushException, webpush
from sqlmodel import Session, select
@@ -15,14 +16,22 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/notifications", tags=["notifications"])
@router.get("/vapid-public-key")
class VapidPublicKeyResponse(BaseModel):
publicKey: str
class SubscriptionStatusResponse(BaseModel):
status: str
@router.get("/vapid-public-key", response_model=VapidPublicKeyResponse)
def get_vapid_public_key(_user: str = Depends(get_current_user)):
if not settings.VAPID_PUBLIC_KEY:
raise HTTPException(status_code=503, detail="Push notifications not configured")
return {"publicKey": settings.VAPID_PUBLIC_KEY}
@router.post("/subscribe", status_code=201)
@router.post("/subscribe", status_code=201, response_model=SubscriptionStatusResponse)
def subscribe(
data: PushSubscriptionCreate,
session: Session = Depends(get_session),
@@ -48,7 +57,7 @@ def subscribe(
return {"status": "subscribed"}
@router.delete("/unsubscribe")
@router.delete("/unsubscribe", response_model=SubscriptionStatusResponse)
def unsubscribe(
data: PushSubscriptionCreate,
session: Session = Depends(get_session),

View File

@@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlmodel import Session
from app.auth import get_current_user
@@ -8,7 +9,22 @@ from app.services.sync_status import compute_sync_status
router = APIRouter(prefix="/sync-status", tags=["sync-status"])
@router.get("/")
class SyncSourceStatus(BaseModel):
key: str
label: str
description: str
last_received: str | None
age_days: float | None
warn_after_days: float
status: str # 'ok' | 'warning' | 'never'
class SyncStatusResponse(BaseModel):
sources: list[SyncSourceStatus]
warnings: int
@router.get("/", response_model=SyncStatusResponse)
def sync_status(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),

View File

@@ -106,7 +106,11 @@ class BulkActionRequest(BaseModel):
deferred: Optional[bool] = None # for set_deferred
@router.post("/bulk")
class BulkActionResponse(BaseModel):
affected: int
@router.post("/bulk", response_model=BulkActionResponse)
def bulk_action(
req: BulkActionRequest,
session: Session = Depends(get_session),