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, @RequestMapping y @GetMapping, @PostMapping, @PutMapping, @PatchMapping, @DeleteMapping
  • Recibir datos con @PathVariable, @RequestParam y @RequestBody, y responder con ResponseEntity y el código adecuado (201 con Location, 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 @RestControllerAdvice y ProblemDetail (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 .http de IntelliJ, y consumir APIs desde Java con RestClient

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).

Recursos de colección y de elemento de la API de productos con los verbos GET, POST, PUT, PATCH y DELETE y sus códigos 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.

Ciclo de una petición REST: cliente, DispatcherServlet, HandlerMapping, controlador, servicio, repositorio, MySQL y conversión a JSON con Jackson Figura 2. Ciclo de una petición REST en Spring Boot.

  1. Tomcat recibe la petición HTTP y se la pasa al DispatcherServlet, el front controller único de Spring MVC.
  2. El HandlerMapping busca qué método de qué controlador corresponde a esa URI y ese verbo.
  3. 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.
  4. El controlador llama al servicio, que aplica las reglas de negocio dentro de una transacción y usa los repositorios.
  5. El controlador devuelve un objeto (un DTO); un HttpMessageConverter lo convierte en JSON según la cabecera Accept.
  6. Si en cualquier punto se lanza una excepción, el @RestControllerAdvice la 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) y dto (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));
    }
}
  • @RestController indica que los métodos devuelven datos (que se escriben en el cuerpo, normalmente como JSON), no nombres de vistas como el @Controller del módulo 21.
  • @RequestMapping en 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 (/baratos y /{id}), Spring elige la más específica: GET /api/productos/baratos va 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 @RequestParam es obligatorio: si falta, la respuesta es un 400. Usa required = false (llega null) o defaultValue = "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 -parameters del 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:

La entidad Producto se queda dentro de la aplicación; ProductoRequest entra y ProductoDto sale por la red 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.desde lee p.getCategoria().getNombre(), una relación perezosa. Como el controlador está fuera de la transacción (y open-in-view está 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 un LazyInitializationException en 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_PRIMITIVES está activada por defecto. En la primera versión de ProductoRequest, el campo descatalogado era un boolean primitivo 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 como Boolean (que admite null) y tratar el null como false:

        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 @Validated en la clase: @RequestParam @Positive BigDecimal max devuelve un 400 si max es 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.

Las excepciones del servicio y de la validación llegan a ApiExceptionHandler, que genera un ProblemDetail 404 con formato RFC 9457 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 ResponseEntityExceptionHandler hace que todas las excepciones estándar de Spring MVC (400 por tipo incorrecto, 405, 415…) respondan ya con ProblemDetail. Sobrescribimos dos métodos para personalizar los mensajes.
  • basePackages limita 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 paquete servicio y no saben nada de HTTP… salvo una anotación @ResponseStatus que usa la parte web cuando no hay un manejador más específico.
  • @Nullable es 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/5001 no devuelve un ProblemDetail, sino el JSON de error clásico de Spring Boot (timestamp, status, error, path): como ningún método de nuestros controladores acepta DELETE en esa ruta, la excepción se produce antes de elegir controlador y nuestro advice, limitado a basePackages, no interviene. Si quieres ProblemDetail en todas las respuestas de error, activa spring.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.
  • @PageableDefault fija el tamaño y el orden por defecto.
  • @ParameterObject (de springdoc) hace que Swagger UI muestre page, size y sort como tres parámetros separados en lugar de un objeto JSON.
  • Con spring.data.web.pageable.serialization-mode=via-dto (en application.properties), el JSON tiene un formato estable con content y page.
  • Por seguridad, Spring limita el tamaño de página a 2000 (spring.data.web.pageable.max-page-size); que nadie pida size=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 POST con JSON), el navegador envía antes una petición OPTIONS de comprobación (preflight) con las cabeceras Origin y Access-Control-Request-Method. Si el servidor responde con Access-Control-Allow-Origin y los métodos permitidos, el navegador hace la petición real. CORS lo aplica el navegador: curl o 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)

Swagger UI de empresa-app con el título API de EMPRESA S.A., el servidor localhost:8080 y los endpoints de categorías, empleados, pedidos y productos 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:

Operación POST /api/productos en Swagger UI tras pulsar Execute: cuerpo de la petición, curl equivalente y respuesta 201 con la cabecera location 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=false y springdoc.swagger-ui.enabled=false (el propio springdoc lo recuerda con un WARN al arrancar). Curiosidad: springdoc usa por dentro la librería swagger-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, curl puede ser un alias de Invoke-WebRequest. Usa curl.exe explí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 sobre RestClient, igual que Spring Data hace con los repositorios. Y para aplicaciones reactivas (Spring WebFlux) existe WebClient, no bloqueante, que devuelve Mono/Flux. En una aplicación Spring MVC como la nuestra, RestClient es 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 entorno local de http-client.env.json.
  • El bloque > {% ... %} se ejecuta tras recibir la respuesta: client.test y client.assert comprueban el código, y client.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