«1. Обзор

В этой статье мы рассмотрим, как работать с отношениями между сущностями в Spring Data REST.

Мы сосредоточимся на ресурсах ассоциации, которые Spring Data REST предоставляет для репозитория, принимая во внимание каждый тип отношений, который можно определить.

Чтобы избежать дополнительной настройки, мы будем использовать встроенную базу данных H2 для примеров. Вы можете увидеть список необходимых зависимостей в нашей статье Introduction to Spring Data REST.

И, если вы хотите начать работу с Spring Data REST, вот хороший способ начать работу:

2. Отношение один к одному

2.1. Модель данных

Давайте определим два класса сущностей Library и Address, имеющие отношение один к одному, используя аннотацию @OneToOne. Ассоциация принадлежит библиотеке в конце ассоциации:

@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;
    
    // standard constructor, getters, setters
}
@Entity
public class Address {

    @Id
    @GeneratedValue
    private long id;

    @Column(nullable = false)
    private String location;

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

    // standard constructor, getters, setters
}

Аннотация @RestResource является необязательной и может использоваться для настройки конечной точки.

Мы должны быть осторожны, чтобы иметь разные имена для каждого ресурса ассоциации. В противном случае мы столкнемся с JsonMappingException с сообщением: «Обнаружено несколько ассоциативных ссылок с одинаковым типом отношения! Неоднозначная ассоциация».

Имя ассоциации по умолчанию совпадает с именем свойства и может быть настроено с помощью атрибута rel аннотации @RestResource:

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

Если бы мы добавили свойство secondAddress выше в класс Library, у нас было бы два ресурса с именами адрес, и мы столкнулись бы с конфликтом.

Мы можем решить эту проблему, указав другое значение для атрибута rel или опустив аннотацию RestResource, чтобы имя ресурса по умолчанию было secondAddress.

2.2. Репозитории

Чтобы представить эти сущности как ресурсы, давайте создадим два интерфейса репозитория для каждого из них, расширив интерфейс CrudRepository:

public interface LibraryRepository extends CrudRepository<Library, Long> {}
public interface AddressRepository extends CrudRepository<Address, Long> {}

2.3. Создание ресурсов

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

Во-первых, давайте добавим экземпляр библиотеки для работы:

{
  "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"
    }
  }
}

API возвращает объект JSON:

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

Обратите внимание, что если вы используете curl в Windows, вы должны избегайте символа двойной кавычки внутри строки, представляющей тело JSON:

В теле ответа мы видим, что ресурс ассоциации был представлен в конечной точке library/{libraryId}/address.

Прежде чем мы создадим ассоциацию, отправка запроса GET на эту конечную точку вернет пустой объект.

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

Однако, если мы хотим добавить ассоциацию, мы должны сначала также создать экземпляр 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"
    }
  }
}

Результатом запроса POST является объект JSON, содержащий запись Address:

2.4 . Создание ассоциаций

После сохранения обоих экземпляров мы можем установить связь, используя один из ресурсов ассоциации.

Это делается с помощью HTTP-метода PUT, который поддерживает медиа-тип text/uri-list и тело, содержащее URI ресурса для привязки к ассоциации.

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

Так как объект Library является владельцем ассоциации, давайте добавим адрес в библиотеку:

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

В случае успеха возвращается статус 204. Чтобы проверить, давайте проверим ресурс ассоциации библиотеки с адресом: ~ ~~

Это должно вернуть объект библиотеки JSON с именем «Моя библиотека».

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

Чтобы удалить ассоциацию, мы можем вызвать конечную точку с помощью метода DELETE, обязательно используя ресурс ассоциации владельца отношения:

3. Отношение «один ко многим»

Один Отношение «ко многим» определяется с помощью аннотаций @OneToMany и @ManyToOne и может иметь необязательную аннотацию @RestResource для настройки ресурса ассоциации.

3.1. Модель данных

@Entity
public class Book {

    @Id
    @GeneratedValue
    private long id;
    
    @Column(nullable=false)
    private String title;
    
    @ManyToOne
    @JoinColumn(name="library_id")
    private Library library;
    
    // standard constructor, getter, setter
}

Чтобы проиллюстрировать отношение «один ко многим», давайте добавим новый объект «Книга», который будет представлять «многие» конец отношения с объектом «Библиотека»:

public class Library {
 
    //...
 
    @OneToMany(mappedBy = "library")
    private List<Book> books;
 
    //...
 
}

Давайте добавим отношение к класс Library:

3.2. Репозиторий

public interface BookRepository extends CrudRepository<Book, Long> { }

Нам также необходимо создать BookRepository:

3.3. Ресурсы ассоциации

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

Чтобы добавить книгу в библиотеку, нам нужно сначала создать экземпляр книги, используя ресурс коллекции /books:

{
  "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"
    }
  }
}

«

А вот ответ на POST-запрос:

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

В теле ответа мы видим, что конечная точка ассоциации /books/{bookId}/library создана.

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

Давайте свяжем книгу с библиотекой, созданной в предыдущем разделе, отправив запрос PUT на ресурс ассоциации, который содержит URI ресурса библиотеки:

{
  "_embedded" : {
    "books" : [ {
      "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"
        }
      }
    } ]
  },
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/libraries/1/books"
    }
  }
}

Мы можем проверить книги в библиотеке, используя GET метод для ресурса ассоциации /books библиотеки:

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

Возвращенный объект JSON будет содержать массив книг:

Чтобы удалить ассоциацию, мы можем использовать метод DELETE для ресурса ассоциации:

4. Связь «многие ко многим»

Связь «многие ко многим» определяется с помощью аннотации @ManyToMany, к которой мы можем добавить @RestResource.

@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<Book> books;

    //standard constructors, getters, setters
}

4.1. Модель данных

public class Book {
 
    //...
 
    @ManyToMany(mappedBy = "books")
    private List<Author> authors;
 
    //...
}

Чтобы создать пример отношения «многие ко многим», добавим новый класс модели Author, который будет иметь отношение «многие ко многим» с сущностью «Книга»:

Давайте добавим класс ассоциация в классе Book:

public interface AuthorRepository extends CrudRepository<Author, Long> { }

4.2. Репозиторий

Давайте создадим интерфейс репозитория для управления сущностью Author:

4.3. Ресурсы ассоциации

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

Как и в предыдущих разделах, мы должны сначала создать ресурсы, прежде чем мы сможем установить ассоциацию.

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

Давайте сначала создадим экземпляр Author, отправив запросы POST к ресурсу коллекции /authors:

{
  "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"
    }
  }
}

Затем добавим в нашу базу данных вторую запись Book:

Выполним запрос GET в нашей записи автора, чтобы просмотреть URL-адрес ассоциации:

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

Теперь мы можем создать ассоциацию между двумя записями книги и записью автора, используя конечную точку author/1/books с методом PUT, который поддерживает медиа-тип текста. /uri-list и может принимать более одного URI.

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

Чтобы отправить несколько URI, мы должны разделить их разрывом строки:

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

Файл uris.txt содержит URI книг, каждый в отдельной строке:

{
  "_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"
    }
  }
}

Чтобы проверить оба книги были связаны с автором, мы можем отправить запрос GET в конечную точку ассоциации:

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

И мы получим этот ответ:

Чтобы удалить ассоциацию, мы можем отправить запрос с методом DELETE к URL-адресу ресурса ассоциации, за которым следует {bookId}:

@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. Тестирование конечных точек с помощью TestRestTemplate

Давайте создадим тестовый класс, который внедряет экземпляр TestRestTemplate и определяет константы, которые мы будем использовать:

5.1. Тестирование отношения «один к одному»

@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<String> httpEntity 
      = new HttpEntity<>(ADDRESS_ENDPOINT + "/1", requestHeaders);
    template.exchange(LIBRARY_ENDPOINT + "/1/libraryAddress", 
      HttpMethod.PUT, httpEntity, String.class);

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

Давайте создадим метод @Test, который сохраняет объекты Library и Address, отправляя POST-запросы к ресурсам коллекции.

Затем он сохраняет связь с запросом PUT к ресурсу ассоциации и проверяет, что она была установлена ​​с помощью запроса GET к тому же ресурсу:

@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<String> 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<Library> libraryGetResponse = 
      template.getForEntity(BOOK_ENDPOINT + "/1/library", Library.class);
    assertEquals("library is incorrect", 
      libraryGetResponse.getBody().getName(), LIBRARY_NAME);
}

5.2. Тестирование отношения «один ко многим»

Давайте создадим метод @Test, который сохраняет экземпляр Library и два экземпляра Book, отправляет запрос PUT на ресурс ассоциации /library каждого объекта Book и проверяет сохранение отношения: ~ ~~

5.3. Тестирование отношения «многие ко многим»

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

Для тестирования отношения «многие ко многим» между сущностями «Книга» и «Автор» мы создадим тестовый метод, сохраняющий одну запись «Автор» и две записи «Книга».

Затем он отправляет запрос PUT на ресурс ассоциации /books с URI двух книг и проверяет, что отношения были установлены:

6. Заключение