Aller au contenu principal

Réponses personnalisées avec FastAPI

remarque

Ce chapitre vient après Docker avec FastAPI. Il montre comment faire évoluer une API FastAPI au-delà du simple return {...} en utilisant des réponses plus ciblées : JSON enrichi, fichier à télécharger, redirection, HTML rendu par template et classe de réponse personnalisée.

Introduction

Jusqu’ici, notre projet renvoie surtout des réponses JSON classiques.

C’est normal, parce qu’une API FastAPI vit d’abord autour de :

  • routes,
  • schémas,
  • validation,
  • erreurs,
  • authentification,
  • base de données.

Mais dans une vraie application, il arrive souvent qu’on ait besoin de réponses plus spécifiques :

  • un JSON un peu plus riche que la sortie brute d’un schéma,
  • un fichier à télécharger,
  • une redirection vers une autre route,
  • une page HTML ciblée,
  • un format texte custom pour un besoin particulier.

C’est exactement le sujet de ce chapitre.

Ce que ce chapitre va accomplir

À la fin de ce chapitre, tu auras :

  • une compréhension claire de la différence entre :
    • response_model,
    • response_class,
    • TemplateResponse,
    • classe Response personnalisée,
  • un fichier core/responses.py,
  • un dossier templates/,
  • un dossier exports/,
  • des exemples concrets dans api/routers/task.py pour :
    • JSONResponse,
    • FileResponse,
    • RedirectResponse,
    • TemplateResponse,
    • réponse texte personnalisée,
  • des vérifications avec curl -i pour voir les headers et les content-type.

Pourquoi ce sujet arrive maintenant

Ce sujet est un peu différent des chapitres précédents.

Il ne change pas le socle métier autant que :

  • l’auth,
  • les relations SQL,
  • la gestion d’erreurs,
  • les tests,
  • Docker.

En revanche, il améliore beaucoup la couche de sortie de l’application.

Autrement dit :

  • les chapitres précédents ont surtout renforcé le backend interne,
  • celui-ci renforce la manière dont l’application répond au client.

Sources d’inspiration réelles

Ce chapitre s’appuie principalement sur :

  • 19-Custom Response/106-app.py
  • 19-Custom Response/105-shipment.py
  • 19-Custom Response/105-track.html

Ces sources montrent :

  • JSONResponse,
  • FileResponse,
  • RedirectResponse,
  • une classe Response custom,
  • un rendu HTML via Jinja2Templates.

Choix pédagogique de ce chapitre

Comme d’habitude, on ne copie pas les sources telles quelles.

La source 105-shipment.py parle d’un suivi de livraison. Pour notre wiki, on adapte cette logique à notre domaine courant, qui reste celui d’une application de tâches.

Donc ici, on transforme les exemples source en cas plus cohérents avec notre projet :

  • un JSON enrichi pour une tâche,
  • un téléchargement d’exemple d’export,
  • une redirection vers un aperçu HTML d’une tâche,
  • un template HTML de détail de tâche,
  • une réponse texte custom pour un résumé lisible.

Important : response_modelresponse_class

Avant de coder, il faut bien distinguer deux choses.

response_model

response_model sert surtout à :

  • documenter la sortie attendue,
  • filtrer/normaliser les données,
  • faire apparaître un schéma clair dans OpenAPI.

response_class

response_class sert surtout à :

  • choisir comment la réponse est renvoyée,
  • contrôler le content-type,
  • renvoyer un fichier,
  • renvoyer une redirection,
  • renvoyer du HTML,
  • ou appliquer un rendu personnalisé.

En pratique :

  • response_model parle surtout de la forme métier/documentée,
  • response_class parle surtout du transport concret de la réponse.

Architecture visée après ce chapitre

Après ce chapitre, le projet peut ressembler à ceci :

fastapi-base-api/
├── main.py
├── config.py
├── requirements.txt
├── .env
├── .env.example
├── core/
│ ├── __init__.py
│ ├── security.py
│ ├── exceptions.py
│ ├── middleware.py
│ └── responses.py
├── api/
│ ├── __init__.py
│ ├── router.py
│ ├── dependencies.py
│ ├── routers/
│ │ ├── __init__.py
│ │ ├── task.py
│ │ └── user.py
│ └── schemas/
│ ├── __init__.py
│ ├── task.py
│ └── user.py
├── database/
│ ├── __init__.py
│ ├── models.py
│ ├── session.py
│ └── redis.py
├── services/
│ ├── __init__.py
│ ├── task.py
│ └── user.py
├── templates/
│ └── task_detail.html
└── exports/
└── task-api-example.txt

Repères utiles avant le code

Avant de commencer, retiens ceci :

1. Le JSON classique reste la norme

Une API FastAPI reste d’abord une API JSON. Les réponses personnalisées servent à ajouter des cas utiles, pas à remplacer toute l’API.

2. JSONResponse devient utile quand tu veux emballer davantage de contexte

Par exemple :

  • un message,
  • des liens,
  • des métadonnées,
  • une structure de réponse que tu veux imposer.

3. FileResponse attend un vrai fichier sur disque

Si le fichier n’existe pas, la route échouera. Donc il faut penser en amont à :

  • où stocker le fichier,
  • s’il est statique ou généré,
  • s’il faut le protéger.

4. TemplateResponse a besoin du request

C’est un détail très important. Sans request dans le context, le rendu du template cassera.

5. Les classes Response custom sont puissantes, mais à utiliser avec parcimonie

Si tout peut être fait proprement avec :

  • JSONResponse,
  • FileResponse,
  • RedirectResponse,
  • TemplateResponse,

alors il ne faut pas créer une classe custom juste pour faire "plus avancé".

Étape 1 — vérifier les dépendances utiles

Notre projet a déjà scalar-fastapi dans les chapitres précédents. Pour le rendu HTML avec templates, ajoute jinja2 si ce n’est pas déjà fait.

Dans requirements.txt :

fastapi
uvicorn[standard]
sqlmodel
sqlalchemy
asyncpg
pydantic-settings
scalar-fastapi
passlib[bcrypt]
PyJWT
python-multipart
email-validator
pytest
pytest-asyncio
httpx
aiosqlite
jinja2

Puis installe ou rebuild selon ton contexte :

pip install -r requirements.txt

ou si tu es dans la stack Docker :

docker compose up --build

Étape 2 — créer les nouveaux dossiers utiles

Crée maintenant :

mkdir -p templates exports

Étape 3 — créer un fichier d’export d’exemple

Pour illustrer FileResponse, on va d’abord utiliser un fichier simple existant sur disque.

Crée exports/task-api-example.txt :

Task API export example
=======================

This file is here to demonstrate FastAPI FileResponse.

- rich JSON responses
- HTML preview pages
- redirect endpoints
- text summary responses

Replace this static example later with a real generated export if needed.

Pourquoi commencer par un fichier statique ?

Parce que pédagogiquement, c’est plus simple.

On veut d’abord comprendre :

  • comment FileResponse fonctionne,
  • comment renvoyer un fichier proprement,
  • quels headers sont envoyés.

Plus tard, tu pourras remplacer ce fichier par :

  • un export généré à la demande,
  • un CSV,
  • un PDF,
  • un rapport texte,
  • un artefact produit par Celery.

Étape 4 — créer core/responses.py

On va y placer notre classe de réponse personnalisée.

Crée core/responses.py :

from fastapi import Response


class TaskSummaryTextResponse(Response):
media_type = "text/plain; charset=utf-8"

def render(self, content: str) -> bytes:
if content is None:
content = ""

if not isinstance(content, str):
content = str(content)

return content.upper().encode("utf-8")

Pourquoi cette classe est volontairement simple

La source montre une UpperResponse. Ici, on garde la même idée pédagogique, mais on la renomme pour mieux coller à notre projet.

Le but n’est pas de créer une réponse métier révolutionnaire. Le but est de comprendre :

  • comment hériter de Response,
  • comment définir un media_type,
  • comment surcharger render().

Étape 5 — préparer le rendu HTML avec Jinja2

On va maintenant créer un petit template HTML de détail de tâche.

Crée templates/task_detail.html :

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Détail tâche #{{ task.id }}</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}

body {
font-family: Inter, Arial, sans-serif;
background: #f5f7fb;
color: #1f2937;
padding: 32px 16px;
}

.card {
max-width: 760px;
margin: 0 auto;
background: white;
border: 1px solid #e5e7eb;
border-radius: 20px;
padding: 28px;
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.08);
}

.eyebrow {
color: #6b7280;
font-size: 14px;
margin-bottom: 12px;
}

h1 {
font-size: 30px;
margin-bottom: 12px;
}

.description {
color: #4b5563;
line-height: 1.6;
margin-bottom: 24px;
}

.status {
display: inline-block;
margin-bottom: 18px;
padding: 8px 14px;
border-radius: 999px;
background: #ede9fe;
color: #5b21b6;
font-weight: 600;
}

.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}

.panel {
border: 1px solid #e5e7eb;
border-radius: 16px;
padding: 16px;
background: #fafafa;
}

.panel h2 {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #6b7280;
margin-bottom: 10px;
}

.panel p {
font-size: 16px;
line-height: 1.5;
}
</style>
</head>
<body>
<main class="card">
<p class="eyebrow">Aperçu HTML rendu par FastAPI</p>
<span class="status">{{ status_label }}</span>
<h1>{{ task.title }}</h1>
<p class="description">{{ task.description }}</p>

<section class="grid">
<article class="panel">
<h2>ID</h2>
<p>#{{ task.id }}</p>
</article>

<article class="panel">
<h2>Statut brut</h2>
<p>{{ task.status.value }}</p>
</article>

<article class="panel">
<h2>Propriétaire</h2>
<p>{{ current_user.name }}</p>
</article>

<article class="panel">
<h2>Email</h2>
<p>{{ current_user.email }}</p>
</article>
</section>
</main>
</body>
</html>

Pourquoi ce template reste volontairement simple

La source 105-track.html est plus riche visuellement. Ici, on garde l’idée :

  • une page HTML rendue côté FastAPI,
  • des données injectées par le backend,
  • un affichage lisible.

Mais on évite d’introduire trop de bruit visuel dans un chapitre déjà chargé conceptuellement.

Étape 6 — faire évoluer api/routers/task.py

C’est ici qu’on va brancher les différents types de réponses.

Imports à ajouter

from pathlib import Path

from fastapi import APIRouter, Request, status
from fastapi.encoders import jsonable_encoder
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates

from api.dependencies import CurrentUserDep, TaskServiceDep
from core.responses import TaskSummaryTextResponse

Préparer les chemins et les templates

Ajoute ensuite en haut du fichier :

BASE_DIR = Path(__file__).resolve().parent.parent.parent
TEMPLATES_DIR = BASE_DIR / "templates"
EXPORTS_DIR = BASE_DIR / "exports"

templates = Jinja2Templates(directory=str(TEMPLATES_DIR))

Pourquoi cette approche de chemin est saine

Le routeur task.py est enfoui dans api/routers/. Donc si on veut accéder à :

  • templates/
  • exports/

il faut remonter proprement à la racine du projet.

C’est plus robuste qu’un chemin relatif fragile écrit "à la main".

Étape 7 — ajouter un endpoint JSONResponse

Ajoute cette route :

@router.get("/{task_id}/rich-json", response_class=JSONResponse)
async def get_task_rich_json(
task_id: int,
current_user: CurrentUserDep,
service: TaskServiceDep,
):
task = await service.get_task_for_user(task_id, current_user.id)

return JSONResponse(
content={
"ok": True,
"message": "Task loaded successfully",
"task": jsonable_encoder(task),
"links": {
"preview_html": f"/task/{task.id}/preview",
"summary_text": f"/task/{task.id}/summary.txt",
},
}
)

Ce que montre cet endpoint

Il montre qu’on peut renvoyer plus qu’un simple objet de tâche. On peut ajouter :

  • un flag ok,
  • un message,
  • des liens utiles,
  • une structure de réponse plus maîtrisée.

Très important : pourquoi jsonable_encoder() ?

Parce qu’un objet SQLModel/Pydantic n’est pas toujours sérialisable tel quel par JSONResponse.

jsonable_encoder() aide à convertir proprement :

  • les objets Pydantic,
  • certains enums,
  • les dates,
  • d’autres types Python utiles.

Étape 8 — ajouter un endpoint FileResponse

Ajoute maintenant :

@router.get("/export/example", response_class=FileResponse)
async def download_task_export_example(current_user: CurrentUserDep):
file_path = EXPORTS_DIR / "task-api-example.txt"

return FileResponse(
path=file_path,
media_type="text/plain",
filename="task-api-example.txt",
)

Ce que montre cet endpoint

Cette route montre comment :

  • pointer vers un fichier sur disque,
  • forcer un nom de téléchargement,
  • renvoyer une réponse dont le contenu n’est pas généré comme JSON.

Pourquoi protéger quand même cette route ?

Même si le fichier ici n’est qu’un exemple, garder current_user: CurrentUserDep rappelle une bonne habitude :

  • tout ce qui ressemble à de l’export applicatif n’est pas forcément public.

Étape 9 — ajouter un endpoint RedirectResponse

Ajoute maintenant :

@router.get(
"/{task_id}/go",
response_class=RedirectResponse,
include_in_schema=False,
)
async def redirect_to_task_preview(
task_id: int,
current_user: CurrentUserDep,
service: TaskServiceDep,
):
await service.get_task_for_user(task_id, current_user.id)

return RedirectResponse(
url=f"/task/{task_id}/preview",
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)

Pourquoi cette redirection est utile

Elle montre un cas simple mais réaliste :

  • tu reçois une URL technique,
  • tu renvoies ensuite vers une vue plus lisible.

Pourquoi include_in_schema=False ?

Parce qu’une route de redirection pure n’a pas toujours besoin d’encombrer la doc OpenAPI. Elle peut exister comme route auxiliaire interne ou route d’ergonomie.

Pourquoi 307 ?

307 garde mieux la sémantique de la requête que certains redirects plus anciens. Pour un chapitre pédagogique, c’est une bonne valeur par défaut à connaître.

Étape 10 — ajouter un endpoint TemplateResponse

Ajoute maintenant :

@router.get("/{task_id}/preview", include_in_schema=False)
async def preview_task_html(
request: Request,
task_id: int,
current_user: CurrentUserDep,
service: TaskServiceDep,
):
task = await service.get_task_for_user(task_id, current_user.id)

context = {
"request": request,
"task": task,
"current_user": current_user,
"status_label": task.status.value.replace("_", " ").title(),
}

return templates.TemplateResponse(
name="task_detail.html",
context=context,
)

Ce que montre cet endpoint

Ici, FastAPI ne renvoie pas du JSON. Il rend une vraie page HTML avec :

  • les données de la tâche,
  • l’utilisateur courant,
  • une petite transformation lisible du statut.

Pourquoi request doit être dans context ?

Parce que TemplateResponse s’appuie sur le contexte Starlette/FastAPI. Sans la clé request, le rendu du template peut échouer.

Pourquoi ce type de route peut être utile

Même dans une API, on peut avoir des cas ciblés comme :

  • une page de preview,
  • une page de suivi,
  • un mini écran interne,
  • un rendu HTML de debug/admin,
  • une vue simple pour inspection manuelle.

Étape 11 — ajouter un endpoint avec réponse texte custom

Ajoute maintenant :

@router.get("/{task_id}/summary.txt", response_class=TaskSummaryTextResponse)
async def get_task_summary_text(
task_id: int,
current_user: CurrentUserDep,
service: TaskServiceDep,
):
task = await service.get_task_for_user(task_id, current_user.id)

return (
f"task #{task.id} | {task.title} | "
f"status={task.status.value} | owner={current_user.email}"
)

Ce que montre cet endpoint

Ici, on voit concrètement le rôle de notre classe TaskSummaryTextResponse :

  • la route retourne un texte simple,
  • la classe custom décide ensuite comment ce texte est rendu,
  • ici le rendu transforme le contenu en majuscules.

Encore une fois, le but n’est pas de dire que toutes les APIs ont besoin de ça. Le but est de comprendre comment FastAPI laisse personnaliser le rendu.

Étape 12 — ordre logique des routes dans task.py

Tu peux ranger ces routes comme ceci :

# routes JSON API classiques
# routes enrichies JSON / export / preview / summary

L’important est de garder une séparation mentale claire :

  • routes CRUD cœur métier,
  • routes de représentation / rendu / export.

C’est plus lisible que de tout mélanger sans logique.

Étape 13 — bonus : lien avec /scalar

La source 106-app.py montre aussi une route /scalar. Bonne nouvelle : notre projet a déjà repris cette idée dans les chapitres précédents.

Donc ici, il n’y a rien d’obligatoire à ajouter de plus pour ce point.

L’idée importante à retenir est simplement que FastAPI peut aussi servir :

  • des docs alternatives,
  • du HTML,
  • du fichier,
  • pas seulement du JSON brut.

Vérifications pratiques avec curl

Dans les exemples suivants, remplace TOKEN par un vrai JWT obtenu au chapitre Authentification avec FastAPI.

1. Vérifier le JSON enrichi

curl -i \
-H "Authorization: Bearer TOKEN" \
http://localhost:8000/task/1/rich-json

Tu dois observer :

  • un 200 OK,
  • un content-type: application/json,
  • une structure avec ok, message, task, links.

2. Vérifier le téléchargement du fichier

curl -i \
-H "Authorization: Bearer TOKEN" \
http://localhost:8000/task/export/example

Tu dois observer :

  • un 200 OK,
  • un header content-disposition,
  • un content-type texte.

3. Vérifier la redirection

curl -i \
-H "Authorization: Bearer TOKEN" \
http://localhost:8000/task/1/go

Tu dois observer :

  • un 307 Temporary Redirect,
  • un header location: /task/1/preview.

4. Suivre réellement la redirection

curl -i -L \
-H "Authorization: Bearer TOKEN" \
http://localhost:8000/task/1/go

Tu dois alors finir sur la page HTML.

5. Vérifier directement le rendu HTML

curl -i \
-H "Authorization: Bearer TOKEN" \
http://localhost:8000/task/1/preview

Tu dois observer :

  • un 200 OK,
  • un content-type: text/html.

6. Vérifier la réponse texte custom

curl -i \
-H "Authorization: Bearer TOKEN" \
http://localhost:8000/task/1/summary.txt

Tu dois observer :

  • un 200 OK,
  • un content-type: text/plain,
  • un contenu en majuscules.

Erreurs fréquentes

Erreur 1 — Object of type Task is not JSON serializable

Cause fréquente :

  • tu passes directement un objet SQLModel à JSONResponse.

Correction :

  • utilise jsonable_encoder(task).

Erreur 2 — le template casse au rendu

Cause fréquente :

  • tu as oublié request dans le context.

Correction :

context = {
"request": request,
...
}

Erreur 3 — FileResponse ne trouve pas le fichier

Cause fréquente :

  • mauvais chemin,
  • dossier exports/ absent,
  • fichier non créé.

Correction :

  • vérifie le chemin EXPORTS_DIR / "task-api-example.txt".

Erreur 4 — tu pollues la doc OpenAPI avec des routes HTML auxiliaires

Cause fréquente :

  • routes utilitaires laissées visibles dans le schema.

Correction :

  • ajoute include_in_schema=False sur les routes qui ne sont pas destinées à la doc API publique.

Erreur 5 — tu crées trop vite des classes custom

Cause fréquente :

  • envie de faire "avancé" alors qu’une réponse standard suffisait.

Correction :

  • garde les classes custom pour les vrais besoins de rendu.

Bonnes pratiques à retenir

1. Garde le JSON comme sortie principale

Les réponses personnalisées sont des compléments ciblés.

2. Centralise les classes custom

core/responses.py est un meilleur endroit que de disperser des classes dans plusieurs routeurs.

3. Sépare logique métier et logique de rendu

Le service récupère la donnée. La route choisit comment la renvoyer.

4. Protège les routes de preview/export si nécessaire

Le fait qu’une route renvoie du HTML ou un fichier ne veut pas dire qu’elle doit devenir publique.

5. Utilise include_in_schema=False avec intention

Très utile pour les routes utilitaires, previews, redirections techniques.

Checklist de validation

À la fin, tu dois pouvoir cocher tout ceci :

  • j’ai ajouté jinja2 si nécessaire
  • j’ai créé core/responses.py
  • j’ai créé templates/task_detail.html
  • j’ai créé exports/task-api-example.txt
  • je sais configurer Jinja2Templates
  • je comprends la différence entre response_model et response_class
  • je sais quand utiliser JSONResponse
  • je sais quand utiliser FileResponse
  • je sais quand utiliser RedirectResponse
  • je sais rendre du HTML avec TemplateResponse
  • je sais créer une réponse custom simple en héritant de Response

Résumé final

Dans ce chapitre, tu as appris que FastAPI ne se limite pas à renvoyer du JSON "par défaut".

Tu peux désormais ajouter proprement :

  • un JSON enrichi,
  • un téléchargement de fichier,
  • une redirection,
  • un rendu HTML,
  • une classe de réponse personnalisée.

Le plus important à retenir est simple :

  • la donnée métier reste dans les services,
  • la route choisit le type de représentation,
  • FastAPI te donne plusieurs manières de rendre cette représentation proprement selon le besoin.

Pour aller plus loin