Implementación de un API REST con Spring
Spring MVC y @RestController
Sección titulada «Spring MVC y @RestController»Spring MVC es la tecnología que usamos para construir servicios web que siguen la arquitectura REST. Una clase anotada con @RestController agrupa las implementaciones correspondientes a cada verbo HTTP (GET, POST, PUT, DELETE) asociado a cada URI de un recurso. Por ejemplo, una clase controlador puede reunir todos los servicios que se ofrecen para el recurso Book.
Cuando un cliente HTTP invoca un servicio, por ejemplo POST /books, ocurre lo siguiente:
- La petición
POST /bookses procesada del lado del servidor por Spring. - Spring identifica el endpoint: el método asociado con la ruta
/booksy con el verboPOST. - El método se ejecuta.
- Al terminar, la respuesta se transforma al formato del protocolo HTTP y se envía de regreso al cliente en la representación elegida por el desarrollador, normalmente JSON.
En la petición viaja un objeto JSON; cuando Spring la procesa, el método correspondiente recibe un objeto Java (un DTO, por ejemplo BookDTO). Recorre el siguiente ejemplo para ver el camino completo de una petición a través de las tres capas:
Recorre una petición completa
Cambia de operación y sigue el dato desde el cliente hasta la base de datos y de regreso.
GET /api/artistas/1Enviar GET
El cliente solicita el artista con identificador 1.
GET /api/artistas/1Implementación de las representaciones de los recursos
Sección titulada «Implementación de las representaciones de los recursos»Mientras que el cliente de los servicios REST utiliza representaciones JSON de los recursos, la implementación de esos recursos usa objetos de una clase Java. Las instancias de esa clase solo tienen los valores de los atributos y se suelen llamar POJO (Plain Old Java Object). Por convención, cada clase Java que representa un recurso se llama RecursoDTO, donde Recurso es el nombre del recurso: para el recurso Book, la clase es BookDTO.
Cómo se convierte el JSON en un DTO (y viceversa)
Sección titulada «Cómo se convierte el JSON en un DTO (y viceversa)»Nadie escribe a mano el código que lee cada propiedad del JSON y la asigna al atributo correspondiente del DTO. Esa conversión la hace un mapper: una librería que, dados un JSON y una clase Java, empareja cada propiedad con el atributo del mismo nombre. En Java, la librería estándar para esto es Jackson, y su clase principal es ObjectMapper.
Explora el ejemplo: un EstudianteDTO con su carnet, su nombre y la lista de cursos que aprobó, donde cada curso es a su vez otro objeto con su propio nombre.
JSON
{
"carnet": 12345,
"carne": 12345,
"nombre": "Camila Ruiz",
"cursosAprobados": [
{ "nombre": "Bases de Datos" },
{ "nombre": "Ingeniería de Software" }
]
}Java (DTOs)
@Data @NoArgsConstructor @AllArgsConstructor
public class EstudianteDTO {
private Long carnet;
private String nombre;
private List<CursoDTO> cursosAprobados;
}
public class CursoDTO {
private String nombre;
}Haz clic en una propiedad del JSON o en un atributo de la clase Java para ver con cuál se corresponde.
✅ Con estos nombres, el mapper asigna 12345 a EstudianteDTO.carnet.
⚠️ Con "carne" en el JSON, el mapper no encuentra una propiedad llamada carnet: el atributo queda en null, sin ningún error ni advertencia.
Implementación de una clase recurso
Sección titulada «Implementación de una clase recurso»Supongamos que queremos implementar los servicios REST de la página de diseño para una entidad City: crear una ciudad, obtener todas las ciudades, obtener una por id, actualizarla y borrarla.
| Método | Path | Acción | Parámetros | Cuerpo | Retorno |
|---|---|---|---|---|---|
| GET | /cities |
Lista los registros de City |
Colección de City |
||
| GET | /cities/:id |
Obtiene una instancia de City con identificador id |
@PathVariable id |
Atributos de la instancia | |
| POST | /cities |
Crea una instancia nueva de City |
Atributos de la instancia a crear | Instancia creada, con su id | |
| PUT | /cities/:id |
Actualiza la instancia con identificador id |
@PathVariable id |
Objeto JSON de City |
Instancia actualizada |
| DELETE | /cities/:id |
Borra la instancia con identificador id |
@PathVariable id |
La clase CityController tendrá entonces los siguientes métodos:
| Método | Descripción |
|---|---|
List<CityDTO> findAll() |
Retorna la lista de ciudades. |
CityDTO create(CityDTO city) |
Crea una ciudad con la información recibida por parámetro. |
CityDTO findOne(Long id) |
Retorna la ciudad identificada con id. |
CityDTO updateCity(Long id, CityDTO city) |
Actualiza la información de la ciudad identificada con id. |
void delete(Long id) |
Borra la ciudad identificada con id. |
Para que Spring procese una clase como recurso, esta debe estar anotada: @RestController indica que la clase es un controlador, y @RequestMapping indica el path del recurso.
Anotaciones para indicar el verbo HTTP
Sección titulada «Anotaciones para indicar el verbo HTTP»| Anotación | Método | Descripción |
|---|---|---|
@GetMapping |
List<CityDTO> findAll() |
Retorna la lista de ciudades. |
@PostMapping |
CityDTO create(CityDTO city) |
Crea una ciudad con la información recibida por parámetro. |
@GetMapping |
CityDTO findOne(Long id) |
Retorna la ciudad identificada con id. |
@PutMapping |
CityDTO update(Long id, CityDTO city) |
Actualiza la ciudad id con la información de city. |
@DeleteMapping |
void delete(Long id) |
Borra la ciudad identificada con id. |
Anotación para indicar el path
Sección titulada «Anotación para indicar el path»Cuando definimos el servicio GET /cities, cities es el path. Cuando todos los métodos de una clase recurso comparten el mismo path inicial, ese path se anota al comienzo de la clase:
@RequestMapping("/cities")public class CityController { // ...}Cuando el path necesita más información que la del comienzo de la clase (por ejemplo GET /cities/id, donde id es el identificador numérico de la ciudad) se anota directamente sobre el método:
@GetMapping(value = "/{id}")public CityDTO findOne(@PathVariable("id") Long id) throws EntityNotFoundException { // ...}@PathVariable("id") conecta el segmento {id} de la ruta con el parámetro id del método: Spring lo extrae del path y lo convierte al tipo declarado, en este caso Long.
Convertir entre entidad y DTO en el Controller
Sección titulada «Convertir entre entidad y DTO en el Controller»La capa de lógica de negocio trabaja con
entidades: recibe y devuelve ArtistaEntity. El cliente, en cambio, solo
ve DTOs. El Controller es quien convierte en los dos sentidos, y por eso
recibe dos dependencias por constructor: el Service y un ModelMapper.
@RestController@RequestMapping("/artistas")public class ArtistaController {
private final ArtistaService artistaService; private final ModelMapper modelMapper;
public ArtistaController(ArtistaService artistaService, ModelMapper modelMapper) { this.artistaService = artistaService; this.modelMapper = modelMapper; }}modelMapper.map(origen, Destino.class) crea un objeto de la clase destino y le
copia los atributos del origen que se llaman igual, también los de las clases
anidadas. Por eso conviene que la entidad y el DTO usen los mismos nombres: un
atributo que se llama distinto no se copia, y un atributo cuyo nombre se parece
a otro puede emparejarse con el equivocado.
Dos DTOs por recurso: DTO y DTOCompleto
Sección titulada «Dos DTOs por recurso: DTO y DTOCompleto»Cada recurso tiene dos representaciones, igual que en el diseño del API: la básica y la completa.
public class ArtistaDTO { private Long id; private String nombre; private String paisOrigen; // ...demás atributos básicos}
public class ArtistaDTOCompleto extends ArtistaDTO { private List<AlbumDTO> albumes = new ArrayList<>();}ArtistaDTOtiene solo los atributos básicos, sin colecciones. Es lo que el cliente envía en el cuerpo de unPOSTo unPUT, y lo que devuelven esos dos métodos.ArtistaDTOCompletoextiende al anterior y agrega las colecciones de asociaciones de cardinalidad múltiple, como lista de DTOs básicos del tipo relacionado (AlbumDTO, noAlbumDTOCompleto). Es lo que devuelven todos losGET.
Que la colección anidada use el DTO básico y no el completo es lo que evita los
ciclos: un AlbumDTO no vuelve a incluir a sus artistas, así que el JSON es un
árbol y no un grafo con vueltas.
Con esto, los dos métodos de lectura quedan así:
@GetMappingpublic List<ArtistaDTOCompleto> findAll() { return artistaService.findAll().stream() .map(artista -> modelMapper.map(artista, ArtistaDTOCompleto.class)) .toList();}
@GetMapping("/{id}")public ArtistaDTOCompleto findById(@PathVariable Long id) throws EntityNotFoundException { return modelMapper.map(artistaService.findById(id), ArtistaDTOCompleto.class);}Códigos de respuesta del CRUD
Sección titulada «Códigos de respuesta del CRUD»Cada método responde con un código HTTP distinto cuando todo sale bien:
| Método | Éxito | Qué devuelve |
|---|---|---|
GET /artistas |
200 |
La lista de ArtistaDTOCompleto |
GET /artistas/{id} |
200 |
Un ArtistaDTOCompleto |
POST /artistas |
201 |
El ArtistaDTO creado, con su id |
PUT /artistas/{id} |
200 |
El ArtistaDTO actualizado |
DELETE /artistas/{id} |
204 |
Nada (sin cuerpo) |
Por defecto Spring responde 200. Para el 201 y el 204 se anota el método
con @ResponseStatus:
@PostMapping@ResponseStatus(HttpStatus.CREATED)public ArtistaDTO create(@RequestBody ArtistaDTO artistaDTO) throws IllegalOperationException { ArtistaEntity artista = artistaService.create(modelMapper.map(artistaDTO, ArtistaEntity.class)); return modelMapper.map(artista, ArtistaDTO.class);}
@PutMapping("/{id}")public ArtistaDTO update(@PathVariable Long id, @RequestBody ArtistaDTO artistaDTO) throws EntityNotFoundException, IllegalOperationException { ArtistaEntity artista = artistaService.update(id, modelMapper.map(artistaDTO, ArtistaEntity.class)); return modelMapper.map(artista, ArtistaDTO.class);}
@DeleteMapping("/{id}")@ResponseStatus(HttpStatus.NO_CONTENT)public void delete(@PathVariable Long id) throws EntityNotFoundException { artistaService.delete(id);}Fíjate en el sentido de la conversión: en create y update el DTO que llega
se convierte a entidad para entregárselo al Service, y la entidad que el
Service devuelve se convierte a DTO para responder.
De la excepción del Service al código HTTP
Sección titulada «De la excepción del Service al código HTTP»El Service lanza EntityNotFoundException o IllegalOperationException, y el
Controller no tiene ningún try/catch: solo declara throws. Quien las traduce
a una respuesta HTTP es una clase aparte, anotada con @RestControllerAdvice,
que atiende las excepciones lanzadas desde cualquier controller:
@RestControllerAdvicepublic class RestExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class) public ResponseEntity<ApiError> handleEntityNotFound(EntityNotFoundException ex) { return new ResponseEntity<>(new ApiError(HttpStatus.NOT_FOUND, ex.getMessage()), HttpStatus.NOT_FOUND); }
@ExceptionHandler(IllegalOperationException.class) public ResponseEntity<ApiError> handleIllegalOperation(IllegalOperationException ex) { return new ResponseEntity<>(new ApiError(HttpStatus.BAD_REQUEST, ex.getMessage()), HttpStatus.BAD_REQUEST); }}ApiError es un DTO sencillo con el código, el mensaje de la excepción y la
hora:
public class ApiError { private int status; private String message; private LocalDateTime timestamp = LocalDateTime.now();
public ApiError(HttpStatus status, String message) { this.status = status.value(); this.message = message; }}El cliente recibe el código HTTP y, en el cuerpo, el mensaje que escribió quien programó la regla:
{"status":404,"message":"El artista no existe","timestamp":"2026-10-10T19:30:18.2758687"}| Excepción | Código | Cuándo |
|---|---|---|
EntityNotFoundException |
404 |
El recurso que se busca por la URL no existe |
IllegalOperationException |
400 |
La operación viola una regla de negocio |
CORS: por qué el navegador bloquea la respuesta
Sección titulada «CORS: por qué el navegador bloquea la respuesta»El front en Angular se sirve desde http://localhost:4200 y el back responde en http://localhost:8080. Para el navegador son dos orígenes distintos: un origen es la combinación de esquema (http), host (localhost) y puerto, y basta con que el puerto cambie. Por seguridad, el navegador no le entrega al código de una página la respuesta de otro origen, a menos que ese origen declare que lo permite. Esa regla se llama CORS (Cross-Origin Resource Sharing).
Hay tres ideas que conviene tener claras:
- La regla la aplica el navegador, no Spring. El back responde bien y el navegador descarta la respuesta. Postman, Newman y las pruebas del back no son navegadores: no aplican la regla y por eso no pueden avisar.
- El permiso lo da el back. Lo hace con una cabecera de la respuesta,
Access-Control-Allow-Origin, que dice qué origen puede leerla. - El error no aparece en Spring. Aparece en la consola del navegador (
blocked by CORS policy) y en Angular como una respuesta constatusigual a0, como se explica en Cliente HTTP con httpResource.
La petición previa (preflight)
Sección titulada «La petición previa (preflight)»Un GET se envía directo. Pero antes de un PUT, un DELETE o un POST con cuerpo JSON, el navegador envía una petición de prueba, un OPTIONS a la misma URL, para preguntar si el back acepta ese método desde ese origen. Solo si la respuesta lo permite, envía la petición real.
Eso cambia lo que le ocurre al back cuando falta la configuración. Con un back sin CORS, un GET llega al back y se ejecuta, pero el navegador descarta la respuesta; en cambio un PUT o un DELETE ni siquiera se ejecutan, porque la petición previa ya falló.
Configurarlo en Spring
Sección titulada «Configurarlo en Spring»Se declara una sola vez para todo el back, en una clase de configuración:
@Configurationpublic class CorsConfig implements WebMvcConfigurer {
@Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("http://localhost:4200") .allowedMethods("GET", "POST", "PUT", "DELETE"); }}- Dónde va la clase: en el mismo paquete de la clase principal (la anotada con
@SpringBootApplication), junto a los controllers, o en un subpaquete de ese. Spring Boot solo busca clases de configuración desde ese paquete hacia abajo. addMapping("/**")aplica la regla a todas las rutas del back. Un controller nuevo queda cubierto sin tocarlo.allowedOriginses el origen del front. Es lo único que cambia cuando el front se despliega en otra dirección.allowedMethodsson los verbos que el back acepta desde ese origen.
Endpoints de asociación
Sección titulada «Endpoints de asociación»Los endpoints de una asociación entre dos recursos raíz son los del diseño del API: un subrecurso anidado en la URL del recurso dueño. Para libros y autores, /books/{bookId}/authors. Se implementan en su propio controller, que usa el BookAuthorService de la lógica de negocio:
@RestController@RequestMapping("/books/{bookId}/authors")public class BookAuthorController {
private final BookAuthorService bookAuthorService; private final ModelMapper modelMapper;
public BookAuthorController(BookAuthorService bookAuthorService, ModelMapper modelMapper) { this.bookAuthorService = bookAuthorService; this.modelMapper = modelMapper; }
@GetMapping public List<AuthorDTOCompleto> getAuthors(@PathVariable Long bookId) throws EntityNotFoundException { return bookAuthorService.getAuthors(bookId).stream() .map(author -> modelMapper.map(author, AuthorDTOCompleto.class)) .toList(); }
@PostMapping("/{authorId}") public BookDTOCompleto addAuthor(@PathVariable Long bookId, @PathVariable Long authorId) throws EntityNotFoundException, IllegalOperationException { BookEntity book = bookAuthorService.addAuthor(bookId, authorId); return modelMapper.map(book, BookDTOCompleto.class); }
@DeleteMapping("/{authorId}") @ResponseStatus(HttpStatus.NO_CONTENT) public void removeAuthor(@PathVariable Long bookId, @PathVariable Long authorId) throws EntityNotFoundException, IllegalOperationException { bookAuthorService.removeAuthor(bookId, authorId); }}@RequestMappingde la clase lleva una variable ({bookId}): todos los métodos la reciben con@PathVariable, y cada uno agrega solo lo que le falta (/{authorId}).- El
POSTno tiene cuerpo. Los dos identificadores están en la URL, y no se crea ningún recurso nuevo: se asocian dos que ya existen. - El
GETtambién devuelve elDTOCompleto. TodoGETlo hace, incluido el de una asociación: cada autor de la lista llega con su colecciónbooks.
| Método | Éxito | Qué devuelve | Errores |
|---|---|---|---|
GET /books/{bookId}/authors |
200 |
La lista de AuthorDTOCompleto |
404 si el libro no existe |
POST /books/{bookId}/authors/{authorId} |
200 |
El BookDTOCompleto, ya con el autor |
404 si el libro o el autor no existen; 400 si ya estaban asociados |
DELETE /books/{bookId}/authors/{authorId} |
204 |
Nada | 404 si el libro o el autor no existen; 400 si no estaban asociados |
El POST responde 200 y no 201: no creó un recurso con una URL propia, modificó el libro. Por eso devuelve el libro completo, para que el cliente vea la colección ya actualizada. El RestExceptionHandler es el mismo de siempre: atiende las excepciones de este controller sin cambios. Tampoco lleva anotación de CORS: lo cubre la clase CorsConfig.