Spring Framework
Construire des API REST avec @RestController dans Spring MVC
Intermédiaire6 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
Spring MVC sert du JSON par la même DispatcherServlet que celle qui rend les vues, les message converters prenant la place des view resolvers. Ce guide couvre le mapping des requêtes et des réponses, les codes de statut et les en-têtes avec ResponseEntity, la validation des corps et des paramètres, des réponses d'erreur homogènes avec ProblemDetail, la négociation de contenu et CORS, ainsi que RestClient et les clients HTTP interface de Spring 6.1 pour appeler d'autres API.
Ce que vous allez apprendre
- Mapper des requêtes et des réponses JSON avec @RestController, @RequestBody et HttpMessageConverter
- Renvoyer les bons codes de statut, l'en-tête Location et des corps vides avec ResponseEntity
- Valider les corps de requête et les paramètres de requête, et transformer les échecs en réponses 400
- Produire des erreurs application/problem+json homogènes avec ProblemDetail et @RestControllerAdvice
- Consommer d'autres API REST avec RestClient et des clients HTTP interface déclaratifs
Dans Spring, une API REST, c'est Spring MVC avec un autre étage de sortie : au lieu de résoudre une vue, la valeur de retour d'une méthode de contrôleur est sérialisée directement dans le corps de la réponse par un HttpMessageConverter, et les corps de requête sont désérialisés de la même manière à l'entrée. Tout le reste — handler mapping, résolution des arguments, intercepteurs, exception resolvers — est partagé avec la partie qui rend des vues. Ce guide cible Spring Framework 6.x avec Jackson 2 pour le JSON, et suppose que vous savez comment DispatcherServlet achemine une requête.
Mapper les requêtes et les réponses
@RestController
@RequestMapping(path = "/api/products", produces = MediaType.APPLICATION_JSON_VALUE)
public class ProductController {
private final ProductService products;
public ProductController(ProductService products) {
this.products = products;
}
@GetMapping
public List<ProductResponse> list(@RequestParam(required = false) String category,
@RequestParam(defaultValue = "20") int limit) {
return products.list(category, limit).stream().map(ProductResponse::from).toList();
}
@GetMapping("/{id}")
public ProductResponse get(@PathVariable long id) {
return products.find(id).map(ProductResponse::from)
.orElseThrow(() -> new ProductNotFoundException(id));
}
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ProductResponse> create(@Valid @RequestBody CreateProductRequest request) {
Product created = products.create(request.name(), request.priceCents());
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(created.id()).toUri();
return ResponseEntity.created(location).body(ProductResponse.from(created)); // 201 + Location
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable long id) {
products.delete(id);
}
}
public record CreateProductRequest(@NotBlank @Size(max = 120) String name,
@Positive long priceCents) {}
public record ProductResponse(long id, String name, long priceCents) {
static ProductResponse from(Product p) { return new ProductResponse(p.id(), p.name(), p.priceCents()); }
}
@RequestBody demande à Spring de lire le corps via un converter choisi d'après le Content-Type de la requête ; @RestController fait sortir chaque valeur de retour par un converter choisi d'après l'en-tête Accept de la requête et la valeur produces du mapping. Avec Jackson dans le classpath, MappingJackson2HttpMessageConverter gère application/json dans les deux sens. Les records sont des types de requête et de réponse idéaux : immuables, concis, et Jackson 2.12+ les désérialise via le constructeur canonique sans la moindre annotation.
Séparez délibérément les types de requête, de réponse et de domaine. Sérialiser directement une entité JPA expose des champs internes, entraîne les associations lazy dans la sérialisation et couple la forme de votre API à votre schéma de base de données. Le code de mapping est ennuyeux, mais c'est précisément là que se protège la compatibilité de l'API.
Codes de statut et en-têtes avec ResponseEntity
ResponseEntity<T> regroupe statut, en-têtes et corps. Ses builders couvrent les cas courants :
| Situation | Code | Forme idiomatique |
|---|---|---|
| Ressource créée | 201 | ResponseEntity.created(location).body(dto) |
| Acceptée pour traitement asynchrone | 202 | ResponseEntity.accepted().build() |
| Succès sans corps | 204 | ResponseEntity.noContent().build() ou @ResponseStatus(NO_CONTENT) sur une méthode void |
| Non modifiée (GET conditionnel) | 304 | ResponseEntity.ok().eTag(tag) avec WebRequest.checkNotModified(tag) |
| Erreur client avec corps problem | 4xx | ResponseEntity.status(HttpStatus.CONFLICT).body(problemDetail) |
Ne renvoyez pas de ResponseEntity<Object> pour faire passer un corps d'erreur en contournant le système de types : les réponses d'erreur ont leur place dans les exception handlers, ce qui laisse les contrôleurs sur le chemin nominal.
Validation
@Valid sur un paramètre @RequestBody exécute Jakarta Bean Validation sur l'objet désérialisé. Un échec lève une MethodArgumentNotValidException, que Spring traduit par défaut en 400. Depuis Spring Framework 6.1, les contraintes posées directement sur les paramètres @RequestParam, @PathVariable et @RequestHeader — @Min(1) int page — sont validées par Spring MVC lui-même, qui lève une HandlerMethodValidationException, elle aussi traduite en 400. Les versions antérieures exigeaient @Validated sur la classe du contrôleur et produisaient une ConstraintViolationException depuis le proxy AOP de validation de méthode, qui aboutissait à un 500 si vous ne la traitiez pas ; si vous rencontrez encore ce pattern, c'est du legacy.
@GetMapping("/search")
public List<ProductResponse> search(@RequestParam @Size(min = 2, max = 50) String q,
@RequestParam(defaultValue = "1") @Min(1) int page) { … }
Bean Validation couvre la forme. Les règles métier — « le SKU doit être unique », « le prix ne peut pas baisser de plus de 50 % » — relèvent du service, qui lève alors une exception métier qu'un advice traduit en 409 ou 422.
Des réponses d'erreur homogènes avec ProblemDetail
Spring Framework 6.0 a ajouté une prise en charge native du format problem details standardisé par la RFC 9457 (qui rend obsolète la RFC 7807). Un objet ProblemDetail est sérialisé en application/problem+json :
{
"type": "https://api.example.com/problems/product-not-found",
"title": "Product not found",
"status": 404,
"detail": "No product with id 42",
"instance": "/api/products/42",
"productId": 42
}
Branchez-le dans un @RestControllerAdvice. En étendant ResponseEntityExceptionHandler, vous faites en sorte que les exceptions propres à Spring — JSON mal formé, paramètres manquants, échecs de validation, media types non pris en charge — produisent le même format de corps :
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(ProductNotFoundException.class)
ProblemDetail notFound(ProductNotFoundException ex) {
var problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Product not found");
problem.setType(URI.create("https://api.example.com/problems/product-not-found"));
problem.setProperty("productId", ex.id());
return problem; // Spring sets the status from the ProblemDetail
}
@ExceptionHandler(DuplicateSkuException.class)
ProblemDetail conflict(DuplicateSkuException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, "SKU " + ex.sku() + " already exists");
}
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
HttpHeaders headers, HttpStatusCode status, WebRequest request) {
var problem = ex.getBody(); // ProblemDetail prepared by Spring
problem.setProperty("errors", ex.getFieldErrors().stream()
.map(e -> Map.of("field", e.getField(), "message", String.valueOf(e.getDefaultMessage())))
.toList());
return handleExceptionInternal(ex, problem, headers, status, request);
}
}
Pour les cas simples, il existe deux alternatives. Lever une ErrorResponseException (ou une sous-classe) depuis n'importe où transporte un ProblemDetail avec elle, sans handler dédié. Et Spring Boot 3 peut activer le rendu problem details pour les exceptions intégrées avec une seule propriété. Quelle que soit l'option choisie, ne laissez jamais des stack traces ou des noms de classes internes atteindre le champ detail : journalisez-les côté serveur avec un identifiant de corrélation, et placez cet identifiant dans le corps si le support en a besoin. Coller une réponse dans le formateur JSON est un moyen rapide de vérifier la forme de ce que vous renvoyez réellement.
Négociation de contenu et CORS
L'attribut produces et l'en-tête Accept du client sélectionnent le converter de réponse. Ajouter un converter XML (le module XML de Jackson) permet au même contrôleur de servir application/xml à la demande ; pour un client qui n'envoie pas d'Accept, le type par défaut est le premier type que le mapping peut produire. La négociation par extension de chemin (/products.json) a été désactivée par défaut dans Spring 5.3, et le suffix-pattern matching sous-jacent a été supprimé en 6.0, parce qu'il était une source de problèmes de sécurité : tenez-vous-en aux en-têtes, ou activez délibérément un paramètre de requête.
Les navigateurs bloquent les appels JavaScript cross-origin tant que l'API ne les autorise pas explicitement par des en-têtes CORS. Configurez-les à un seul endroit plutôt que méthode par méthode :
@Configuration
public class ApiCorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://app.example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowCredentials(true)
.maxAge(3600);
}
}
@CrossOrigin sur un contrôleur ou une méthode existe pour les cas particuliers. allowedOrigins("*") combiné à allowCredentials(true) est refusé par les navigateurs comme par Spring ; utilisez allowedOriginPatterns quand vous avez réellement besoin d'un joker. Si Spring Security est présent, enregistrez aussi la configuration CORS auprès de lui, pour que les requêtes preflight ne soient pas rejetées avant d'atteindre MVC.
Appeler d'autres API : RestClient et HTTP interfaces
Spring Framework 6.1 a introduit RestClient, un client synchrone et fluide qui partage l'infrastructure des converters et fonctionne avec n'importe quelle ClientHttpRequestFactory — le HttpClient du JDK, Apache HttpClient 5, Reactor Netty ou Jetty.
@Configuration
public class ClientConfig {
@Bean
RestClient inventoryClient(RestClient.Builder builder) { // Boot supplies the builder; else RestClient.builder()
return builder
.baseUrl("https://inventory.internal/api")
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.requestInterceptor((request, body, execution) -> {
request.getHeaders().setBearerAuth(Tokens.current());
return execution.execute(request, body);
})
.build();
}
}
@Service
public class InventoryGateway {
private final RestClient client;
public InventoryGateway(RestClient inventoryClient) { this.client = inventoryClient; }
public StockLevel stockFor(String sku) {
return client.get()
.uri("/stock/{sku}", sku)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (request, response) -> {
throw new UnknownSkuException(sku); // default would throw HttpClientErrorException
})
.body(StockLevel.class);
}
public void reserve(String sku, int quantity) {
client.post()
.uri("/reservations")
.contentType(MediaType.APPLICATION_JSON)
.body(new ReservationRequest(sku, quantity))
.retrieve()
.toBodilessEntity();
}
}
retrieve() lève des sous-classes de RestClientResponseException pour les 4xx et 5xx, sauf si onStatus en décide autrement ; exchange() vous remet la réponse brute quand vous avez besoin d'un contrôle total. Les timeouts et le pool de connexions sont des propriétés de la request factory, pas du client : réglez-les là, systématiquement, car les valeurs par défaut du JDK sont illimitées.
Pour les clients qui exposent plusieurs opérations, une HTTP interface élimine le boilerplate restant :
@HttpExchange("/api/stock")
public interface StockApi {
@GetExchange("/{sku}")
StockLevel get(@PathVariable String sku);
@PostExchange
ResponseEntity<Void> reserve(@RequestBody ReservationRequest request);
}
@Bean
StockApi stockApi(RestClient inventoryClient) {
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(inventoryClient))
.build()
.createClient(StockApi.class);
}
Spring génère un proxy qui traduit chaque appel en requête HTTP à partir des annotations. La même interface peut aussi s'appuyer sur WebClient dans les applications réactives. RestTemplate fonctionne toujours et n'est pas déprécié, mais il appartient au passé : le code neuf doit utiliser RestClient.
Erreurs courantes
Renvoyer des entités. Les associations lazy déclenchent des requêtes ou une LazyInitializationException pendant la sérialisation, les relations bidirectionnelles bouclent indéfiniment, et chaque évolution du schéma devient un changement d'API. Mappez vers des records de réponse.
Du 200 pour tout. Renvoyer {"error": "not found"} avec un statut 200 casse tous les clients qui vérifient les codes de statut, supervision comprise. Utilisez le code qui décrit ce qui s'est réellement passé.
Attraper les exceptions dans les contrôleurs. Chaque contrôleur finit par inventer son propre format d'erreur, incohérent avec les autres. Un seul @RestControllerAdvice donne le même corps à tous les endpoints.
Compter sur @Valid pour les règles métier. L'unicité et les transitions d'état passent par le service et la base de données ; une annotation de contrainte ne voit pas les autres lignes.
Pas de timeout sur les appels sortants. Une seule dépendance lente épuise le pool de threads de traitement des requêtes. Configurez les timeouts de connexion et de lecture sur la request factory de chaque RestClient.
Joker CORS avec credentials. Les navigateurs le refusent ; et si cela fonctionnait, autoriser n'importe quelle origine à envoyer des cookies serait une faille grave.
Négociation de contenu par extension de chemin. Les attaques par reflected file download et par content sniffing sont la raison de sa désactivation : ne la réactivez pas.
Points clés à retenir
@RestControllerfait passer les valeurs de retour par desHttpMessageConverter; Jackson gère le JSON sans aucune configuration.- Utilisez des records dédiés pour les requêtes et les réponses, et
ResponseEntitylorsque le statut ou les en-têtes sont porteurs de sens. @Validsur les corps et les annotations de contrainte sur les paramètres produisent des 400 pour les erreurs de forme ; les règles métier relèvent du service.ProblemDetail, associé à un@RestControllerAdvicequi étendResponseEntityExceptionHandler, produit des corps d'erreur RFC 9457 uniformes.- Configurez CORS de manière centralisée et ne combinez jamais une origine joker avec des credentials.
RestClient(6.1) et les clients HTTP interface sont la manière moderne d'appeler d'autres services ; réglez toujours les timeouts sur la request factory.
Sources et lectures complémentaires
Questions fréquentes
- Quelle différence entre @Controller et @RestController ?
- @RestController, c'est @Controller plus @ResponseBody au niveau de la classe. La valeur de retour de chaque handler est écrite dans le corps de la réponse par un message converter, au lieu d'être interprétée comme un nom de vue. Un @Controller peut obtenir le même effet méthode par méthode en ajoutant @ResponseBody, ce qui est pratique quand un même contrôleur sert à la fois des pages et quelques endpoints JSON.
- Faut-il renvoyer un ResponseEntity depuis chaque endpoint ?
- Seulement lorsque vous devez fixer explicitement le statut ou les en-têtes. Pour le cas courant du 200, un type de retour simple, éventuellement accompagné de @ResponseStatus, est plus court, et Spring le convertit exactement de la même façon. Réservez ResponseEntity au 201 Created avec en-tête Location, au 204 No Content, aux réponses conditionnelles et à tous les cas où le statut dépend du résultat.
- Pourquoi mon API renvoie-t-elle une page d'erreur HTML au lieu de JSON ?
- L'exception a échappé aux resolvers de Spring MVC et a atteint la gestion d'erreurs par défaut du conteneur de servlets. Ajoutez un @RestControllerAdvice avec un @ExceptionHandler pour cette exception (ou un handler fourre-tout qui renvoie un ProblemDetail), ou étendez ResponseEntityExceptionHandler pour que les exceptions de binding et de conversion de Spring produisent elles aussi des corps problem+json.
- RestTemplate est-il déprécié ?
- Non, mais il est en mode maintenance et ne reçoit plus de nouvelles fonctionnalités. Spring Framework 6.1 a introduit RestClient, une API fluide et synchrone qui repose sur la même infrastructure de client HTTP ; c'est le choix recommandé pour du code neuf. RestTemplate reste pris en charge, et RestClient.create(restTemplate) vous permet de migrer progressivement en conservant vos intercepteurs et converters existants.