Ir al contenido

Implementación de un API REST con Spring

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:

  1. La petición POST /books es procesada del lado del servidor por Spring.
  2. Spring identifica el endpoint: el método asociado con la ruta /books y con el verbo POST.
  3. El método se ejecuta.
  4. 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.

EscenarioGET /api/artistas/1
ClientePaso 1 de 10
Enviar GET

El cliente solicita el artista con identificador 1.

GET /api/artistas/1

Implementació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,
  "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.

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.

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.

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.

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<>();
}
  • ArtistaDTO tiene solo los atributos básicos, sin colecciones. Es lo que el cliente envía en el cuerpo de un POST o un PUT, y lo que devuelven esos dos métodos.
  • ArtistaDTOCompleto extiende al anterior y agrega las colecciones de asociaciones de cardinalidad múltiple, como lista de DTOs básicos del tipo relacionado (AlbumDTO, no AlbumDTOCompleto). Es lo que devuelven todos los GET.

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í:

@GetMapping
public 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);
}

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:

@RestControllerAdvice
public 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 con status igual a 0, como se explica en Cliente HTTP con httpResource.

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

Se declara una sola vez para todo el back, en una clase de configuración:

@Configuration
public 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.
  • allowedOrigins es el origen del front. Es lo único que cambia cuando el front se despliega en otra dirección.
  • allowedMethods son los verbos que el back acepta desde ese origen.

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);
}
}
  • @RequestMapping de 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 POST no 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 GET también devuelve el DTOCompleto. Todo GET lo hace, incluido el de una asociación: cada autor de la lista llega con su colección books.
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.

1. ¿Qué anotación identifica una clase como controlador REST en Spring?
2. Para el servicio DELETE /cities/id, ¿qué anotación se usa sobre el método del controller?
3. En @GetMapping(value="/{id}") public CityDTO findOne(@PathVariable("id") Long id), ¿de dónde toma Spring el valor de id?
4. ¿Qué recibe el método de un @RestController cuando el cliente envía un JSON en el body de un POST?
5. Si todos los métodos de un controller comparten el path inicial /cities, ¿dónde conviene anotarlo?
6. Un GET /artistas/1 debe devolver también los álbumes del artista. ¿Qué clase se usa para la respuesta?
7. ¿Qué código HTTP devuelve un DELETE exitoso en este curso?
8. ¿Quién traduce EntityNotFoundException a una respuesta 404?
9. ¿Por qué la lista albumes de ArtistaDTOCompleto es de AlbumDTO y no de AlbumDTOCompleto?
10. ¿En qué se basa ModelMapper para copiar un atributo de la entidad al DTO?
11. En POST /books/7/authors/3, ¿qué devuelve el servicio cuando todo sale bien?
12. Un endpoint responde bien en Postman, pero desde el front falla con "blocked by CORS policy". ¿Por qué Postman no lo detectó?
13. Un PUT desde el front falla por CORS. ¿Qué pasó con esa petición en el back?
14. Desde el front, listar y crear libros funcionan, pero editar y borrar fallan por CORS. ¿Qué se revisa primero en CorsConfig?