diff --git a/README.adoc b/README.adoc index 4789db62..a5364a18 100644 --- a/README.adoc +++ b/README.adoc @@ -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 diff --git a/data-rest-composite-id/.gitattributes b/data-rest-composite-id/.gitattributes new file mode 100644 index 00000000..8af972cd --- /dev/null +++ b/data-rest-composite-id/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/data-rest-composite-id/.gitignore b/data-rest-composite-id/.gitignore new file mode 100644 index 00000000..c2065bc2 --- /dev/null +++ b/data-rest-composite-id/.gitignore @@ -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/ diff --git a/data-rest-composite-id/README.adoc b/data-rest-composite-id/README.adoc new file mode 100644 index 00000000..ec9cdf83 --- /dev/null +++ b/data-rest-composite-id/README.adoc @@ -0,0 +1,280 @@ += Spring Data REST with Composite ID +:source-highlighter: highlight.js +Rashidi Zin +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 { +} +---- + +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 { +} +---- + +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 { + + @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 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. \ No newline at end of file diff --git a/data-rest-composite-id/build.gradle.kts b/data-rest-composite-id/build.gradle.kts new file mode 100644 index 00000000..85230006 --- /dev/null +++ b/data-rest-composite-id/build.gradle.kts @@ -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 { + useJUnitPlatform() +} diff --git a/data-rest-composite-id/settings.gradle.kts b/data-rest-composite-id/settings.gradle.kts new file mode 100644 index 00000000..7da8964b --- /dev/null +++ b/data-rest-composite-id/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "data-rest-composite-id" diff --git a/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/DataRestCompositeIdApplication.java b/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/DataRestCompositeIdApplication.java new file mode 100644 index 00000000..f36ee2c0 --- /dev/null +++ b/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/DataRestCompositeIdApplication.java @@ -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); + } + +} diff --git a/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/Author.java b/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/Author.java new file mode 100644 index 00000000..ba75e7d8 --- /dev/null +++ b/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/Author.java @@ -0,0 +1,39 @@ +package zin.rashidi.datarest.compositeid.book; + +import jakarta.persistence.*; + +import java.io.Serializable; + +/** + * @author Rashidi Zin + */ +@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) { } + +} diff --git a/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/AuthorIdConverter.java b/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/AuthorIdConverter.java new file mode 100644 index 00000000..7484dc6d --- /dev/null +++ b/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/AuthorIdConverter.java @@ -0,0 +1,31 @@ +package zin.rashidi.datarest.compositeid.book; + +import org.springframework.data.rest.webmvc.spi.BackendIdConverter; +import org.springframework.stereotype.Component; + +import java.io.Serializable; + +import static java.lang.Long.parseLong; + +/** + * @author Rashidi Zin + */ +@Component +class AuthorIdConverter implements BackendIdConverter { + + @Override + public Serializable fromRequestId(String id, Class entityType) { + return new Author.Id().id(parseLong(id)); + } + + @Override + public String toRequestId(Serializable id, Class entityType) { + return ((Author.Id) id).id().toString(); + } + + @Override + public boolean supports(Class aClass) { + return Author.class.isAssignableFrom(aClass); + } + +} diff --git a/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/AuthorIdReferencedConverter.java b/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/AuthorIdReferencedConverter.java new file mode 100644 index 00000000..2ec23fb7 --- /dev/null +++ b/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/AuthorIdReferencedConverter.java @@ -0,0 +1,17 @@ +package zin.rashidi.datarest.compositeid.book; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.convert.ReadingConverter; + +/** + * @author Rashidi Zin + */ +@ReadingConverter +class AuthorIdReferencedConverter implements Converter { + + @Override + public Author.Id convert(String source) { + return new Author.Id().id(Long.parseLong(source)); + } + +} diff --git a/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/AuthorRepository.java b/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/AuthorRepository.java new file mode 100644 index 00000000..761d31ef --- /dev/null +++ b/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/AuthorRepository.java @@ -0,0 +1,11 @@ +package zin.rashidi.datarest.compositeid.book; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.rest.core.annotation.RepositoryRestResource; + +/** + * @author Rashidi Zin + */ +@RepositoryRestResource +interface AuthorRepository extends JpaRepository { +} diff --git a/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/Book.java b/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/Book.java new file mode 100644 index 00000000..8f4924f0 --- /dev/null +++ b/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/Book.java @@ -0,0 +1,64 @@ +package zin.rashidi.datarest.compositeid.book; + +import jakarta.persistence.*; + +import java.io.Serializable; + +/** + * @author Rashidi Zin + */ +@Entity +class Book { + + @EmbeddedId + private Isbn isbn = new Isbn(); + + @ManyToOne(optional = false) + private Author author; + + private String title; + + public void setIsbn(Isbn isbn) { + this.isbn = isbn; + } + + public void setAuthor(Author author) { + this.author = author; + } + + public String getTitle() { + return title; + } + + @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); + } + + } + +} diff --git a/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/BookIdConverter.java b/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/BookIdConverter.java new file mode 100644 index 00000000..48cb4f6e --- /dev/null +++ b/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/BookIdConverter.java @@ -0,0 +1,29 @@ +package zin.rashidi.datarest.compositeid.book; + +import org.springframework.data.rest.webmvc.spi.BackendIdConverter; +import org.springframework.stereotype.Component; + +import java.io.Serializable; + +/** + * @author Rashidi Zin + */ +@Component +class BookIdConverter implements BackendIdConverter { + + @Override + public Serializable fromRequestId(String id, Class entityType) { + return new Book.Isbn(id); + } + + @Override + public String toRequestId(Serializable id, Class entityType) { + return id.toString(); + } + + @Override + public boolean supports(Class aClass) { + return Book.class.isAssignableFrom(aClass); + } + +} diff --git a/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/BookRepository.java b/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/BookRepository.java new file mode 100644 index 00000000..479dfd94 --- /dev/null +++ b/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/BookRepository.java @@ -0,0 +1,12 @@ +package zin.rashidi.datarest.compositeid.book; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.rest.core.annotation.RepositoryRestResource; +import zin.rashidi.datarest.compositeid.book.Book.Isbn; + +/** + * @author Rashidi Zin + */ +@RepositoryRestResource +interface BookRepository extends JpaRepository { +} diff --git a/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/BookRepositoryRestConfigurer.java b/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/BookRepositoryRestConfigurer.java new file mode 100644 index 00000000..026ab56b --- /dev/null +++ b/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/BookRepositoryRestConfigurer.java @@ -0,0 +1,18 @@ +package zin.rashidi.datarest.compositeid.book; + +import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.support.ConfigurableConversionService; +import org.springframework.data.rest.webmvc.config.RepositoryRestConfigurer; + +/** + * @author Rashidi Zin + */ +@Configuration +class BookRepositoryRestConfigurer implements RepositoryRestConfigurer { + + @Override + public void configureConversionService(ConfigurableConversionService conversionService) { + conversionService.addConverter(new AuthorIdReferencedConverter()); + } + +} diff --git a/data-rest-composite-id/src/main/resources/application.properties b/data-rest-composite-id/src/main/resources/application.properties new file mode 100644 index 00000000..00b44835 --- /dev/null +++ b/data-rest-composite-id/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.application.name=data-rest-composite-id \ No newline at end of file diff --git a/data-rest-composite-id/src/test/java/zin/rashidi/datarest/compositeid/TestDataRestCompositeIdApplication.java b/data-rest-composite-id/src/test/java/zin/rashidi/datarest/compositeid/TestDataRestCompositeIdApplication.java new file mode 100644 index 00000000..2458e312 --- /dev/null +++ b/data-rest-composite-id/src/test/java/zin/rashidi/datarest/compositeid/TestDataRestCompositeIdApplication.java @@ -0,0 +1,11 @@ +package zin.rashidi.datarest.compositeid; + +import org.springframework.boot.SpringApplication; + +public class TestDataRestCompositeIdApplication { + + public static void main(String[] args) { + SpringApplication.from(DataRestCompositeIdApplication::main).with(TestcontainersConfiguration.class).run(args); + } + +} diff --git a/data-rest-composite-id/src/test/java/zin/rashidi/datarest/compositeid/TestcontainersConfiguration.java b/data-rest-composite-id/src/test/java/zin/rashidi/datarest/compositeid/TestcontainersConfiguration.java new file mode 100644 index 00000000..8de66d22 --- /dev/null +++ b/data-rest-composite-id/src/test/java/zin/rashidi/datarest/compositeid/TestcontainersConfiguration.java @@ -0,0 +1,18 @@ +package zin.rashidi.datarest.compositeid; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; + +@TestConfiguration(proxyBeanMethods = false) +public class TestcontainersConfiguration { + + @Bean + @ServiceConnection + PostgreSQLContainer postgresContainer() { + return new PostgreSQLContainer<>(DockerImageName.parse("postgres:latest")); + } + +} diff --git a/data-rest-composite-id/src/test/java/zin/rashidi/datarest/compositeid/book/CreateAuthorTests.java b/data-rest-composite-id/src/test/java/zin/rashidi/datarest/compositeid/book/CreateAuthorTests.java new file mode 100644 index 00000000..ab75b0e2 --- /dev/null +++ b/data-rest-composite-id/src/test/java/zin/rashidi/datarest/compositeid/book/CreateAuthorTests.java @@ -0,0 +1,58 @@ +package zin.rashidi.datarest.compositeid.book; + +import org.assertj.core.api.Condition; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.assertj.MockMvcTester; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.shaded.org.apache.commons.lang3.math.NumberUtils; +import zin.rashidi.datarest.compositeid.TestcontainersConfiguration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; +import static org.springframework.http.HttpHeaders.LOCATION; +import static org.springframework.http.MediaType.APPLICATION_JSON; + +/** + * @author Rashidi Zin + */ +@Import(TestcontainersConfiguration.class) +@Testcontainers +@SpringBootTest(webEnvironment = RANDOM_PORT, properties = "spring.jpa.hibernate.ddl-auto=create-drop") +@AutoConfigureMockMvc +class CreateAuthorTests { + + @Autowired + private MockMvcTester mvc; + + @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 numeric() { + return new Condition<>(NumberUtils::isDigits, "is a number"); + } + + private String idFromLocation(String location) { + return location.substring(location.lastIndexOf("/") + 1); + } + +} diff --git a/data-rest-composite-id/src/test/java/zin/rashidi/datarest/compositeid/book/CreateBookTests.java b/data-rest-composite-id/src/test/java/zin/rashidi/datarest/compositeid/book/CreateBookTests.java new file mode 100644 index 00000000..bf9a2637 --- /dev/null +++ b/data-rest-composite-id/src/test/java/zin/rashidi/datarest/compositeid/book/CreateBookTests.java @@ -0,0 +1,46 @@ +package zin.rashidi.datarest.compositeid.book; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.assertj.MockMvcTester; +import org.testcontainers.junit.jupiter.Testcontainers; +import zin.rashidi.datarest.compositeid.TestcontainersConfiguration; + +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; +import static org.springframework.http.HttpHeaders.LOCATION; + +/** + * @author Rashidi Zin + */ +@Import(TestcontainersConfiguration.class) +@Testcontainers +@SpringBootTest(webEnvironment = RANDOM_PORT, properties = "spring.jpa.hibernate.ddl-auto=create-drop") +@AutoConfigureMockMvc +class CreateBookTests { + + @Autowired + private MockMvcTester mvc; + + @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"); + } + +} diff --git a/data-rest-composite-id/src/test/java/zin/rashidi/datarest/compositeid/book/GetBookTests.java b/data-rest-composite-id/src/test/java/zin/rashidi/datarest/compositeid/book/GetBookTests.java new file mode 100644 index 00000000..da81f84d --- /dev/null +++ b/data-rest-composite-id/src/test/java/zin/rashidi/datarest/compositeid/book/GetBookTests.java @@ -0,0 +1,44 @@ +package zin.rashidi.datarest.compositeid.book; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.assertj.MockMvcTester; +import org.testcontainers.junit.jupiter.Testcontainers; +import zin.rashidi.datarest.compositeid.TestcontainersConfiguration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; + +/** + * @author Rashidi Zin + */ +@Import(TestcontainersConfiguration.class) +@Testcontainers +@SpringBootTest(webEnvironment = RANDOM_PORT, properties = "spring.jpa.hibernate.ddl-auto=create-drop") +@AutoConfigureMockMvc +class GetBookTests { + + @Autowired + private MockMvcTester mvc; + + @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")); + } + +} diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index 86d3a361..97fc7019 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -16,6 +16,7 @@ ** xref:data-mongodb-full-text-search.adoc[MongoDB Full Text Search] ** xref:data-mongodb-transactional.adoc[MongoDB Transactional] ** xref:data-repository-definition.adoc[Repository Definition] +** xref:data-rest-composite-id.adoc[Composite Id with Spring Data REST] ** xref:data-rest-validation.adoc[REST Validation] * Spring GraphQL ** xref:graphql.adoc[GraphQL Server] diff --git a/docs/modules/ROOT/pages/data-rest-composite-id.adoc b/docs/modules/ROOT/pages/data-rest-composite-id.adoc new file mode 100644 index 00000000..e1d2b319 --- /dev/null +++ b/docs/modules/ROOT/pages/data-rest-composite-id.adoc @@ -0,0 +1,280 @@ += Spring Data REST with Composite ID +:source-highlighter: highlight.js +Rashidi Zin +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 { +} +---- + +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 { +} +---- + +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 { + + @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 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": "9781509827829", + "title": "The Jungle Book", + "author": "http://localhost/authors/100" + } + """) + .assertThat().headers() + .extracting(LOCATION).asString().isEqualTo("http://localhost/books/9781509827829"); +} +---- + +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 (100, 'Rudyard', 'Kipling')", + "INSERT INTO book (prefix, registration_group, registrant, publication, check_digit, author_id, title) VALUES (978, 1, 509, 82782, 9, 100, '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. \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 4d1ed0e6..03e57166 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,6 +15,7 @@ include("data-mongodb-full-text-search") include("data-mongodb-tc-data-load") include("data-mongodb-transactional") include("data-repository-definition") +include("data-rest-composite-id") include("data-rest-validation") include("graphql") include("jooq")