API REST¶
Ce chapitre décortique TodoController : comment une requête HTTP devient un appel de méthode Java, comment le JSON entre et sort, comment on valide les données et comment on renvoie des erreurs propres.
Dans ce chapitre
@RestController,@RequestMappinget les@GetMapping,@PostMapping...- Lire le chemin, les paramètres et le corps :
@PathVariable,@RequestParam,@RequestBody - Des DTO en
record, convertis en JSON par Jackson 3 - Choisir le code HTTP :
ResponseEntity,201 Created,204 No Content - Valider avec
@Valid, et répondre aux erreurs au format Problem Details (RFC 9457) - Le trajet complet d'une requête, de Tomcat à la base
@RestController et @Controller¶
@RestController est une annotation composée : @Controller (« ce bean reçoit des requêtes ») plus @ResponseBody (« ce que renvoie la méthode est le corps de la réponse »). Jackson convertit l'objet renvoyé en JSON ; une String est envoyée telle quelle. @Controller seul sert aux pages HTML générées côté serveur (avec un moteur de gabarits comme Thymeleaf) : la String renvoyée est alors le nom d'une vue à afficher.
Piège : @Controller au lieu de @RestController
Avec @Controller sur HelloController, curl -i http://localhost:8080/hello répond 404 : Spring cherche une vue nommée Bonjour le monde !, ne la trouve pas, et les logs parlent d'un chemin invalide /Bonjour le monde !. Pour une API, c'est toujours @RestController.
Les routes¶
@RestController
@RequestMapping("/api/todos") // (1)!
public class TodoController {
// ... champ service et constructeur
@GetMapping // (2)!
public List<TodoResponse> list(@RequestParam(required = false) Boolean done) { ... }
@GetMapping("/{id}")
public TodoResponse get(@PathVariable long id) { ... }
@PostMapping
public ResponseEntity<TodoResponse> create(@Valid @RequestBody CreateTodoRequest request) { ... }
@PatchMapping("/{id}")
public TodoResponse update(@PathVariable long id, @Valid @RequestBody UpdateTodoRequest request) { ... }
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable long id) { ... }
}
- Préfixe commun à toutes les routes de la classe.
GET /api/todos. Il existe une annotation par verbe HTTP :@GetMapping,@PostMapping,@PutMapping,@PatchMapping,@DeleteMapping; toutes sont des raccourcis de@RequestMapping(method = ...).
Au démarrage, Spring MVC lit ces annotations par réflexion et construit sa table de routage (le mini routeur d'Annotations et réflexion, en plus complet). Il répond seul aux cas non prévus : PUT /api/todos/1 donne 405 avec l'en-tête Allow: GET, DELETE, PATCH, un chemin inconnu donne 404.
Lire la requête¶
@PathVariable- Un morceau du chemin :
{id}est relié au paramètreid(grâce à-parameters, voir Premier projet) et converti enlong.GET /api/todos/abcrépond400:"Failed to convert 'id' with value: 'abc'". @RequestParam- Un paramètre de requête (
?done=true), obligatoire par défaut : avec@RequestParam boolean done, une requête sans?done=reçoit400("Required parameter 'done' is not present."). D'oùrequired = falseet le type objetBoolean, qui vautnullsi le paramètre manque : l'équivalent d'unOption<bool>dans leQueryd'Axum, ou dedone: bool | None = Noneen FastAPI. Autre option, une valeur par défaut :@RequestParam(defaultValue = "le monde") String namedansHelloController. @RequestBody- Le corps de la requête, converti de JSON en objet par Jackson. Sans
Content-Type: application/json, la réponse est415 Unsupported Media Type; un JSON mal formé donne400("Failed to read request"). Même modèle pour un en-tête (@RequestHeader) ou un cookie (@CookieValue).
Les DTO : des records, pas l'entité¶
Ce qui entre et sort en JSON est décrit par des records (voir Records, enums, sealed), un par usage :
public record CreateTodoRequest(
@NotBlank @Size(max = 200) String title) {
}
public record UpdateTodoRequest(
@Nullable @Size(min = 1, max = 200) String title, // null accepté, "" refusé
@Nullable Boolean done) {
}
public record TodoResponse(Long id, String title, boolean done, Instant createdAt) {
public static TodoResponse from(Todo todo) {
return new TodoResponse(todo.getId(), todo.getTitle(), todo.isDone(), todo.getCreatedAt());
}
}
Dans UpdateTodoRequest, un champ absent du JSON vaut null et signifie « ne pas modifier » : d'où Boolean (objet) et pas boolean (qui vaudrait false). Pourquoi ne pas utiliser directement l'entité Todo ?
- Le format de l'API ne dépend pas du schéma : renommer une colonne ne casse pas les clients.
- Le client ne touche qu'à ce qu'il a le droit de modifier : avec l'entité en entrée, il pourrait envoyer un
idou uncreatedAt. - Pas de surprise à la sérialisation : une entité JPA peut contenir des relations chargées à la demande, que Jackson déclencherait (ou ferait échouer) hors de toute transaction.
Ce que fait Jackson 3, branché par Spring Boot :
- il lit et écrit les records sans annotation, dans l'ordre de leurs composants ;
- il écrit un
Instanten texte ISO-8601 :"createdAt":"2026-10-21T15:40:49.827439Z"; - il ignore les champs inconnus :
{"title": "Ok", "done": "peut-être"}crée bien la tâche (201), carCreateTodoRequestn'a pas dedone; - son code est dans les paquets
tools.jackson.*, mais ses annotations sont restées danscom.fasterxml.jackson.annotation:@JsonProperty("created_at") Instant createdAtdansTodoResponseproduit"created_at":"...".
Choisir le code de retour¶
Une méthode qui renvoie un objet répond 200 OK. Pour le reste :
@PostMapping
public ResponseEntity<TodoResponse> create(@Valid @RequestBody CreateTodoRequest request) {
TodoResponse created = service.create(request);
URI location = ServletUriComponentsBuilder.fromCurrentRequest() // (1)!
.path("/{id}")
.buildAndExpand(created.id())
.toUri();
return ResponseEntity.created(location).body(created); // (2)!
}
- Part de l'URL de la requête en cours (
http://localhost:8080/api/todos) et ajoute/{id}. ResponseEntitydécrit toute la réponse : statut, en-têtes, corps. C'est l'équivalent du tuple(StatusCode, HeaderMap, Json<T>)d'Axum. Autres fabriques :ResponseEntity.ok(...),ResponseEntity.noContent().build()...
$ curl -i -X POST http://localhost:8080/api/todos -H 'Content-Type: application/json' -d '{"title": "Acheter du pain"}'
HTTP/1.1 201
Location: http://localhost:8080/api/todos/1
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 21 Oct 2026 15:40:49 GMT
{"id":1,"title":"Acheter du pain","done":false,"createdAt":"2026-10-21T15:40:49.827439Z"}
Pour un code fixe, plus simple : @ResponseStatus(HttpStatus.NO_CONTENT) sur delete (bloc des routes, plus haut) fait répondre 204 No Content, sans corps, à une méthode void.
La validation¶
Le starter spring-boot-starter-validation apporte Hibernate Validator, l'implémentation de référence de Jakarta Validation. Les contraintes se posent sur les composants des records ; @Valid devant @RequestBody déclenche la vérification avant l'appel de ta méthode. Les contraintes courantes : @NotNull, @NotBlank (ni null, ni vide, ni que des espaces), @Size(min, max), @Min, @Max, @Positive, @Email, @Pattern(regexp). Toutes, sauf @NotNull, @NotBlank et @NotEmpty, considèrent null comme valide : c'est ce qui permet à UpdateTodoRequest d'accepter un title absent.
Piège : oublier @Valid
Sans @Valid, les annotations ne sont que des étiquettes (voir Annotations et réflexion) : personne ne les lit. {"title": ""} crée alors une tâche au titre vide (201), et {} finit en 500 Internal Server Error quand la base refuse le titre NULL (NULL not allowed for column "TITLE").
Les messages d'erreur suivent l'en-tête Accept-Language de la requête, ou à défaut la langue de la JVM ; pour un message à toi : @NotBlank(message = "le titre est obligatoire").
$ curl -X POST http://localhost:8080/api/todos -H 'Content-Type: application/json' -H 'Accept-Language: fr' -d '{"title": ""}'
{"detail":"Invalid request content.","instance":"/api/todos","status":400,"title":"Bad Request","errors":{"title":"ne doit pas être vide"}}
Les erreurs au format Problem Details¶
Les exceptions métier, TodoNotFoundException et TooManyOpenTodosException, héritent de RuntimeException et ne savent rien de HTTP (voir Exceptions et Optional). Un seul endroit les traduit en réponses :
@RestControllerAdvice // (1)!
public class ApiExceptionHandler extends ResponseEntityExceptionHandler { // (2)!
@ExceptionHandler // (3)!
public ProblemDetail handleTodoNotFound(TodoNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler
public ProblemDetail handleTooManyOpenTodos(TooManyOpenTodosException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
problem.setProperty("maxOpen", ex.getMaxOpen()); // champ supplémentaire dans le JSON
return problem;
}
@Override // (4)!
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
Map<String, String> errors = new LinkedHashMap<>();
for (FieldError error : ex.getBindingResult().getFieldErrors()) {
errors.putIfAbsent(error.getField(), error.getDefaultMessage());
}
ex.getBody().setProperty("errors", errors);
return super.handleMethodArgumentNotValid(ex, headers, status, request);
}
}
- Un bean qui s'applique à tous les contrôleurs : le gestionnaire global, l'équivalent d'un
impl IntoResponse for ApiErroren Axum ou d'un@app.exception_handleren FastAPI. - La classe parente traduit déjà les erreurs de Spring MVC (JSON illisible, paramètre mal typé,
405,415...) au même format. - Le type d'exception traité est déduit du paramètre de la méthode.
- Une validation ratée lève
MethodArgumentNotValidException; on ajoute au corps par défaut un champerrorsqui dit, champ par champ, ce qui ne va pas.
ProblemDetail suit la RFC 9457 : un JSON de type application/problem+json avec type (about:blank par défaut, alors omis), title, status, detail, instance (le chemin), plus tes propres champs. Les vraies réponses :
$ curl -i http://localhost:8080/api/todos/999
HTTP/1.1 404
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Wed, 21 Oct 2026 15:40:50 GMT
{"detail":"Todo 999 not found","instance":"/api/todos/999","status":404,"title":"Not Found"}
$ curl -X POST http://localhost:8080/api/todos -H 'Content-Type: application/json' -d '{"title": "Une de trop"}'
{"detail":"Too many open todos (max 3): finish or delete some first","instance":"/api/todos","status":409,"title":"Conflict","maxOpen":3}
$ curl -X POST http://localhost:8080/api/todos -H 'Content-Type: application/json' -d '{"title": ""}'
{"detail":"Invalid request content.","instance":"/api/todos","status":400,"title":"Bad Request","errors":{"title":"must not be blank"}}
Le 409 a été obtenu avec la limite abaissée à 3 tâches ouvertes (--todo.max-open=3, voir Configuration). Sans gestionnaire pour TodoNotFoundException, l'exception remonterait jusqu'à Spring Boot, qui répondrait 500 dans son format par défaut : {"timestamp":"...","status":500,"error":"Internal Server Error","path":"/api/todos/999"}.
Les endpoints en pratique¶
| Requête | Méthode Java | Succès | Erreurs |
|---|---|---|---|
GET /api/todos?done= |
list |
200 |
400 (done invalide) |
GET /api/todos/{id} |
get |
200 |
404 |
POST /api/todos |
create |
201 + Location |
400, 409 |
PATCH /api/todos/{id} |
update |
200 |
400, 404, 409 |
DELETE /api/todos/{id} |
delete |
204 |
404 |
Une session complète, sur une base vide :
$ curl -X POST http://localhost:8080/api/todos -H 'Content-Type: application/json' -d '{"title": "Acheter du pain"}'
{"id":1,"title":"Acheter du pain","done":false,"createdAt":"2026-10-21T16:35:46.131388Z"}
$ curl -X POST http://localhost:8080/api/todos -H 'Content-Type: application/json' -d '{"title": "Lire la doc de Spring"}'
{"id":2,"title":"Lire la doc de Spring","done":false,"createdAt":"2026-10-21T16:35:46.261559Z"}
$ curl -X PATCH http://localhost:8080/api/todos/1 -H 'Content-Type: application/json' -d '{"done": true}'
{"id":1,"title":"Acheter du pain","done":true,"createdAt":"2026-10-21T16:35:46.131388Z"}
$ curl "http://localhost:8080/api/todos?done=false"
[{"id":2,"title":"Lire la doc de Spring","done":false,"createdAt":"2026-10-21T16:35:46.261559Z"}]
$ curl -i -X DELETE http://localhost:8080/api/todos/2
HTTP/1.1 204
Date: Wed, 21 Oct 2026 16:35:46 GMT
$ curl http://localhost:8080/api/todos/2
{"detail":"Todo 2 not found","instance":"/api/todos/2","status":404,"title":"Not Found"}
Le trajet d'une requête¶
sequenceDiagram
participant C as curl
participant T as Tomcat
participant D as DispatcherServlet
participant J as Jackson et Validator
participant TC as TodoController
participant S as TodoService (proxy)
participant R as TodoRepository
C->>T: POST /api/todos (JSON)
T->>D: un thread du pool traite la requête
D->>D: cherche la méthode (table de routage)
D->>J: JSON vers CreateTodoRequest, puis @Valid
J-->>D: si invalide, exception puis 400 (ApiExceptionHandler)
D->>TC: create(request)
TC->>S: create(request)
S->>S: proxy, BEGIN
S->>R: countByDoneFalse() puis save(todo)
R-->>S: Todo avec son id
S->>S: proxy, COMMIT
S-->>TC: TodoResponse
TC-->>D: ResponseEntity 201 + Location
D->>J: TodoResponse vers JSON
D-->>C: HTTP/1.1 201
- Tomcat confie la requête à un thread de son pool (voir Threads et concurrence).
- Le
DispatcherServlet, point d'entrée unique de Spring MVC, trouve la méthode qui correspond au verbe et au chemin, puis prépare ses arguments : conversion du chemin et des paramètres, lecture du corps par Jackson, validation si@Valid. - Le contrôleur appelle le service, en réalité son proxy transactionnel (voir Injection de dépendances), qui encadre l'appel par la transaction. Le retour repasse par Jackson ; une exception, à n'importe quelle étape, est confiée à
ApiExceptionHandler.
Comparaison : Axum, FastAPI, Spring¶
Le même POST /api/todos (extraits volontairement incomplets) :
public record CreateTodoRequest(@NotBlank @Size(max = 200) String title) {}
@PostMapping
public ResponseEntity<TodoResponse> create(@Valid @RequestBody CreateTodoRequest request) {
TodoResponse created = service.create(request);
URI location = ...; // construite comme plus haut
return ResponseEntity.created(location).body(created);
}
#[derive(Deserialize, Validate)]
struct CreateTodoRequest { #[validate(length(min = 1, max = 200))] title: String }
async fn create(
State(service): State<Arc<TodoService>>,
Json(request): Json<CreateTodoRequest>,
) -> Result<(StatusCode, [(HeaderName, String); 1], Json<TodoResponse>), ApiError> {
request.validate()?; // validation explicite (crate validator)
let todo = service.create(request).await?;
let location = format!("/api/todos/{}", todo.id);
Ok((StatusCode::CREATED, [(header::LOCATION, location)], Json(todo)))
}
class CreateTodoRequest(BaseModel):
title: str = Field(min_length=1, max_length=200)
@app.post("/api/todos", status_code=status.HTTP_201_CREATED)
def create(request: CreateTodoRequest, response: Response) -> TodoResponse:
todo = service.create(request)
response.headers["Location"] = f"/api/todos/{todo.id}"
return todo
FastAPI valide automatiquement, comme Spring avec @Valid, mais répond 422 Unprocessable Entity là où Spring répond 400. En Rust, la validation est un appel explicite, et c'est la conversion de ApiError en réponse qui fixe le format des erreurs.
Exercices¶
1. Statistiques. Ajoute GET /api/todos/stats, qui renvoie {"total": 3, "open": 2, "done": 1}. Spring confond-il /stats avec /{id} ?
Solution
public record TodoStats(long total, long open, long done) { // TodoStats.java
}
@Transactional(readOnly = true) // dans TodoService
public TodoStats stats() {
long total = repository.count();
long open = repository.countByDoneFalse();
return new TodoStats(total, open, total - open);
}
@GetMapping("/stats") // dans TodoController
public TodoStats stats() {
return service.stats();
}
Après trois créations et un PATCH à done: true :
Pas de confusion : quand plusieurs routes correspondent, Spring choisit la plus précise, et un segment littéral (/stats) l'emporte sur une variable (/{id}). count() est fourni par JpaRepository.
2. Un titre blanc au PATCH. {"title": " "} est refusé à la création mais accepté par le PATCH : @Size(min = 1) ne voit pas que le titre ne contient que des espaces. Corrige UpdateTodoRequest pour refuser un titre blanc tout en acceptant un titre absent, avec le même message (traduit) que @NotBlank.
Solution
@NotBlank refuserait aussi null. Une expression régulière fait l'affaire, car @Pattern accepte null :
public record UpdateTodoRequest(
@Nullable
@Size(max = 200)
// au moins un caractère qui n'est pas un espace ; null reste accepté
@Pattern(regexp = ".*\\S.*", message = "{jakarta.validation.constraints.NotBlank.message}")
String title,
@Nullable Boolean done) {
}
Le message entre accolades est une clé : celle du message de @NotBlank, déjà traduit dans Hibernate Validator.
$ curl -X PATCH http://localhost:8080/api/todos/1 -H 'Content-Type: application/json' -d '{"title": " "}'
{"detail":"Invalid request content.","instance":"/api/todos/1","status":400,"title":"Bad Request","errors":{"title":"must not be blank"}}
$ curl -X PATCH http://localhost:8080/api/todos/1 -H 'Content-Type: application/json' -H 'Accept-Language: fr' -d '{"title": ""}'
{"detail":"Invalid request content.","instance":"/api/todos/1","status":400,"title":"Bad Request","errors":{"title":"ne doit pas être vide"}}
$ curl -X PATCH http://localhost:8080/api/todos/1 -H 'Content-Type: application/json' -d '{"done": true}'
{"id":1,"title":"Acheter du pain","done":true,"createdAt":"2026-10-21T16:12:41.154261Z"}
3. Une priorité. Ajoute aux tâches une priorité LOW, NORMAL ou HIGH, facultative à la création (NORMAL par défaut) et renvoyée dans les réponses ; une valeur inconnue doit donner 400. Il faut une colonne de plus : ajoute une migration V2__add_priority.sql à côté de la V1 (Flyway est détaillé dans Base de données (JPA)).
Solution
Migration src/main/resources/db/migration/V2__add_priority.sql : ALTER TABLE todo ADD COLUMN priority VARCHAR(10) DEFAULT 'NORMAL' NOT NULL;. Puis :
public enum Priority { LOW, NORMAL, HIGH }
@Enumerated(EnumType.STRING) // stocké en texte ('HIGH'), pas en numéro d'ordre
@Column(nullable = false, length = 10)
private Priority priority;
public Todo(String title) { this(title, Priority.NORMAL); } // gardé pour les tests
public Todo(String title, Priority priority) {
this.title = title;
this.priority = priority;
// ... done et createdAt comme avant
}
public Priority getPriority() { return priority; }
public record CreateTodoRequest(
@NotBlank @Size(max = 200) String title,
@Nullable Priority priority) { // absent du JSON : null, donc NORMAL
public CreateTodoRequest(String title) { // les tests existants compilent toujours
this(title, null);
}
}
TodoResponse gagne un composant Priority priority (rempli par todo.getPriority() dans from), et TodoService.create fait new Todo(request.title(), priority) avec NORMAL si request.priority() est null. Le compilateur signale enfin les deux new TodoResponse(...) de TodoControllerTest : ajoute Priority.NORMAL, et "priority": "NORMAL" dans le JSON attendu. ./mvnw verify passe ; Flyway affiche Migrating schema "PUBLIC" to version "2 - add priority".
$ curl -X POST http://localhost:8080/api/todos -H 'Content-Type: application/json' -d '{"title": "Payer le loyer", "priority": "HIGH"}'
{"id":1,"title":"Payer le loyer","done":false,"createdAt":"2026-10-21T16:13:37.490446Z","priority":"HIGH"}
$ curl -X POST http://localhost:8080/api/todos -H 'Content-Type: application/json' -d '{"title": "Ranger le bureau"}'
{"id":2,"title":"Ranger le bureau","done":false,"createdAt":"2026-10-21T16:13:37.656452Z","priority":"NORMAL"}
$ curl -X POST http://localhost:8080/api/todos -H 'Content-Type: application/json' -d '{"title": "Urgent !", "priority": "URGENT"}'
{"detail":"Failed to read request","instance":"/api/todos","status":400,"title":"Bad Request"}
Jackson convertit les enums par leur nom : URGENT rend le JSON illisible pour lui, d'où le 400 générique.
À retenir¶
@RestController=@Controller+@ResponseBody: la valeur renvoyée est le corps de la réponse, en JSON pour un objet.@RequestMappingsur la classe donne le préfixe ;@GetMapping,@PostMapping... relient chaque méthode à un verbe et un chemin.@PathVariable,@RequestParam(Boolean+required = falsepour un paramètre facultatif) et@RequestBodylisent la requête ; une conversion ratée donne400.- Les DTO sont des records, un par usage ; l'entité JPA ne sort jamais du service.
ResponseEntitychoisit statut et en-têtes (201+Location) ;@ResponseStatusfixe un code constant (204).- Sans
@Valid,@NotBlank,@Size... sont ignorées ; avec, une requête invalide n'atteint jamais ta méthode. - Les exceptions métier deviennent des réponses Problem Details dans un seul
@RestControllerAdvicequi hérite deResponseEntityExceptionHandler.