Aller au contenu

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, @RequestMapping et 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

src/main/java/xyz/dunkan/todo/todo/TodoController.java (extrait)
@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) { ... }
}
  1. Préfixe commun à toutes les routes de la classe.
  2. 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ètre id (grâce à -parameters, voir Premier projet) et converti en long. GET /api/todos/abc répond 400 : "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çoit 400 ("Required parameter 'done' is not present."). D'où required = false et le type objet Boolean, qui vaut null si le paramètre manque : l'équivalent d'un Option<bool> dans le Query d'Axum, ou de done: bool | None = None en FastAPI. Autre option, une valeur par défaut : @RequestParam(defaultValue = "le monde") String name dans HelloController.
@RequestBody
Le corps de la requête, converti de JSON en objet par Jackson. Sans Content-Type: application/json, la réponse est 415 Unsupported Media Type ; un JSON mal formé donne 400 ("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 :

src/main/java/xyz/dunkan/todo/todo/ (extraits de 3 fichiers)
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 id ou un createdAt.
  • 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 Instant en 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), car CreateTodoRequest n'a pas de done ;
  • son code est dans les paquets tools.jackson.*, mais ses annotations sont restées dans com.fasterxml.jackson.annotation : @JsonProperty("created_at") Instant createdAt dans TodoResponse produit "created_at":"...".

Choisir le code de retour

Une méthode qui renvoie un objet répond 200 OK. Pour le reste :

TodoController.java (extrait)
@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)!
}
  1. Part de l'URL de la requête en cours (http://localhost:8080/api/todos) et ajoute /{id}.
  2. ResponseEntity dé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 :

src/main/java/xyz/dunkan/todo/todo/ApiExceptionHandler.java (extrait)
@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);
    }
}
  1. Un bean qui s'applique à tous les contrôleurs : le gestionnaire global, l'équivalent d'un impl IntoResponse for ApiError en Axum ou d'un @app.exception_handler en FastAPI.
  2. La classe parente traduit déjà les erreurs de Spring MVC (JSON illisible, paramètre mal typé, 405, 415...) au même format.
  3. Le type d'exception traité est déduit du paramètre de la méthode.
  4. Une validation ratée lève MethodArgumentNotValidException ; on ajoute au corps par défaut un champ errors qui 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
  1. Tomcat confie la requête à un thread de son pool (voir Threads et concurrence).
  2. 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.
  3. 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 :

$ curl http://localhost:8080/api/todos/stats
{"total":3,"open":2,"done":1}

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 :

UpdateTodoRequest.java
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 :

Priority.java, puis ajouts dans Todo.java
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; }
CreateTodoRequest.java
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.
  • @RequestMapping sur la classe donne le préfixe ; @GetMapping, @PostMapping... relient chaque méthode à un verbe et un chemin.
  • @PathVariable, @RequestParam (Boolean + required = false pour un paramètre facultatif) et @RequestBody lisent la requête ; une conversion ratée donne 400.
  • Les DTO sont des records, un par usage ; l'entité JPA ne sort jamais du service.
  • ResponseEntity choisit statut et en-têtes (201 + Location) ; @ResponseStatus fixe 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 @RestControllerAdvice qui hérite de ResponseEntityExceptionHandler.