Skip to content

Add tutorial using composite Id with Data REST #252

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jul 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ All tutorials are documented in AsciiDoc format and published as an https://anto
|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
|link:data-mongodb-transactional[Spring Data MongoDB: Transactional] |Enable `@Transactional` support for Spring Data MongoDB
|link:data-repository-definition[Spring Data: Repository Definition] |Implement custom repository interfaces with `@RepositoryDefinition` annotation
|link:data-rest-composite-id[Spring Data REST with Composite ID] |Implementing and exposing entities with composite IDs through Spring Data REST
|link:data-rest-validation[Spring Data REST: Validation] |Perform validation with Spring Data REST
|link:graphql[Spring GraphQL Server] |Implement GraphQL server with Spring GraphQL Server
|link:jooq[jOOQ] | Implement an alternative to Jpa using https://www.jooq.org/[jOOQ] and Gradle
Expand Down
3 changes: 3 additions & 0 deletions data-rest-composite-id/.gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/gradlew text eol=lf
*.bat text eol=crlf
*.jar binary
37 changes: 37 additions & 0 deletions data-rest-composite-id/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
HELP.md
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/

### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/

### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/

### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/

### VS Code ###
.vscode/
280 changes: 280 additions & 0 deletions data-rest-composite-id/README.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
= Spring Data REST with Composite ID
:source-highlighter: highlight.js
Rashidi Zin <[email protected]>
1.0, July 13, 2025
:toc:
:nofooter:
:icons: font
:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/data-rest-composite-id
:source-main: {url-quickref}/src/main/java/zin/rashidi/datarest/compositeid
:source-test: {url-quickref}/src/test/java/zin/rashidi/datarest/compositeid

Implementing and exposing entities with composite IDs through Spring Data REST.

== Background

https://docs.spring.io/spring-data/rest/docs/current/reference/html/[Spring Data REST] allows you to expose your
Spring Data repositories as REST resources. However, when working with entities that have composite IDs, additional
configuration is required to properly handle these IDs in the REST API.

This example demonstrates how to implement and expose entities with composite IDs through Spring Data REST.

== Entity Classes

In this example, we have two entity classes: `Book`[${source-main}/book/Book.java] and `Author`[${source-main}/book/Author.java].
Both use composite IDs implemented as embedded classes.

=== Book Entity

The `Book` entity uses an embedded `Isbn` class as its ID:

[source,java]
----
@Entity
class Book {

@EmbeddedId
private Isbn isbn = new Isbn();

@ManyToOne(optional = false)
private Author author;

private String title;

// Getters and setters omitted

@Embeddable
static class Isbn implements Serializable {

private Integer prefix;

@Column(name = "registration_group")
private Integer group;

private Integer registrant;
private Integer publication;

@Column(name = "check_digit")
private Integer check;

protected Isbn() {}

public Isbn(String isbn) {
this.prefix = Integer.parseInt(isbn.substring(0, 3));
this.group = Integer.parseInt(isbn.substring(3, 4));
this.registrant = Integer.parseInt(isbn.substring(4, 7));
this.publication = Integer.parseInt(isbn.substring(7, 12));
this.check = Integer.parseInt(isbn.substring(12));
}

@Override
public String toString() {
return String.format("%d%d%d%d%d", prefix, group, registrant, publication, check);
}

}
}
----

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.

=== Author Entity

The `Author` entity uses an embedded `Id` class as its ID:

[source,java]
----
@Entity
class Author {

@EmbeddedId
private Id id = new Id();

@Embedded
private Name name;

@Embeddable
static class Id implements Serializable {

@GeneratedValue
private Long id;

public Long id() {
return id;
}

public Id id(Long id) {
this.id = id;
return this;
}

}

@Embeddable
record Name(@Column(name = "first_name") String first, @Column(name = "last_name") String last) { }

}
----

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.

== Repository Interfaces

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.

=== BookRepository

[source,java]
----
@RepositoryRestResource
interface BookRepository extends JpaRepository<Book, Isbn> {
}
----

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.

=== AuthorRepository

[source,java]
----
@RepositoryRestResource
interface AuthorRepository extends JpaRepository<Author, Author.Id> {
}
----

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.

== Custom Converters

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.

=== AuthorIdReferencedConverter

[source,java]
----
@ReadingConverter
class AuthorIdReferencedConverter implements Converter<String, Author.Id> {

@Override
public Author.Id convert(String source) {
return new Author.Id().id(Long.parseLong(source));
}

}
----

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.

=== Configuring Converters

To register the custom converters, we need to implement `RepositoryRestConfigurer`:

[source,java]
----
@Configuration
class BookRepositoryRestConfigurer implements RepositoryRestConfigurer {

@Override
public void configureConversionService(ConfigurableConversionService conversionService) {
conversionService.addConverter(new AuthorIdReferencedConverter());
}

}
----

This configuration class adds the `AuthorIdReferencedConverter` to the conversion service, allowing Spring Data REST to convert between string representations and `Author.Id` objects.

== Testing

Let's verify that our implementation works by writing tests that create and retrieve entities with composite IDs through the REST API.

=== Creating an Author

[source,java]
----
@Test
@DisplayName("When an Author is created Then its ID should be a number")
void create() {
mvc
.post().uri("/authors")
.contentType(APPLICATION_JSON)
.content("""
{
"name": {
"first": "Rudyard",
"last": "Kipling"
}
}
""")
.assertThat().headers()
.extracting(LOCATION).asString().satisfies(location -> assertThat(idFromLocation(location)).is(numeric()));
}

private Condition<String> numeric() {
return new Condition<>(NumberUtils::isDigits, "is a number");
}

private String idFromLocation(String location) {
return location.substring(location.lastIndexOf("/") + 1);
}
----

This test creates an Author with a first and last name, then verifies that the returned location header contains a numeric ID.

=== Creating a Book

[source,java]
----
@Test
@DisplayName("When a Book is created with an ISBN Then its Location should consists of the ISBN")
@Sql(statements = "INSERT INTO author (id, first_name, last_name) VALUES (100, 'Rudyard', 'Kipling')")
void create() {
mvc
.post().uri("/books")
.content("""
{
"isbn": "9781402745777",
"title": "The Jungle Book",
"author": "http://localhost/authors/100"
}
""")
.assertThat().headers()
.extracting(LOCATION).asString().isEqualTo("http://localhost/books/9781402745777");
}
----

This test creates a Book with an ISBN, title, and author reference, then verifies that the returned location header contains the ISBN.

=== Retrieving a Book

[source,java]
----
@Test
@Sql(statements = {
"INSERT INTO author (id, first_name, last_name) VALUES (200, 'Rudyard', 'Kipling')",
"INSERT INTO book (prefix, registration_group, registrant, publication, check_digit, author_id, title) VALUES (978, 1, 509, 82782, 9, 200, 'The Jungle Book')"
})
@DisplayName("Given a book is available When I request by its ISBN Then its information should be returned")
void get() {
mvc
.get().uri("/books/9781509827829")
.assertThat().bodyJson()
.hasPathSatisfying("$.title", title -> assertThat(title).asString().isEqualTo("The Jungle Book"))
.hasPathSatisfying("$._links.author.href", authorUri -> assertThat(authorUri).asString().isEqualTo("http://localhost/books/9781509827829/author"))
.hasPathSatisfying("$._links.self.href", uri -> assertThat(uri).asString().isEqualTo("http://localhost/books/9781509827829"));
}
----

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.

== Conclusion

In this article, we've demonstrated how to implement and expose entities with composite IDs through Spring Data REST. The key points are:

1. Use `@EmbeddedId` to define composite IDs in your entity classes.
2. Implement `Serializable` for your composite ID classes.
3. Create repository interfaces that extend `JpaRepository` with the entity class and its ID class as type parameters.
4. Provide custom converters if needed to handle the conversion between the composite ID and its string representation.
5. Configure the converters by implementing `RepositoryRestConfigurer`.

With these steps, you can successfully work with composite IDs in your Spring Data REST applications.
33 changes: 33 additions & 0 deletions data-rest-composite-id/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
plugins {
java
id("org.springframework.boot") version "3.5.3"
id("io.spring.dependency-management") version "1.1.7"
}

group = "zin.rashidi"
version = "0.0.1-SNAPSHOT"

java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}

repositories {
mavenCentral()
}

dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-data-rest")
runtimeOnly("org.postgresql:postgresql")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:postgresql")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.withType<Test> {
useJUnitPlatform()
}
1 change: 1 addition & 0 deletions data-rest-composite-id/settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
rootProject.name = "data-rest-composite-id"
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package zin.rashidi.datarest.compositeid;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DataRestCompositeIdApplication {

public static void main(String[] args) {
SpringApplication.run(DataRestCompositeIdApplication.class, args);
}

}
Loading
Loading