Spring Framework
Cómo construir APIs REST con @RestController en Spring MVC
Intermedio6 min de lecturaActualizado
Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés
Spring MVC sirve JSON a través del mismo DispatcherServlet que renderiza las vistas, con los message converters en lugar de los view resolvers. Esta guía cubre el mapeo de peticiones y respuestas, los códigos de estado y las cabeceras con ResponseEntity, la validación de cuerpos y parámetros, respuestas de error coherentes con ProblemDetail, la negociación de contenido y CORS, y RestClient y los clientes HTTP interface de Spring 6.1 para llamar a otras APIs.
Lo que aprenderás
- Mapear peticiones y respuestas JSON con @RestController, @RequestBody y HttpMessageConverter
- Devolver los códigos de estado correctos, cabeceras Location y cuerpos vacíos con ResponseEntity
- Validar cuerpos de petición y parámetros de consulta, y convertir los fallos en respuestas 400
- Generar errores application/problem+json coherentes con ProblemDetail y @RestControllerAdvice
- Consumir otras APIs REST con RestClient y clientes HTTP interface declarativos
En Spring, una API REST es Spring MVC con una etapa de salida distinta: en lugar de resolver una vista, el valor de retorno de un método del controlador se serializa directamente en el cuerpo de la respuesta mediante un HttpMessageConverter, y los cuerpos de las peticiones se deserializan del mismo modo a la entrada. Todo lo demás —handler mapping, resolución de argumentos, interceptores, exception resolvers— se comparte con la parte que renderiza vistas. Esta guía se centra en Spring Framework 6.x con Jackson 2 para JSON y da por hecho que sabes cómo DispatcherServlet enruta una petición.
Mapeo de peticiones y respuestas
@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 indica a Spring que lea el cuerpo con un converter elegido según el Content-Type de la petición; @RestController hace que cada valor de retorno salga por un converter elegido según la cabecera Accept de la petición y el valor produces del mapeo. Con Jackson en el classpath, MappingJackson2HttpMessageConverter gestiona application/json en ambos sentidos. Los records son tipos ideales para peticiones y respuestas: inmutables, concisos, y Jackson 2.12+ los deserializa a través del constructor canónico sin necesidad de anotaciones.
Separa a propósito los tipos de petición, de respuesta y de dominio. Serializar directamente una entidad JPA filtra campos internos, arrastra asociaciones lazy a la serialización y acopla la forma de tu API al esquema de la base de datos. El código de mapeo es aburrido, pero es justo donde se protege la compatibilidad de la API.
Códigos de estado y cabeceras con ResponseEntity
ResponseEntity<T> agrupa estado, cabeceras y cuerpo. Sus builders cubren los casos habituales:
| Situación | Código | Forma idiomática |
|---|---|---|
| Recurso creado | 201 | ResponseEntity.created(location).body(dto) |
| Aceptado para procesamiento asíncrono | 202 | ResponseEntity.accepted().build() |
| Éxito sin cuerpo | 204 | ResponseEntity.noContent().build() o @ResponseStatus(NO_CONTENT) en un método void |
| No modificado (GET condicional) | 304 | ResponseEntity.ok().eTag(tag) junto con WebRequest.checkNotModified(tag) |
| Error del cliente con cuerpo problem | 4xx | ResponseEntity.status(HttpStatus.CONFLICT).body(problemDetail) |
No devuelvas ResponseEntity<Object> para colar un cuerpo de error esquivando el sistema de tipos; las respuestas de error van en los exception handlers, que mantienen los controladores en el camino feliz.
Validación
@Valid en un parámetro @RequestBody ejecuta Jakarta Bean Validation sobre el objeto deserializado. Si falla, se lanza MethodArgumentNotValidException, que Spring traduce a 400 por defecto. Desde Spring Framework 6.1, las restricciones colocadas directamente en parámetros @RequestParam, @PathVariable y @RequestHeader —@Min(1) int page— las valida el propio Spring MVC, que lanza HandlerMethodValidationException, también un 400. Las versiones anteriores exigían @Validated en la clase del controlador y producían una ConstraintViolationException desde el proxy AOP de validación de métodos, que acababa en un 500 si no la gestionabas; si todavía ves ese patrón, es código heredado.
@GetMapping("/search")
public List<ProductResponse> search(@RequestParam @Size(min = 2, max = 50) String q,
@RequestParam(defaultValue = "1") @Min(1) int page) { … }
Bean Validation cubre la forma. Las reglas de negocio —«el SKU debe ser único», «el precio no puede bajar más de un 50 %»— pertenecen al servicio, que lanza una excepción de dominio que un advice traduce a 409 o 422.
Respuestas de error coherentes con ProblemDetail
Spring Framework 6.0 añadió soporte de primera clase para el formato problem details estandarizado en la RFC 9457 (que deja obsoleta la RFC 7807). Un objeto ProblemDetail se serializa como 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
}
Conéctalo en un @RestControllerAdvice. Si extiendes ResponseEntityExceptionHandler, las propias excepciones de Spring —JSON mal formado, parámetros ausentes, fallos de validación, media types no soportados— producen el mismo formato de cuerpo:
@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);
}
}
Para los casos sencillos hay dos alternativas. Lanzar una ErrorResponseException (o una subclase) desde cualquier punto lleva consigo un ProblemDetail, sin necesidad de handler. Y Spring Boot 3 puede activar el renderizado problem details para las excepciones integradas con una sola propiedad. Elijas lo que elijas, no dejes nunca que stack traces ni nombres de clases internas lleguen al campo detail; regístralos en el log del servidor con un identificador de correlación y pon ese identificador en el cuerpo si el equipo de soporte lo necesita. Pegar una respuesta en el formateador JSON es una forma rápida de comprobar la forma de lo que realmente devuelves.
Negociación de contenido y CORS
El atributo produces y la cabecera Accept del cliente seleccionan el converter de la respuesta. Añadir un converter XML (el módulo XML de Jackson) permite que el mismo controlador sirva application/xml cuando se pida; para un cliente que no envía Accept, el tipo por defecto es el primero que se pueda producir. La negociación por extensión de ruta (/products.json) se desactivó por defecto en Spring 5.3 y el suffix-pattern matching subyacente se eliminó en la 6.0, porque era una fuente de problemas de seguridad: quédate con las cabeceras, o habilita un parámetro de consulta de forma deliberada.
Los navegadores bloquean las llamadas JavaScript cross-origin a menos que la API lo permita de forma explícita con cabeceras CORS. Configúralo en un único sitio en lugar de método a método:
@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 en un controlador o método queda reservado para los casos puntuales. allowedOrigins("*") junto con allowCredentials(true) lo rechazan tanto los navegadores como Spring; usa allowedOriginPatterns cuando de verdad necesites un comodín. Si está presente Spring Security, registra también ahí la configuración CORS para que las peticiones preflight no se rechacen antes de llegar a MVC.
Llamar a otras APIs: RestClient y HTTP interfaces
Spring Framework 6.1 introdujo RestClient, un cliente síncrono y fluido que comparte la infraestructura de converters y funciona con cualquier ClientHttpRequestFactory: el HttpClient del JDK, Apache HttpClient 5, Reactor Netty o 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() lanza subclases de RestClientResponseException para los 4xx y 5xx salvo que onStatus indique otra cosa; exchange() te entrega la respuesta en bruto cuando necesitas control total. Los timeouts y el pool de conexiones son propiedades de la request factory, no del cliente: configúralos ahí, siempre, porque los valores por defecto del JDK no tienen límite.
Para clientes con varias operaciones, una HTTP interface elimina el boilerplate que queda:
@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 genera un proxy que traduce cada llamada en una petición HTTP a partir de las anotaciones. La misma interfaz también puede apoyarse en WebClient en aplicaciones reactivas. RestTemplate sigue funcionando y no está obsoleto, pero es el pasado: el código nuevo debería usar RestClient.
Errores habituales
Devolver entidades. Las asociaciones lazy disparan consultas o una LazyInitializationException durante la serialización, las relaciones bidireccionales entran en recursión infinita y cada cambio de esquema se convierte en un cambio de API. Mapea a records de respuesta.
Un 200 para todo. Devolver {"error": "not found"} con estado 200 rompe todos los clientes que comprueban los códigos de estado, incluida la monitorización. Usa el código que signifique lo que realmente ha pasado.
Capturar excepciones en los controladores. Cada controlador acaba con su propio formato de error, incoherente con el resto. Un único @RestControllerAdvice da el mismo cuerpo a todos los endpoints.
Confiar en @Valid para las reglas de negocio. La unicidad y las transiciones de estado necesitan el servicio y la base de datos; una anotación de restricción no puede ver otras filas.
Llamadas salientes sin timeouts. Una sola dependencia lenta agota el pool de threads de peticiones. Configura los timeouts de conexión y de lectura en la request factory de cada RestClient.
Comodín CORS con credenciales. Los navegadores lo rechazan; y si funcionara, permitir que cualquier origen enviara cookies sería una vulnerabilidad grave.
Negociación de contenido por extensión de ruta. Los ataques de reflected file download y de content sniffing fueron el motivo de desactivarla; no vuelvas a activarla.
Puntos clave
@RestControllerhace pasar los valores de retorno porHttpMessageConverters; Jackson gestiona JSON sin ninguna configuración.- Usa records dedicados para peticiones y respuestas, y
ResponseEntitycuando el estado o las cabeceras aportan significado. @Validen los cuerpos y las anotaciones de restricción en los parámetros producen 400 para los errores de forma; las reglas de negocio van en el servicio.ProblemDetailcon un@RestControllerAdviceque extiendeResponseEntityExceptionHandlergenera cuerpos de error RFC 9457 uniformes.- Configura CORS de forma centralizada y nunca combines un origen comodín con credenciales.
RestClient(6.1) y los clientes HTTP interface son la forma moderna de llamar a otros servicios; configura siempre los timeouts en la request factory.
Fuentes y lecturas recomendadas
Preguntas frecuentes
- ¿Qué diferencia hay entre @Controller y @RestController?
- @RestController es @Controller más @ResponseBody a nivel de clase. El valor de retorno de cada handler se escribe en el cuerpo de la respuesta mediante un message converter, en lugar de interpretarse como el nombre de una vista. Un @Controller puede hacer lo mismo método a método añadiendo @ResponseBody, algo útil cuando un mismo controlador sirve tanto páginas como unos cuantos endpoints JSON.
- ¿Debo devolver ResponseEntity en todos los endpoints?
- Solo cuando necesites fijar el estado o las cabeceras de forma explícita. Para el caso habitual del 200, un tipo de retorno normal, con un @ResponseStatus opcional, es más corto, y Spring lo convierte exactamente igual. Usa ResponseEntity para un 201 Created con cabecera Location, un 204 No Content, las respuestas condicionales y cualquier caso en el que el estado dependa del resultado.
- ¿Por qué mi API devuelve una página de error HTML en lugar de JSON?
- La excepción se escapó de los resolvers de Spring MVC y llegó al manejo de errores por defecto del contenedor de servlets. Añade un @RestControllerAdvice con un @ExceptionHandler para esa excepción (o uno genérico que devuelva ProblemDetail), o extiende ResponseEntityExceptionHandler para que las propias excepciones de binding y conversión de Spring también generen cuerpos problem+json.
- ¿Está obsoleto RestTemplate?
- No, pero está en modo mantenimiento y ya no recibe funcionalidades nuevas. Spring Framework 6.1 introdujo RestClient, una API fluida y síncrona que usa la misma infraestructura de cliente HTTP, y es la opción recomendada para código nuevo. RestTemplate sigue teniendo soporte, y RestClient.create(restTemplate) te permite migrar poco a poco conservando los interceptores y converters que ya tienes.