Skip to content

Commit b4f8943

Browse files
authored
Add example using composite Id with Data REST (#252)
1 parent 360a57e commit b4f8943

24 files changed

+1049
-0
lines changed

README.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ All tutorials are documented in AsciiDoc format and published as an https://anto
5555
|link:data-mongodb-full-text-search[Spring Data MongoDB: Full Text Search] |Implement link:https://docs.mongodb.com/manual/text-search/[MongoDB Full Text Search] with Spring Data MongoDB
5656
|link:data-mongodb-transactional[Spring Data MongoDB: Transactional] |Enable `@Transactional` support for Spring Data MongoDB
5757
|link:data-repository-definition[Spring Data: Repository Definition] |Implement custom repository interfaces with `@RepositoryDefinition` annotation
58+
|link:data-rest-composite-id[Spring Data REST with Composite ID] |Implementing and exposing entities with composite IDs through Spring Data REST
5859
|link:data-rest-validation[Spring Data REST: Validation] |Perform validation with Spring Data REST
5960
|link:graphql[Spring GraphQL Server] |Implement GraphQL server with Spring GraphQL Server
6061
|link:jooq[jOOQ] | Implement an alternative to Jpa using https://www.jooq.org/[jOOQ] and Gradle

data-rest-composite-id/.gitattributes

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/gradlew text eol=lf
2+
*.bat text eol=crlf
3+
*.jar binary

data-rest-composite-id/.gitignore

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
HELP.md
2+
.gradle
3+
build/
4+
!gradle/wrapper/gradle-wrapper.jar
5+
!**/src/main/**/build/
6+
!**/src/test/**/build/
7+
8+
### STS ###
9+
.apt_generated
10+
.classpath
11+
.factorypath
12+
.project
13+
.settings
14+
.springBeans
15+
.sts4-cache
16+
bin/
17+
!**/src/main/**/bin/
18+
!**/src/test/**/bin/
19+
20+
### IntelliJ IDEA ###
21+
.idea
22+
*.iws
23+
*.iml
24+
*.ipr
25+
out/
26+
!**/src/main/**/out/
27+
!**/src/test/**/out/
28+
29+
### NetBeans ###
30+
/nbproject/private/
31+
/nbbuild/
32+
/dist/
33+
/nbdist/
34+
/.nb-gradle/
35+
36+
### VS Code ###
37+
.vscode/

data-rest-composite-id/README.adoc

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
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.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
plugins {
2+
java
3+
id("org.springframework.boot") version "3.5.3"
4+
id("io.spring.dependency-management") version "1.1.7"
5+
}
6+
7+
group = "zin.rashidi"
8+
version = "0.0.1-SNAPSHOT"
9+
10+
java {
11+
toolchain {
12+
languageVersion = JavaLanguageVersion.of(21)
13+
}
14+
}
15+
16+
repositories {
17+
mavenCentral()
18+
}
19+
20+
dependencies {
21+
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
22+
implementation("org.springframework.boot:spring-boot-starter-data-rest")
23+
runtimeOnly("org.postgresql:postgresql")
24+
testImplementation("org.springframework.boot:spring-boot-starter-test")
25+
testImplementation("org.springframework.boot:spring-boot-testcontainers")
26+
testImplementation("org.testcontainers:junit-jupiter")
27+
testImplementation("org.testcontainers:postgresql")
28+
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
29+
}
30+
31+
tasks.withType<Test> {
32+
useJUnitPlatform()
33+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
rootProject.name = "data-rest-composite-id"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package zin.rashidi.datarest.compositeid;
2+
3+
import org.springframework.boot.SpringApplication;
4+
import org.springframework.boot.autoconfigure.SpringBootApplication;
5+
6+
@SpringBootApplication
7+
public class DataRestCompositeIdApplication {
8+
9+
public static void main(String[] args) {
10+
SpringApplication.run(DataRestCompositeIdApplication.class, args);
11+
}
12+
13+
}

0 commit comments

Comments
 (0)