feat(backend): documente le contrat d'erreur dans l'OpenAPI
Le schéma ne déclarait aucun code d'erreur : ni 401, ni 403, ni 404, ni 409,
ni 429. Swagger affirmait que /auth/login ne pouvait répondre que 200 ou 422,
alors que 31-contrat-authentification.md décrit ces codes comme le contrat que
le frontend doit traiter.
Le 422 publié était pire qu'absent : le schéma exposait HTTPValidationError,
le modèle par défaut de FastAPI avec sa clé `loc`, quand
validation_error_handler renvoie {"detail": [{"champ", "type"}]}. Un client
codé sur la documentation lisait une clé qui n'arrive jamais.
Les métadonnées arrivent avec : description, résumé et une description par
tag. `servers`, `license_info` et `contact` restent absents, ils poseraient
des décisions qui ne sont pas prises.
Le cookie de rafraîchissement devient visible par un APIKeyCookie en
auto_error=False, purement documentaire : lit_le_cookie() reste seul maître du
401 de /auth/refresh.
Au passage, health.py posait son tag deux fois, une fois sur son APIRouter et
une fois à l'include_router.
This commit is contained in:
@@ -11,6 +11,12 @@ from app.api.deps import (
|
||||
get_client_ip,
|
||||
require_trusted_origin,
|
||||
)
|
||||
from app.api.openapi import (
|
||||
REPONSE_VALIDATION,
|
||||
REPONSES_AUTHENTIFIEES,
|
||||
Reponses,
|
||||
cookie_de_rafraichissement,
|
||||
)
|
||||
from app.core.cookies import RefreshCookie, cookie_name
|
||||
from app.core.logging import get_logger
|
||||
from app.schemas.auth import (
|
||||
@@ -19,6 +25,7 @@ from app.schemas.auth import (
|
||||
PrincipalResponse,
|
||||
TokenResponse,
|
||||
)
|
||||
from app.schemas.errors import ErrorResponse
|
||||
from app.services.auth import (
|
||||
AuthenticatedSession,
|
||||
InvalidCredentialsError,
|
||||
@@ -32,6 +39,45 @@ logger = get_logger(__name__)
|
||||
DETAIL_IDENTIFIANTS = "Identifiants invalides"
|
||||
DETAIL_SESSION = "Session invalide"
|
||||
|
||||
REPONSES_LOGIN: Reponses = {
|
||||
**REPONSE_VALIDATION,
|
||||
401: {
|
||||
"model": ErrorResponse,
|
||||
"description": (
|
||||
"Identifiants faux, compte inconnu ou compte désactivé. Le message est le même dans "
|
||||
"les trois cas, et n'apprend donc rien sur l'existence du compte."
|
||||
),
|
||||
},
|
||||
429: {
|
||||
"model": ErrorResponse,
|
||||
"description": "Trop de tentatives sur cette fenêtre glissante.",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Secondes à attendre avant une nouvelle tentative.",
|
||||
"schema": {"type": "integer"},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
REPONSES_REFRESH: Reponses = {
|
||||
401: {
|
||||
"model": ErrorResponse,
|
||||
"description": (
|
||||
"Cookie absent, session expirée, révoquée, ou jeton déjà tourné. Dans ce dernier cas "
|
||||
"toute la famille de sessions est révoquée et le cookie est effacé avec la réponse."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
REPONSES_MOT_DE_PASSE: Reponses = {
|
||||
**REPONSE_VALIDATION,
|
||||
401: {
|
||||
"model": ErrorResponse,
|
||||
"description": "Jeton d'accès invalide, ou mot de passe courant faux.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def repond(
|
||||
response: Response, settings: SettingsDep, session: AuthenticatedSession
|
||||
@@ -61,7 +107,12 @@ def lit_le_cookie(request: Request, settings: SettingsDep) -> str:
|
||||
return secret
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse, summary="Ouvre une session")
|
||||
@router.post(
|
||||
"/login",
|
||||
response_model=TokenResponse,
|
||||
summary="Ouvre une session",
|
||||
responses=REPONSES_LOGIN,
|
||||
)
|
||||
async def login(
|
||||
payload: LoginRequest,
|
||||
request: Request,
|
||||
@@ -98,7 +149,8 @@ async def login(
|
||||
"/refresh",
|
||||
response_model=TokenResponse,
|
||||
summary="Fait tourner la session",
|
||||
dependencies=[Depends(require_trusted_origin)],
|
||||
dependencies=[Depends(require_trusted_origin), Depends(cookie_de_rafraichissement)],
|
||||
responses=REPONSES_REFRESH,
|
||||
)
|
||||
async def refresh(
|
||||
request: Request,
|
||||
@@ -133,7 +185,7 @@ async def refresh(
|
||||
"/logout",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Ferme la session courante",
|
||||
dependencies=[Depends(require_trusted_origin)],
|
||||
dependencies=[Depends(require_trusted_origin), Depends(cookie_de_rafraichissement)],
|
||||
)
|
||||
async def logout(
|
||||
request: Request, response: Response, settings: SettingsDep, service: AuthServiceDep
|
||||
@@ -150,6 +202,7 @@ async def logout(
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Ferme toutes les sessions du compte",
|
||||
dependencies=[Depends(require_trusted_origin)],
|
||||
responses=REPONSES_AUTHENTIFIEES,
|
||||
)
|
||||
async def logout_all(
|
||||
principal: CurrentPrincipalDep,
|
||||
@@ -163,7 +216,12 @@ async def logout_all(
|
||||
response.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs())
|
||||
|
||||
|
||||
@router.get("/me", response_model=PrincipalResponse, summary="Décrit le compte connecté")
|
||||
@router.get(
|
||||
"/me",
|
||||
response_model=PrincipalResponse,
|
||||
summary="Décrit le compte connecté",
|
||||
responses=REPONSES_AUTHENTIFIEES,
|
||||
)
|
||||
async def me(principal: CurrentPrincipalDep) -> PrincipalResponse:
|
||||
return PrincipalResponse.from_principal(principal)
|
||||
|
||||
@@ -173,6 +231,7 @@ async def me(principal: CurrentPrincipalDep) -> PrincipalResponse:
|
||||
response_model=TokenResponse,
|
||||
summary="Change son propre mot de passe",
|
||||
dependencies=[Depends(require_trusted_origin)],
|
||||
responses=REPONSES_MOT_DE_PASSE,
|
||||
)
|
||||
async def change_password(
|
||||
payload: PasswordChangeRequest,
|
||||
|
||||
@@ -3,16 +3,17 @@ from sqlalchemy import text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.api.deps import SessionDep, SettingsDep
|
||||
from app.api.openapi import REPONSE_INDISPONIBLE
|
||||
from app.core.logging import get_logger
|
||||
from app.schemas.health import LivenessStatus, ReadinessStatus
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(tags=["health"])
|
||||
router = APIRouter()
|
||||
|
||||
TIMESCALEDB_VERSION = text("SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'")
|
||||
|
||||
|
||||
@router.get("/live", summary="Sonde de vivacite")
|
||||
@router.get("/live", summary="Sonde de vivacité")
|
||||
async def liveness(settings: SettingsDep) -> LivenessStatus:
|
||||
return LivenessStatus(
|
||||
status="ok",
|
||||
@@ -22,7 +23,7 @@ async def liveness(settings: SettingsDep) -> LivenessStatus:
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ready", summary="Sonde de disponibilite")
|
||||
@router.get("/ready", summary="Sonde de disponibilité", responses=REPONSE_INDISPONIBLE)
|
||||
async def readiness(session: SessionDep) -> ReadinessStatus:
|
||||
try:
|
||||
version: str | None = await session.scalar(TIMESCALEDB_VERSION)
|
||||
|
||||
@@ -3,7 +3,9 @@ from uuid import UUID
|
||||
from fastapi import APIRouter, HTTPException, Response, status
|
||||
|
||||
from app.api.deps import AdminDep, UserServiceDep
|
||||
from app.api.openapi import REPONSE_VALIDATION, Reponses
|
||||
from app.core.logging import get_logger
|
||||
from app.schemas.errors import ErrorResponse
|
||||
from app.schemas.user import (
|
||||
TemporaryPasswordResponse,
|
||||
UserCreateRequest,
|
||||
@@ -15,6 +17,28 @@ from app.services.user import EmailAlreadyUsedError, LastAdminError, UserNotFoun
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
REPONSES_CREATION: Reponses = {
|
||||
**REPONSE_VALIDATION,
|
||||
409: {"model": ErrorResponse, "description": "Adresse déjà portée par un autre compte."},
|
||||
}
|
||||
|
||||
REPONSES_INTROUVABLE: Reponses = {
|
||||
**REPONSE_VALIDATION,
|
||||
404: {"model": ErrorResponse, "description": "Aucun compte ne porte cet identifiant."},
|
||||
}
|
||||
|
||||
REPONSES_MODIFICATION: Reponses = {
|
||||
**REPONSES_INTROUVABLE,
|
||||
400: {"model": ErrorResponse, "description": "Corps vide, aucune modification demandée."},
|
||||
409: {
|
||||
"model": ErrorResponse,
|
||||
"description": (
|
||||
"L'opération laisserait la plateforme sans administrateur actif, qu'il s'agisse de "
|
||||
"rétrograder le dernier ou de le désactiver."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserResponse], summary="Liste les comptes")
|
||||
async def list_users(_: AdminDep, service: UserServiceDep) -> list[UserResponse]:
|
||||
@@ -27,6 +51,7 @@ async def list_users(_: AdminDep, service: UserServiceDep) -> list[UserResponse]
|
||||
response_model=TemporaryPasswordResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Crée un compte avec un mot de passe provisoire",
|
||||
responses=REPONSES_CREATION,
|
||||
)
|
||||
async def create_user(
|
||||
payload: UserCreateRequest,
|
||||
@@ -55,7 +80,12 @@ async def create_user(
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserResponse, summary="Change le rôle ou l'activation")
|
||||
@router.patch(
|
||||
"/{user_id}",
|
||||
response_model=UserResponse,
|
||||
summary="Change le rôle ou l'activation",
|
||||
responses=REPONSES_MODIFICATION,
|
||||
)
|
||||
async def update_user(
|
||||
user_id: UUID,
|
||||
payload: UserUpdateRequest,
|
||||
@@ -92,6 +122,7 @@ async def update_user(
|
||||
"/{user_id}/password-reset",
|
||||
response_model=TemporaryPasswordResponse,
|
||||
summary="Réinitialise le mot de passe et ferme les sessions",
|
||||
responses=REPONSES_INTROUVABLE,
|
||||
)
|
||||
async def reset_password(
|
||||
user_id: UUID, acteur: AdminDep, service: UserServiceDep, response: Response
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN
|
||||
from app.api.v1.endpoints import auth, health, users
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router = APIRouter(responses=REPONSE_SERVEUR)
|
||||
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
api_router.include_router(users.router, prefix="/users", tags=["users"])
|
||||
api_router.include_router(users.router, prefix="/users", tags=["users"], responses=REPONSES_ADMIN)
|
||||
|
||||
Reference in New Issue
Block a user