Spring Boot et Spring Cloud
API REST avec Spring Boot 3 : contrôleurs, DTO, codes de statut et clients
Intermédiaire9 min de lectureMis à jour
Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise
Vous saurez construire une API JSON propre avec Spring Boot 3 : associer les ressources à des contrôleurs, utiliser des records comme corps de requête et de réponse, renvoyer les bons codes de statut et en-têtes, gérer la pagination et la négociation de contenu, configurer CORS en connaissance de cause et appeler d'autres services avec le RestClient apparu dans Spring Framework 6.1.
Ce que vous allez apprendre
- Structurer une API orientée ressources avec @RestController et les annotations de mapping composées
- Utiliser les records Java comme DTO de requête et de réponse et garder les entités JPA hors du contrat de l'API
- Renvoyer les bons codes de statut HTTP, en-têtes Location et corps ResponseEntity pour la création, la mise à jour et la suppression
- Prendre en charge la pagination, le tri et la négociation de contenu sans exposer les détails de persistance
- Appeler des services HTTP en aval avec RestClient et des clients déclaratifs HTTP interface
La plupart des services Spring Boot existent pour exposer une API HTTP que d'autres programmes appellent. Spring MVC rend la mécanique facile, et c'est précisément pour cela que tant d'API finissent incohérentes : un endpoint renvoie 200 à la création, un autre renvoie des entités qui lèvent des exceptions de chargement lazy, un troisième accepte une taille de page d'un million. Cette page montre comment construire une API qui se comporte comme une API bien conçue, de la couche contrôleur jusqu'aux DTO, aux codes de statut et à la pagination, et se termine côté client, car un service au sein d'un système de services est aussi un consommateur. Elle suppose Spring Boot 3.2 ou plus récent, pour disposer de RestClient.
Le cycle de vie d'une requête en un paragraphe
Une requête arrive dans le Tomcat embarqué, traverse la chaîne de filtres servlet (sécurité, CORS, traçage) et atterrit dans la DispatcherServlet. Le dispatcher demande à ses handler mappings quelle méthode de contrôleur correspond au chemin et à la méthode HTTP, convertit les variables de chemin, les paramètres de requête et le corps en arguments de méthode, invoque la méthode, puis confie la valeur de retour à un convertisseur de messages qui écrit du JSON (Jackson par défaut). Les exceptions remontent jusqu'aux gestionnaires d'exceptions, qui produisent une réponse d'erreur. Tout ce que vous configurez se situe à l'un de ces points : savoir où se trouve une fonctionnalité (filtre, argument resolver, convertisseur de messages, gestionnaire d'exceptions) vous indique comment la personnaliser.
Des contrôleurs orientés ressources
Une API REST modélise des ressources adressées par des URL et manipulées avec des méthodes HTTP. Traduit en Spring, cela donne un contrôleur par agrégat, avec un chemin au niveau de la classe et les verbes au niveau des méthodes :
package com.example.shop.order;
import java.net.URI;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
@RestController
@RequestMapping("/api/orders")
class OrderController {
private final OrderService orders;
OrderController(OrderService orders) {
this.orders = orders;
}
@GetMapping("/{id}")
OrderResponse get(@PathVariable long id) {
return OrderResponse.from(orders.get(id)); // throws OrderNotFoundException -> 404
}
@PostMapping
ResponseEntity<OrderResponse> create(@RequestBody CreateOrderRequest request) {
Order created = orders.create(request.customerId(), request.lines());
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(created.id()).toUri();
return ResponseEntity.created(location).body(OrderResponse.from(created));
}
@PutMapping("/{id}/shipping-address")
OrderResponse changeAddress(@PathVariable long id, @RequestBody Address address) {
return OrderResponse.from(orders.changeShippingAddress(id, address));
}
@DeleteMapping("/{id}")
@ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT)
void cancel(@PathVariable long id) {
orders.cancel(id);
}
}
Plusieurs de ces choix sont délibérés. L'injection par constructeur permet de tester le contrôleur sans conteneur. Le contrôleur ne contient aucune logique métier : il traduit le HTTP en appels de service et les objets du domaine en réponses. @ResponseStatus sert quand le statut est fixe, ResponseEntity quand le statut ou les en-têtes dépendent du résultat. Le chemin utilise des noms et une sous-ressource imbriquée (/shipping-address) plutôt qu'un verbe comme /updateAddress.
Spring MVC fait correspondre les chemins avec PathPatternParser depuis Boot 2.6, et Spring Framework 6 a cessé de tolérer par défaut la barre oblique finale : /api/orders/ n'est plus routé vers /api/orders. Si des clients dépendent de l'ancien comportement, ajoutez une redirection ou configurez UrlHandlerFilter plutôt que de rétablir l'option globale.
Lier les données de la requête
| Annotation | Lie | Usage typique |
|---|---|---|
@PathVariable | Un segment de gabarit d'URI | Identifiants de ressources |
@RequestParam | Un paramètre de requête ou de formulaire | Filtres, pagination, indicateurs ; required = false et defaultValue pour les paramètres optionnels |
@RequestBody | Le corps entier, via un convertisseur de messages | Charges utiles JSON |
@RequestHeader | Un en-tête | Identifiants de corrélation, requêtes conditionnelles |
@ModelAttribute (implicite) | Plusieurs paramètres de requête dans un seul objet | Formulaires de recherche avec plusieurs champs optionnels |
Depuis Spring Framework 6.1, les noms de paramètres ne sont plus accessibles par réflexion à moins que le code ne soit compilé avec -parameters ; le POM parent de Boot et le plugin Gradle positionnent ce flag, mais un build personnalisé doit l'ajouter, faute de quoi chaque @PathVariable exige un nom explicite.
Des records comme DTO
Utilisez des records dans les deux sens. Ils sont immuables, concis, et Jackson (fourni par le starter web) les sérialise et les désérialise sans aucune annotation :
public record CreateOrderRequest(long customerId, List<OrderLine> lines) {
public record OrderLine(String sku, int quantity) {}
}
public record OrderResponse(long id, String status, Money total, List<OrderLine> lines) {
static OrderResponse from(Order order) {
return new OrderResponse(order.id(), order.status().name(),
order.total(), order.lines().stream().map(OrderLine::from).toList());
}
}
Garder les entités JPA hors du contrat évite trois problèmes récurrents : des collections lazy qui explosent en LazyInitializationException pendant la sérialisation, des associations bidirectionnelles qui partent en récursion infinie, et l'exposition accidentelle de colonnes que personne n'avait l'intention de publier. Les méthodes de mapping sont fastidieuses à écrire mais faciles à relire ; MapStruct les génère si leur nombre augmente.
Quelques réglages Jackson dont presque toutes les API ont besoin se configurent dans les propriétés plutôt que par des annotations :
spring.jackson.default-property-inclusion=non_null
spring.jackson.serialization.write-dates-as-timestamps=false
spring.jackson.deserialization.fail-on-unknown-properties=false
Avec le deuxième réglage, les types java.time sont sérialisés en chaînes ISO-8601 ; le premier retire les valeurs nulles des réponses ; le troisième est la valeur par défaut de Boot et préserve les anciens clients quand vous ajoutez des champs. Si vous devez interdire les champs inconnus sur un type de requête précis, annotez ce record avec @JsonIgnoreProperties(ignoreUnknown = false).
Des codes de statut et des en-têtes qui ont un sens
La sémantique HTTP (RFC 9110) est le contrat que vos clients comprennent déjà ; utilisez-la au lieu d'inventer une enveloppe avec "success": true :
| Situation | Statut | Remarques |
|---|---|---|
| Lecture réussie | 200 OK | Le corps est la représentation |
| Ressource créée | 201 Created | En-tête Location ; corps facultatif mais utile |
| Accepté pour un traitement asynchrone | 202 Accepted | Le corps ou un en-tête indique au client où suivre l'avancement |
| Mise à jour ou suppression sans rien à renvoyer | 204 No Content | Aucun corps |
| Entrée mal formée ou invalide | 400 Bad Request | Ou 422 quand la syntaxe est correcte mais pas la sémantique |
| Non authentifié / interdit | 401 / 403 | Produits par Spring Security |
| Ressource inconnue | 404 Not Found | Aussi pour des identifiants valides que l'appelant n'a pas le droit de voir, si vous masquez leur existence |
| Conflit de version, doublon | 409 Conflict | Verrouillage optimiste, contraintes d'unicité |
| Échec inattendu | 500 Internal Server Error | Ne jamais divulguer de stack trace |
ResponseEntity est l'outil adapté à tout ce qui va au-delà d'un statut fixe :
@GetMapping("/{id}")
ResponseEntity<OrderResponse> get(@PathVariable long id,
@RequestHeader(value = "If-None-Match", required = false) String etag) {
Order order = orders.get(id);
String current = "\"" + order.version() + "\"";
if (current.equals(etag)) {
return ResponseEntity.status(304).eTag(current).build();
}
return ResponseEntity.ok().eTag(current).body(OrderResponse.from(order));
}
Pour le côté erreur (traduire les exceptions en réponses ProblemDetail), consultez le guide validation et gestion des erreurs ; ce qu'il faut retenir ici, c'est que le cas nominal lui-même doit déjà être précis. La référence des codes de statut HTTP, dans la rubrique Web, liste chaque code et indique quand l'utiliser.
Pagination, tri et filtrage
Les collections non bornées sont le bug de performance le plus courant dans les API. Le Pageable de Spring Data peut être injecté directement dans une méthode de contrôleur lorsque le support web de Spring Data est présent sur le classpath, ce qui est le cas avec spring-boot-starter-data-jpa :
@GetMapping
Page<OrderSummary> list(@RequestParam(required = false) String status,
@PageableDefault(size = 20, sort = "createdAt") Pageable pageable) {
return orders.search(status, pageable).map(OrderSummary::from);
}
Les clients envoient ?page=0&size=20&sort=createdAt,desc. Plafonnez la taille (spring.data.web.pageable.max-page-size, 2000 par défaut, est généralement bien trop généreux pour une API) et envisagez de renvoyer une forme de page stable et minimale. Depuis Spring Data 3.3, sérialiser directement une Page produit un avertissement dans les logs, car sa forme JSON n'est pas considérée comme un contrat stable ; positionnez spring.data.web.pageable.serialization-mode=via_dto pour obtenir la structure plus simple de PagedModel, ou encapsulez la page dans votre propre record afin que le contrat ne bouge pas quand une bibliothèque évolue. La pagination par offset convient aux écrans d'administration ; pour les flux et les grandes tables, préférez la pagination par clé (keyset, ?after=<last id>), qui reste rapide quelle que soit la profondeur.
Le filtrage passe par les paramètres de requête (?status=SHIPPED&customerId=42), jamais par le corps d'un GET. Si une recherche est assez complexe pour nécessiter un corps, modélisez-la sous la forme d'un POST /api/orders/search et documentez le fait qu'elle est sûre et idempotente.
Négociation de contenu et versionnement
Spring MVC choisit le format de la réponse d'après l'en-tête Accept. Si seul Jackson est présent, la réponse est toujours du JSON ; ajoutez jackson-dataformat-xml et le même contrôleur sert du XML au client qui le demande. Activer le paramètre de requête format (spring.mvc.contentnegotiation.favor-parameter=true) rend service quand des navigateurs ou des proxys altèrent les en-têtes.
Le versionnement n'a pas de réponse unique. Les préfixes d'URL (/api/v2/orders) sont les plus simples à router, à mettre en cache et à documenter ; un type de média personnalisé (Accept: application/vnd.shop.v2+json) garde les URL stables au prix de la découvrabilité ; un en-tête (X-API-Version: 2) est simple mais invisible pour les caches. Quel que soit votre choix, ne créez une nouvelle version que pour les changements cassants, et faites les changements additifs (nouveaux champs optionnels) sans changer de version.
CORS
Les navigateurs refusent les appels JavaScript cross-origin tant que le serveur ne les autorise pas explicitement. Une application monopage servie depuis https://shop.example.com qui appelle https://api.example.com a besoin d'une politique CORS (Cross-Origin Resource Sharing) :
@Configuration
class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://shop.example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("Authorization", "Content-Type")
.maxAge(3600);
}
}
@CrossOrigin sur un contrôleur fait la même chose par classe ou par méthode. Si Spring Security est dans la chaîne, CORS doit aussi y être activé (http.cors(Customizer.withDefaults())) pour que la requête OPTIONS de pré-vérification (preflight) ne soit pas rejetée avant d'atteindre MVC. Les navigateurs refusent les origines génériques combinées aux credentials, et une origine générique seule signifie que n'importe quel site web peut appeler votre API depuis le navigateur d'un utilisateur connecté : listez les origines que vous servez réellement.
Appeler d'autres services
Spring Framework 6.1 a introduit RestClient, un client synchrone qui reprend le style fluent de WebClient sans dépendance réactive. Boot 3.2 auto-configure un RestClient.Builder qui embarque déjà les convertisseurs de messages et tous les beans RestClientCustomizer (les en-têtes de traçage Micrometer, par exemple). Injectez donc le builder plutôt que d'appeler RestClient.create() :
@Service
class InventoryClient {
private final RestClient client;
InventoryClient(RestClient.Builder builder,
@Value("${app.inventory.base-url}") String baseUrl) {
this.client = builder.baseUrl(baseUrl).build();
}
StockLevel stockFor(String sku) {
return client.get()
.uri("/stock/{sku}", sku)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError,
(req, res) -> { throw new UnknownSkuException(sku); })
.body(StockLevel.class);
}
}
Pour un client qui couvre davantage d'opérations, déclarez une HTTP interface et laissez Spring en générer l'implémentation :
interface InventoryApi {
@GetExchange("/stock/{sku}")
StockLevel stock(@PathVariable String sku);
@PostExchange("/reservations")
Reservation reserve(@RequestBody ReservationRequest request);
}
@Bean
InventoryApi inventoryApi(RestClient.Builder builder, AppProperties props) {
RestClient client = builder.baseUrl(props.inventory().baseUrl()).build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(client))
.build()
.createClient(InventoryApi.class);
}
Quel que soit le client, définissez des timeouts de connexion et de lecture (spring.http.client.connect-timeout et read-timeout à partir de Boot 3.4, ou via la request factory avant cela). Un client sans timeout de lecture transforme une dépendance lente en épuisement des threads de votre propre service.
Erreurs courantes
Des verbes dans les URL et tout en POST. /api/getOrder?id=1 sacrifie le cache, la sémantique des méthodes et la capacité des outils à comprendre l'API. Associez les lectures à GET, la création à POST, le remplacement complet à PUT, les mises à jour partielles à PATCH et la suppression à DELETE.
Renvoyer 200 pour tout, avec un indicateur d'erreur dans le corps. Les clients, les proxys, la supervision et la logique de retry s'appuient tous sur le code de statut. Un 200 qui signifie « échec » est invisible pour eux tous.
Exposer les entités et compter sur @JsonIgnore pour masquer des champs. Cela fonctionne jusqu'au jour où quelqu'un ajoute un champ, et cela couple le schéma à l'API. Passez par des records.
Open Session in View qui masque le chargement lazy. Boot active spring.jpa.open-in-view par défaut et émet un avertissement à ce sujet. Le contexte de persistance reste ouvert pendant la sérialisation JSON, si bien que les associations lazy se chargent une requête à la fois pendant l'écriture de la réponse. Positionnez-le à false et chargez ce dont le DTO a besoin dans le service ou le repository.
Des tailles de page illimitées et des timeouts absents. Les deux ne posent aucun problème en développement et deviennent catastrophiques sous charge ; fixez des limites dès le premier jour.
Ignorer -parameters. Un build Maven ou Gradle personnalisé qui ne l'active pas fait échouer à l'exécution chaque @PathVariable long id sur Spring Framework 6.1+, avec un message signalant des noms de paramètres manquants.
Points clés à retenir
- Modélisez les ressources sous forme de contrôleurs, avec les chemins au niveau de la classe et les verbes au niveau des méthodes ; gardez des contrôleurs légers qui délèguent aux services.
- Utilisez des records comme DTO de requête et de réponse, et ne sérialisez jamais directement des entités JPA.
- Renvoyez les codes de statut et les en-têtes que HTTP définit déjà : 201 avec
Locationà la création, 204 pour un succès sans contenu, 4xx pour les erreurs du client. - Bornez chaque endpoint de collection par une pagination et exposez une forme de page stable ; préférez la pagination par clé pour les listes profondes ou très sollicitées.
- Activez CORS explicitement pour les origines que vous servez, dans MVC comme dans Spring Security.
- Appelez les autres services avec
RestClientou une HTTP interface construite à partir du builder auto-configuré par Boot, et définissez toujours des timeouts.
Sources et lectures complémentaires
Questions fréquentes
- Faut-il renvoyer directement des entités JPA depuis un contrôleur ?
- Mieux vaut l'éviter. Les entités portent des associations lazy qui se sérialisent mal ou déclenchent des requêtes supplémentaires, elles transforment la structure de la base en contrat d'API, et tout champ ajouté pour des besoins de persistance devient public sans que personne ne le remarque. Un DTO sous forme de record par endpoint permet à l'API et au schéma d'évoluer indépendamment et rend la forme du JSON explicite dans le code.
- Quelle différence entre @Controller et @RestController ?
- @RestController, c'est @Controller plus @ResponseBody. Avec un simple @Controller, une valeur de retour String est interprétée comme le nom d'une vue à rendre ; avec @RestController, chaque valeur de retour est écrite dans le corps de la réponse par un HttpMessageConverter, ce qui signifie du JSON via Jackson pour les objets. Utilisez @RestController pour les API et @Controller pour le HTML rendu côté serveur.
- Quel code de statut doit renvoyer un POST qui crée une ressource ?
- 201 Created, avec un en-tête Location qui pointe vers la nouvelle ressource et, en général, la représentation créée dans le corps. Ne renvoyez 200 que si le POST ne crée rien d'adressable (une recherche ou une commande, par exemple). Pour DELETE, 204 No Content est l'usage quand rien n'est renvoyé, et 404 si la ressource est introuvable, sauf si vous choisissez délibérément de traiter les suppressions comme idempotentes.
- RestTemplate est-il déprécié ?
- RestTemplate est en mode maintenance plutôt que déprécié : il fonctionne toujours et reçoit des correctifs, mais les nouveautés vont à RestClient (synchrone, fluent, apparu dans Spring Framework 6.1) et à WebClient (réactif). Pour un nouveau service Spring Boot 3.2+, utilisez RestClient, ou un client HTTP interface qui s'appuie dessus, et migrez le code RestTemplate lorsque vous y touchez.