CodeGym /Cours /Spring REST & MVC /@ModelAttribute et TaskSearchCriteria

@ModelAttribute et TaskSearchCriteria

Spring REST & MVC
Niveau 6, Leçon 4
Disponible

1. List-endpoint et augmentation des paramètres

Quand vous commencez tout juste à écrire une API, la liste semble inoffensive : « donne-moi toutes les tâches » — et voilà. Mais deux jours de vie réelle du projet plus tard (ou deux questions du premier client), et soudainement apparaissent « tu peux seulement TODO ? », « on peut chercher par texte ? », « on peut avoir la page et la taille ? ». À ce moment-là, la méthode du contrôleur commence à ressembler à quelqu’un qui essaie de remplir une déclaration d’impôts à votre place.

Imaginez que nous souhaitions accepter au moins un ensemble minimal de paramètres de requête : page, taille, statut, un morceau de texte pour la recherche. Si on le fait « à l’arrache » avec @RequestParam, la signature s’allonge rapidement :

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@GetMapping("/api/v1/tasks")
public void findTasks(@RequestParam(defaultValue = "0") int page,
                      @RequestParam(defaultValue = "20") int size,
                      @RequestParam(required = false) TaskStatus status,
                      @RequestParam(required = false) String q) {
    // page/size — paramètres de pagination avec valeurs par défaut (si le client ne les a pas envoyés, on prend les valeurs de defaultValue)
    // status/q — filtres : peuvent être absents (required = false), alors ils seront null
    // ...
}

Pour l’instant, c’est tolérable. Mais généralement s’ajoutent ensuite priority, assigneeName, archived, dueBefore, dueAfter, sort… Et le contrôleur commence à ressembler à une méthode qui « fait tout et n’importe quoi » (surtout n’importe quoi). Techniquement ça fonctionne, mais lire et maintenir devient de plus en plus pénible.

Le problème principal n’est même pas le nombre de lignes. Le problème, c’est que la signature de la méthode cesse d’être une « description du contrat » pour se transformer en « liste de hasards ». Vous ne voyez plus quels paramètres appartiennent à un même scénario, lesquels sont obligatoires, et lesquels ne sont que des filtres.

2. Criteria DTO : qu’est-ce que c’est et ce que ce n’est pas

Un Criteria DTO est un petit objet qui décrit les « conditions de la requête » pour un scénario de liste/recherche. Ce n’est ni une « tâche », ni un « modèle du domaine », ni un « DTO de création », mais une boîte soignée dans laquelle on range tous les paramètres de requête liés à un endpoint donné. Par analogie, cela ressemble à une check-list : « page tant, statut tel, on cherche ceci ».

Il est très important de comprendre ce qu’un Criteria DTO n’est pas. Il ne doit pas se transformer en un « second modèle Task » et contenir des champs qui concernent la tâche elle-même en tant qu’entité (par exemple id, createdAt et autres « intérieurs »), car ce n’est pas un objet de données, mais un objet de requête. Il ne doit pas non plus être « unifié pour tout le projet » à la manière d’un CommonSearchCriteriaForEverythingInTheUniverse. Dans ce cours, les critères doivent être étroits et scénarisés : TaskSearchCriteria pour les tâches, CommentSearchCriteria si jamais nécessaire pour les commentaires, et ainsi de suite.

Et encore un « ne pas » : le Criteria DTO ne doit pas attirer en lui taskId. Si un endpoint adresse une ressource unique et concrète, c’est un path, et c’est un sens différent. Les critères, c’est pour une liste.

Pour le dire de façon très humaine : les critères, c’est « comment je veux chercher », alors que Task, c’est « ce que je stocke ». Ce sont des rôles différents, et les mélanger revient à essayer de cuisiner un bortsch directement dans le réfrigérateur : c’est possible, mais après tout le monde est très triste.

3. @ModelAttribute : construction d’un objet à partir des paramètres de requête

@ModelAttribute est un mécanisme de Spring MVC qui sait créer un objet et le remplir avec les valeurs de la requête. Dans le MVC classique (avec pages et formulaires), on l’utilise souvent pour le form-binding. Mais dans un contrôleur REST, on peut tout aussi bien employer @ModelAttribute pour rassembler les paramètres de la query string. C’est comme un « constructeur d’entrée intelligent » qui nous épargne une dizaine de @RequestParam dans la signature.

Le modèle mental est simple et très utile pour un débutant : la requête arrive dans le contrôleur, et avant d’appeler votre méthode, Spring essaie de préparer les arguments. Pour @ModelAttribute, cela se déroule ainsi :

flowchart TD
    A["HTTP request
/api/v1/tasks?page=1&status=TODO&q=docs"] --> B["Spring MVC
résolution des arguments"] B --> C["Créer TaskSearchCriteria
new TaskSearchCriteria()"] C --> D["Remplir les champs à partir des query params
page=1, status=TODO, q=docs"] D --> E["Appeler la méthode du contrôleur
findTasks(criteria)"]

Il est important de ne pas confondre la mécanique : @ModelAttribute n’invente pas un mécanisme de binding séparé. Spring utilise le même pipeline que pour les @RequestParam individuels : les valeurs brutes de la requête arrivent sous forme de texte, puis sont converties en int, TaskStatus, LocalDate, et ce n’est qu’ensuite que l’objet parvient à la méthode. Donc un enum ou une date incorrecte cassent la requête au même endroit – avant même d’entrer dans la méthode du contrôleur.

Autrement dit, Spring prend l’objet, trouve ses champs/setters, fait correspondre les noms des paramètres (status) aux noms des propriétés (status) et tente d’effectuer la conversion de types. Et c’est là qu’il y a un lien important avec la leçon précédente : les types à l’intérieur du Criteria DTO seront convertis selon les mêmes règles que les @RequestParam individuels. Si le critère contient un TaskStatus status, Spring essaiera de transformer la chaîne "TODO" en enum. S’il y a un LocalDate dueBefore, il essaiera d’analyser la date.

Dans les contrôleurs REST, il y a encore un détail agréable : pour les « types non triviaux », Spring interprète souvent l’argument comme @ModelAttribute par défaut. Mais pour un projet pédagogique, je recommande d’écrire l’annotation explicitement – non pas parce que « sans elle ça ne marche pas », mais parce que le lecteur du code (c’est-à-dire vous dans une semaine) ne doit pas avoir à deviner d’où viennent les données.

4. TaskSearchCriteria : squelette minimal

Construisons maintenant notre Criteria DTO. Nous voulons qu’il reflète les paramètres d’entrée du list-endpoint GET /api/v1/tasks. Et nous ne sommes pas obligés d’implémenter tout de suite le filtrage au niveau service – dans cette leçon, notre objectif est justement de « bien accepter l’entrée », pas de « faire une recherche parfaite ». Mais le DTO doit déjà être correct : avec des types, des valeurs par défaut et des noms clairs.

Commençons par une version minimale. La technique clé avec les valeurs par défaut est la suivante : si un paramètre n’est pas fourni, nous voulons que les valeurs par défaut restent dans l’objet. Le moyen le plus simple est de les définir directement lors de la déclaration du champ.

import java.time.LocalDate;

import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.format.annotation.DateTimeFormat.ISO;

public class TaskSearchCriteria {
    // Pagination : nous voulons un comportement prévisible même sans paramètres de requête
    private int page = 0;
    private int size = 20;

    // Filtres : absence de paramètre = null (filtre non appliqué)
    private TaskStatus status;
    private String q;

    // Exemple de filtre par date : si le paramètre n’est pas fourni, le champ reste null
    @DateTimeFormat(iso = ISO.DATE)
    private LocalDate dueBefore;

    // getters/setters (omis pour la concision)
}

Plusieurs points importants apparaissent immédiatement :

Premièrement : page et size sont des paramètres techniques de restitution. Nous ne voulons pas qu’ils soient null, car « une page sans page » est un objet philosophique étrange. Donc int avec valeurs par défaut est un choix normal.

Deuxièmement : status, q, dueBefore sont des filtres. Leur absence est un scénario normal (« filtre non appliqué »), donc null est ici tout à fait légal et même souhaitable : null signifie « le client n’a pas demandé de filtrage sur ce champ ».

Si vous voulez montrer une version un peu plus « ressemblante à la réalité » (mais toujours pédagogiquement simple), vous pouvez ajouter quelques champs issus de la future spécification de l’endpoint. Même si le service ne les regardera pas encore avec enthousiasme, le contrôleur pourra déjà accepter correctement l’entrée contractuelle :

import java.time.LocalDate;

import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.format.annotation.DateTimeFormat.ISO;

public class TaskSearchCriteria {
    // Pagination (les valeurs par défaut restent dans l’objet si le client n’envoie pas de paramètres)
    private int page = 0;
    private int size = 20;

    // Filtres sur des enums : Spring essaiera de convertir la chaîne de la requête en valeur d’enum
    private TaskStatus status;
    private TaskPriority priority;

    // Filtres textuels : null signifie « ne pas filtrer selon ce critère »
    private String assigneeName;
    private String tag;
    private String q;

    // Plage de dates : chaque borne peut être absente indépendamment
    @DateTimeFormat(iso = ISO.DATE)
    private LocalDate dueBefore;
    @DateTimeFormat(iso = ISO.DATE)
    private LocalDate dueAfter;

    // getters/setters (omis)
}

Remarquez : ici encore, il n’y a pas de taskId. Et c’est une bonne chose. taskId est pour adresser une tâche unique, alors que les critères sont les conditions de recherche des tâches.

Si vous êtes tenté d’ajouter le champ taskId « au cas où », arrêtez-vous, respirez, expirez et rappelez-vous : @ModelAttribute concerne les paramètres de requête, pas le path. Et « au cas où » est le principal fournisseur de bugs futurs.

5. TaskSearchCriteria dans TaskController

Vient maintenant le plus agréable : la signature du contrôleur devient courte et lisible. Nous remplaçons littéralement un « drap de paramètres » par un seul objet. C’est comme si, au lieu de dix sacs de courses, vous preniez un seul grand sac : la physique reste la même, mais la vie est plus facile.

Exemple de contrôleur dans le style de notre projet (avec le chemin de base /api/v1/tasks) :

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TaskController {

    @GetMapping("/api/v1/tasks")
    public void findTasks(@ModelAttribute TaskSearchCriteria criteria) {
        // criteria est déjà construit à partir des paramètres de requête (page/size/status/q/...)
        taskService.findTasks(criteria);
    }
}

Oui, taskService n’est pas montré ici — c’est normal : dans cette leçon, la mécanique d’entrée nous importe. Mais vous voyez déjà le résultat clé : la méthode se lit comme « trouve les tâches selon les critères », et non « trouve les tâches selon ceci, cela, cela, cela et encore cela ».

Pour que l’image devienne « transversale » au niveau du projet, on peut ajouter dans le service une méthode qui accepte les critères. À ce stade, on peut faire une implémentation factice pour vérifier que tout s’articule et que l’application démarre :

import java.util.List;

public class TaskService {

    public List<Task> findTasks(TaskSearchCriteria criteria) {
        // Pour l’instant on ignore criteria et on retourne une liste vide — c’est un stub pour vérifier le couplage des couches
        return List.of(); // pour l’instant sans filtrage réel
    }
}

Ce n’est pas la « logique métier finale », mais simplement une façon normale de vérifier que le contrôleur sait obtenir un objet et que le service sait l’accepter.

Si vous voulez vérifier rapidement que les critères arrivent vraiment, vous pouvez temporairement afficher quelques champs dans le log/console (oui, c’est ce fameux « println de débogage rapide » qui ne vit généralement pas en production, mais qui aide à comprendre la mécanique dans un projet pédagogique) :

public List<Task> findTasks(TaskSearchCriteria criteria) {
    // Vérification rapide : on s’assure que Spring a bien construit l’objet à partir des paramètres de requête
    System.out.println("page = " + criteria.getPage());       // par exemple : page = 1
    System.out.println("status = " + criteria.getStatus());   // par exemple : status = TODO
    return List.of();
}

6. Subtilités utiles

Exemples de requêtes

Gardez en tête une chose simple : à l’intérieur de TaskSearchCriteria, la même couche de conversion (conversion layer) est à l’œuvre que dans la leçon précédente. Autrement dit, status=TODO se transforme en TaskStatus.TODO, page=1 devient un int, et dueBefore=2026-03-21 devient un LocalDate. Si le format est incorrect, l’objet ne se construira tout simplement pas correctement.

Faisons maintenant ce qui est particulièrement utile pour les débutants : « traduisons » des URL réelles en valeurs de champs de l’objet. C’est le moment où @ModelAttribute cesse d’être une annotation abstraite pour devenir un mécanisme que vous pouvez ressentir avec les doigts.

# Exemple : pagination + filtre par statut + recherche textuelle
GET http://localhost:8080/api/v1/tasks?page=1&size=5&status=TODO&q=docs
Accept: application/json

Si tout est configuré normalement, Spring construit l’objet comme suit :

Paramètre de requête Champ dans TaskSearchCriteria Type du champ Valeur
page=1 page int 1
size=5 size int 5
status=TODO status TaskStatus TaskStatus.TODO
q=docs q String "docs"

Si le client n’envoie pas page et size, les valeurs par défaut restent dans l’objet. C’est un aspect particulièrement agréable : vous n’avez pas à disperser dans le code des « si page n’est pas fourni, alors 0 », à condition d’avoir bien choisi les types et les valeurs par défaut dans les critères.

Voici maintenant un exemple avec une date, pour voir qu’à l’intérieur du critère vit la même conversion de type qu’avec @RequestParam :

# Exemple : filtre par date (Spring essaiera d’analyser la chaîne en LocalDate)
GET http://localhost:8080/api/v1/tasks?dueBefore=2026-03-21
Accept: application/json

Si le critère contient LocalDate dueBefore, il recevra la valeur LocalDate.of(2026, 3, 21). Le format de date n’est pas une « magie de date », mais une convention du convertisseur. Dans une API pédagogique, il est important de garder le format prévisible, sinon le client enverra 21.03.2026 et le serveur sera triste.

Règles pour un bon TaskSearchCriteria

Le DTO de critères a un destin étonnant : soit il améliore le code, soit il se transforme lentement en une classe gigantesque où l’on « jette tout ce qu’on a peur de perdre ». Il est donc utile d’établir à l’avance quelques règles pour que l’objet reste un outil, pas une décharge.

Première règle : les critères doivent être liés à un seul endpoint spécifique. TaskSearchCriteria est pour GET /api/v1/tasks. Si vous avez un autre scénario avec une sémantique différente, n’essayez pas de faire un « critère universel » qui sait tout faire, tout le temps et tout de suite. Les choses universelles dans les projets pédagogiques deviennent généralement universellement incompréhensibles.

Deuxième règle : ne pas mélanger les canaux d’entrée. Les critères doivent contenir les paramètres de requête, pas les variables de chemin (path). Si vous ressentez l’envie de mettre taskId dans les critères, c’est que vous commencez à effacer la frontière entre « liste » et « ressource unique ».

Troisième règle : faire correspondre au maximum les noms des paramètres et des champs. assigneeName dans la requête et assigneeName dans le champ – ce n’est pas du « pédantisme », mais une façon de réduire le nombre de décalages inattendus. Moins il y a de « mappage magique de noms », plus vous vivez sereinement.

Quatrième règle : les valeurs par défaut doivent vivre près de l’entrée. Si page a pour valeur par défaut 0 et size 20, il est pratique de les garder dans les critères. Ainsi, quiconque ouvre la classe voit immédiatement « comment se comporte l’endpoint sans paramètres ». Cela rend le contrat prévisible même sans lire le service.

Et une dernière règle, très humaine : le DTO de critères doit rester ennuyeux. C’est un compliment. Il ne doit pas contenir de logique métier, de calculs, d’appels réseau ou de philosophie. Sa mission est d’être un conteneur de données d’entrée. Plus il est ennuyeux, mieux il fait son travail.

7. Erreurs typiques : @ModelAttribute et criteria DTO

Erreur n°1 : transformer le DTO de critères en « objet universel pour tous les cas de la vie ».
On est souvent tenté de créer un seul SearchCriteria qui convienne aussi bien aux tâches qu’aux commentaires, aux pièces jointes et « pour les futurs utilisateurs ». Cela se termine généralement par un objet contenant des champs sans sémantique claire, la moitié des paramètres ne concernant pas l’endpoint courant, et le contrôleur acceptant un « fourre-tout ». Avec les critères, c’est le principe inverse qui fonctionne : plus ils sont étroits et scénarisés, plus ils sont faciles à maintenir.

Erreur n°2 : ajouter aux critères ce qui relève du path (par exemple taskId).
C’est presque toujours un signe que vous avez mélangé adressage et filtrage. taskId répond à la question « quelle tâche manipulons-nous ? », et c’est un chemin, donc @PathVariable. Les critères répondent à la question « comment sélectionner des tâches dans la collection ? », et c’est la query. Si vous mettez taskId dans les critères, l’URI devient illisible et la logique d’entrée devient floue.

Erreur n°3 : essayer de stocker dans les critères une « logique métier toute prête » au lieu des paramètres d’entrée.
Parfois, on commence à ajouter des méthodes comme isEmpty(), hasSearch() ou même des calculs « intelligents ». Ce n’est pas toujours un mal, mais à un stade précoce, il vaut mieux garder l’objet le plus simple possible : champs + getters/setters. Quand les critères commencent à « devenir intelligents », ils entrent souvent en conflit avec le service pour savoir qui est le chef. Dans notre projet, les critères doivent rester une simple description de l’entrée.

Erreur n°4 : choisir des types inappropriés, rendant impossible l’expression « paramètre absent ».
Si un paramètre de filtre est optionnel, int (primitif) peut être un mauvais choix car un primitif n’a pas de null. Par exemple, le filtre priority est mieux stocké comme TaskPriority plutôt que int priority = 0 (que signifie « 0 » ?). Pour les éléments facultatifs, choisissez des types où l’absence de valeur s’exprime naturellement — le plus souvent null pour un type référence.

Erreur n°5 : oublier qu’à l’intérieur des critères fonctionne la conversion de types, et que des valeurs « incorrectes » cassent la requête avant même le service.
Si le client envoie status=DOING alors que l’enum ne connaît que TODO, IN_PROGRESS etc., Spring ne pourra pas construire correctement TaskSearchCriteria. Et votre méthode de contrôleur ne sera même pas appelée. C’est normal et attendu, mais il est important de le comprendre comme faisant partie du tableau d’ingénierie : « l’erreur s’est produite à l’entrée, lors de la construction des arguments ». Dans le cadre de cette leçon, nous n’examinons pas comment ces erreurs se transforment en réponse HTTP, mais pour comprendre la mécanique, il est crucial de se rappeler : parfois, le service n’est pas en faute — on n’est simplement pas arrivé jusqu’à lui.

1
Mission
Spring REST & MVC, niveau 6, leçon 4
Bloqué
Recherche de podcasts via `PodcastSearchCriteria`
Recherche de podcasts via `PodcastSearchCriteria`
1
Mission
Spring REST & MVC, niveau 6, leçon 4
Bloqué
Recherche d'événements avec `EventSearchCriteria`
Recherche d'événements avec `EventSearchCriteria`
1
Étude/Quiz
Paramètres HTTP, niveau 6, leçon 4
Indisponible
Paramètres HTTP
Chemins, filtres et en-têtes
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION