Aprende a Trabajar con Relaciones en Spring Data REST

Introducción

En este tutorial, aprenderemos cómo trabajar con relaciones entre entidades en Spring Data REST. Nos centraremos en los recursos de asociación que Spring Data REST expone para un repositorio, considerando cada tipo de relación que podemos definir. Para evitar configuraciones adicionales, utilizaremos la base de datos embebida H2 para los ejemplos. Puedes consultar la lista de dependencias requeridas en nuestro artículo sobre la Introducción a Spring Data REST.

1. Visión general

Spring Data REST facilita la creación de APIs RESTful con soporte para relaciones entre entidades. Comprender cómo manejar estas relaciones es esencial para diseñar una aplicación bien estructurada y fácil de usar. Este artículo abordará los siguientes tipos de relaciones:

  • Uno a Uno
  • Uno a Muchos
  • Muchos a Muchos

2. Relación Uno a Uno

2.1. El modelo de datos

Para definir dos clases de entidad, Library y Address, utilizaremos la anotación @OneToOne para establecer una relación uno a uno. La asociación es poseída por el extremo de Library de la relación:


@Entity
public class Library {

    @Id
    @GeneratedValue
    private long id;

    @Column
    private String name;

    @OneToOne
    @JoinColumn(name = "address_id")
    @RestResource(path = "libraryAddress", rel="address")
    private Address address;

    // constructor estándar, getters, setters
}

@Entity
public class Address {

    @Id
    @GeneratedValue
    private long id;

    @Column(nullable = false)
    private String location;

    @OneToOne(mappedBy = "address")
    private Library library;

    // constructor estándar, getters, setters
}

La anotación @RestResource es opcional y se puede utilizar para personalizar el punto final. Es importante tener diferentes nombres para cada recurso de asociación; de lo contrario, enfrentaremos una JsonMappingException con el mensaje “¡Se detectaron múltiples enlaces de asociación con el mismo tipo de relación! Desambiguar la asociación.”

2.2. Los repositorios

Para exponer estas entidades como recursos, crearemos dos interfaces de repositorio para cada una de ellas extendiendo la interfaz CrudRepository:


public interface LibraryRepository extends CrudRepository {}

public interface AddressRepository extends CrudRepository {}

2.3. Creando los recursos

Primero, agregaremos una instancia de Library para trabajar con ella:


curl -i -X POST -H "Content-Type:application/json" \
  -d '{"name":"My Library"}' http://localhost:8080/libraries

La API devolverá el objeto JSON:


{
  "name" : "My Library",
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/libraries/1"
    },
    "library" : {
      "href" : "http://localhost:8080/libraries/1"
    },
    "address" : {
      "href" : "http://localhost:8080/libraries/1/libraryAddress"
    }
  }
}

Notarás que al usar curl en Windows, debemos escapar el carácter de comillas dobles dentro de la cadena que representa el cuerpo de JSON:


-d "{\"name\":\"My Library\"}"

Verás que en el cuerpo de respuesta se ha expuesto un recurso de asociación en el punto final libraries/{libraryId}/address.

Antes de crear una asociación, si enviamos una solicitud GET a este punto final, devolverá un objeto vacío.

Sin embargo, si queremos agregar una asociación, primero debemos crear una instancia de Address:


curl -i -X POST -H "Content-Type:application/json" \
  -d '{"location":"Main Street nr 5"}' http://localhost:8080/addresses

El resultado de la solicitud POST es un objeto JSON que contiene el registro de Address:


{
  "location" : "Main Street nr 5",
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/addresses/1"
    },
    "address" : {
      "href" : "http://localhost:8080/addresses/1"
    },
    "library" : {
      "href" : "http://localhost:8080/addresses/1/library"
    }
  }
}

2.4. Creando las asociaciones

Después de persistir ambas instancias, podemos establecer la relación utilizando uno de los recursos de asociación.

Esto se realiza utilizando el método HTTP PUT, que admite un tipo de medio de text/uri-list, y un cuerpo que contiene la URI del recurso a bind a la asociación.

Dado que la entidad Library es la propietaria de la asociación, agregaremos una dirección a una biblioteca:


curl -i -X PUT -d "http://localhost:8080/addresses/1" \
  -H "Content-Type:text/uri-list" http://localhost:8080/libraries/1/libraryAddress

Si tiene éxito, devolverá el estado 204. Para verificar esto, podemos comprobar el recurso de asociación library de la address:


curl -i -X GET http://localhost:8080/addresses/1/library

Debería devolver el objeto JSON de Library con el nombre “My Library.”

Para eliminar una asociación, llamamos al punto final con el método DELETE, asegurándonos de utilizar el recurso de asociación del propietario de la relación:


curl -i -X DELETE http://localhost:8080/libraries/1/libraryAddress

3. Relación Uno a Muchos

Definimos una relación uno a muchos utilizando las anotaciones @OneToMany y @ManyToOne. También podemos agregar la anotación @RestResource opcional para personalizar el recurso de asociación.

3.1. El modelo de datos

Para ejemplificar una relación uno a muchos, agregaremos una nueva entidad Book que representa el “muchos” de una relación con la entidad Library:


@Entity
public class Book {

    @Id
    @GeneratedValue
    private long id;

    @Column(nullable=false)
    private String title;

    @ManyToOne
    @JoinColumn(name="library_id")
    private Library library;

    // constructor estándar, getters, setters
}

Luego, agregaremos la relación a la clase Library también:


public class Library {
 
    //...

    @OneToMany(mappedBy = "library")
    private List books;

    //...
}

3.2. El repositorio

También necesitamos crear un BookRepository:


public interface BookRepository extends CrudRepository { }

3.3. Los recursos de asociación

Para agregar un libro a una biblioteca, primero necesitamos crear una instancia de Book utilizando el recurso de colección /books:


curl -i -X POST -d "{\"title\":\"Book1\"}" \
  -H "Content-Type:application/json" http://localhost:8080/books

Y aquí está la respuesta de la solicitud POST:


{
  "title" : "Book1",
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/books/1"
    },
    "book" : {
      "href" : "http://localhost:8080/books/1"
    },
    "bookLibrary" : {
      "href" : "http://localhost:8080/books/1/library"
    }
  }
}

En el cuerpo de la respuesta, podemos ver que se ha creado el punto final de asociación /books/{bookId}/library.

Ahora vamos a asociar el libro con la biblioteca que creamos en la sección anterior enviando una solicitud PUT al recurso de asociación que contiene la URI del recurso de biblioteca:


curl -i -X PUT -H "Content-Type:text/uri-list" \
-d "http://localhost:8080/libraries/1" http://localhost:8080/books/1/library

Podemos verificar los libros en la biblioteca utilizando el método GET en el recurso de asociación /books de la biblioteca:


curl -i -X GET http://localhost:8080/libraries/1/books

El objeto JSON devuelto contendrá un arreglo de books:


{
  "_embedded" : {
    "books" : [ {
      "title" : "Book1",
      "_links" : {
        "self" : {
          "href" : "http://localhost:8080/books/1"
        }
      }
    } ]
  },
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/libraries/1/books"
    }
  }
}

Para eliminar una asociación, podemos utilizar el método DELETE en el recurso de asociación:


curl -i -X DELETE http://localhost:8080/books/1/library

4. Relación Muchos a Muchos

Definimos una relación muchos a muchos utilizando la anotación @ManyToMany, a la que también podemos agregar @RestResource.

4.1. El modelo de datos

Para crear un ejemplo de una relación muchos a muchos, agregaremos una nueva clase de modelo, Author, que tiene una relación muchos a muchos con la entidad Book:


@Entity
public class Author {

    @Id
    @GeneratedValue
    private long id;

    @Column(nullable = false)
    private String name;

    @ManyToMany(cascade = CascadeType.ALL)
    @JoinTable(name = "book_author", 
      joinColumns = @JoinColumn(name = "book_id", referencedColumnName = "id"), 
      inverseJoinColumns = @JoinColumn(name = "author_id", 
      referencedColumnName = "id"))
    private List books;

    // constructores estándar, getters, setters
}

Luego, agregaremos la asociación en la clase Book también:


public class Book {
 
    //...

    @ManyToMany(mappedBy = "books")
    private List authors;

    //...
}

4.2. El repositorio

A continuación, crearemos una interfaz de repositorio para gestionar la entidad Author:


public interface AuthorRepository extends CrudRepository { }

4.3. Los recursos de asociación

Al igual que en las secciones anteriores, debemos crear los recursos antes de poder establecer la asociación.

Crearemos una instancia de Author enviando una solicitud POST a la colección de /authors:


curl -i -X POST -H "Content-Type:application/json" \
  -d "{\"name\":\"author1\"}" http://localhost:8080/authors

A continuación, agregaremos un segundo registro de Book a nuestra base de datos:


curl -i -X POST -H "Content-Type:application/json" \
  -d "{\"title\":\"Book 2\"}" http://localhost:8080/books

Luego, ejecutaremos una solicitud GET en nuestro registro de Author para ver la URL de asociación:


{
  "name" : "author1",
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/authors/1"
    },
    "author" : {
      "href" : "http://localhost:8080/authors/1"
    },
    "books" : {
      "href" : "http://localhost:8080/authors/1/books"
    }
  }
}

Ahora podemos crear una asociación entre los dos registros de Book y el registro de Author utilizando el extremo authors/1/books con el método PUT, que admite un tipo de medio de text/uri-list y puede recibir más de una URI.

Para enviar múltiples URI, debemos separarlas por un salto de línea:


curl -i -X PUT -H "Content-Type:text/uri-list" \
--data-binary @uris.txt http://localhost:8080/authors/1/books

El archivo uris.txt contiene las URI de los libros, cada una en una línea separada:


http://localhost:8080/books/1
http://localhost:8080/books/2

Para verificar que ambos libros están asociados con el autor, podemos enviar una solicitud GET al extremo de asociación:


curl -i -X GET http://localhost:8080/authors/1/books

Y recibiremos esta respuesta:


{
  "_embedded" : {
    "books" : [ {
      "title" : "Book 1",
      "_links" : {
        "self" : {
          "href" : "http://localhost:8080/books/1"
        }
      }
    }, {
      "title" : "Book 2",
      "_links" : {
        "self" : {
          "href" : "http://localhost:8080/books/2"
        }
      }
    } ]
  },
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/authors/1/books"
    }
  }
}

Para eliminar una asociación, podemos enviar una solicitud con el método DELETE a la URL del recurso de asociación seguida de {bookId}:


curl -i -X DELETE http://localhost:8080/authors/1/books/1

5. Probando los puntos finales con TestRestTemplate

Creamos una clase de prueba que inyecta una instancia de TestRestTemplate, y define las constantes que utilizaremos:


@RunWith(SpringRunner.class)
@SpringBootTest(classes = SpringDataRestApplication.class, 
  webEnvironment = WebEnvironment.DEFINED_PORT)
public class SpringDataRelationshipsTest {

    @Autowired
    private TestRestTemplate template;

    private static String BOOK_ENDPOINT = "http://localhost:8080/books/";
    private static String AUTHOR_ENDPOINT = "http://localhost:8080/authors/";
    private static String ADDRESS_ENDPOINT = "http://localhost:8080/addresses/";
    private static String LIBRARY_ENDPOINT = "http://localhost:8080/libraries/";

    private static String LIBRARY_NAME = "My Library";
    private static String AUTHOR_NAME = "George Orwell";
}

5.1. Probando la relación Uno a Uno

Crearemos un método @Test que guarda los objetos Library y Address mediante solicitudes POST a los recursos de colección. Luego guardará la relación con una solicitud PUT al recurso de asociación, y verificará que se ha establecido con una solicitud GET al mismo recurso:


@Test
public void whenSaveOneToOneRelationship_thenCorrect() {
    Library library = new Library(LIBRARY_NAME);
    template.postForEntity(LIBRARY_ENDPOINT, library, Library.class);
   
    Address address = new Address("Main street, nr 1");
    template.postForEntity(ADDRESS_ENDPOINT, address, Address.class);
    
    HttpHeaders requestHeaders = new HttpHeaders();
    requestHeaders.add("Content-type", "text/uri-list");
    HttpEntity httpEntity = new HttpEntity<>(ADDRESS_ENDPOINT + "/1", requestHeaders);
    template.exchange(LIBRARY_ENDPOINT + "/1/libraryAddress", 
      HttpMethod.PUT, httpEntity, String.class);

    ResponseEntity libraryGetResponse = 
      template.getForEntity(ADDRESS_ENDPOINT + "/1/library", Library.class);
    assertEquals("library is incorrect", 
      libraryGetResponse.getBody().getName(), LIBRARY_NAME);
}

5.2. Probando la relación Uno a Muchos

Ahora crearemos un método @Test que guarda una instancia de Library y dos instancias de Book, envía una solicitud PUT a cada uno de los objetos Book sobre el recurso de /library, y verifica que la relación se ha guardado:


@Test
public void whenSaveOneToManyRelationship_thenCorrect() {
    Library library = new Library(LIBRARY_NAME);
    template.postForEntity(LIBRARY_ENDPOINT, library, Library.class);

    Book book1 = new Book("Dune");
    template.postForEntity(BOOK_ENDPOINT, book1, Book.class);

    Book book2 = new Book("1984");
    template.postForEntity(BOOK_ENDPOINT, book2, Book.class);

    HttpHeaders requestHeaders = new HttpHeaders();
    requestHeaders.add("Content-Type", "text/uri-list");    
    HttpEntity bookHttpEntity = new HttpEntity<>(LIBRARY_ENDPOINT + "/1", requestHeaders);
    template.exchange(BOOK_ENDPOINT + "/1/library", 
      HttpMethod.PUT, bookHttpEntity, String.class);
    template.exchange(BOOK_ENDPOINT + "/2/library", 
      HttpMethod.PUT, bookHttpEntity, String.class);

    ResponseEntity libraryGetResponse = 
      template.getForEntity(BOOK_ENDPOINT + "/1/library", Library.class);
    assertEquals("library is incorrect", 
      libraryGetResponse.getBody().getName(), LIBRARY_NAME);
}

5.3. Probando la relación Muchos a Muchos

Para probar la relación muchos a muchos entre las entidades Book y Author, crearemos un método de prueba que guarda un registro de Author y dos registros de Book. Luego enviará una solicitud PUT al recurso de asociación /books con las dos URI de Books, y verifica que la relación se haya establecido:


@Test
public void whenSaveManyToManyRelationship_thenCorrect() {
    Author author1 = new Author(AUTHOR_NAME);
    template.postForEntity(AUTHOR_ENDPOINT, author1, Author.class);

    Book book1 = new Book("Animal Farm");
    template.postForEntity(BOOK_ENDPOINT, book1, Book.class);

    Book book2 = new Book("1984");
    template.postForEntity(BOOK_ENDPOINT, book2, Book.class);

    HttpHeaders requestHeaders = new HttpHeaders();
    requestHeaders.add("Content-type", "text/uri-list");
    HttpEntity httpEntity = new HttpEntity<>(
      BOOK_ENDPOINT + "/1\n" + BOOK_ENDPOINT + "/2", requestHeaders);
    template.exchange(AUTHOR_ENDPOINT + "/1/books", 
      HttpMethod.PUT, httpEntity, String.class);

    String jsonResponse = template
      .getForObject(BOOK_ENDPOINT + "/1/authors", String.class);
    JSONObject jsonObj = new JSONObject(jsonResponse).getJSONObject("_embedded");
    JSONArray jsonArray = jsonObj.getJSONArray("authors");
    assertEquals("author is incorrect", 
      jsonArray.getJSONObject(0).getString("name"), AUTHOR_NAME);
}

6. Conclusión

En este artículo, demostramos el uso de diferentes tipos de relaciones con Spring Data REST. Aprendimos cómo definir y manejar relaciones entre entidades, y probamos nuestra implementación utilizando TestRestTemplate. Utilizar Spring Data REST para manejar asociaciones de entidades puede simplificar la creación de APIs RESTful y mejorar la organización del código. Para seguir aprendiendo sobre este tema y otros aspectos de Spring, te invitamos a consultar más recursos en la documentación oficial y tutoriales adicionales.

Esperamos que este tutorial te haya sido útil para desarrollar tus habilidades en la programación en Java y en el uso de Spring Data REST. ¡Sigue explorando y aprendiendo sobre este poderoso framework!