|
| 1 | += Spring Data REST with Composite ID |
| 2 | +:source-highlighter: highlight.js |
| 3 | +Rashidi Zin <rashidi@zin.my> |
| 4 | +1.0, July 13, 2025 |
| 5 | +:toc: |
| 6 | +:nofooter: |
| 7 | +:icons: font |
| 8 | +:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/data-rest-composite-id |
| 9 | +:source-main: {url-quickref}/src/main/java/zin/rashidi/datarest/compositeid |
| 10 | +:source-test: {url-quickref}/src/test/java/zin/rashidi/datarest/compositeid |
| 11 | + |
| 12 | +Implementing and exposing entities with composite IDs through Spring Data REST. |
| 13 | + |
| 14 | +== Background |
| 15 | + |
| 16 | +https://docs.spring.io/spring-data/rest/docs/current/reference/html/[Spring Data REST] allows you to expose your |
| 17 | +Spring Data repositories as REST resources. However, when working with entities that have composite IDs, additional |
| 18 | +configuration is required to properly handle these IDs in the REST API. |
| 19 | + |
| 20 | +This example demonstrates how to implement and expose entities with composite IDs through Spring Data REST. |
| 21 | + |
| 22 | +== Entity Classes |
| 23 | + |
| 24 | +In this example, we have two entity classes: `Book`[${source-main}/book/Book.java] and `Author`[${source-main}/book/Author.java]. |
| 25 | +Both use composite IDs implemented as embedded classes. |
| 26 | + |
| 27 | +=== Book Entity |
| 28 | + |
| 29 | +The `Book` entity uses an embedded `Isbn` class as its ID: |
| 30 | + |
| 31 | +[source,java] |
| 32 | +---- |
| 33 | +@Entity |
| 34 | +class Book { |
| 35 | +
|
| 36 | + @EmbeddedId |
| 37 | + private Isbn isbn = new Isbn(); |
| 38 | +
|
| 39 | + @ManyToOne(optional = false) |
| 40 | + private Author author; |
| 41 | +
|
| 42 | + private String title; |
| 43 | +
|
| 44 | + // Getters and setters omitted |
| 45 | +
|
| 46 | + @Embeddable |
| 47 | + static class Isbn implements Serializable { |
| 48 | +
|
| 49 | + private Integer prefix; |
| 50 | +
|
| 51 | + @Column(name = "registration_group") |
| 52 | + private Integer group; |
| 53 | +
|
| 54 | + private Integer registrant; |
| 55 | + private Integer publication; |
| 56 | +
|
| 57 | + @Column(name = "check_digit") |
| 58 | + private Integer check; |
| 59 | +
|
| 60 | + protected Isbn() {} |
| 61 | +
|
| 62 | + public Isbn(String isbn) { |
| 63 | + this.prefix = Integer.parseInt(isbn.substring(0, 3)); |
| 64 | + this.group = Integer.parseInt(isbn.substring(3, 4)); |
| 65 | + this.registrant = Integer.parseInt(isbn.substring(4, 7)); |
| 66 | + this.publication = Integer.parseInt(isbn.substring(7, 12)); |
| 67 | + this.check = Integer.parseInt(isbn.substring(12)); |
| 68 | + } |
| 69 | +
|
| 70 | + @Override |
| 71 | + public String toString() { |
| 72 | + return String.format("%d%d%d%d%d", prefix, group, registrant, publication, check); |
| 73 | + } |
| 74 | +
|
| 75 | + } |
| 76 | +} |
| 77 | +---- |
| 78 | + |
| 79 | +The `Isbn` class is annotated with `@Embeddable` and implements `Serializable`. It contains multiple fields that together form the ISBN. The class also provides a constructor that parses a string representation of an ISBN into its component parts and a `toString()` method that converts the component parts back to a string. |
| 80 | + |
| 81 | +=== Author Entity |
| 82 | + |
| 83 | +The `Author` entity uses an embedded `Id` class as its ID: |
| 84 | + |
| 85 | +[source,java] |
| 86 | +---- |
| 87 | +@Entity |
| 88 | +class Author { |
| 89 | +
|
| 90 | + @EmbeddedId |
| 91 | + private Id id = new Id(); |
| 92 | +
|
| 93 | + @Embedded |
| 94 | + private Name name; |
| 95 | +
|
| 96 | + @Embeddable |
| 97 | + static class Id implements Serializable { |
| 98 | +
|
| 99 | + @GeneratedValue |
| 100 | + private Long id; |
| 101 | +
|
| 102 | + public Long id() { |
| 103 | + return id; |
| 104 | + } |
| 105 | +
|
| 106 | + public Id id(Long id) { |
| 107 | + this.id = id; |
| 108 | + return this; |
| 109 | + } |
| 110 | +
|
| 111 | + } |
| 112 | +
|
| 113 | + @Embeddable |
| 114 | + record Name(@Column(name = "first_name") String first, @Column(name = "last_name") String last) { } |
| 115 | +
|
| 116 | +} |
| 117 | +---- |
| 118 | + |
| 119 | +The `Id` class is annotated with `@Embeddable` and implements `Serializable`. It contains a single field with `@GeneratedValue`. The `Author` entity also has an embedded `Name` record that contains first and last name fields. |
| 120 | + |
| 121 | +== Repository Interfaces |
| 122 | + |
| 123 | +To expose these entities through Spring Data REST, we need to create repository interfaces that extend `JpaRepository` with the entity class and its ID class as type parameters. |
| 124 | + |
| 125 | +=== BookRepository |
| 126 | + |
| 127 | +[source,java] |
| 128 | +---- |
| 129 | +@RepositoryRestResource |
| 130 | +interface BookRepository extends JpaRepository<Book, Isbn> { |
| 131 | +} |
| 132 | +---- |
| 133 | + |
| 134 | +The `BookRepository` interface extends `JpaRepository` with `Book` as the entity type and `Isbn` (the composite ID class) as the ID type. It's annotated with `@RepositoryRestResource`, which exposes it through Spring Data REST. |
| 135 | + |
| 136 | +=== AuthorRepository |
| 137 | + |
| 138 | +[source,java] |
| 139 | +---- |
| 140 | +@RepositoryRestResource |
| 141 | +interface AuthorRepository extends JpaRepository<Author, Author.Id> { |
| 142 | +} |
| 143 | +---- |
| 144 | + |
| 145 | +The `AuthorRepository` interface extends `JpaRepository` with `Author` as the entity type and `Author.Id` (the composite ID class) as the ID type. It's annotated with `@RepositoryRestResource`, which exposes it through Spring Data REST. |
| 146 | + |
| 147 | +== Custom Converters |
| 148 | + |
| 149 | +When working with composite IDs in Spring Data REST, you may need to provide custom converters to handle the conversion between the composite ID and its string representation in the REST API. |
| 150 | + |
| 151 | +=== AuthorIdReferencedConverter |
| 152 | + |
| 153 | +[source,java] |
| 154 | +---- |
| 155 | +@ReadingConverter |
| 156 | +class AuthorIdReferencedConverter implements Converter<String, Author.Id> { |
| 157 | +
|
| 158 | + @Override |
| 159 | + public Author.Id convert(String source) { |
| 160 | + return new Author.Id().id(Long.parseLong(source)); |
| 161 | + } |
| 162 | +
|
| 163 | +} |
| 164 | +---- |
| 165 | + |
| 166 | +The `AuthorIdReferencedConverter` implements the `Converter` interface to convert from a String to an `Author.Id`. It's annotated with `@ReadingConverter`, indicating it's used when reading data. The conversion simply parses the string as a Long and creates a new `Author.Id` with that value. |
| 167 | + |
| 168 | +=== Configuring Converters |
| 169 | + |
| 170 | +To register the custom converters, we need to implement `RepositoryRestConfigurer`: |
| 171 | + |
| 172 | +[source,java] |
| 173 | +---- |
| 174 | +@Configuration |
| 175 | +class BookRepositoryRestConfigurer implements RepositoryRestConfigurer { |
| 176 | +
|
| 177 | + @Override |
| 178 | + public void configureConversionService(ConfigurableConversionService conversionService) { |
| 179 | + conversionService.addConverter(new AuthorIdReferencedConverter()); |
| 180 | + } |
| 181 | +
|
| 182 | +} |
| 183 | +---- |
| 184 | + |
| 185 | +This configuration class adds the `AuthorIdReferencedConverter` to the conversion service, allowing Spring Data REST to convert between string representations and `Author.Id` objects. |
| 186 | + |
| 187 | +== Testing |
| 188 | + |
| 189 | +Let's verify that our implementation works by writing tests that create and retrieve entities with composite IDs through the REST API. |
| 190 | + |
| 191 | +=== Creating an Author |
| 192 | + |
| 193 | +[source,java] |
| 194 | +---- |
| 195 | +@Test |
| 196 | +@DisplayName("When an Author is created Then its ID should be a number") |
| 197 | +void create() { |
| 198 | + mvc |
| 199 | + .post().uri("/authors") |
| 200 | + .contentType(APPLICATION_JSON) |
| 201 | + .content(""" |
| 202 | + { |
| 203 | + "name": { |
| 204 | + "first": "Rudyard", |
| 205 | + "last": "Kipling" |
| 206 | + } |
| 207 | + } |
| 208 | + """) |
| 209 | + .assertThat().headers() |
| 210 | + .extracting(LOCATION).asString().satisfies(location -> assertThat(idFromLocation(location)).is(numeric())); |
| 211 | +} |
| 212 | +
|
| 213 | +private Condition<String> numeric() { |
| 214 | + return new Condition<>(NumberUtils::isDigits, "is a number"); |
| 215 | +} |
| 216 | +
|
| 217 | +private String idFromLocation(String location) { |
| 218 | + return location.substring(location.lastIndexOf("/") + 1); |
| 219 | +} |
| 220 | +---- |
| 221 | + |
| 222 | +This test creates an Author with a first and last name, then verifies that the returned location header contains a numeric ID. |
| 223 | + |
| 224 | +=== Creating a Book |
| 225 | + |
| 226 | +[source,java] |
| 227 | +---- |
| 228 | +@Test |
| 229 | +@DisplayName("When a Book is created with an ISBN Then its Location should consists of the ISBN") |
| 230 | +@Sql(statements = "INSERT INTO author (id, first_name, last_name) VALUES (100, 'Rudyard', 'Kipling')") |
| 231 | +void create() { |
| 232 | + mvc |
| 233 | + .post().uri("/books") |
| 234 | + .content(""" |
| 235 | + { |
| 236 | + "isbn": "9781402745777", |
| 237 | + "title": "The Jungle Book", |
| 238 | + "author": "http://localhost/authors/100" |
| 239 | + } |
| 240 | + """) |
| 241 | + .assertThat().headers() |
| 242 | + .extracting(LOCATION).asString().isEqualTo("http://localhost/books/9781402745777"); |
| 243 | +} |
| 244 | +---- |
| 245 | + |
| 246 | +This test creates a Book with an ISBN, title, and author reference, then verifies that the returned location header contains the ISBN. |
| 247 | + |
| 248 | +=== Retrieving a Book |
| 249 | + |
| 250 | +[source,java] |
| 251 | +---- |
| 252 | +@Test |
| 253 | +@Sql(statements = { |
| 254 | + "INSERT INTO author (id, first_name, last_name) VALUES (200, 'Rudyard', 'Kipling')", |
| 255 | + "INSERT INTO book (prefix, registration_group, registrant, publication, check_digit, author_id, title) VALUES (978, 1, 509, 82782, 9, 200, 'The Jungle Book')" |
| 256 | +}) |
| 257 | +@DisplayName("Given a book is available When I request by its ISBN Then its information should be returned") |
| 258 | +void get() { |
| 259 | + mvc |
| 260 | + .get().uri("/books/9781509827829") |
| 261 | + .assertThat().bodyJson() |
| 262 | + .hasPathSatisfying("$.title", title -> assertThat(title).asString().isEqualTo("The Jungle Book")) |
| 263 | + .hasPathSatisfying("$._links.author.href", authorUri -> assertThat(authorUri).asString().isEqualTo("http://localhost/books/9781509827829/author")) |
| 264 | + .hasPathSatisfying("$._links.self.href", uri -> assertThat(uri).asString().isEqualTo("http://localhost/books/9781509827829")); |
| 265 | +} |
| 266 | +---- |
| 267 | + |
| 268 | +This test sets up a Book with an ISBN and other details using SQL, then retrieves it using the ISBN in the URL. It verifies that the returned book has the expected title and links. |
| 269 | + |
| 270 | +== Conclusion |
| 271 | + |
| 272 | +In this article, we've demonstrated how to implement and expose entities with composite IDs through Spring Data REST. The key points are: |
| 273 | + |
| 274 | +1. Use `@EmbeddedId` to define composite IDs in your entity classes. |
| 275 | +2. Implement `Serializable` for your composite ID classes. |
| 276 | +3. Create repository interfaces that extend `JpaRepository` with the entity class and its ID class as type parameters. |
| 277 | +4. Provide custom converters if needed to handle the conversion between the composite ID and its string representation. |
| 278 | +5. Configure the converters by implementing `RepositoryRestConfigurer`. |
| 279 | + |
| 280 | +With these steps, you can successfully work with composite IDs in your Spring Data REST applications. |
0 commit comments