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:
@@ -0,0 +1,114 @@
|
|||||||
|
# Piège : `cookie_de_rafraichissement` est purement documentaire, d'où son `auto_error=False`.
|
||||||
|
# Avec la valeur par défaut, FastAPI répondrait 403 avant d'atteindre `lit_le_cookie()`, et
|
||||||
|
# `/auth/refresh` cesserait de rendre le 401 que le frontend attend.
|
||||||
|
|
||||||
|
from typing import Any, Final
|
||||||
|
|
||||||
|
from fastapi.security import APIKeyCookie
|
||||||
|
|
||||||
|
from app.core.config import REFRESH_COOKIE_DEFAUT
|
||||||
|
from app.schemas.errors import ErrorResponse, InternalErrorResponse, ValidationErrorResponse
|
||||||
|
|
||||||
|
Reponses = dict[int | str, dict[str, Any]]
|
||||||
|
|
||||||
|
SUMMARY: Final = "Collecte, analyse et restitution de séries temporelles énergétiques."
|
||||||
|
|
||||||
|
DESCRIPTION: Final = """
|
||||||
|
Toutes les routes sont préfixées par `/api/v1`.
|
||||||
|
|
||||||
|
**Authentification.** Le jeton d'accès se présente dans l'en-tête `Authorization: Bearer ...`.
|
||||||
|
Le jeton de rafraîchissement est un cookie `HttpOnly` que le code client ne voit jamais : il
|
||||||
|
suffit d'émettre les requêtes avec les identifiants de session. `POST /auth/refresh` rend un
|
||||||
|
nouveau jeton d'accès et fait tourner le cookie.
|
||||||
|
|
||||||
|
**Rôles.** `lecteur`, puis `operateur`, puis `admin`. Chaque rôle couvre les droits du
|
||||||
|
précédent.
|
||||||
|
|
||||||
|
**Erreurs.** Le corps porte toujours une clé `detail`. Un `403` dont le `detail` vaut
|
||||||
|
`password_change_required` n'est pas un refus de droits : il exige le changement du mot de passe
|
||||||
|
provisoire avant toute autre action.
|
||||||
|
|
||||||
|
Le parcours de session complet est décrit dans
|
||||||
|
`docs/architecture/31-contrat-authentification.md`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
TAGS: Final[list[dict[str, Any]]] = [
|
||||||
|
{
|
||||||
|
"name": "health",
|
||||||
|
"description": (
|
||||||
|
"Sondes d'infrastructure, publiques. `live` prouve que le processus répond, `ready` "
|
||||||
|
"que la base répond et que l'extension TimescaleDB est chargée."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "auth",
|
||||||
|
"description": (
|
||||||
|
"Ouverture, rotation et fermeture de session, et changement de son propre mot de passe."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "users",
|
||||||
|
"description": "Administration des comptes. Réservé au rôle `admin`.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
cookie_de_rafraichissement = APIKeyCookie(
|
||||||
|
name=REFRESH_COOKIE_DEFAUT,
|
||||||
|
scheme_name="Cookie de rafraîchissement",
|
||||||
|
description=(
|
||||||
|
"Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le "
|
||||||
|
"préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers "
|
||||||
|
"`/api/v1/auth`."
|
||||||
|
),
|
||||||
|
auto_error=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Le 422 n'est déclaré que sur les routes qui acceptent un corps ou un paramètre : ailleurs,
|
||||||
|
# aucune validation ne peut échouer et l'annoncer serait faux.
|
||||||
|
REPONSE_VALIDATION: Final[Reponses] = {
|
||||||
|
422: {
|
||||||
|
"model": ValidationErrorResponse,
|
||||||
|
"description": (
|
||||||
|
"Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la "
|
||||||
|
"valeur envoyée."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
REPONSE_SERVEUR: Final[Reponses] = {
|
||||||
|
500: {
|
||||||
|
"model": InternalErrorResponse,
|
||||||
|
"description": (
|
||||||
|
"Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas "
|
||||||
|
"renvoyée au client."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
REPONSE_INDISPONIBLE: Final[Reponses] = {
|
||||||
|
503: {
|
||||||
|
"model": ErrorResponse,
|
||||||
|
"description": "Base injoignable, ou extension TimescaleDB absente de la base.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
REPONSES_AUTHENTIFIEES: Final[Reponses] = {
|
||||||
|
401: {
|
||||||
|
"model": ErrorResponse,
|
||||||
|
"description": (
|
||||||
|
"Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une "
|
||||||
|
"désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
REPONSES_ADMIN: Final[Reponses] = {
|
||||||
|
**REPONSES_AUTHENTIFIEES,
|
||||||
|
403: {
|
||||||
|
"model": ErrorResponse,
|
||||||
|
"description": (
|
||||||
|
"Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut "
|
||||||
|
"`password_change_required`."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -11,6 +11,12 @@ from app.api.deps import (
|
|||||||
get_client_ip,
|
get_client_ip,
|
||||||
require_trusted_origin,
|
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.cookies import RefreshCookie, cookie_name
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
from app.schemas.auth import (
|
from app.schemas.auth import (
|
||||||
@@ -19,6 +25,7 @@ from app.schemas.auth import (
|
|||||||
PrincipalResponse,
|
PrincipalResponse,
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
)
|
)
|
||||||
|
from app.schemas.errors import ErrorResponse
|
||||||
from app.services.auth import (
|
from app.services.auth import (
|
||||||
AuthenticatedSession,
|
AuthenticatedSession,
|
||||||
InvalidCredentialsError,
|
InvalidCredentialsError,
|
||||||
@@ -32,6 +39,45 @@ logger = get_logger(__name__)
|
|||||||
DETAIL_IDENTIFIANTS = "Identifiants invalides"
|
DETAIL_IDENTIFIANTS = "Identifiants invalides"
|
||||||
DETAIL_SESSION = "Session invalide"
|
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(
|
def repond(
|
||||||
response: Response, settings: SettingsDep, session: AuthenticatedSession
|
response: Response, settings: SettingsDep, session: AuthenticatedSession
|
||||||
@@ -61,7 +107,12 @@ def lit_le_cookie(request: Request, settings: SettingsDep) -> str:
|
|||||||
return secret
|
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(
|
async def login(
|
||||||
payload: LoginRequest,
|
payload: LoginRequest,
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -98,7 +149,8 @@ async def login(
|
|||||||
"/refresh",
|
"/refresh",
|
||||||
response_model=TokenResponse,
|
response_model=TokenResponse,
|
||||||
summary="Fait tourner la session",
|
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(
|
async def refresh(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -133,7 +185,7 @@ async def refresh(
|
|||||||
"/logout",
|
"/logout",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
summary="Ferme la session courante",
|
summary="Ferme la session courante",
|
||||||
dependencies=[Depends(require_trusted_origin)],
|
dependencies=[Depends(require_trusted_origin), Depends(cookie_de_rafraichissement)],
|
||||||
)
|
)
|
||||||
async def logout(
|
async def logout(
|
||||||
request: Request, response: Response, settings: SettingsDep, service: AuthServiceDep
|
request: Request, response: Response, settings: SettingsDep, service: AuthServiceDep
|
||||||
@@ -150,6 +202,7 @@ async def logout(
|
|||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
summary="Ferme toutes les sessions du compte",
|
summary="Ferme toutes les sessions du compte",
|
||||||
dependencies=[Depends(require_trusted_origin)],
|
dependencies=[Depends(require_trusted_origin)],
|
||||||
|
responses=REPONSES_AUTHENTIFIEES,
|
||||||
)
|
)
|
||||||
async def logout_all(
|
async def logout_all(
|
||||||
principal: CurrentPrincipalDep,
|
principal: CurrentPrincipalDep,
|
||||||
@@ -163,7 +216,12 @@ async def logout_all(
|
|||||||
response.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs())
|
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:
|
async def me(principal: CurrentPrincipalDep) -> PrincipalResponse:
|
||||||
return PrincipalResponse.from_principal(principal)
|
return PrincipalResponse.from_principal(principal)
|
||||||
|
|
||||||
@@ -173,6 +231,7 @@ async def me(principal: CurrentPrincipalDep) -> PrincipalResponse:
|
|||||||
response_model=TokenResponse,
|
response_model=TokenResponse,
|
||||||
summary="Change son propre mot de passe",
|
summary="Change son propre mot de passe",
|
||||||
dependencies=[Depends(require_trusted_origin)],
|
dependencies=[Depends(require_trusted_origin)],
|
||||||
|
responses=REPONSES_MOT_DE_PASSE,
|
||||||
)
|
)
|
||||||
async def change_password(
|
async def change_password(
|
||||||
payload: PasswordChangeRequest,
|
payload: PasswordChangeRequest,
|
||||||
|
|||||||
@@ -3,16 +3,17 @@ from sqlalchemy import text
|
|||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from app.api.deps import SessionDep, SettingsDep
|
from app.api.deps import SessionDep, SettingsDep
|
||||||
|
from app.api.openapi import REPONSE_INDISPONIBLE
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
from app.schemas.health import LivenessStatus, ReadinessStatus
|
from app.schemas.health import LivenessStatus, ReadinessStatus
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
router = APIRouter(tags=["health"])
|
router = APIRouter()
|
||||||
|
|
||||||
TIMESCALEDB_VERSION = text("SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'")
|
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:
|
async def liveness(settings: SettingsDep) -> LivenessStatus:
|
||||||
return LivenessStatus(
|
return LivenessStatus(
|
||||||
status="ok",
|
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:
|
async def readiness(session: SessionDep) -> ReadinessStatus:
|
||||||
try:
|
try:
|
||||||
version: str | None = await session.scalar(TIMESCALEDB_VERSION)
|
version: str | None = await session.scalar(TIMESCALEDB_VERSION)
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ from uuid import UUID
|
|||||||
from fastapi import APIRouter, HTTPException, Response, status
|
from fastapi import APIRouter, HTTPException, Response, status
|
||||||
|
|
||||||
from app.api.deps import AdminDep, UserServiceDep
|
from app.api.deps import AdminDep, UserServiceDep
|
||||||
|
from app.api.openapi import REPONSE_VALIDATION, Reponses
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
|
from app.schemas.errors import ErrorResponse
|
||||||
from app.schemas.user import (
|
from app.schemas.user import (
|
||||||
TemporaryPasswordResponse,
|
TemporaryPasswordResponse,
|
||||||
UserCreateRequest,
|
UserCreateRequest,
|
||||||
@@ -15,6 +17,28 @@ from app.services.user import EmailAlreadyUsedError, LastAdminError, UserNotFoun
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = get_logger(__name__)
|
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")
|
@router.get("", response_model=list[UserResponse], summary="Liste les comptes")
|
||||||
async def list_users(_: AdminDep, service: UserServiceDep) -> list[UserResponse]:
|
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,
|
response_model=TemporaryPasswordResponse,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
summary="Crée un compte avec un mot de passe provisoire",
|
summary="Crée un compte avec un mot de passe provisoire",
|
||||||
|
responses=REPONSES_CREATION,
|
||||||
)
|
)
|
||||||
async def create_user(
|
async def create_user(
|
||||||
payload: UserCreateRequest,
|
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(
|
async def update_user(
|
||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
payload: UserUpdateRequest,
|
payload: UserUpdateRequest,
|
||||||
@@ -92,6 +122,7 @@ async def update_user(
|
|||||||
"/{user_id}/password-reset",
|
"/{user_id}/password-reset",
|
||||||
response_model=TemporaryPasswordResponse,
|
response_model=TemporaryPasswordResponse,
|
||||||
summary="Réinitialise le mot de passe et ferme les sessions",
|
summary="Réinitialise le mot de passe et ferme les sessions",
|
||||||
|
responses=REPONSES_INTROUVABLE,
|
||||||
)
|
)
|
||||||
async def reset_password(
|
async def reset_password(
|
||||||
user_id: UUID, acteur: AdminDep, service: UserServiceDep, response: Response
|
user_id: UUID, acteur: AdminDep, service: UserServiceDep, response: Response
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN
|
||||||
from app.api.v1.endpoints import auth, health, users
|
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(health.router, prefix="/health", tags=["health"])
|
||||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
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)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Environment = Literal["local", "dev", "staging", "prod"]
|
|||||||
SameSite = Literal["lax", "strict", "none"]
|
SameSite = Literal["lax", "strict", "none"]
|
||||||
|
|
||||||
SECRET_KEY_MIN_LENGTH = 32
|
SECRET_KEY_MIN_LENGTH = 32
|
||||||
|
REFRESH_COOKIE_DEFAUT = "ev_refresh"
|
||||||
SENTINELLES_INTERDITES = frozenset(
|
SENTINELLES_INTERDITES = frozenset(
|
||||||
{"change_me", "changeme", "secret", "secret-de-test", "changez-moi", "todo"}
|
{"change_me", "changeme", "secret", "secret-de-test", "changez-moi", "todo"}
|
||||||
)
|
)
|
||||||
@@ -38,7 +39,7 @@ class Settings(BaseSettings):
|
|||||||
access_token_ttl_seconds: int = Field(default=900, ge=60, le=3600)
|
access_token_ttl_seconds: int = Field(default=900, ge=60, le=3600)
|
||||||
refresh_token_ttl_seconds: int = Field(default=604800, ge=3600, le=2592000)
|
refresh_token_ttl_seconds: int = Field(default=604800, ge=3600, le=2592000)
|
||||||
|
|
||||||
refresh_cookie_name: str = "ev_refresh"
|
refresh_cookie_name: str = REFRESH_COOKIE_DEFAUT
|
||||||
cookie_path: str = "/api/v1/auth"
|
cookie_path: str = "/api/v1/auth"
|
||||||
cookie_samesite: SameSite = "strict"
|
cookie_samesite: SameSite = "strict"
|
||||||
cookie_secure: bool | None = None
|
cookie_secure: bool | None = None
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from prometheus_fastapi_instrumentator import Instrumentator
|
|||||||
|
|
||||||
from app.api.errors import register_error_handlers
|
from app.api.errors import register_error_handlers
|
||||||
from app.api.middleware import SecurityHeadersMiddleware
|
from app.api.middleware import SecurityHeadersMiddleware
|
||||||
|
from app.api.openapi import DESCRIPTION, SUMMARY, TAGS
|
||||||
from app.api.security import require_metrics_token
|
from app.api.security import require_metrics_token
|
||||||
from app.api.v1.router import api_router
|
from app.api.v1.router import api_router
|
||||||
from app.core.config import Settings, get_settings
|
from app.core.config import Settings, get_settings
|
||||||
@@ -37,6 +38,9 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
application = FastAPI(
|
application = FastAPI(
|
||||||
title=resolved.name,
|
title=resolved.name,
|
||||||
version=resolved.version,
|
version=resolved.version,
|
||||||
|
summary=SUMMARY,
|
||||||
|
description=DESCRIPTION,
|
||||||
|
openapi_tags=TAGS,
|
||||||
debug=resolved.debug,
|
debug=resolved.debug,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
docs_url="/docs" if documentee else None,
|
docs_url="/docs" if documentee else None,
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Piège : ces modèles ne décrivent rien, ils publient. Ce sont eux que Swagger montre, donc ils
|
||||||
|
# doivent suivre `validation_error_handler()` et `unhandled_error_handler()` d'`app/api/errors.py`
|
||||||
|
# à la lettre. Un champ renommé là-bas sans l'être ici rend la documentation fausse en silence.
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorResponse(BaseModel):
|
||||||
|
detail: str
|
||||||
|
|
||||||
|
|
||||||
|
class FieldError(BaseModel):
|
||||||
|
champ: str
|
||||||
|
type: str
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationErrorResponse(BaseModel):
|
||||||
|
detail: list[FieldError]
|
||||||
|
|
||||||
|
|
||||||
|
class InternalErrorResponse(BaseModel):
|
||||||
|
detail: str
|
||||||
|
correlation: str
|
||||||
Reference in New Issue
Block a user