Módulo 20: APIs REST con Spring
Objetivos del módulo
- Comprender los principios de una API REST: recursos, URI, verbos HTTP, códigos de estado y representaciones JSON
- Seguir el recorrido de una petición en Spring MVC:
DispatcherServlet→ controlador → servicio → repositorio - Crear controladores con
@RestController,@RequestMappingy@GetMapping,@PostMapping,@PutMapping,@PatchMapping,@DeleteMapping - Recibir datos con
@PathVariable,@RequestParamy@RequestBody, y responder conResponseEntityy el código adecuado (201 conLocation, 204, 404…) - Separar la API de las entidades con DTO y mapear entidad ↔ DTO
- Validar las peticiones con Jakarta Bean Validation y devolver errores 400 claros
- Centralizar la gestión de errores con
@RestControllerAdviceyProblemDetail(RFC 9457) - Paginar resultados, configurar CORS y documentar la API con springdoc-openapi y Swagger UI
- Probar la API con curl, HTTPie y los ficheros
.httpde IntelliJ, y consumir APIs desde Java conRestClient
1. ¿Qué es una API REST?
Una API (Application Programming Interface) web permite que otros programas (una aplicación móvil, un frontend en React, otro servicio) usen las funciones de tu aplicación a través de HTTP. En el módulo 09 consumiste APIs con HttpClient; ahora vas a crear una para empresa-app.
📘 Concepto: REST (REpresentational State Transfer) es un estilo de arquitectura para APIs web definido por Roy Fielding (2000). Sus ideas principales son:
- Todo lo que se gestiona es un recurso (un producto, un pedido…) identificado por una URI (
/api/productos/6).- Sobre los recursos se usan los verbos estándar de HTTP (
GET,POST,PUT,PATCH,DELETE), sin inventar operaciones en la URL.- El servidor devuelve representaciones del recurso (normalmente JSON) y un código de estado que indica el resultado.
- Sin estado (stateless): cada petición lleva toda la información necesaria; el servidor no guarda una «sesión de conversación».
Una analogía: una API REST es como el mostrador de una biblioteca con un reglamento estricto. Los libros (recursos) tienen una signatura (URI); solo puedes pedir, devolver, renovar o dar de baja (verbos) y el bibliotecario siempre responde con un sello que indica qué ha pasado (código de estado).
Figura 1. Recursos y verbos de la API de productos de empresa-app.
Verbos HTTP
| Verbo | Uso en REST | ¿Seguro? | ¿Idempotente? | Ejemplo |
|---|---|---|---|---|
GET | Leer un recurso o una colección | Sí | Sí | GET /api/productos/6 |
POST | Crear un recurso nuevo en una colección | No | No | POST /api/productos |
PUT | Reemplazar un recurso completo | No | Sí | PUT /api/productos/21 |
PATCH | Modificar parte de un recurso | No | No (en general) | PATCH /api/productos/21 |
DELETE | Borrar un recurso | No | Sí | DELETE /api/productos/21 |
Seguro: no cambia nada en el servidor. Idempotente: repetir la petición deja el mismo estado (borrar dos veces el producto 21 lo deja borrado, aunque la segunda respuesta sea un 404).
Códigos de estado
| Código | Nombre | Cuándo lo usa empresa-app |
|---|---|---|
| 200 | OK | Lectura o modificación correctas, con cuerpo |
| 201 | Created | Recurso creado; la cabecera Location indica su URI |
| 204 | No Content | Borrado correcto, sin cuerpo |
| 400 | Bad Request | JSON mal formado, tipo incorrecto o datos que no superan la validación |
| 401 | Unauthorized | Falta autenticación (módulo 22) |
| 403 | Forbidden | Autenticado pero sin permiso (módulo 22), o CORS denegado |
| 404 | Not Found | El recurso no existe (/api/productos/999) |
| 405 | Method Not Allowed | Verbo no admitido en esa URI (DELETE /api/pedidos/5001) |
| 409 | Conflict | Regla de negocio violada: stock insuficiente, borrar algo en uso, duplicado |
| 415 | Unsupported Media Type | El cuerpo no es application/json |
| 500 | Internal Server Error | Error inesperado del servidor (un bug) |
💡 Consejo: Las URI se escriben con sustantivos en plural y en minúsculas (
/api/productos,/api/pedidos/5001/lineas), nunca con verbos (/api/crearProducto,/api/borrarPedido?id=3). El verbo ya lo pone HTTP.
2. Recorrido de una petición en Spring
En empresa-app ya tienes todo lo necesario: spring-boot-starter-webmvc trae Spring MVC, Tomcat embebido y Jackson 3 para convertir objetos Java en JSON.
Figura 2. Ciclo de una petición REST en Spring Boot.
- Tomcat recibe la petición HTTP y se la pasa al
DispatcherServlet, el front controller único de Spring MVC. - El
HandlerMappingbusca qué método de qué controlador corresponde a esa URI y ese verbo. - Los argument resolvers preparan los parámetros del método: extraen
{id}de la ruta, los parámetros de la consulta, convierten el cuerpo JSON en un objeto (con Jackson) y lo validan. - El controlador llama al servicio, que aplica las reglas de negocio dentro de una transacción y usa los repositorios.
- El controlador devuelve un objeto (un DTO); un
HttpMessageConverterlo convierte en JSON según la cabeceraAccept. - Si en cualquier punto se lanza una excepción, el
@RestControllerAdvicela traduce en una respuesta de error.
📘 Concepto: La separación en capas es la del módulo 18:
web.api(controladores: solo HTTP),servicio(reglas de negocio y transacciones),repositorio(acceso a datos) ydto(lo que viaja por la red). Un controlador nunca usa un repositorio directamente ni contiene lógica de negocio.
3. @RestController y las anotaciones de mapeo
@RestController // = @Controller + @ResponseBody en cada método
@RequestMapping("/api/productos") // prefijo común de todas las rutas de la clase
public class ProductoApiController {
private final ProductoService servicio;
public ProductoApiController(ProductoService servicio) {
this.servicio = servicio;
}
@GetMapping("/{id}") // GET /api/productos/6
public ProductoDto obtener(@PathVariable Integer id) {
return ProductoDto.desde(servicio.buscar(id));
}
}
@RestControllerindica que los métodos devuelven datos (que se escriben en el cuerpo, normalmente como JSON), no nombres de vistas como el@Controllerdel módulo 21.@RequestMappingen la clase fija el prefijo; en los métodos se usan los atajos de cada verbo:
| Anotación | Equivale a |
|---|---|
@GetMapping("/x") | @RequestMapping(path = "/x", method = RequestMethod.GET) |
@PostMapping | method = POST |
@PutMapping | method = PUT |
@PatchMapping | method = PATCH |
@DeleteMapping | method = DELETE |
Todas admiten, además de la ruta, produces (tipos de respuesta que genera, p. ej. MediaType.APPLICATION_JSON_VALUE), consumes (tipos de cuerpo que acepta), params y headers para afinar el mapping.
⚠️ Importante: Si dos métodos tienen la misma ruta y el mismo verbo, la aplicación no arranca (
Ambiguous mapping). Y si escribes una ruta literal y otra con variable (/baratosy/{id}), Spring elige la más específica:GET /api/productos/baratosva al primero aunque"baratos"también encajaría en{id}.
4. Recibir datos: @PathVariable, @RequestParam y @RequestBody
| Anotación | De dónde sale el dato | Ejemplo de URI / cuerpo |
|---|---|---|
@PathVariable | Un trozo de la ruta | /api/productos/{id} → id = 6 |
@RequestParam | Parámetro de la consulta (o de un formulario) | /api/productos?texto=monitor |
@RequestBody | El cuerpo de la petición (JSON → objeto) | {"nombre": "...", "precio": 24.90} |
@GetMapping
public Page<ProductoDto> listar(@RequestParam(required = false) String texto, // opcional
@RequestParam(required = false) Integer categoriaId,
@ParameterObject @PageableDefault(size = 5, sort = "id") Pageable pageable) {
return servicio.listar(texto, categoriaId, pageable).map(ProductoDto::desde);
}
@PutMapping("/{id}")
public ProductoDto reemplazar(@PathVariable Integer id, @Valid @RequestBody ProductoRequest req) {
return ProductoDto.desde(servicio.reemplazar(id, req));
}
- Por defecto
@RequestParames obligatorio: si falta, la respuesta es un 400. Usarequired = false(lleganull) odefaultValue = "10". - Spring convierte los tipos:
"6"→Integer,"2026-10-04"→LocalDate,"PENDIENTE"→EstadoPedido. Si no puede, responde 400. Por ejemplo,GET /api/productos/abc:
curl -s -i "http://localhost:8080/api/productos/abc"
HTTP/1.1 400
Content-Type: application/problem+json
{
"detail": "Failed to convert 'id' with value: 'abc'",
"instance": "/api/productos/abc",
"status": 400,
"title": "Bad Request"
}
- El nombre del parámetro Java se usa como nombre del parámetro HTTP (gracias a la opción
-parametersdel compilador que activa Spring Boot); si quieres otro,@RequestParam("cat") Integer categoriaId.
5. Respuestas: ResponseEntity y códigos de estado
Si un método devuelve un objeto, la respuesta es un 200 OK con ese objeto en JSON. Para controlar el código y las cabeceras, devuelve un ResponseEntity<T> o usa @ResponseStatus.
201 Created con Location
Al crear un recurso, lo correcto es responder 201 y la URI del nuevo recurso en la cabecera Location:
@PostMapping
public ResponseEntity<ProductoDto> crear(@Valid @RequestBody ProductoRequest req) {
Producto nuevo = servicio.crear(req);
URI location = ServletUriComponentsBuilder.fromCurrentRequest() // http://localhost:8080/api/productos
.path("/{id}").buildAndExpand(nuevo.getId()).toUri(); // .../api/productos/21
return ResponseEntity.created(location).body(ProductoDto.desde(nuevo));
}
ServletUriComponentsBuilder.fromCurrentRequest() construye la URI a partir de la petición actual, así funciona igual en localhost, detrás de un proxy o con otro puerto. Respuesta real (el cuerpo es el de producto-nuevo.json, que verás en la sección 12):
curl -s -i -X POST "http://localhost:8080/api/productos" -H "Content-Type: application/json" -d @producto-nuevo.json
HTTP/1.1 201
Location: http://localhost:8080/api/productos/21
Content-Type: application/json
{
"id": 21,
"nombre": "Alfombrilla gaming XL",
"descripcion": "Alfombrilla de 90 x 40 cm con base antideslizante",
"precio": 24.90,
"stock": 50,
"descatalogado": false,
"fechaAlta": "2026-10-04",
"categoriaId": 5,
"categoria": "Ratones y teclados"
}
204 No Content
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT) // 204 sin cuerpo
public void borrar(@PathVariable Integer id) {
servicio.borrar(id);
}
o, equivalente, return ResponseEntity.noContent().build(); como en CategoriaApiController.
curl -s -i -X DELETE "http://localhost:8080/api/productos/21"
HTTP/1.1 204
404 Not Found
No hace falta comprobar en cada controlador si el recurso existe: el servicio lanza RecursoNoEncontradoException y el manejador global de errores (sección 8) la convierte en 404. Borrar otra vez el producto 21:
curl -s -i -X DELETE "http://localhost:8080/api/productos/21"
HTTP/1.1 404
Content-Type: application/problem+json
{
"detail": "No existe producto con id 21",
"instance": "/api/productos/21",
"status": 404,
"title": "Recurso no encontrado",
"type": "https://empresa.es/errores/no-encontrado"
}
Resumen de ResponseEntity
| Código | Con ResponseEntity |
|---|---|
| 200 | ResponseEntity.ok(cuerpo) |
| 201 | ResponseEntity.created(uri).body(cuerpo) |
| 204 | ResponseEntity.noContent().build() |
| 400 | ResponseEntity.badRequest().body(error) |
| 404 | ResponseEntity.notFound().build() |
| Cualquiera | ResponseEntity.status(HttpStatus.CONFLICT).header("X-Algo", "v").body(cuerpo) |
6. DTO: no expongas tus entidades
Sería muy tentador devolver directamente las entidades JPA desde el controlador. No lo hagas:
Figura 3. La entidad se queda dentro; por la red viajan DTO.
| Problema al exponer la entidad | Qué ocurre |
|---|---|
| Relaciones perezosas | Jackson intenta leer producto.categoria fuera de la transacción → LazyInitializationException |
| Ciclos | Departamento → empleados → departamento → ... produce un JSON infinito (o un error) |
| Seguridad | Se publican campos internos; con @RequestBody Entidad un cliente podría modificar campos que no debería (mass assignment) |
| Acoplamiento | Cualquier cambio en la tabla rompe a los clientes de la API |
📘 Concepto: Un DTO (Data Transfer Object) es un objeto pensado solo para viajar por la red. Los records de Java son perfectos para ello: inmutables, concisos y Jackson 3 los convierte a JSON (y desde JSON) sin configurar nada.
En empresa-app hay dos tipos de DTO por recurso: uno de salida (lo que devuelve la API) y otro de entrada (lo que el cliente envía, con sus validaciones):
/** Lo que la API devuelve de un producto (nunca la entidad). */
public record ProductoDto(
Integer id,
String nombre,
String descripcion,
BigDecimal precio,
int stock,
boolean descatalogado,
LocalDate fechaAlta,
Integer categoriaId,
String categoria) {
/** Mapeo entidad → DTO. */
public static ProductoDto desde(Producto p) {
return new ProductoDto(p.getId(), p.getNombre(), p.getDescripcion(), p.getPrecio(),
p.getStock(), p.isDescatalogado(), p.getFechaAlta(),
p.getCategoria().getId(), p.getCategoria().getNombre());
}
}
El mapeo inverso (DTO de entrada → entidad) lo hace el servicio, que es quien sabe buscar la categoría:
@Transactional
public Producto crear(ProductoRequest req) {
Producto p = new Producto(req.nombre(), req.descripcion(), categoria(req.categoriaId()),
req.precio(), req.stock());
p.setDescatalogado(req.estaDescatalogado());
return productos.save(p); // la auditoría rellena fechaAlta
}
⚠️ Importante:
ProductoDto.desdeleep.getCategoria().getNombre(), una relación perezosa. Como el controlador está fuera de la transacción (yopen-in-viewestá desactivado), la categoría debe venir ya cargada. Por eso los métodos del repositorio que usa la API llevan@EntityGraph(attributePaths = "categoria")(módulo 19). Si se te olvida, verás unLazyInitializationExceptionen el log y un 500 en la respuesta.
💡 Consejo: Para proyectos con muchos DTO existe MapStruct, que genera el código de mapeo en tiempo de compilación a partir de una interfaz. Para un proyecto como este, los métodos
desde(...)escritos a mano son más claros.
Jackson 3: lo que debes saber
Spring Boot 4 usa Jackson 3 (la versión la fija el BOM de Boot; en el módulo 01 viste que el groupId es tools.jackson.core), cuyos paquetes son tools.jackson.*, por ejemplo tools.jackson.databind.json.JsonMapper (en Jackson 2 eran com.fasterxml.jackson.*; las anotaciones como @JsonIgnore siguen en com.fasterxml.jackson.annotation). Algunos valores por defecto cambiaron, y uno nos afectó directamente al escribir este módulo:
⚠️ Importante: En Jackson 3,
DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVESestá activada por defecto. En la primera versión deProductoRequest, el campodescatalogadoera unbooleanprimitivo y, si el cliente no lo enviaba, Jackson rechazaba el JSON entero con un 400 genérico antes de llegar a la validación. La solución fue declararlo comoBoolean(que admitenull) y tratar elnullcomofalse:
Boolean descatalogado) { // Boolean (no boolean): Jackson 3 rechaza null en primitivos
/** Si no se envía, el producto no está descatalogado. */
public boolean estaDescatalogado() {
return Boolean.TRUE.equals(descatalogado);
}
}
Las fechas (LocalDate) se escriben en formato ISO ("2026-10-04"), y los BigDecimal como números JSON (24.90).
7. Validación con Jakarta Bean Validation
Nunca te fíes de lo que envía el cliente. Con spring-boot-starter-validation (Hibernate Validator 9) declaras las reglas como anotaciones en el DTO de entrada y Spring las comprueba al recibir la petición si el parámetro lleva @Valid:
/** Datos que el cliente envía para crear o reemplazar (PUT) un producto. */
public record ProductoRequest(
@NotBlank(message = "El nombre es obligatorio")
@Size(max = 60, message = "El nombre no puede superar los 60 caracteres")
String nombre,
@Size(max = 200, message = "La descripción no puede superar los 200 caracteres")
String descripcion,
@NotNull(message = "El precio es obligatorio")
@Positive(message = "El precio debe ser mayor que cero")
@Digits(integer = 6, fraction = 2, message = "El precio admite 6 enteros y 2 decimales")
BigDecimal precio,
@NotNull(message = "El stock es obligatorio")
@PositiveOrZero(message = "El stock no puede ser negativo")
Integer stock,
@NotNull(message = "La categoría es obligatoria")
Integer categoriaId,
Boolean descatalogado) { ... }
@PostMapping
public ResponseEntity<ProductoDto> crear(@Valid @RequestBody ProductoRequest req) { ... }
Las restricciones más usadas
| Anotación | Comprueba | Ejemplo en EMPRESA |
|---|---|---|
@NotNull | No es null | categoriaId |
@NotBlank | Texto no nulo y con algún carácter que no sea espacio | nombre |
@NotEmpty | Colección/texto no nulo y no vacío | líneas de un pedido |
@Size(min, max) | Longitud de texto o tamaño de colección | @Size(max = 60) (columna VARCHAR(60)) |
@Positive, @PositiveOrZero | Número > 0 / ≥ 0 | precio, stock |
@Min, @Max | Entero entre límites | @Max(200) tamaño de página |
@DecimalMin, @DecimalMax | Decimal entre límites (como texto) | descuento entre 0 y 100 |
@Digits(integer, fraction) | Número de dígitos | DECIMAL(8,2) |
@Email | Formato de email | email de cliente |
@Pattern(regexp) | Expresión regular | NIF, teléfono |
@Past, @PastOrPresent, @Future | Fechas | fechaAlta no futura |
@AssertTrue | Un método boolean devuelve true | Validaciones entre campos (módulo 21) |
@Valid | Valida en cascada un objeto o los elementos de una lista | List<@Valid Linea> lineas |
Para un pedido con varias líneas, la validación entra en cada elemento gracias a @Valid en el tipo de la lista:
public record NuevoPedidoRequest(
@NotNull(message = "El cliente es obligatorio") Integer clienteId,
Integer empleadoId,
@NotEmpty(message = "El pedido debe tener al menos una línea")
List<@Valid Linea> lineas) {
public record Linea(
@NotNull(message = "El producto es obligatorio") Integer productoId,
@NotNull @Positive(message = "La cantidad debe ser mayor que cero") Integer cantidad,
@DecimalMin(value = "0", message = "El descuento no puede ser negativo")
@DecimalMax(value = "100", message = "El descuento no puede superar el 100 %")
BigDecimal descuento) { }
}
La respuesta 400
Si la validación falla, Spring lanza MethodArgumentNotValidException sin ejecutar el método. Nuestro manejador (sección 8) la convierte en una respuesta 400 con la lista de campos. Enviando {"nombre": "", "precio": -5, "stock": -1}:
curl -s -i -X POST "http://localhost:8080/api/productos" -H "Content-Type: application/json" -d @producto-malo.json
HTTP/1.1 400
Content-Type: application/problem+json
{
"detail": "La petición tiene 4 error(es) de validación",
"instance": "/api/productos",
"status": 400,
"title": "Datos no válidos",
"type": "https://empresa.es/errores/validacion",
"errores": {
"categoriaId": "La categoría es obligatoria",
"nombre": "El nombre es obligatorio",
"precio": "El precio debe ser mayor que cero",
"stock": "El stock no puede ser negativo"
}
}
💡 Consejo: Desde Spring Framework 6.1 también se validan los parámetros del método sin necesidad de
@Validateden la clase:@RequestParam @Positive BigDecimal maxdevuelve un 400 simaxes negativo (lo practicarás en el ejercicio 4).
⚠️ Importante: La validación de la API no sustituye a las restricciones de la base de datos (
NOT NULL,CHECK,UNIQUE, claves ajenas): son complementarias. La API da mensajes claros al cliente; la base de datos garantiza la integridad pase lo que pase.
8. Gestión global de errores con @RestControllerAdvice y ProblemDetail
Repetir try/catch en cada controlador sería un desastre. Spring permite centralizar el tratamiento de excepciones en una clase anotada con @RestControllerAdvice, cuyos métodos @ExceptionHandler se aplican a todos los controladores.
¿Y qué formato debe tener una respuesta de error? Existe un estándar: RFC 9457 «Problem Details for HTTP APIs» (que sustituye al RFC 7807). Define un JSON con el tipo application/problem+json y los campos type, title, status, detail e instance, más los que quieras añadir. Spring lo implementa con la clase ProblemDetail.
Figura 4. Gestión global de errores con ProblemDetail.
El manejador de empresa-app (paquete web.api):
@RestControllerAdvice(basePackages = "es.curso.empresa.web.api")
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(RecursoNoEncontradoException.class)
public ProblemDetail noEncontrado(RecursoNoEncontradoException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setTitle("Recurso no encontrado");
pd.setType(URI.create("https://empresa.es/errores/no-encontrado"));
return pd;
}
@ExceptionHandler(ReglaNegocioException.class)
public ProblemDetail reglaNegocio(ReglaNegocioException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
pd.setTitle("Operación no permitida");
pd.setType(URI.create("https://empresa.es/errores/regla-negocio"));
return pd;
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ProblemDetail integridad(DataIntegrityViolationException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
"La operación viola una restricción de la base de datos (registro en uso o duplicado)");
pd.setTitle("Conflicto de integridad");
return pd;
}
/** JSON mal formado o con un tipo incorrecto (texto en un número, enum desconocido...). */
@Override
protected @Nullable ResponseEntity<Object> handleHttpMessageNotReadable(
HttpMessageNotReadableException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
"El cuerpo de la petición no es un JSON válido o algún valor tiene un tipo incorrecto");
pd.setTitle("Petición mal formada");
return ResponseEntity.badRequest().body(pd);
}
/** Errores de @Valid en el cuerpo: 400 con la lista de campos erróneos. */
@Override
protected @Nullable ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
Map<String, String> errores = new TreeMap<>();
ex.getBindingResult().getFieldErrors()
.forEach(e -> errores.putIfAbsent(e.getField(), e.getDefaultMessage()));
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
"La petición tiene " + ex.getErrorCount() + " error(es) de validación");
pd.setTitle("Datos no válidos");
pd.setType(URI.create("https://empresa.es/errores/validacion"));
pd.setProperty("errores", errores);
return ResponseEntity.badRequest().body(pd);
}
}
Puntos clave:
- Heredar de
ResponseEntityExceptionHandlerhace que todas las excepciones estándar de Spring MVC (400 por tipo incorrecto, 405, 415…) respondan ya conProblemDetail. Sobrescribimos dos métodos para personalizar los mensajes. basePackageslimita el advice a los controladores REST: la web Thymeleaf del módulo 21 tiene sus propias páginas de error.- Las excepciones de negocio (
RecursoNoEncontradoException,ReglaNegocioException) son nuestras, viven en el paqueteservicioy no saben nada de HTTP… salvo una anotación@ResponseStatusque usa la parte web cuando no hay un manejador más específico. @Nullablees de JSpecify (org.jspecify.annotations): Spring Framework 7 usa estas anotaciones de nulabilidad en toda su API.
Respuestas reales de los distintos errores:
409 por regla de negocio (cambiar el estado de un pedido ya entregado):
curl -s -i -X PATCH "http://localhost:8080/api/pedidos/5001/estado" -H "Content-Type: application/json" -d '{"estado": "CANCELADO"}'
HTTP/1.1 409
Content-Type: application/problem+json
{
"detail": "El pedido 5001 ya está ENTREGADO",
"instance": "/api/pedidos/5001/estado",
"status": 409,
"title": "Operación no permitida",
"type": "https://empresa.es/errores/regla-negocio"
}
409 por integridad (borrar el producto 6, que aparece en líneas de pedido: MySQL rechaza el DELETE por la clave ajena fk_lin_producto):
curl -s -i -X DELETE "http://localhost:8080/api/productos/6"
HTTP/1.1 409
Content-Type: application/problem+json
{
"detail": "La operación viola una restricción de la base de datos (registro en uso o duplicado)",
"instance": "/api/productos/6",
"status": 409,
"title": "Conflicto de integridad"
}
400 por JSON mal formado:
curl -s -i -X POST "http://localhost:8080/api/productos" -H "Content-Type: application/json" -d '{"nombre": "x", "precio": }'
HTTP/1.1 400
Content-Type: application/problem+json
{
"detail": "El cuerpo de la petición no es un JSON válido o algún valor tiene un tipo incorrecto",
"instance": "/api/productos",
"status": 400,
"title": "Petición mal formada"
}
400 por un valor de enumerado desconocido ("estado": "PERDIDO"):
curl -s -i -X PATCH "http://localhost:8080/api/pedidos/5024/estado" -H "Content-Type: application/json" -d '{"estado": "PERDIDO"}'
HTTP/1.1 400
Content-Type: application/problem+json
{
"detail": "El cuerpo de la petición no es un JSON válido o algún valor tiene un tipo incorrecto",
"instance": "/api/pedidos/5024/estado",
"status": 400,
"title": "Petición mal formada"
}
💡 Consejo: Fíjate en que
DELETE /api/pedidos/5001no devuelve unProblemDetail, sino el JSON de error clásico de Spring Boot (timestamp,status,error,path): como ningún método de nuestros controladores aceptaDELETEen esa ruta, la excepción se produce antes de elegir controlador y nuestro advice, limitado abasePackages, no interviene. Si quieresProblemDetailen todas las respuestas de error, activaspring.mvc.problemdetails.enabled=true.
curl -s -i -X DELETE "http://localhost:8080/api/pedidos/5001"
HTTP/1.1 405
Allow: GET
Content-Type: application/json
{
"timestamp": "2026-10-04T13:46:45.998Z",
"status": 405,
"error": "Method Not Allowed",
"path": "/api/pedidos/5001"
}
9. Paginación en la API
Los listados de empresa-app devuelven Page<Dto> y reciben un Pageable (módulo 19). Spring crea el Pageable con los parámetros page, size y sort:
@GetMapping
public Page<PedidoResumenDto> listar(
@RequestParam(required = false) EstadoPedido estado,
@ParameterObject @PageableDefault(size = 10, sort = "fechaPedido", direction = Sort.Direction.DESC)
Pageable pageable) {
return servicio.listar(estado, pageable).map(PedidoResumenDto::desde);
}
Page.map(...)convierte cada entidad en su DTO manteniendo la información de paginación.@PageableDefaultfija el tamaño y el orden por defecto.@ParameterObject(de springdoc) hace que Swagger UI muestrepage,sizeysortcomo tres parámetros separados en lugar de un objeto JSON.- Con
spring.data.web.pageable.serialization-mode=via-dto(enapplication.properties), el JSON tiene un formato estable concontentypage. - Por seguridad, Spring limita el tamaño de página a 2000 (
spring.data.web.pageable.max-page-size); que nadie pidasize=1000000.
GET /api/productos?page=0&size=3&sort=precio,desc (respuesta real, formateada):
curl -s "http://localhost:8080/api/productos?page=0&size=3&sort=precio,desc"
{
"content": [
{
"id": 5,
"nombre": "Torre Workstation",
"descripcion": "Estación de trabajo para diseño y CAD",
"precio": 1899.00,
"stock": 4,
"descatalogado": false,
"fechaAlta": "2024-06-01",
"categoriaId": 3,
"categoria": "Ordenadores sobremesa"
},
{
"id": 2,
"nombre": "Portátil Gaming 16",
"descripcion": "Portátil de 16 pulgadas con gráfica dedicada",
"precio": 1499.00,
"stock": 10,
"descatalogado": false,
"fechaAlta": "2024-03-15",
"categoriaId": 2,
"categoria": "Portátiles"
},
{
"id": 12,
"nombre": "Smartphone Gama Alta",
"descripcion": "Teléfono móvil de 512 GB",
"precio": 999.00,
"stock": 8,
"descatalogado": false,
"fechaAlta": "2025-01-15",
"categoriaId": 8,
"categoria": "Telefonía"
}
],
"page": {
"size": 3,
"number": 0,
"totalElements": 20,
"totalPages": 7
}
}
10. CORS
Si un frontend servido desde http://localhost:5173 (por ejemplo, una aplicación Vite del Curso de JavaScript) llama con fetch a http://localhost:8080/api/productos, el navegador bloquea la respuesta: son orígenes distintos (cambia el puerto) y, por la política del mismo origen, el navegador solo permite la llamada si el servidor lo autoriza con cabeceras CORS (Cross-Origin Resource Sharing).
📘 Concepto: Para peticiones que no son «simples» (por ejemplo un
POSTcon JSON), el navegador envía antes una peticiónOPTIONSde comprobación (preflight) con las cabecerasOriginyAccess-Control-Request-Method. Si el servidor responde conAccess-Control-Allow-Originy los métodos permitidos, el navegador hace la petición real. CORS lo aplica el navegador:curlo Postman no se ven afectados.
En empresa-app se configura de forma global en WebConfig:
@Configuration
public class WebConfig implements WebMvcConfigurer {
/** Permite que un frontend en otro origen (p. ej. Vite en el puerto 5173) llame a la API. */
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:5173", "http://localhost:3000")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE")
.allowedHeaders("*")
.exposedHeaders("Location") // para que el JavaScript pueda leer Location
.maxAge(3600); // el navegador cachea el preflight una hora
}
}
Simulamos con curl el preflight de un navegador desde un origen permitido y desde otro que no lo está:
curl -s -i -X OPTIONS "http://localhost:8080/api/productos" -H "Origin: http://localhost:5173" -H "Access-Control-Request-Method: POST"
HTTP/1.1 200
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE
Access-Control-Expose-Headers: Location
Access-Control-Max-Age: 3600
Allow: GET, HEAD, POST, PUT, DELETE, OPTIONS, TRACE, PATCH
curl -s -i -X OPTIONS "http://localhost:8080/api/productos" -H "Origin: http://malicioso.example" -H "Access-Control-Request-Method: POST"
HTTP/1.1 403
Invalid CORS request
⚠️ Importante: No uses
allowedOrigins("*")en una API con datos privados o con autenticación por cookies: cualquier web podría usarla desde el navegador de tus usuarios. Enumera los orígenes concretos. Cuando añadas Spring Security (módulo 22), CORS se configurará también en la cadena de filtros de seguridad. Para un solo controlador existe la anotación@CrossOrigin(origins = "http://localhost:5173").
11. Documentar la API con springdoc-openapi
Una API sin documentación no se puede usar. OpenAPI (antes llamado Swagger) es el estándar para describir APIs REST en un documento JSON o YAML: rutas, parámetros, cuerpos, respuestas y esquemas. springdoc-openapi genera ese documento automáticamente analizando tus controladores, y además sirve Swagger UI, una página web para explorar y probar la API.
Versión compatible con Spring Boot 4
| springdoc-openapi | Spring Boot |
|---|---|
| 2.x | 3.x |
| 3.x | 4.x |
Spring Initializr ofrece la dependencia SpringDoc OpenAPI (grupo Web) y añade la versión 3.1.0; en octubre de 2026 la última en Maven Central es la 3.1.1, que es la que usamos. Como no la gestiona Spring Boot, aquí sí hay que escribir la versión:
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>3.1.1</version>
</dependency>
Sin escribir nada más, al arrancar tienes:
| URL | Contenido |
|---|---|
| http://localhost:8080/v3/api-docs | Documento OpenAPI 3.1 en JSON |
| http://localhost:8080/v3/api-docs.yaml | El mismo en YAML |
| http://localhost:8080/swagger-ui.html | Swagger UI (redirige a /swagger-ui/index.html) |
Figura 5. Swagger UI con los endpoints de empresa-app. Captura real.
Personalizar la documentación
Los datos generales salen de un bean OpenAPI (que reutiliza las EmpresaProperties del módulo 18):
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI empresaOpenApi(EmpresaProperties props) {
return new OpenAPI().info(new Info()
.title("API de " + props.nombre())
.description("API REST de productos, categorías, pedidos y empleados de la base de datos EMPRESA")
.version("1.0")
.contact(new Contact().name("Departamento de Informática").email(props.emailContacto()))
.license(new License().name("CC BY-SA 4.0")));
}
}
Y en los controladores, las anotaciones de io.swagger.v3.oas.annotations describen cada parte:
| Anotación | Dónde | Para qué |
|---|---|---|
@Tag(name, description) | Clase | Agrupa los endpoints («Productos», «Pedidos»…) |
@Operation(summary, description) | Método | Descripción de la operación |
@ApiResponse(responseCode, description) | Método | Documenta cada código de respuesta posible |
@Parameter(description, example) | Parámetro | Describe un parámetro |
@Schema(description, example) | Campo de DTO | Describe un campo del esquema |
@ParameterObject | Parámetro (Pageable) | Despliega un objeto en parámetros sueltos |
Y en application.properties limitamos la documentación a la API (sin las rutas de la web MVC) y ordenamos la vista:
springdoc.paths-to-match=/api/**
springdoc.swagger-ui.operations-sorter=method
springdoc.swagger-ui.tags-sorter=alpha
Probar desde Swagger UI
Despliega una operación, pulsa Try it out, edita el cuerpo y pulsa Execute. Swagger UI muestra el curl equivalente, la URL, el código, el cuerpo y las cabeceras de la respuesta:
Figura 6. Crear un producto desde Swagger UI: respuesta 201 con Location. Captura real.
💡 Consejo: En la captura, Swagger UI marca el 201 como Undocumented y documenta un 200 que la operación nunca devuelve: springdoc no puede adivinar los códigos de un
ResponseEntity. Documéntalos con@ApiResponse(ejercicio 6).
⚠️ Importante: En producción, desactiva la documentación o protégela:
springdoc.api-docs.enabled=falseyspringdoc.swagger-ui.enabled=false(el propio springdoc lo recuerda con unWARNal arrancar). Curiosidad: springdoc usa por dentro la libreríaswagger-core, que aún depende de Jackson 2, así que en el JAR conviven Jackson 2 y 3 sin conflicto porque sus paquetes son distintos.
12. Probar la API: curl, HTTPie y ficheros .http
curl
curl viene con Windows 10/11, Linux y macOS. Las opciones que más usarás:
| Opción | Para qué |
|---|---|
-i | Mostrar las cabeceras de la respuesta |
-s | Silencioso (sin barra de progreso) |
-X MÉTODO | Verbo HTTP (POST, PUT, PATCH, DELETE, OPTIONS) |
-H "Cabecera: valor" | Añadir una cabecera |
-d '{...}' o -d @fichero.json | Cuerpo de la petición |
⚠️ Importante: En PowerShell,
curlpuede ser un alias deInvoke-WebRequest. Usacurl.exeexplícitamente, y cuidado con las comillas del JSON: lo más cómodo es guardar el cuerpo en un fichero y usar-d @producto-nuevo.json.
El fichero producto-nuevo.json que usamos en las pruebas:
{
"nombre": "Alfombrilla gaming XL",
"descripcion": "Alfombrilla de 90 x 40 cm con base antideslizante",
"precio": 24.90,
"stock": 50,
"categoriaId": 5,
"descatalogado": false
}
Ciclo completo sobre el producto creado (21): PUT para reemplazarlo y PATCH para cambiar solo el stock:
curl -s -i -X PUT http://localhost:8080/api/productos/21 -H "Content-Type: application/json" -d @producto-put.json
curl -s -i -X PATCH http://localhost:8080/api/productos/21 -H "Content-Type: application/json" -d '{"stock": 15}'
curl -s -i -X PUT "http://localhost:8080/api/productos/21" -H "Content-Type: application/json" -d @producto-put.json
HTTP/1.1 200
Content-Type: application/json
{
"id": 21,
"nombre": "Alfombrilla gaming XXL",
"descripcion": "Alfombrilla de 120 x 60 cm con base antideslizante",
"precio": 29.90,
"stock": 40,
"descatalogado": false,
"fechaAlta": "2026-10-04",
"categoriaId": 5,
"categoria": "Ratones y teclados"
}
curl -s -i -X PATCH "http://localhost:8080/api/productos/21" -H "Content-Type: application/json" -d '{"stock": 15}'
HTTP/1.1 200
Content-Type: application/json
{
"id": 21,
"nombre": "Alfombrilla gaming XXL",
"descripcion": "Alfombrilla de 120 x 60 cm con base antideslizante",
"precio": 29.90,
"stock": 15,
"descatalogado": false,
"fechaAlta": "2026-10-04",
"categoriaId": 5,
"categoria": "Ratones y teclados"
}
HTTPie
HTTPie (pip install httpie o winget install httpie) es una alternativa a curl más legible: entiende JSON por defecto y colorea la salida. Las mismas peticiones:
http :8080/api/productos page==0 size==3 sort==precio,desc # == parámetros de consulta
http :8080/api/productos/6
http POST :8080/api/productos nombre="Alfombrilla gaming XL" precio:=24.90 stock:=50 categoriaId:=5 # := valor JSON no textual
Ficheros .http de IntelliJ IDEA
IntelliJ IDEA (y otros IDE como VS Code con la extensión REST Client) permite escribir peticiones en ficheros .http dentro del proyecto y ejecutarlas con el botón ▶ que aparece junto a cada una. Se guardan en Git y sirven como documentación viva. Admiten variables de entorno (http-client.env.json) y pequeños scripts en JavaScript para comprobar respuestas y encadenar peticiones:
http-client.env.json:
{
"local": {
"host": "http://localhost:8080"
}
}
empresa-api.http:
### Listar categorías
GET {{host}}/api/categorias/4/subcategorias
Accept: application/json
### Crear una categoría y guardar su id para las siguientes peticiones
POST {{host}}/api/categorias
Content-Type: application/json
{
"nombre": "Almacenamiento",
"padreId": 1
}
> {%
client.test("Se crea la categoría", () => {
client.assert(response.status === 201, "Se esperaba 201 y llegó " + response.status);
});
client.global.set("categoriaId", response.body.id);
%}
### Consultar la categoría recién creada
GET {{host}}/api/categorias/{{categoriaId}}
### Intentar crearla otra vez: 409
POST {{host}}/api/categorias
Content-Type: application/json
{
"nombre": "Almacenamiento",
"padreId": 1
}
### Borrarla: 204
DELETE {{host}}/api/categorias/{{categoriaId}}
Salida real del cliente HTTP de JetBrains en línea de órdenes (la misma herramienta que usa IntelliJ), primero el resumen:
Request 'Listar categorías' GET http://localhost:8080/api/categorias/4/subcategorias
Response code: 200; Time: 5153ms (5 s 153 ms); Content length: 138 bytes (138 B)
Request 'Crear una categoría y guardar su id para las siguientes peticiones' POST http://localhost:8080/api/categorias
Response code: 201; Time: 326ms (326 ms); Content length: 69 bytes (69 B)
Request 'Consultar la categoría recién creada' GET http://localhost:8080/api/categorias/13
Response code: 200; Time: 114ms (114 ms); Content length: 69 bytes (69 B)
Request 'Intentar crearla otra vez: 409' POST http://localhost:8080/api/categorias
Response code: 409; Time: 134ms (134 ms); Content length: 175 bytes (175 B)
Request 'Borrarla: 204' DELETE http://localhost:8080/api/categorias/13
Response code: 204; Time: 174ms (174 ms); Content length: 0 bytes (0 B)
5 requests completed, 0 have failed tests
RUN SUCCESSFUL
Y el detalle de la creación y del intento repetido (modo detallado, -L VERBOSE; se han quitado las cabeceras Vary):
Request 'Crear una categoría y guardar su id para las siguientes peticiones' POST http://localhost:8080/api/categorias
= request =>
POST http://localhost:8080/api/categorias
Content-Type: application/json
Content-Length: 48
User-Agent: IntelliJ HTTP Client/CLI 2026.2
Accept-Encoding: br, deflate, gzip, x-gzip
Accept: */*
host: localhost:8080
{
"nombre": "Almacenamiento",
"padreId": 1
}
<= response =
HTTP/1.1 201
Location: http://localhost:8080/api/categorias/13
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 04 Oct 2026 13:40:30 GMT
{"id":13,"nombre":"Almacenamiento","padreId":1,"padre":"Informática"}
Response code: 201; Time: 326ms (326 ms); Content length: 69 bytes (69 B)
Request 'Intentar crearla otra vez: 409' POST http://localhost:8080/api/categorias
= request =>
POST http://localhost:8080/api/categorias
Content-Type: application/json
Content-Length: 48
User-Agent: IntelliJ HTTP Client/CLI 2026.2
Accept-Encoding: br, deflate, gzip, x-gzip
Accept: */*
host: localhost:8080
{
"nombre": "Almacenamiento",
"padreId": 1
}
<= response =
HTTP/1.1 409
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Sun, 04 Oct 2026 13:40:51 GMT
{"detail":"Ya existe la categoría Almacenamiento","instance":"/api/categorias","status":409,"title":"Operación no permitida","type":"https://empresa.es/errores/regla-negocio"}
Response code: 409; Time: 134ms (134 ms); Content length: 175 bytes (175 B)
El script guarda el id devuelto (13) y las peticiones siguientes lo usan en {{categoriaId}}. En IntelliJ, la respuesta de cada petición aparece en el panel Services y se guarda en .idea/httpRequests/.
13. La API completa de empresa-app
La API que queda al final del módulo (los controladores están en es.curso.empresa.web.api):
| Método y URI | Descripción | Respuestas |
|---|---|---|
GET /api/productos?texto=&categoriaId=&page=&size=&sort= | Productos paginados con filtros | 200 |
GET /api/productos/{id} | Un producto | 200, 404 |
POST /api/productos | Crear producto | 201, 400, 404 |
PUT /api/productos/{id} | Reemplazar producto | 200, 400, 404 |
PATCH /api/productos/{id} | Cambiar precio, stock o catálogo | 200, 400, 404 |
DELETE /api/productos/{id} | Borrar producto | 204, 404, 409 |
GET /api/categorias | Todas las categorías con su padre | 200 |
GET /api/categorias/{id} | Una categoría | 200, 404 |
GET /api/categorias/{id}/subcategorias | Hijas de una categoría | 200, 404 |
GET /api/categorias/{id}/productos | Productos de una categoría (paginado) | 200, 404 |
POST /api/categorias | Crear categoría | 201, 400, 409 |
PUT /api/categorias/{id} | Modificar categoría | 200, 400, 404, 409 |
DELETE /api/categorias/{id} | Borrar categoría sin productos ni hijas | 204, 404, 409 |
GET /api/pedidos?estado=&page=&size=&sort= | Pedidos paginados (resumen) | 200 |
GET /api/pedidos/{id} | Pedido con líneas y total | 200, 404 |
POST /api/pedidos | Crear pedido y descontar stock | 201, 400, 404, 409 |
PATCH /api/pedidos/{id}/estado | Cambiar el estado | 200, 400, 404, 409 |
GET /api/empleados?texto=&oficio=&departamento=&salarioMin= | Búsqueda dinámica de empleados | 200 |
GET /api/empleados/{id} | Un empleado | 200, 404 |
El controlador de pedidos
@RestController
@RequestMapping("/api/pedidos")
@Tag(name = "Pedidos", description = "Pedidos de clientes y sus líneas")
public class PedidoApiController {
private final PedidoService servicio;
public PedidoApiController(PedidoService servicio) {
this.servicio = servicio;
}
@GetMapping
public Page<PedidoResumenDto> listar(
@RequestParam(required = false) EstadoPedido estado,
@ParameterObject @PageableDefault(size = 10, sort = "fechaPedido", direction = Sort.Direction.DESC) Pageable pageable) {
return servicio.listar(estado, pageable).map(PedidoResumenDto::desde);
}
@GetMapping("/{id}")
public PedidoDto obtener(@PathVariable Integer id) {
return PedidoDto.desde(servicio.buscar(id));
}
@PostMapping
public ResponseEntity<PedidoDto> crear(@Valid @RequestBody NuevoPedidoRequest req) {
Pedido p = servicio.crear(req);
var location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(p.getId()).toUri();
return ResponseEntity.created(location).body(PedidoDto.desde(p));
}
@PatchMapping("/{id}/estado")
public PedidoDto cambiarEstado(@PathVariable Integer id, @Valid @RequestBody CambioEstadoRequest req) {
return PedidoDto.desde(servicio.cambiarEstado(id, req.estado()));
}
}
Los DTO de pedidos calculan el importe de cada línea y el total:
public record PedidoDto(Integer id, LocalDate fechaPedido, LocalDate fechaEnvio, EstadoPedido estado,
Integer clienteId, String cliente, String empleado,
List<LineaPedidoDto> lineas, BigDecimal total) {
public static PedidoDto desde(Pedido p) {
return new PedidoDto(p.getId(), p.getFechaPedido(), p.getFechaEnvio(), p.getEstado(),
p.getCliente().getId(), p.getCliente().getNombre(),
p.getEmpleado() == null ? null : p.getEmpleado().getNombreCompleto(),
p.getLineas().stream().map(LineaPedidoDto::desde).toList(),
p.getTotal());
}
}
Probando los pedidos
Un pedido existente, GET /api/pedidos/5001:
curl -s "http://localhost:8080/api/pedidos/5001"
{
"id": 5001,
"fechaPedido": "2025-01-15",
"fechaEnvio": "2025-01-17",
"estado": "ENTREGADO",
"clienteId": 1001,
"cliente": "Talleres Martín S.L.",
"empleado": "Carmen Ruiz Ortega",
"lineas": [
{
"numLinea": 1,
"productoId": 2,
"producto": "Portátil Gaming 16",
"cantidad": 1,
"precioUnitario": 1499.00,
"descuento": 0.00,
"importe": 1499.00
},
{
"numLinea": 2,
"productoId": 6,
"producto": "Ratón inalámbrico",
"cantidad": 2,
"precioUnitario": 19.90,
"descuento": 0.00,
"importe": 39.80
},
{
"numLinea": 3,
"productoId": 9,
"producto": "Monitor 24 FHD",
"cantidad": 2,
"precioUnitario": 139.00,
"descuento": 5.00,
"importe": 264.10
}
],
"total": 1802.90
}
Los pendientes, GET /api/pedidos?estado=PENDIENTE:
curl -s "http://localhost:8080/api/pedidos?estado=PENDIENTE"
{
"content": [
{
"id": 5024,
"fechaPedido": "2026-07-10",
"estado": "PENDIENTE",
"cliente": "Gestoría Delta"
},
{
"id": 5023,
"fechaPedido": "2026-07-08",
"estado": "PENDIENTE",
"cliente": "Laura Benítez Sanz"
},
{
"id": 5022,
"fechaPedido": "2026-07-01",
"estado": "PENDIENTE",
"cliente": "Diseños Creativos S.L."
}
],
"page": {
"size": 10,
"number": 0,
"totalElements": 3,
"totalPages": 1
}
}
Crear un pedido (pedido-nuevo.json: el cliente 1004 compra 3 monitores con un 5 % de descuento y 3 ratones):
{
"clienteId": 1004,
"empleadoId": 111,
"lineas": [
{ "productoId": 9, "cantidad": 3, "descuento": 5 },
{ "productoId": 6, "cantidad": 3 }
]
}
curl -s -i -X POST "http://localhost:8080/api/pedidos" -H "Content-Type: application/json" -d @pedido-nuevo.json
HTTP/1.1 201
Location: http://localhost:8080/api/pedidos/5025
Content-Type: application/json
{
"id": 5025,
"fechaPedido": "2026-10-04",
"fechaEnvio": null,
"estado": "PENDIENTE",
"clienteId": 1004,
"cliente": "Academia Nova",
"empleado": "David Torres Romero",
"lineas": [
{
"numLinea": 1,
"productoId": 9,
"producto": "Monitor 24 FHD",
"cantidad": 3,
"precioUnitario": 139.00,
"descuento": 5,
"importe": 396.15
},
{
"numLinea": 2,
"productoId": 6,
"producto": "Ratón inalámbrico",
"cantidad": 3,
"precioUnitario": 19.90,
"descuento": 0,
"importe": 59.70
}
],
"total": 455.85
}
Y enviarlo (cambiar su estado a ENVIADO rellena la fecha de envío):
curl -s -i -X PATCH "http://localhost:8080/api/pedidos/5025/estado" -H "Content-Type: application/json" -d '{"estado": "ENVIADO"}'
HTTP/1.1 200
Content-Type: application/json
{
"id": 5025,
"fechaPedido": "2026-10-04",
"fechaEnvio": "2026-10-04",
"estado": "ENVIADO",
"clienteId": 1004,
"cliente": "Academia Nova",
"empleado": "David Torres Romero",
"lineas": [
{
"numLinea": 1,
"productoId": 9,
"producto": "Monitor 24 FHD",
"cantidad": 3,
"precioUnitario": 139.00,
"descuento": 5.00,
"importe": 396.15
},
{
"numLinea": 2,
"productoId": 6,
"producto": "Ratón inalámbrico",
"cantidad": 3,
"precioUnitario": 19.90,
"descuento": 0.00,
"importe": 59.70
}
],
"total": 455.85
}
Empleados de Informática (departamento 30) que cobran al menos 2.500 €, del que más al que menos (Specifications del módulo 19):
curl -s "http://localhost:8080/api/empleados?departamento=30&salarioMin=2500&sort=salario,desc"
{
"content": [
{
"id": 102,
"nombre": "Marta",
"apellidos": "Sánchez Gil",
"email": "marta.sanchez@empresa.es",
"oficio": "DIRECTOR",
"salario": 5000.00,
"fechaAlta": "2011-06-20",
"departamento": "INFORMÁTICA"
},
{
"id": 120,
"nombre": "Pablo",
"apellidos": "Moreno Herrera",
"email": "pablo.moreno@empresa.es",
"oficio": "ANALISTA",
"salario": 3500.00,
"fechaAlta": "2014-05-05",
"departamento": "INFORMÁTICA"
},
{
"id": 121,
"nombre": "Raquel",
"apellidos": "Molina Prieto",
"email": "raquel.molina@empresa.es",
"oficio": "PROGRAMADOR",
"salario": 2800.00,
"fechaAlta": "2018-10-01",
"departamento": "INFORMÁTICA"
},
{
"id": 122,
"nombre": "Iván",
"apellidos": "Castro Méndez",
"email": "ivan.castro@empresa.es",
"oficio": "PROGRAMADOR",
"salario": 2600.00,
"fechaAlta": "2020-03-16",
"departamento": "INFORMÁTICA"
},
{
"id": 124,
"nombre": "Hugo",
"apellidos": "Rubio Marín",
"email": "hugo.rubio@empresa.es",
"oficio": "TÉCNICO SISTEMAS",
"salario": 2500.00,
"fechaAlta": "2019-11-20",
"departamento": "INFORMÁTICA"
}
],
"page": {
"size": 10,
"number": 0,
"totalElements": 5,
"totalPages": 1
}
}
14. Consumir APIs desde Java con RestClient
Tu aplicación también puede ser cliente de otras APIs (un servicio de pagos, otra aplicación de la empresa…). En el módulo 09 usaste java.net.http.HttpClient; en Spring la herramienta recomendada es RestClient (Spring Framework 6.1+), una API fluida y síncrona que reutiliza los mismos conversores JSON que el servidor.
Con spring-boot-starter-restclient (lo añadimos al pom.xml), Spring Boot autoconfigura un RestClient.Builder con Jackson 3 ya configurado. Este cliente de la API de productos se ejecuta como un CommandLineRunner con el perfil cliente (en el proyecto de referencia está en el paquete es.curso.empresa.ejercicios, junto con las soluciones de los ejercicios):
/** Cliente de la API de productos con RestClient (se ejecuta con el perfil "cliente"). */
@Component
@Profile("cliente")
public class CatalogoCliente implements CommandLineRunner {
// Records "espejo" del JSON que devuelve la API (solo los campos que nos interesan)
record Producto(Integer id, String nombre, BigDecimal precio, int stock, String categoria) { }
record Pagina(List<Producto> content, Map<String, Object> page) { }
private final RestClient http;
public CatalogoCliente(RestClient.Builder builder, @Value("${catalogo.url}") String url) {
this.http = builder.baseUrl(url).build(); // el Builder lo autoconfigura Spring Boot
}
@Override
public void run(String... args) {
// 1. GET de una página y conversión automática del JSON a records
Pagina pagina = http.get()
.uri("/api/productos?size={tam}&sort={orden}", 4, "precio")
.retrieve()
.body(Pagina.class);
System.out.println("Total de productos: " + pagina.page().get("totalElements"));
pagina.content().forEach(p -> System.out.printf(" %-24s %8s €%n", p.nombre(), p.precio()));
// 2. GET de una lista con tipo genérico
List<Producto> baratos = http.get()
.uri(b -> b.path("/api/productos/baratos").queryParam("max", 40).build())
.retrieve()
.body(new ParameterizedTypeReference<>() { });
System.out.println("Productos de 40 € o menos: " + baratos.size());
// 3. POST con cuerpo JSON y lectura de la cabecera Location
ResponseEntity<Producto> creado = http.post()
.uri("/api/productos")
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("nombre", "Hub USB-C", "precio", 39.90, "stock", 25, "categoriaId", 4))
.retrieve()
.toEntity(Producto.class);
System.out.println("Creado: " + creado.getStatusCode() + " " + creado.getHeaders().getLocation());
// 4. Errores: 4xx/5xx lanzan RestClientResponseException...
try {
http.get().uri("/api/productos/{id}", 999).retrieve().body(Producto.class);
} catch (RestClientResponseException ex) {
ProblemDetail pd = ex.getResponseBodyAs(ProblemDetail.class);
System.out.println("Error " + ex.getStatusCode().value() + ": " + pd.getDetail());
}
// ...o se tratan con onStatus sin excepciones
http.delete().uri("/api/productos/{id}", 6)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (req, resp) ->
System.out.println("No se puede borrar: HTTP " + resp.getStatusCode().value()))
.toBodilessEntity();
// 5. Limpieza: borrar el producto creado (204 No Content)
ResponseEntity<Void> borrado = http.delete().uri(creado.getHeaders().getLocation())
.retrieve().toBodilessEntity();
System.out.println("Borrado: " + borrado.getStatusCode());
}
}
Con empresa-app arrancada, lanzamos una segunda instancia como cliente (sin servidor web):
java -jar target/empresa-app-0.0.1-SNAPSHOT.jar --spring.profiles.active=dev,cliente \
--spring.main.web-application-type=none --catalogo.url=http://localhost:8080
Salida real (el endpoint /api/productos/baratos es el del ejercicio 4):
Total de productos: 20
Ratón inalámbrico 19.90 €
Pack teclado y ratón 34.50 €
Webcam HD 45.00 €
Altavoz Inteligente 49.00 €
Productos de 40 € o menos: 2
Creado: 201 CREATED http://localhost:8080/api/productos/21
Error 404: No existe producto con id 999
No se puede borrar: HTTP 409
Borrado: 204 NO_CONTENT
Observa varias cosas: RestClient convierte el JSON en records aunque estos solo tengan algunos de los campos (los demás se ignoran); el 404 llega como RestClientResponseException y su cuerpo se puede leer como ProblemDetail; y el 409 del borrado del producto 6 se trata con onStatus sin excepción.
Método de RestClient | Para qué |
|---|---|
get(), post(), put(), patch(), delete() | Verbo |
.uri("/ruta/{id}", id) o .uri(b -> b.path(...).queryParam(...).build()) | URI con variables o constructor |
.header(...), .contentType(...), .body(objeto) | Cabeceras y cuerpo (se convierte a JSON) |
.retrieve() | Ejecuta la petición |
.body(Clase.class) / .body(new ParameterizedTypeReference<List<T>>() {}) | Cuerpo convertido |
.toEntity(Clase.class) | Cuerpo + código + cabeceras |
.toBodilessEntity() | Solo código y cabeceras |
.onStatus(predicado, manejador) | Tratar ciertos códigos sin excepción |
💡 Consejo: Spring permite también declarar un cliente como una interfaz con anotaciones
@GetExchange,@PostExchange… (HTTP interface clients), y Spring genera la implementación sobreRestClient, igual que Spring Data hace con los repositorios. Y para aplicaciones reactivas (Spring WebFlux) existeWebClient, no bloqueante, que devuelveMono/Flux. En una aplicación Spring MVC como la nuestra,RestClientes la opción natural;RestTemplate, la API clásica, sigue existiendo pero ya no recibe novedades.
15. Ejercicios
Ejercicio 1: API de clientes (lectura)
Crea ClienteDto (id, nombre, NIF, email, ciudad, provincia, límite de crédito y nombre del comercial), un ClienteService y un ClienteApiController con GET /api/clientes (paginado, 5 por página, ordenado por nombre) y GET /api/clientes/{id}. Evita la LazyInitializationException del comercial.
Ver solución
public record ClienteDto(Integer id, String nombre, String nif, String email, String ciudad,
String provincia, BigDecimal limiteCredito, String comercial) {
public static ClienteDto desde(Cliente c) {
return new ClienteDto(c.getId(), c.getNombre(), c.getNif(), c.getEmail(), c.getCiudad(),
c.getProvincia(), c.getLimiteCredito(),
c.getComercial() == null ? null : c.getComercial().getNombreCompleto());
}
}
En ClienteRepository, el comercial se carga en la misma consulta:
@Override
@EntityGraph(attributePaths = "comercial")
Page<Cliente> findAll(Pageable pageable);
@Override
@EntityGraph(attributePaths = "comercial")
Optional<Cliente> findById(Integer id);
@Service
@Transactional(readOnly = true)
public class ClienteService {
private final ClienteRepository clientes;
private final EmpleadoRepository empleados;
public ClienteService(ClienteRepository clientes, EmpleadoRepository empleados) {
this.clientes = clientes;
this.empleados = empleados;
}
public Page<Cliente> listar(Pageable pageable) {
return clientes.findAll(pageable);
}
public Cliente buscar(Integer id) {
return clientes.findById(id).orElseThrow(() -> new RecursoNoEncontradoException("cliente", id));
}
}
@RestController
@RequestMapping("/api/clientes")
@Tag(name = "Clientes")
public class ClienteApiController {
private final ClienteService servicio;
public ClienteApiController(ClienteService servicio) {
this.servicio = servicio;
}
@GetMapping
public Page<ClienteDto> listar(@ParameterObject @PageableDefault(size = 5, sort = "nombre") Pageable pageable) {
return servicio.listar(pageable).map(ClienteDto::desde);
}
@GetMapping("/{id}")
public ClienteDto obtener(@PathVariable Integer id) {
return ClienteDto.desde(servicio.buscar(id));
}
}
curl -s "http://localhost:8080/api/clientes?size=2"
curl -s http://localhost:8080/api/clientes/1009
{
"content": [
{
"id": 1004,
"nombre": "Academia Nova",
"nif": "B34567890",
"email": "secretaria@academianova.es",
"ciudad": "Madrid",
"provincia": "Madrid",
"limiteCredito": 8000.00,
"comercial": "David Torres Romero"
},
{
"id": 1006,
"nombre": "Antonio Ruiz Pérez",
"nif": "23456789D",
"email": null,
"ciudad": "Sevilla",
"provincia": "Sevilla",
"limiteCredito": 1500.00,
"comercial": "Elena Navarro Castro"
}
],
"page": {
"size": 2,
"number": 0,
"totalElements": 14,
"totalPages": 7
}
}
{
"id": 1009,
"nombre": "Laura Benítez Sanz",
"nif": "34567890V",
"email": "laurabs@correo.es",
"ciudad": "Bilbao",
"provincia": "Bizkaia",
"limiteCredito": 1000.00,
"comercial": null
}
El cliente 1009 no tiene comercial, por eso comercial es null.
Ejercicio 2: Alta de clientes con validación
Añade POST /api/clientes con un ClienteRequest validado: nombre obligatorio (máx. 60), NIF obligatorio con el patrón «letra o número + 7 números + letra o número», email con formato válido y límite de crédito no negativo (3000 si no se indica). Si el NIF ya existe, responde 409.
Ver solución
public record ClienteRequest(
@NotBlank(message = "El nombre es obligatorio")
@Size(max = 60, message = "El nombre no puede superar los 60 caracteres")
String nombre,
@NotBlank(message = "El NIF es obligatorio")
@Pattern(regexp = "[0-9A-Z][0-9]{7}[0-9A-Z]", message = "El NIF debe tener 9 caracteres: letra o número, 7 números y letra o número")
String nif,
@Email(message = "El email no tiene un formato válido")
String email,
@Size(max = 40) String ciudad,
@Size(max = 30) String provincia,
@PositiveOrZero(message = "El límite de crédito no puede ser negativo")
BigDecimal limiteCredito,
Integer comercialId) { }
// ClienteService (y en ClienteRepository: boolean existsByNif(String nif);)
@Transactional
public Cliente crear(ClienteRequest req) {
if (clientes.existsByNif(req.nif())) {
throw new ReglaNegocioException("Ya existe un cliente con el NIF " + req.nif());
}
Cliente c = new Cliente(req.nombre(), req.nif());
c.setEmail(req.email());
c.setCiudad(req.ciudad());
c.setProvincia(req.provincia());
c.setLimiteCredito(req.limiteCredito() == null ? new BigDecimal("3000") : req.limiteCredito());
if (req.comercialId() != null) {
c.setComercial(empleados.findById(req.comercialId())
.orElseThrow(() -> new RecursoNoEncontradoException("empleado", req.comercialId())));
}
return clientes.save(c);
}
// ClienteApiController
@PostMapping
public ResponseEntity<ClienteDto> crear(@Valid @RequestBody ClienteRequest req) {
Cliente c = servicio.crear(req);
var location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(c.getId()).toUri();
return ResponseEntity.created(location).body(ClienteDto.desde(c));
}
Pruebas reales: un alta correcta, una con errores y una con un NIF repetido:
HTTP/1.1 201
Location: http://localhost:8126/api/clientes/1015
Content-Type: application/json
{
"id": 1015,
"nombre": "Librería Cervantes",
"nif": "B11223344",
"email": "pedidos@libreriacervantes.es",
"ciudad": "Salamanca",
"provincia": "Salamanca",
"limiteCredito": 3000,
"comercial": "Elena Navarro Castro"
}
HTTP/1.1 400
Content-Type: application/problem+json
{
"detail": "La petición tiene 4 error(es) de validación",
"instance": "/api/clientes",
"status": 400,
"title": "Datos no válidos",
"type": "https://empresa.es/errores/validacion",
"errores": {
"email": "El email no tiene un formato válido",
"limiteCredito": "El límite de crédito no puede ser negativo",
"nif": "El NIF debe tener 9 caracteres: letra o número, 7 números y letra o número",
"nombre": "El nombre es obligatorio"
}
}
HTTP/1.1 409
Content-Type: application/problem+json
{
"detail": "Ya existe un cliente con el NIF B11223344",
"instance": "/api/clientes",
"status": 409,
"title": "Operación no permitida",
"type": "https://empresa.es/errores/regla-negocio"
}
El nuevo cliente toma el límite de crédito por defecto (3000). (Esta prueba se hizo con la aplicación publicada en el puerto 8126 de la máquina, por eso la cabecera Location lo indica; en tu equipo verás 8080.)
Ejercicio 3: Subrecurso de líneas
Añade GET /api/pedidos/{id}/lineas, que devuelva solo las líneas del pedido (lista de LineaPedidoDto), con 404 si el pedido no existe.
Ver solución
@GetMapping("/{id}/lineas")
public List<LineaPedidoDto> lineas(@PathVariable Integer id) {
return servicio.buscar(id).getLineas().stream().map(LineaPedidoDto::desde).toList();
}
[
{
"numLinea": 1,
"productoId": 3,
"producto": "Portátil Básico 15",
"cantidad": 1,
"precioUnitario": 499.00,
"descuento": 0.00,
"importe": 499.00
},
{
"numLinea": 2,
"productoId": 13,
"producto": "Auriculares Bluetooth",
"cantidad": 1,
"precioUnitario": 59.90,
"descuento": 0.00,
"importe": 59.90
}
]
HTTP/1.1 404
Content-Type: application/problem+json
{
"detail": "No existe pedido con id 9999",
"instance": "/api/pedidos/9999/lineas",
"status": 404,
"title": "Recurso no encontrado",
"type": "https://empresa.es/errores/no-encontrado"
}
servicio.buscar(id) usa findConLineasById, con @EntityGraph sobre las líneas y sus productos, así que todo llega cargado en una única consulta y el 404 lo genera RecursoNoEncontradoException.
Ejercicio 4: Validar parámetros de la URL
Crea GET /api/productos/baratos?max=... que devuelva los productos con precio menor o igual que max, ordenados por precio. max debe ser obligatorio y positivo: comprueba qué responde la API con max=-5 y sin max.
Ver solución
// ProductoApiController
@GetMapping("/baratos")
public List<ProductoDto> baratos(@RequestParam @Positive(message = "max debe ser positivo") BigDecimal max) {
return servicio.baratos(max).stream().map(ProductoDto::desde).toList();
}
// ProductoService
public List<Producto> baratos(BigDecimal max) {
return productos.findByPrecioLessThanEqualOrderByPrecio(max); // con @EntityGraph("categoria")
}
GET /api/productos/baratos?max=40:
[
{
"id": 6,
"nombre": "Ratón inalámbrico",
"descripcion": "Ratón óptico inalámbrico",
"precio": 19.90,
"stock": 200,
"descatalogado": false,
"fechaAlta": "2022-02-01",
"categoriaId": 5,
"categoria": "Ratones y teclados"
},
{
"id": 8,
"nombre": "Pack teclado y ratón",
"descripcion": "Conjunto inalámbrico de teclado y ratón",
"precio": 34.50,
"stock": 0,
"descatalogado": false,
"fechaAlta": "2022-11-10",
"categoriaId": 5,
"categoria": "Ratones y teclados"
}
]
GET /api/productos/baratos?max=-5 y sin el parámetro:
HTTP/1.1 400
Content-Type: application/problem+json
{
"detail": "Validation failure",
"instance": "/api/productos/baratos",
"status": 400,
"title": "Bad Request"
}
HTTP/1.1 400
Content-Type: application/problem+json
{
"detail": "Required parameter 'max' is not present.",
"instance": "/api/productos/baratos",
"status": 400,
"title": "Bad Request"
}
La ruta literal /baratos tiene prioridad sobre /{id}. Con max=-5, Spring aplica la validación de métodos (sin necesidad de @Validated) y lanza HandlerMethodValidationException, que ResponseEntityExceptionHandler convierte en un ProblemDetail 400; sin max, el parámetro obligatorio falta y también es un 400.
Ejercicio 5: Un fichero .http con pruebas encadenadas
Escribe un fichero .http que cree una categoría «Almacenamiento» dentro de «Informática», guarde su id en una variable, la consulte, intente crearla de nuevo (409) y la borre (204). Comprueba con un script de respuesta que la creación devuelve 201.
Ver solución
Es el fichero empresa-api.http de la sección 12. Las claves son:
{{host}}sale del entornolocaldehttp-client.env.json.- El bloque
> {% ... %}se ejecuta tras recibir la respuesta:client.testyclient.assertcomprueban el código, yclient.global.set("categoriaId", response.body.id)guarda el id para usarlo después como{{categoriaId}}. - Cada petición empieza con
###, que además sirve de título.
En IntelliJ se ejecuta con ▶ Run All Requests in File; fuera del IDE puede ejecutarse con el cliente HTTP de JetBrains en línea de órdenes, como se ha hecho para obtener la salida real de la sección 12.
Ejercicio 6: Documentar los códigos de respuesta
Añade a POST /api/productos las anotaciones de springdoc necesarias para que Swagger UI documente las respuestas 201, 400 y 404 (estas dos con un cuerpo ProblemDetail de tipo application/problem+json). Comprueba el resultado en /v3/api-docs.
Ver solución
@PostMapping
@Operation(summary = "Crea un producto")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "Producto creado; la cabecera Location indica su URL"),
@ApiResponse(responseCode = "400", description = "Datos no válidos",
content = @Content(mediaType = "application/problem+json",
schema = @Schema(implementation = ProblemDetail.class))),
@ApiResponse(responseCode = "404", description = "La categoría no existe",
content = @Content(mediaType = "application/problem+json",
schema = @Schema(implementation = ProblemDetail.class)))
})
public ResponseEntity<ProductoDto> crear(@Valid @RequestBody ProductoRequest req) { ... }
Extracto real de /v3/api-docs para esa operación:
{
"responses": {
"201": {
"description": "Producto creado; la cabecera Location indica su URL",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ProductoDto"
}
}
}
},
"400": {
"description": "Datos no válidos",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetail"
}
}
}
},
"404": {
"description": "La categoría no existe",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetail"
}
}
}
}
}
}
Al declarar las respuestas explícitamente, desaparece el «200 OK» genérico que springdoc deducía y el 201 deja de aparecer como Undocumented en Swagger UI.
Ejercicio 7 (propuesto): Completar la API de clientes
Añade PUT /api/clientes/{id} y DELETE /api/clientes/{id}. El borrado debe responder 409 si el cliente tiene pedidos (compruébalo con el cliente 1001 y con el 1013, que no tiene ninguno).
Ejercicio 8 (propuesto): Filtrar pedidos por fechas
Añade a GET /api/pedidos los parámetros opcionales desde y hasta (LocalDate, con @DateTimeFormat(iso = ISO.DATE)) para filtrar por fechaPedido, y valida que desde no sea posterior a hasta (400 con ProblemDetail).
Ejercicio 9 (propuesto): Estadísticas de ventas
Crea GET /api/estadisticas/ventas-por-categoria, que devuelva una lista de records con el nombre de la categoría, las unidades vendidas y el importe total, usando una consulta JPQL con select new.
Ejercicio 10 (propuesto): ProblemDetail en todas partes
Activa spring.mvc.problemdetails.enabled=true y comprueba de nuevo DELETE /api/pedidos/5001 y una URL inexistente (/api/nada). ¿Qué cambia en las respuestas? ¿Necesitas seguir heredando de ResponseEntityExceptionHandler?
Ejercicio 11 (propuesto): Cliente declarativo
Reescribe el CatalogoCliente como una interfaz ProductosApi con @GetExchange y @PostExchange, créala con HttpServiceProxyFactory a partir de un RestClient y compara la cantidad de código.
Ejercicio 12 (propuesto): Un frontend que consume la API
Con lo aprendido en el Curso de JavaScript, crea una página HTML servida por Vite (puerto 5173) que liste los productos con fetch y permita crear uno con un formulario. Comprueba que, sin la configuración CORS, el navegador bloquea las peticiones, y que con ella funcionan.
Resumen
| Concepto | Sintaxis / ejemplo |
|---|---|
| Controlador REST | @RestController + @RequestMapping("/api/productos") |
| Mapeos | @GetMapping("/{id}"), @PostMapping, @PutMapping, @PatchMapping, @DeleteMapping |
| Datos de entrada | @PathVariable, @RequestParam(required = false), @RequestBody, @RequestHeader |
| 201 + Location | ResponseEntity.created(ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}")...) |
| 204 | @ResponseStatus(HttpStatus.NO_CONTENT) o ResponseEntity.noContent().build() |
| DTO | record ProductoDto(...) + static desde(Producto p); entrada ProductoRequest |
| Validación | @Valid @RequestBody, @NotBlank, @Size, @Positive, @Email, @Pattern, List<@Valid T> |
| Errores | @RestControllerAdvice + @ExceptionHandler + ProblemDetail (RFC 9457) |
| Errores estándar | extends ResponseEntityExceptionHandler, spring.mvc.problemdetails.enabled |
| Paginación | Page<Dto> + @ParameterObject @PageableDefault Pageable, ?page=0&size=5&sort=precio,desc |
| CORS | WebMvcConfigurer.addCorsMappings(...) o @CrossOrigin |
| OpenAPI | springdoc-openapi-starter-webmvc-ui 3.1.x, /v3/api-docs, /swagger-ui.html, @Tag, @Operation, @ApiResponse |
| Probar | curl -i -X POST -H "Content-Type: application/json" -d @f.json, HTTPie, ficheros .http |
| Cliente | RestClient.Builder → get().uri(...).retrieve().body(T.class); WebClient en reactivo |