Ir al contenido

Tutorial: Endpoints de asociación entre entidades

Al terminar este tutorial vas a ser capaz de:

  • Explicar por qué asociar dos entidades que ya existen no es un POST que crea un recurso nuevo.
  • Escribir un <Entidad><Asociada>Service con las reglas de la asociación: que las dos entidades existan y que la asociación no esté repetida.
  • Exponer la asociación con rutas anidadas (/artistas/{artistaId}/albumes/{albumId}) y los códigos 200, 204, 400 y 404.
  • Probar la asociación con Postman y con un ArtistaAlbumServiceTest.

Prerrequisitos: el Tutorial: DTOs y Controller con CRUD completo, el tutorial de @ManyToMany (tienes ArtistaEntity como lado propietario y AlbumRepository), el tutorial de pruebas del Service, y la teoría de Servicios de asociación y de Endpoints de asociación.


Hoy un artista y un álbum se relacionan solo si lo haces tú con código: el ArtistaController no ofrece ninguna forma de decirle a la aplicación “este álbum es de este artista”. Este tutorial agrega esa forma.

El diseño es el que ya conoces del diseño del API: artistas y albumes son dos recursos raíz, y la asociación entre ellos es un subrecurso:

Petición Qué hace Éxito
GET /artistas/{artistaId}/albumes Lista los álbumes del artista 200 con List<AlbumDTOCompleto>
POST /artistas/{artistaId}/albumes/{albumId} Asocia un álbum existente a un artista existente 200 con ArtistaDTOCompleto
DELETE /artistas/{artistaId}/albumes/{albumId} Quita la asociación 204

Antes de escribir código, las reglas, como las registrarías en la tabla del taller de reglas (ronda 2):

# Entidad Operación Regla Condición que la viola Excepción
1 Artista asociar un álbum El artista debe existir El artistaId de la URL no existe EntityNotFoundException (404)
2 Álbum asociar un álbum El álbum debe existir El albumId de la URL no existe EntityNotFoundException (404)
3 Artista asociar un álbum No se asocia dos veces el mismo álbum El artista ya tiene ese álbum IllegalOperationException (400)
4 Artista quitar un álbum Solo se quita un álbum asociado El artista no tiene ese álbum IllegalOperationException (400)

Las reglas 1 y 2 dan 404 porque los dos ids vienen en la URL: el recurso que se direcciona no existe. Las reglas 3 y 4 son reglas de negocio: dan 400.


La lógica de la asociación no va en ArtistaService: va en una clase propia, que por convención se llama <Entidad><Asociada>Service. Primero, agrega a MensajesEs los dos mensajes de las reglas de asociación, en la sección de artista (los de “no existe” ya los tienes):

public static final String ARTISTA_YA_TIENE_ALBUM = "El artista ya tiene ese álbum";
public static final String ARTISTA_NO_TIENE_ALBUM = "El artista no tiene ese álbum";

Crea ArtistaAlbumService.java:

package co.edu.uniandes.musical.artistas;
import co.edu.uniandes.musical.exception.EntityNotFoundException;
import co.edu.uniandes.musical.exception.IllegalOperationException;
import co.edu.uniandes.musical.exception.MensajesEs;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ArtistaAlbumService {
private final ArtistaRepository artistaRepository;
private final AlbumRepository albumRepository;
public ArtistaAlbumService(ArtistaRepository artistaRepository,
AlbumRepository albumRepository) {
this.artistaRepository = artistaRepository;
this.albumRepository = albumRepository;
}
@Transactional(readOnly = true)
public List<AlbumEntity> getAlbumes(Long artistaId) throws EntityNotFoundException {
return buscarArtista(artistaId).getAlbumes();
}
@Transactional
public ArtistaEntity addAlbum(Long artistaId, Long albumId)
throws EntityNotFoundException, IllegalOperationException {
ArtistaEntity artista = buscarArtista(artistaId);
AlbumEntity album = buscarAlbum(albumId);
if (estaAsociado(artista, albumId)) {
throw new IllegalOperationException(MensajesEs.ARTISTA_YA_TIENE_ALBUM);
}
artista.getAlbumes().add(album);
return artistaRepository.save(artista);
}
@Transactional
public void removeAlbum(Long artistaId, Long albumId)
throws EntityNotFoundException, IllegalOperationException {
ArtistaEntity artista = buscarArtista(artistaId);
AlbumEntity album = buscarAlbum(albumId);
if (!estaAsociado(artista, albumId)) {
throw new IllegalOperationException(MensajesEs.ARTISTA_NO_TIENE_ALBUM);
}
artista.getAlbumes().remove(album);
artistaRepository.save(artista);
}
private ArtistaEntity buscarArtista(Long artistaId) throws EntityNotFoundException {
return artistaRepository.findById(artistaId)
.orElseThrow(() -> new EntityNotFoundException(MensajesEs.ARTISTA_NO_EXISTE));
}
private AlbumEntity buscarAlbum(Long albumId) throws EntityNotFoundException {
return albumRepository.findById(albumId)
.orElseThrow(() -> new EntityNotFoundException(MensajesEs.ALBUM_NO_EXISTE));
}
private boolean estaAsociado(ArtistaEntity artista, Long albumId) {
return artista.getAlbumes().stream()
.anyMatch(album -> album.getId().equals(albumId));
}
}

Fíjate en las piezas:

  • Dos repositories, ningún AlbumService. Esta clase solo necesita consultar álbumes por id, y para eso basta el AlbumRepository.
  • orElseThrow es la forma corta de lo que hiciste en findById: si el Optional está vacío, lanza la excepción. Un buscarArtista y un buscarAlbum privados evitan repetir esas líneas en los tres métodos.
  • Las reglas 1 y 2 se evalúan primero, y la 3 y la 4 solo cuando los dos existen. En addAlbum, si el artista no existe, ni siquiera se consulta el álbum.
  • estaAsociado compara por id, no con contains: es una pregunta sobre si ese álbum concreto ya está, no sobre si hay uno igual.
  • Se modifica artista.getAlbumes(), y se guarda el artista. ArtistaEntity es el lado propietario de la relación (el que tiene el @JoinTable): solo lo que cambia ahí llega a la tabla artista_album. Agregar el artista a album.getArtistas() no guardaría nada, como comprobaste en el tutorial de @ManyToMany.
  • removeAlbum no llama a albumRepository.delete. Solo quita el álbum de la lista del artista: la fila del álbum sigue existiendo.

Crea ArtistaAlbumController.java:

package co.edu.uniandes.musical.artistas;
import co.edu.uniandes.musical.exception.EntityNotFoundException;
import co.edu.uniandes.musical.exception.IllegalOperationException;
import java.util.List;
import org.modelmapper.ModelMapper;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/artistas/{artistaId}/albumes")
public class ArtistaAlbumController {
private final ArtistaAlbumService artistaAlbumService;
private final ModelMapper modelMapper;
public ArtistaAlbumController(ArtistaAlbumService artistaAlbumService,
ModelMapper modelMapper) {
this.artistaAlbumService = artistaAlbumService;
this.modelMapper = modelMapper;
}
@GetMapping
public List<AlbumDTOCompleto> getAlbumes(@PathVariable Long artistaId)
throws EntityNotFoundException {
return artistaAlbumService.getAlbumes(artistaId).stream()
.map(album -> modelMapper.map(album, AlbumDTOCompleto.class))
.toList();
}
@PostMapping("/{albumId}")
public ArtistaDTOCompleto addAlbum(@PathVariable Long artistaId,
@PathVariable Long albumId)
throws EntityNotFoundException, IllegalOperationException {
ArtistaEntity artista = artistaAlbumService.addAlbum(artistaId, albumId);
return modelMapper.map(artista, ArtistaDTOCompleto.class);
}
@DeleteMapping("/{albumId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void removeAlbum(@PathVariable Long artistaId, @PathVariable Long albumId)
throws EntityNotFoundException, IllegalOperationException {
artistaAlbumService.removeAlbum(artistaId, albumId);
}
}
  • Es un controller aparte, igual que el service. El ArtistaController no cambia.
  • No lleva @CrossOrigin. La clase CorsConfig del R2 ya cubre todas las rutas, también las de este controller.
  • @RequestMapping("/artistas/{artistaId}/albumes") lleva una variable en la ruta de la clase: todos los métodos reciben artistaId con @PathVariable, y cada uno agrega lo que le falta (/{albumId}).
  • Todo GET devuelve el DTOCompleto. El GET devuelve la lista de álbumes con sus canciones y sus artistas, igual que GET /albumes; el POST devuelve el artista con su colección ya actualizada. El DTO básico solo aparece anidado dentro de otro DTOCompleto (los álbumes de un ArtistaDTOCompleto), para que el JSON no dé vueltas.
  • No hay try/catch ni cambios en el RestExceptionHandler. El que ya escribiste atiende las dos excepciones de este controller.

Compila:

Ventana de terminal
./mvnw compile

Arranca la aplicación y prueba con Postman. Los resultados son los de un artista sin álbumes (1) y dos álbumes (1 y 2); con tus datos serán parecidos.

Petición Código Qué debes ver
GET /artistas/1/albumes 200 []
POST /artistas/1/albumes/1 200 El artista con el álbum 1 en albumes
POST /artistas/1/albumes/2 200 El artista con los álbumes 1 y 2
GET /artistas/1/albumes 200 Los dos álbumes
POST /artistas/1/albumes/1 otra vez 400 "message":"El artista ya tiene ese álbum"
POST /artistas/999/albumes/1 404 "message":"El artista no existe"
POST /artistas/1/albumes/999 404 "message":"El álbum no existe"
GET /artistas/999/albumes 404 "message":"El artista no existe"
DELETE /artistas/1/albumes/1 204 Sin cuerpo
DELETE /artistas/1/albumes/1 otra vez 400 "message":"El artista no tiene ese álbum"
GET /artistas/1 200 El artista solo con el álbum 2

Una respuesta exitosa del POST se ve así:

{"albumes":[{"id":1,"titulo":"Pa'lla Voy","fotoPortadaUrl":null,"fechaLanzamiento":"2004-05-25"}],"biografia":null,"fechaNacimiento":null,"fotoArtistaUrl":null,"id":1,"nombre":"Marc Anthony","paisOrigen":"Estados Unidos"}

Dos detalles para notar:

  • Después del DELETE el álbum sigue existiendo. Compruébalo con GET /artistas/1: ya no lo lista, pero el álbum no se borró de la base de datos, y otro artista puede tenerlo.
  • Los dos 404 dicen cuál id falló. Es el mensaje que escribiste en buscarArtista y buscarAlbum: sin él, el cliente no sabría si se equivocó de artista o de álbum.

Siguiendo la convención del curso, las pruebas de la relación van en un archivo aparte de ArtistaServiceTest: si la entidad es X y se relaciona con Y, es XYServiceTest. Crea ArtistaAlbumServiceTest.java en src/test/java:

package co.edu.uniandes.musical.artistas;
import static org.instancio.Select.field;
import static org.junit.jupiter.api.Assertions.*;
import java.util.ArrayList;
import java.util.List;
import org.instancio.Instancio;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
import org.springframework.context.annotation.Import;
import org.springframework.transaction.annotation.Transactional;
@DataJpaTest(properties = "spring.sql.init.mode=never")
@Transactional
@Import(ArtistaAlbumService.class)
class ArtistaAlbumServiceTest {
@Autowired
private ArtistaAlbumService artistaAlbumService;
@Autowired
private TestEntityManager entityManager;
private ArtistaEntity artista;
private List<AlbumEntity> albumList = new ArrayList<>();
@BeforeEach
void setUp() {
clearData();
insertData();
}
private void clearData() {
entityManager.getEntityManager()
.createQuery("delete from ArtistaEntity").executeUpdate();
entityManager.getEntityManager()
.createQuery("delete from AlbumEntity").executeUpdate();
}
private void insertData() {
artista = Instancio.of(ArtistaEntity.class)
.ignore(field(ArtistaEntity::getId))
.ignore(field(ArtistaEntity::getAlbumes))
.create();
entityManager.persist(artista);
for (int i = 0; i < 3; i++) {
AlbumEntity album = Instancio.of(AlbumEntity.class)
.ignore(field(AlbumEntity::getId))
.ignore(field(AlbumEntity::getArtistas))
.ignore(field(AlbumEntity::getCanciones))
.create();
entityManager.persist(album);
albumList.add(album);
}
}
/** Cuenta los álbumes del artista leyéndolos de la base de datos. */
private int albumesGuardados() {
entityManager.flush();
entityManager.clear();
return entityManager.find(ArtistaEntity.class, artista.getId())
.getAlbumes().size();
}
}

La estructura es la del tutorial anterior, con tres diferencias:

  • Se prepara un artista y tres álbumes, ninguno asociado todavía: cada prueba asocia lo que necesita.
  • ignore en las colecciones de las dos entidades (albumes, artistas y canciones): Instancio no debe fabricar relaciones, las armas tú con el Service.
  • albumesGuardados() vacía la memoria de la transacción con flush() y clear() y lee de la base. Sin eso, la prueba leería el mismo objeto Java que acaba de modificar y pasaría aunque no se hubiera guardado nada.

Ahora las pruebas, dentro de la clase. Cada método del Service tiene su caso feliz y sus casos de error:

// ---------- addAlbum
@Test
void testAddAlbum() throws Exception {
AlbumEntity album = albumList.get(0);
ArtistaEntity resultado =
artistaAlbumService.addAlbum(artista.getId(), album.getId());
assertEquals(1, resultado.getAlbumes().size());
assertEquals(1, albumesGuardados());
}
@Test
void testAddAlbumYaAsociado() throws Exception {
AlbumEntity album = albumList.get(0);
artistaAlbumService.addAlbum(artista.getId(), album.getId());
assertThrows(IllegalOperationException.class,
() -> artistaAlbumService.addAlbum(artista.getId(), album.getId()));
}
@Test
void testAddAlbumArtistaInexistente() {
assertThrows(EntityNotFoundException.class,
() -> artistaAlbumService.addAlbum(0L, albumList.get(0).getId()));
}
@Test
void testAddAlbumAlbumInexistente() {
assertThrows(EntityNotFoundException.class,
() -> artistaAlbumService.addAlbum(artista.getId(), 0L));
}
// ---------- getAlbumes
@Test
void testGetAlbumes() throws Exception {
artistaAlbumService.addAlbum(artista.getId(), albumList.get(0).getId());
artistaAlbumService.addAlbum(artista.getId(), albumList.get(1).getId());
assertEquals(2, artistaAlbumService.getAlbumes(artista.getId()).size());
}
@Test
void testGetAlbumesSinAlbumes() throws Exception {
assertTrue(artistaAlbumService.getAlbumes(artista.getId()).isEmpty());
}
@Test
void testGetAlbumesArtistaInexistente() {
assertThrows(EntityNotFoundException.class,
() -> artistaAlbumService.getAlbumes(0L));
}
// ---------- removeAlbum
@Test
void testRemoveAlbum() throws Exception {
AlbumEntity album = albumList.get(0);
artistaAlbumService.addAlbum(artista.getId(), album.getId());
artistaAlbumService.removeAlbum(artista.getId(), album.getId());
assertEquals(0, albumesGuardados());
assertEquals(album.getTitulo(),
entityManager.find(AlbumEntity.class, album.getId()).getTitulo());
}
@Test
void testRemoveAlbumNoAsociado() {
assertThrows(IllegalOperationException.class,
() -> artistaAlbumService.removeAlbum(artista.getId(), albumList.get(0).getId()));
}
@Test
void testRemoveAlbumArtistaInexistente() {
assertThrows(EntityNotFoundException.class,
() -> artistaAlbumService.removeAlbum(0L, albumList.get(0).getId()));
}
@Test
void testRemoveAlbumAlbumInexistente() {
assertThrows(EntityNotFoundException.class,
() -> artistaAlbumService.removeAlbum(artista.getId(), 0L));
}

Observa testRemoveAlbum: además de comprobar que el artista quedó sin álbumes, verifica que el álbum sigue en la base. Es la prueba de que quitar la asociación no borra el álbum.

Córrelas:

Ventana de terminal
./mvnw test -Dtest=ArtistaAlbumServiceTest

Debes ver Tests run: 11, Failures: 0, Errors: 0.

Como en el tutorial anterior, rompe una regla a propósito, mira qué prueba falla y restaura el Service antes del siguiente experimento:

Cambio en ArtistaAlbumService Prueba que debe fallar
En addAlbum, cambiar if (estaAsociado(artista, albumId)) por if (false) testAddAlbumYaAsociado
En removeAlbum, cambiar if (!estaAsociado(artista, albumId)) por if (false) testRemoveAlbumNoAsociado
En addAlbum, borrar la línea artista.getAlbumes().add(album); testAddAlbum
En removeAlbum, borrar la línea artista.getAlbumes().remove(album); testRemoveAlbum

Se puede usar IA para el esqueleto: la clase, la inyección de los dos repositories, las firmas y el controller con las rutas anidadas. Un buen prompt incluye las dos entidades, cuál es el lado propietario de la relación y la tabla de reglas de la sección 3.

Lo que verificas tú:

  • Que se modifique el lado propietario. Una IA puede agregar el artista a album.getArtistas(): compila, responde 200 y no guarda nada.
  • Los códigos y los mensajes. Los dos ids de la URL dan 404, una asociación repetida o inexistente da 400.
  • Que DELETE no borre el álbum. Un albumRepository.delete(album) de más borraría el álbum para todos los artistas.

Al final de este tutorial tienes:

  • Un ArtistaAlbumService con las reglas de la asociación: las dos entidades deben existir, y no se asocia dos veces ni se quita lo que no está asociado.
  • Un ArtistaAlbumController con GET, POST y DELETE en rutas anidadas, y los códigos 200, 204, 400 y 404.
  • Un ArtistaAlbumServiceTest con once pruebas, y la comprobación de que detectan una regla rota.

Para las asociaciones de tu módulo, repite el patrón con tus entidades. Cada endpoint de asociación suma tres requests a tu colección de Postman: el éxito (200), el 404 por la entidad inexistente y el 404 por el asociado inexistente.