Construire une API REST avec Spring Boot : du contrôleur au service (partie 1/2)

Abdoul Kadri ASSOUMANE MAMANE
Abdoul Kadri ASSOUMANE MAMANE Auteur
17 min de lecture
17 vues

Écouter l'article

Série « Construire une API REST avec Spring Boot » : partie 1 sur 2.

Au sommaire de la série :

  1. Du contrôleur au service (cette partie)
  2. Validation, erreurs et tests

Partie suivante : Validation, erreurs et tests

Cette série en deux parties suit une requête de bout en bout dans une API REST Spring Boot : le contrôleur qui la reçoit, le service qui applique les règles, la réponse construite avec ResponseEntity, puis la gestion globale des erreurs, qui garantit que le client reçoit toujours une réponse lisible, même quand quelque chose se passe mal.

Le fil conducteur est une API de gestion de tâches (Task), volontairement petite. Le code cible Spring Boot 3.x et Java 17 ou plus ; il a été compilé et exécuté avec Spring Boot 3.5 (Spring Framework 6.2) et avec Spring Boot 3.4. Sur Spring Boot 3.0, il passe aussi à deux détails près : @MockBean remplace @MockitoBean dans les tests, et RestClient (partie 2) n'existe pas encore. Les imports sont inclus et chaque classe utilisée est définie, pour que tout puisse être recopié dans un projet généré avec Spring Initializr.

Cette première partie couvre les conventions REST, l'architecture en couches avec les DTO, le contrôleur et le service. La deuxième partie traite de la validation, de la gestion globale des erreurs, de la pagination, des tests et de l'appel de l'API.

1. REST en bref

REST est un style d'architecture plus qu'une norme. Pour une API JSON au-dessus de HTTP, il se résume à quelques conventions :

  • Une ressource, une URI. On nomme des choses, pas des actions : /api/v1/tasks et /api/v1/tasks/42, pas /api/v1/getTask. Le nom de la collection est au pluriel, et le verbe HTTP porte l'action.
  • Sans état. Chaque requête contient ce qu'il faut pour être traitée (identifiants, paramètres, jeton). Le serveur ne garde pas de session entre deux appels, ce qui permet de lancer plusieurs instances derrière un répartiteur de charge.
  • Une représentation, ici du JSON. Le client manipule une représentation de la ressource, pas l'objet stocké en base. On y reviendra avec les DTO.
  • Des codes de statut qui disent le résultat. Le client doit pouvoir réagir sans lire le corps de la réponse.

Deux notions de vocabulaire servent à choisir un verbe. Une méthode est sûre quand elle ne modifie rien côté serveur. Elle est idempotente quand l'envoyer une fois ou dix fois laisse le serveur dans le même état.

Verbe Usage Sûr Idempotent
GET Lire une ressource ou une collection Oui Oui
POST Créer une ressource dans une collection Non Non
PUT Remplacer entièrement une ressource Non Oui
PATCH Modifier une partie de la ressource Non Pas garanti
DELETE Supprimer une ressource Non Oui

L'idempotence concerne l'état du serveur, pas la réponse : un second DELETE sur la même tâche peut renvoyer 404 sans que la propriété soit violée, puisque la tâche reste supprimée. Un PATCH qui ajoute un élément à une liste n'est pas idempotent, alors qu'un PATCH qui fixe un statut l'est en pratique.

Les codes de statut utilisés dans la suite :

Code Quand l'utiliser
200 OK Lecture ou modification réussie, avec un corps dans la réponse
201 Created Ressource créée par un POST ; l'en-tête Location donne son URI
204 No Content Succès sans corps : suppression, ou modification qui ne renvoie rien
400 Bad Request Requête mal formée : JSON invalide, champ obligatoire absent, paramètre de mauvais type
401 Unauthorized L'appelant n'est pas authentifié (jeton absent ou invalide)
403 Forbidden L'appelant est identifié mais n'a pas le droit
404 Not Found La ressource demandée n'existe pas
409 Conflict La requête est valide mais entre en conflit avec l'état actuel (doublon, modification d'une ressource verrouillée)
422 Unprocessable Entity La requête est bien formée mais une règle métier la refuse
500 Internal Server Error Erreur inattendue côté serveur

La frontière entre 400 et 422 varie d'une équipe à l'autre. Une convention répandue réserve 400 à ce que Bean Validation et le parseur JSON rejettent, et 422 aux règles du domaine. Ce qui compte est de choisir une convention et de la tenir sur toute l'API.

2. Une architecture en trois couches et des DTO

Le code se range par fonctionnalité, en packages minuscules. Ici, tout ce qui concerne les tâches vit dans le package tasks, sous un package racine qui porte la classe TasksApplication (le scan des composants part de cette classe) :

src/main/java/com/example/
├── TasksApplication.java
├── client/                    # TaskApiClient (appel de l'API, partie 2)
└── tasks/
    ├── TaskController.java    # HTTP : routes, statuts, en-têtes
    ├── TaskService.java       # règles métier, transactions
    ├── TaskRepository.java    # accès aux données (Spring Data JPA)
    ├── Task.java              # entité JPA
    ├── TaskStatus.java        # énumération
    ├── dto/                   # records échangés avec les clients
    └── exception/             # exceptions métier + GlobalExceptionHandler

Les noms suivent les conventions de l'écosystème : XxxController, XxxService, XxxRepository, une entité au singulier (Task), des DTO nommés CreateXxxRequest, UpdateXxxRequest et XxxResponse, des exceptions nommées d'après ce qui s'est passé (TaskNotFoundException) et un GlobalExceptionHandler annoté @RestControllerAdvice. Ce sont les mêmes que dans les séries sur Spring MVC et sur l'application complète.

Le contrôleur traduit HTTP vers des appels Java et inversement. Le service porte les règles et délimite les transactions. Le repository lit et écrit. Un contrôleur qui contient des conditions métier devient difficile à tester, et un service qui manipule HttpServletRequest ne peut plus être réutilisé ailleurs.

Pourquoi des DTO plutôt que l'entité. Renvoyer directement l'entité JPA relie le contrat de l'API au schéma de la base. Ajouter une colonne interne l'expose au client sans que personne l'ait décidé. Les relations paresseuses peuvent déclencher des requêtes pendant la sérialisation. Et la même classe ne peut pas servir à la fois pour créer (pas d'id, pas de date de création) et pour lire. Les records Java conviennent bien : immuables, courts, et la validation s'écrit sur les composants.

Voici les dépendances Maven nécessaires, dans un projet qui hérite de spring-boot-starter-parent :

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

H2 sert uniquement à lancer l'exemple sans installer de base. Les DTO d'abord, puis l'entité. La validation est détaillée à la section 5 (partie 2) ; les annotations sont déjà posées ici.

package com.example.tasks.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;

public record CreateTaskRequest(
        @NotBlank(message = "Le titre est obligatoire")
        @Size(max = 120, message = "Le titre ne doit pas dépasser 120 caractères")
        String title,

        @Size(max = 2000, message = "La description ne doit pas dépasser 2000 caractères")
        String description,

        LocalDate dueDate) {
}
package com.example.tasks.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;

public record UpdateTaskRequest(
        @NotBlank(message = "Le titre est obligatoire")
        @Size(max = 120, message = "Le titre ne doit pas dépasser 120 caractères")
        String title,

        @Size(max = 2000, message = "La description ne doit pas dépasser 2000 caractères")
        String description,

        LocalDate dueDate) {
}
package com.example.tasks.dto;

import com.example.tasks.TaskStatus;
import jakarta.validation.constraints.NotNull;

public record UpdateTaskStatusRequest(
        @NotNull(message = "Le statut est obligatoire")
        TaskStatus status) {
}
package com.example.tasks.dto;

import com.example.tasks.Task;
import com.example.tasks.TaskStatus;
import java.time.Instant;
import java.time.LocalDate;

public record TaskResponse(
        Long id,
        String title,
        String description,
        TaskStatus status,
        LocalDate dueDate,
        Instant createdAt) {

    public static TaskResponse from(Task task) {
        return new TaskResponse(
                task.getId(),
                task.getTitle(),
                task.getDescription(),
                task.getStatus(),
                task.getDueDate(),
                task.getCreatedAt());
    }
}

UpdateTaskRequest ressemble à CreateTaskRequest. On pourrait n'en garder qu'un, mais deux contrats qui évoluent séparément (une création qui demande un champ de plus plus tard, par exemple) restent plus faciles à faire vivre avec deux types.

Pour les listes, on évite de sérialiser directement Page<T> : Spring Data prévient que la structure JSON de PageImpl n'est pas garantie stable d'une version à l'autre. Un petit record dédié fixe le format :

package com.example.tasks.dto;

import java.util.List;
import org.springframework.data.domain.Page;

public record PageResponse<T>(
        List<T> content,
        int page,
        int size,
        long totalElements,
        int totalPages) {

    public static <T> PageResponse<T> from(Page<T> page) {
        return new PageResponse<>(
                page.getContent(),
                page.getNumber(),
                page.getSize(),
                page.getTotalElements(),
                page.getTotalPages());
    }
}

Reste le côté base de données : l'énumération, l'entité et le repository.

package com.example.tasks;

public enum TaskStatus {
    TODO, IN_PROGRESS, DONE
}
package com.example.tasks;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.time.Instant;
import java.time.LocalDate;

@Entity
public class Task {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 120)
    private String title;

    @Column(length = 2000)
    private String description;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private TaskStatus status = TaskStatus.TODO;

    private LocalDate dueDate;

    @Column(nullable = false, updatable = false)
    private Instant createdAt = Instant.now();

    protected Task() {
        // requis par JPA
    }

    public Task(String title, String description, LocalDate dueDate) {
        this.title = title;
        this.description = description;
        this.dueDate = dueDate;
    }

    public void update(String title, String description, LocalDate dueDate) {
        this.title = title;
        this.description = description;
        this.dueDate = dueDate;
    }

    public void changeStatus(TaskStatus status) {
        this.status = status;
    }

    public Long getId() { return id; }
    public String getTitle() { return title; }
    public String getDescription() { return description; }
    public TaskStatus getStatus() { return status; }
    public LocalDate getDueDate() { return dueDate; }
    public Instant getCreatedAt() { return createdAt; }
}
package com.example.tasks;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface TaskRepository extends JpaRepository<Task, Long> {

    Page<Task> findByStatus(TaskStatus status, Pageable pageable);

    boolean existsByTitleIgnoreCase(String title);
}

3. Le contrôleur

@RestController combine @Controller et @ResponseBody : chaque valeur de retour est écrite dans le corps de la réponse, convertie en JSON par Jackson. @RequestMapping("/api/v1/tasks") pose le préfixe commun, et les annotations @GetMapping, @PostMapping, @PutMapping, @PatchMapping et @DeleteMapping associent un verbe à une méthode.

La signature d'une méthode décrit ce qu'elle attend de la requête :

  • @PathVariable Long id lit un segment de l'URI (/tasks/42) ;
  • @RequestParam(required = false) TaskStatus status lit un paramètre de requête (?status=DONE), avec required et defaultValue pour les cas optionnels ;
  • @RequestBody @Valid CreateTaskRequest request désérialise le JSON du corps puis déclenche la validation ;
  • Pageable est résolu à partir de ?page=0&size=20&sort=title,asc.

Le type de retour ResponseEntity<T> donne la main sur le statut, les en-têtes et le corps. Pour créer une ressource, la réponse attendue est un 201 avec l'URI de la nouvelle ressource dans Location. ServletUriComponentsBuilder.fromCurrentRequest() part de l'URL de la requête en cours (/api/v1/tasks) et y ajoute l'identifiant, ce qui évite d'écrire le préfixe en dur.

package com.example.tasks;

import com.example.tasks.dto.CreateTaskRequest;
import com.example.tasks.dto.PageResponse;
import com.example.tasks.dto.TaskResponse;
import com.example.tasks.dto.UpdateTaskStatusRequest;
import com.example.tasks.dto.UpdateTaskRequest;
import com.example.tasks.exception.InvalidSortException;
import jakarta.validation.Valid;
import java.net.URI;
import java.util.Set;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

@RestController
@RequestMapping("/api/v1/tasks")
public class TaskController {

    private static final Set<String> SORTABLE_FIELDS = Set.of("title", "dueDate", "createdAt", "status");

    private final TaskService taskService;

    public TaskController(TaskService taskService) {
        this.taskService = taskService;
    }

    @GetMapping
    public ResponseEntity<PageResponse<TaskResponse>> findAll(
            @RequestParam(required = false) TaskStatus status,
            @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) {
        checkSortableFields(pageable);
        return ResponseEntity.ok(PageResponse.from(taskService.findAll(status, pageable)));
    }

    @GetMapping("/{id}")
    public ResponseEntity<TaskResponse> findById(@PathVariable Long id) {
        return ResponseEntity.ok(taskService.findById(id));
    }

    @PostMapping
    public ResponseEntity<TaskResponse> create(@Valid @RequestBody CreateTaskRequest request) {
        TaskResponse created = taskService.create(request);
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}")
                .buildAndExpand(created.id())
                .toUri();
        return ResponseEntity.created(location).body(created);
    }

    @PutMapping("/{id}")
    public ResponseEntity<TaskResponse> update(
            @PathVariable Long id,
            @Valid @RequestBody UpdateTaskRequest request) {
        return ResponseEntity.ok(taskService.update(id, request));
    }

    @PatchMapping("/{id}/status")
    public ResponseEntity<TaskResponse> changeStatus(
            @PathVariable Long id,
            @Valid @RequestBody UpdateTaskStatusRequest request) {
        return ResponseEntity.ok(taskService.changeStatus(id, request.status()));
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        taskService.delete(id);
        return ResponseEntity.noContent().build();
    }

    private void checkSortableFields(Pageable pageable) {
        pageable.getSort().forEach(order -> {
            if (!SORTABLE_FIELDS.contains(order.getProperty())) {
                throw new InvalidSortException(order.getProperty());
            }
        });
    }
}

checkSortableFields est expliqué à la section 7 (partie 2). Deux remarques sur les choix de signature.

ResponseEntity ou retour direct avec @ResponseStatus. Quand le statut est toujours le même, on peut écrire :

@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
    taskService.delete(id);
}

C'est plus court, et le statut se lit dans l'annotation. ResponseEntity devient préférable dès que la réponse varie ou doit porter des en-têtes : le Location d'un 201 ne peut pas s'exprimer avec @ResponseStatus, de même qu'un 200 ou un 304 selon un ETag. Ce contrôleur utilise ResponseEntity partout, pour que toutes les méthodes aient la même forme. C'est un choix de lisibilité, pas une obligation.

Location avec UriComponentsBuilder. ServletUriComponentsBuilder.fromCurrentRequest() lit la requête en cours depuis un contexte statique. Une alternative est de déclarer un UriComponentsBuilder en paramètre de la méthode, que Spring injecte :

@PostMapping
public ResponseEntity<TaskResponse> create(
        @Valid @RequestBody CreateTaskRequest request,
        UriComponentsBuilder uriBuilder) {
    TaskResponse created = taskService.create(request);
    URI location = uriBuilder.path("/api/v1/tasks/{id}").buildAndExpand(created.id()).toUri();
    return ResponseEntity.created(location).body(created);
}

Le préfixe est alors écrit en dur, mais la dépendance au contexte de la requête est visible dans la signature, ce qui facilite les tests unitaires du contrôleur. Les deux versions produisent le même en-tête. Derrière un reverse proxy, l'hôte et le schéma de l'URL dépendent des en-têtes X-Forwarded-* ; la propriété server.forward-headers-strategy détermine s'ils sont pris en compte.

4. Le service

Le service applique les règles métier. Il est annoté @Service, reçoit ses dépendances par le constructeur (un seul constructeur suffit, sans @Autowired) et délimite les transactions avec @Transactional. Placer @Transactional(readOnly = true) sur la classe donne un comportement par défaut en lecture seule, que les méthodes d'écriture redéfinissent avec @Transactional. Le mode lecture seule signale au fournisseur JPA et à la connexion JDBC que la transaction n'a pas vocation à écrire.

Le service est une classe concrète : une interface TaskService avec une implémentation TaskServiceImpl n'apporterait rien tant qu'il n'existe qu'une implémentation.

Les exceptions métier sont de petites classes qui décrivent ce qui s'est passé, sans rien savoir de HTTP. La traduction en statut se fait dans un seul endroit, à la section 6 (partie 2).

package com.example.tasks.exception;

public class TaskNotFoundException extends RuntimeException {

    public TaskNotFoundException(Long id) {
        super("La tâche " + id + " n'existe pas.");
    }
}
package com.example.tasks.exception;

public class TaskConflictException extends RuntimeException {

    public TaskConflictException(String message) {
        super(message);
    }
}
package com.example.tasks.exception;

public class InvalidTaskException extends RuntimeException {

    public InvalidTaskException(String message) {
        super(message);
    }
}
package com.example.tasks.exception;

public class InvalidSortException extends RuntimeException {

    public InvalidSortException(String property) {
        super("Tri impossible sur le champ « " + property + " ».");
    }
}

Le service utilise ces exceptions pour trois règles : une tâche inconnue donne 404, un titre déjà pris ou une tâche terminée qu'on veut modifier donnent 409, une échéance dans le passé donne 422.

package com.example.tasks;

import com.example.tasks.dto.CreateTaskRequest;
import com.example.tasks.dto.TaskResponse;
import com.example.tasks.dto.UpdateTaskRequest;
import com.example.tasks.exception.InvalidTaskException;
import com.example.tasks.exception.TaskConflictException;
import com.example.tasks.exception.TaskNotFoundException;
import java.time.LocalDate;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true)
public class TaskService {

    private final TaskRepository taskRepository;

    public TaskService(TaskRepository taskRepository) {
        this.taskRepository = taskRepository;
    }

    public Page<TaskResponse> findAll(TaskStatus status, Pageable pageable) {
        Page<Task> page = (status == null)
                ? taskRepository.findAll(pageable)
                : taskRepository.findByStatus(status, pageable);
        return page.map(TaskResponse::from);
    }

    public TaskResponse findById(Long id) {
        return TaskResponse.from(getTask(id));
    }

    @Transactional
    public TaskResponse create(CreateTaskRequest request) {
        checkDueDate(request.dueDate());
        if (taskRepository.existsByTitleIgnoreCase(request.title())) {
            throw new TaskConflictException("Une tâche intitulée « " + request.title() + " » existe déjà.");
        }
        Task task = new Task(request.title(), request.description(), request.dueDate());
        return TaskResponse.from(taskRepository.save(task));
    }

    @Transactional
    public TaskResponse update(Long id, UpdateTaskRequest request) {
        Task task = getTask(id);
        if (task.getStatus() == TaskStatus.DONE) {
            throw new TaskConflictException("Une tâche terminée ne peut plus être modifiée.");
        }
        checkDueDate(request.dueDate());
        boolean titleChanged = !task.getTitle().equalsIgnoreCase(request.title());
        if (titleChanged && taskRepository.existsByTitleIgnoreCase(request.title())) {
            throw new TaskConflictException("Une tâche intitulée « " + request.title() + " » existe déjà.");
        }
        task.update(request.title(), request.description(), request.dueDate());
        return TaskResponse.from(task);
    }

    @Transactional
    public TaskResponse changeStatus(Long id, TaskStatus status) {
        Task task = getTask(id);
        task.changeStatus(status);
        return TaskResponse.from(task);
    }

    @Transactional
    public void delete(Long id) {
        taskRepository.delete(getTask(id));
    }

    private Task getTask(Long id) {
        return taskRepository.findById(id).orElseThrow(() -> new TaskNotFoundException(id));
    }

    private void checkDueDate(LocalDate dueDate) {
        if (dueDate != null && dueDate.isBefore(LocalDate.now())) {
            throw new InvalidTaskException("La date d'échéance ne peut pas être dans le passé.");
        }
    }
}

Dans update, la tâche est chargée dans la transaction : en appelant task.update(...), l'entité est modifiée et JPA écrit le changement au moment du commit. Il n'y a pas besoin d'appeler save. L'appel à LocalDate.now() rend checkDueDate dépendant de l'horloge ; dans un projet plus grand, on injecterait un java.time.Clock pour pouvoir figer la date dans les tests.

Le contrôle d'unicité du titre fait un existsBy... puis un save. Entre les deux, une autre requête peut insérer le même titre. Pour garantir l'unicité, il faut aussi une contrainte UNIQUE en base ; le test applicatif sert à renvoyer un message propre dans le cas courant.

À retenir de cette première partie

  • Une ressource, une URI au pluriel, un verbe HTTP par action, un code de statut qui dit le résultat.
  • Trois couches (contrôleur, service, repository), rangées par fonctionnalité, et des DTO en record plutôt que l'entité.
  • ResponseEntity quand le statut ou les en-têtes varient, notamment pour le Location d'un 201.
  • Les règles métier vivent dans le service, qui lève des exceptions nommées d'après ce qui s'est passé.

La deuxième partie transforme ces exceptions en réponses ProblemDetail, ajoute la pagination et teste l'ensemble.


Série « Construire une API REST avec Spring Boot » : partie 1 sur 2.

Au sommaire de la série :

  1. Du contrôleur au service (cette partie)
  2. Validation, erreurs et tests

Partie suivante : Validation, erreurs et tests

Partager cet article

Envie de ne rien rater ?

Recevez les nouveaux articles directement dans votre boîte mail. Pas de spam, uniquement du contenu technique.

Commentaires (0)

Laisser un commentaire

0 / 2000

Aucun commentaire pour le moment. Soyez le premier à réagir !

Lecture

Articles similaires

Envie d'en discuter ?

Une opportunité d'alternance, un projet ou une question technique : écrivez-moi, je réponds généralement sous 24 à 48 heures.