Tutorial: Endpoints de asociación entre entidades
1. Objetivos de aprendizaje
Sección titulada «1. Objetivos de aprendizaje»Al terminar este tutorial vas a ser capaz de:
- Explicar por qué asociar dos entidades que ya existen no es un
POSTque crea un recurso nuevo. - Escribir un
<Entidad><Asociada>Servicecon 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ódigos200,204,400y404. - 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.
2. De dónde partes
Sección titulada «2. De dónde partes»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 |
3. Las reglas de la asociación
Sección titulada «3. Las reglas de la asociación»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.
4. Crear ArtistaAlbumService
Sección titulada «4. Crear ArtistaAlbumService»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;
@Servicepublic 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 porid, y para eso basta elAlbumRepository. orElseThrowes la forma corta de lo que hiciste enfindById: si elOptionalestá vacío, lanza la excepción. UnbuscarArtistay unbuscarAlbumprivados 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. estaAsociadocompara porid, no concontains: 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.ArtistaEntityes el lado propietario de la relación (el que tiene el@JoinTable): solo lo que cambia ahí llega a la tablaartista_album. Agregar el artista aalbum.getArtistas()no guardaría nada, como comprobaste en el tutorial de@ManyToMany. removeAlbumno llama aalbumRepository.delete. Solo quita el álbum de la lista del artista: la fila del álbum sigue existiendo.
5. Crear ArtistaAlbumController
Sección titulada «5. Crear ArtistaAlbumController»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
ArtistaControllerno cambia. - No lleva
@CrossOrigin. La claseCorsConfigdel 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 recibenartistaIdcon@PathVariable, y cada uno agrega lo que le falta (/{albumId}).- Todo
GETdevuelve elDTOCompleto. ElGETdevuelve la lista de álbumes con sus canciones y sus artistas, igual queGET /albumes; elPOSTdevuelve el artista con su colección ya actualizada. ElDTObásico solo aparece anidado dentro de otroDTOCompleto(los álbumes de unArtistaDTOCompleto), para que el JSON no dé vueltas. - No hay
try/catchni cambios en elRestExceptionHandler. El que ya escribiste atiende las dos excepciones de este controller.
Compila:
./mvnw compile6. Probar cada petición
Sección titulada «6. Probar cada petición»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
DELETEel álbum sigue existiendo. Compruébalo conGET /artistas/1: ya no lo lista, pero el álbum no se borró de la base de datos, y otro artista puede tenerlo. - Los dos
404dicen cuál id falló. Es el mensaje que escribiste enbuscarArtistaybuscarAlbum: sin él, el cliente no sabría si se equivocó de artista o de álbum.
7. Pruebas automáticas de la relación
Sección titulada «7. Pruebas automáticas de la relación»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.
ignoreen las colecciones de las dos entidades (albumes,artistasycanciones): Instancio no debe fabricar relaciones, las armas tú con el Service.albumesGuardados()vacía la memoria de la transacción conflush()yclear()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
@Testvoid testAddAlbum() throws Exception { AlbumEntity album = albumList.get(0);
ArtistaEntity resultado = artistaAlbumService.addAlbum(artista.getId(), album.getId());
assertEquals(1, resultado.getAlbumes().size()); assertEquals(1, albumesGuardados());}
@Testvoid testAddAlbumYaAsociado() throws Exception { AlbumEntity album = albumList.get(0); artistaAlbumService.addAlbum(artista.getId(), album.getId());
assertThrows(IllegalOperationException.class, () -> artistaAlbumService.addAlbum(artista.getId(), album.getId()));}
@Testvoid testAddAlbumArtistaInexistente() { assertThrows(EntityNotFoundException.class, () -> artistaAlbumService.addAlbum(0L, albumList.get(0).getId()));}
@Testvoid testAddAlbumAlbumInexistente() { assertThrows(EntityNotFoundException.class, () -> artistaAlbumService.addAlbum(artista.getId(), 0L));}// ---------- getAlbumes
@Testvoid 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());}
@Testvoid testGetAlbumesSinAlbumes() throws Exception { assertTrue(artistaAlbumService.getAlbumes(artista.getId()).isEmpty());}
@Testvoid testGetAlbumesArtistaInexistente() { assertThrows(EntityNotFoundException.class, () -> artistaAlbumService.getAlbumes(0L));}// ---------- removeAlbum
@Testvoid 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());}
@Testvoid testRemoveAlbumNoAsociado() { assertThrows(IllegalOperationException.class, () -> artistaAlbumService.removeAlbum(artista.getId(), albumList.get(0).getId()));}
@Testvoid testRemoveAlbumArtistaInexistente() { assertThrows(EntityNotFoundException.class, () -> artistaAlbumService.removeAlbum(0L, albumList.get(0).getId()));}
@Testvoid 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:
./mvnw test -Dtest=ArtistaAlbumServiceTestDebes ver Tests run: 11, Failures: 0, Errors: 0.
Comprobar que detectan errores
Sección titulada «Comprobar que detectan errores»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 |
8. Con ayuda de la IA
Sección titulada «8. Con ayuda de la IA»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, responde200y no guarda nada. - Los códigos y los mensajes. Los dos ids de la URL dan
404, una asociación repetida o inexistente da400. - Que
DELETEno borre el álbum. UnalbumRepository.delete(album)de más borraría el álbum para todos los artistas.
¿Dónde quedaste?
Sección titulada «¿Dónde quedaste?»Al final de este tutorial tienes:
- Un
ArtistaAlbumServicecon 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
ArtistaAlbumControllerconGET,POSTyDELETEen rutas anidadas, y los códigos200,204,400y404. - Un
ArtistaAlbumServiceTestcon 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.