From 183e8af1ff3b1389029e082270636d0ab2eeaddf Mon Sep 17 00:00:00 2001 From: Rashidi Zin Date: Sun, 13 Jul 2025 15:15:34 +0800 Subject: [PATCH 1/9] Add tutorial using composite Id with Data REST --- README.adoc | 1 + data-jdbc-schema-generation/user.yaml | 278 +++++++++++++++++ data-rest-composite-id/.gitattributes | 3 + data-rest-composite-id/.gitignore | 37 +++ data-rest-composite-id/README.adoc | 284 ++++++++++++++++++ data-rest-composite-id/build.gradle.kts | 33 ++ data-rest-composite-id/settings.gradle.kts | 1 + .../DataRestCompositeIdApplication.java | 13 + .../datarest/compositeid/book/Author.java | 59 ++++ .../compositeid/book/AuthorIdConverter.java | 31 ++ .../book/AuthorIdReferencedConverter.java | 17 ++ .../compositeid/book/AuthorRepository.java | 11 + .../datarest/compositeid/book/Book.java | 80 +++++ .../compositeid/book/BookIdConverter.java | 29 ++ .../compositeid/book/BookRepository.java | 12 + .../book/BookRepositoryRestConfigurer.java | 18 ++ .../src/main/resources/application.properties | 1 + .../TestDataRestCompositeIdApplication.java | 11 + .../TestcontainersConfiguration.java | 18 ++ .../compositeid/book/CreateAuthorTests.java | 58 ++++ .../compositeid/book/CreateBookTests.java | 46 +++ .../compositeid/book/GetBookTests.java | 44 +++ docs/modules/ROOT/nav.adoc | 1 + .../ROOT/pages/data-rest-composite-id.adoc | 283 +++++++++++++++++ settings.gradle.kts | 1 + 25 files changed, 1370 insertions(+) create mode 100644 data-jdbc-schema-generation/user.yaml create mode 100644 data-rest-composite-id/.gitattributes create mode 100644 data-rest-composite-id/.gitignore create mode 100644 data-rest-composite-id/README.adoc create mode 100644 data-rest-composite-id/build.gradle.kts create mode 100644 data-rest-composite-id/settings.gradle.kts create mode 100644 data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/DataRestCompositeIdApplication.java create mode 100644 data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/Author.java create mode 100644 data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/AuthorIdConverter.java create mode 100644 data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/AuthorIdReferencedConverter.java create mode 100644 data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/AuthorRepository.java create mode 100644 data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/Book.java create mode 100644 data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/BookIdConverter.java create mode 100644 data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/BookRepository.java create mode 100644 data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/BookRepositoryRestConfigurer.java create mode 100644 data-rest-composite-id/src/main/resources/application.properties create mode 100644 data-rest-composite-id/src/test/java/zin/rashidi/datarest/compositeid/TestDataRestCompositeIdApplication.java create mode 100644 data-rest-composite-id/src/test/java/zin/rashidi/datarest/compositeid/TestcontainersConfiguration.java create mode 100644 data-rest-composite-id/src/test/java/zin/rashidi/datarest/compositeid/book/CreateAuthorTests.java create mode 100644 data-rest-composite-id/src/test/java/zin/rashidi/datarest/compositeid/book/CreateBookTests.java create mode 100644 data-rest-composite-id/src/test/java/zin/rashidi/datarest/compositeid/book/GetBookTests.java create mode 100644 docs/modules/ROOT/pages/data-rest-composite-id.adoc 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-jdbc-schema-generation/user.yaml b/data-jdbc-schema-generation/user.yaml new file mode 100644 index 00000000..509a7e3f --- /dev/null +++ b/data-jdbc-schema-generation/user.yaml @@ -0,0 +1,278 @@ +databaseChangeLog: +- changeSet: + id: '1748148271572' + author: Spring Data Relational + objectQuotingStrategy: LEGACY + changes: + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: true + primaryKey: true + name: id + type: BIGINT + - column: + constraints: + nullable: true + name: name + type: VARCHAR(255 BYTE) + - column: + constraints: + nullable: false + name: book + type: BIGINT + tableName: author + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: true + primaryKey: true + name: isbn + type: BIGINT + - column: + constraints: + nullable: true + name: title + type: VARCHAR(255 BYTE) + tableName: book + - addForeignKeyConstraint: + baseColumnNames: book + baseTableName: author + constraintName: book_isbn_fk + referencedColumnNames: isbn + referencedTableName: book +- changeSet: + id: '1748149478922' + author: Spring Data Relational + objectQuotingStrategy: LEGACY + changes: + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: true + primaryKey: true + name: id + type: BIGINT + - column: + constraints: + nullable: true + name: name + type: VARCHAR(255 BYTE) + - column: + constraints: + nullable: false + name: book + type: BIGINT + tableName: author + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: true + primaryKey: true + name: isbn + type: BIGINT + - column: + constraints: + nullable: true + name: title + type: VARCHAR(255 BYTE) + tableName: book + - addForeignKeyConstraint: + baseColumnNames: book + baseTableName: author + constraintName: book_isbn_fk + referencedColumnNames: isbn + referencedTableName: book +- changeSet: + id: '1748153682287' + author: Spring Data Relational + objectQuotingStrategy: LEGACY + changes: + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: true + primaryKey: true + name: isbn + type: BIGINT + - column: + constraints: + nullable: true + name: title + type: VARCHAR(255 BYTE) + tableName: book + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: true + primaryKey: true + name: id + type: BIGINT + - column: + constraints: + nullable: true + name: name + type: VARCHAR(255 BYTE) + - column: + constraints: + nullable: false + name: book + type: BIGINT + tableName: author + - addForeignKeyConstraint: + baseColumnNames: book + baseTableName: author + constraintName: book_isbn_fk + referencedColumnNames: isbn + referencedTableName: book +- changeSet: + id: '1748171516774' + author: Spring Data Relational + objectQuotingStrategy: LEGACY + changes: + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: true + primaryKey: true + name: isbn + type: BIGINT + - column: + constraints: + nullable: true + name: title + type: VARCHAR(255 BYTE) + tableName: book + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: true + primaryKey: true + name: id + type: BIGINT + - column: + constraints: + nullable: true + name: name + type: VARCHAR(255 BYTE) + - column: + constraints: + nullable: false + name: book + type: BIGINT + tableName: author + - addForeignKeyConstraint: + baseColumnNames: book + baseTableName: author + constraintName: book_isbn_fk + referencedColumnNames: isbn + referencedTableName: book +- changeSet: + id: '1748171956993' + author: Spring Data Relational + objectQuotingStrategy: LEGACY + changes: + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: true + primaryKey: true + name: isbn + type: BIGINT + - column: + constraints: + nullable: true + name: title + type: VARCHAR(255 BYTE) + tableName: book + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: true + primaryKey: true + name: id + type: BIGINT + - column: + constraints: + nullable: true + name: name + type: VARCHAR(255 BYTE) + - column: + constraints: + nullable: false + name: book + type: BIGINT + tableName: author + - addForeignKeyConstraint: + baseColumnNames: book + baseTableName: author + constraintName: book_isbn_fk + referencedColumnNames: isbn + referencedTableName: book +- changeSet: + id: '1748172435101' + author: Spring Data Relational + objectQuotingStrategy: LEGACY + changes: + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: true + primaryKey: true + name: id + type: BIGINT + - column: + constraints: + nullable: true + name: name + type: VARCHAR(255 BYTE) + - column: + constraints: + nullable: false + name: book + type: BIGINT + tableName: author + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: true + primaryKey: true + name: isbn + type: BIGINT + - column: + constraints: + nullable: true + name: title + type: VARCHAR(255 BYTE) + tableName: book + - addForeignKeyConstraint: + baseColumnNames: book + baseTableName: author + constraintName: book_isbn_fk + referencedColumnNames: isbn + referencedTableName: book + 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..e979aaf9 --- /dev/null +++ b/data-rest-composite-id/README.adoc @@ -0,0 +1,284 @@ += 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 + +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` and `Author`. 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)); + } + + // equals, hashCode, and toString methods omitted + } +} +---- + +The `Isbn` class breaks down an ISBN string into its components (prefix, group, registrant, publication, and check digit) and stores them as separate fields. This allows for more granular querying and validation. + +=== Author Entity + +The `Author` entity uses an embedded `Id` class and an embedded `Name` class: + +[source,java] +---- +@Entity +class Author { + + @EmbeddedId + private Id id = new Id(); + + @Embedded + private Name name; + + // Getters and setters omitted + + @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; + } + + // equals and hashCode methods omitted + } + + @Embeddable + record Name(@Column(name = "first_name") String first, @Column(name = "last_name") String last) { + + public Name() { + this(null, null); + } + } +} +---- + +The `Id` class wraps a single `Long` value, demonstrating a simpler approach to composite IDs. The `Name` class is implemented as a Java record, showing a modern approach to embedded classes. + +== Repository Interfaces + +The repository interfaces extend `JpaRepository` and are annotated with `@RepositoryRestResource` to expose them as REST resources: + +[source,java] +---- +@RepositoryRestResource +interface BookRepository extends JpaRepository { +} + +@RepositoryRestResource +interface AuthorRepository extends JpaRepository { +} +---- + +== ID Converters + +To handle the conversion between the string representation of IDs in the REST API and the composite ID objects used in the entities, we need to implement `BackendIdConverter` for each entity: + +=== BookIdConverter + +[source,java] +---- +@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); + } +} +---- + +The `BookIdConverter` converts between the string representation of an ISBN in the REST API and the `Isbn` object used in the `Book` entity. + +=== AuthorIdConverter + +[source,java] +---- +@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); + } +} +---- + +The `AuthorIdConverter` converts between the string representation of an Author ID in the REST API and the `Author.Id` object used in the `Author` entity. + +== Additional Configuration + +For handling references to entities with composite IDs, we need to register additional converters: + +[source,java] +---- +@Configuration +class BookRepositoryRestConfigurer implements RepositoryRestConfigurer { + + @Override + public void configureConversionService(ConfigurableConversionService conversionService) { + conversionService.addConverter(new AuthorIdReferencedConverter()); + } +} + +@ReadingConverter +class AuthorIdReferencedConverter implements Converter { + + @Override + public Author.Id convert(String source) { + return new Author.Id().id(Long.parseLong(source)); + } +} +---- + +The `BookRepositoryRestConfigurer` registers the `AuthorIdReferencedConverter` with the conversion service, allowing Spring Data REST to convert string representations of Author IDs to `Author.Id` objects when handling references. + +== Usage Examples + +=== Creating an Author + +[source,http] +---- +POST /authors +Content-Type: application/json + +{ + "name": { + "first": "Rudyard", + "last": "Kipling" + } +} +---- + +The response will include a `Location` header with the URL of the created Author, e.g., `/authors/1`. + +=== Creating a Book + +[source,http] +---- +POST /books +Content-Type: application/json + +{ + "isbn": "9781509827829", + "title": "The Jungle Book", + "author": "http://localhost/authors/1" +} +---- + +The response will include a `Location` header with the URL of the created Book, e.g., `/books/9781509827829`. + +=== Retrieving a Book + +[source,http] +---- +GET /books/9781509827829 +---- + +The response will include the Book's details and links to related resources: + +[source,json] +---- +{ + "title": "The Jungle Book", + "_links": { + "self": { + "href": "http://localhost/books/9781509827829" + }, + "author": { + "href": "http://localhost/books/9781509827829/author" + } + } +} +---- + +== Conclusion + +Working with composite IDs in Spring Data REST requires: + +1. Defining entities with embedded ID classes +2. Implementing `BackendIdConverter` for each entity to handle the conversion between string IDs and composite ID objects +3. Registering additional converters for handling references to entities with composite IDs + +With these components in place, Spring Data REST can properly expose entities with composite IDs as REST resources. \ 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..2b2d7795 --- /dev/null +++ b/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/Author.java @@ -0,0 +1,59 @@ +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; + + public Name getName() { + return 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; + } + + @Override + public final boolean equals(Object o) { + return o instanceof Id another && this.id.equals(another.id); + } + + @Override + public int hashCode() { + return id.hashCode(); + } + + } + + @Embeddable + record Name(@Column(name = "first_name") String first, @Column(name = "last_name") String last) { + + public Name() { + this(null, null); + } + + } + +} 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..02e12a08 --- /dev/null +++ b/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/Book.java @@ -0,0 +1,80 @@ +package zin.rashidi.datarest.compositeid.book; + +import jakarta.persistence.*; + +import java.io.Serializable; +import java.util.Objects; + +/** + * @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 final boolean equals(Object o) { + return o instanceof Isbn isbn + && prefix.equals(isbn.prefix) + && group.equals(isbn.group) + && registrant.equals(isbn.registrant) + && publication.equals(isbn.publication) + && check.equals(isbn.check); + } + + @Override + public int hashCode() { + return Objects.hash(prefix, group, registrant, publication, check); + } + + @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..83280e26 --- /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": "9781509827829", + "title": "The Jungle Book", + "author": "http://localhost/authors/100" + } + """) + .assertThat().headers() + .extracting(LOCATION).asString().isEqualTo("http://localhost/books/9781509827829"); + } + +} 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..a33eb7ba --- /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 (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).isEqualTo("The Jungle Book")) + .hasPathSatisfying("$._links.author.href", authorUri -> assertThat(authorUri).isEqualTo("http://localhost/books/9781509827829/author")) + .hasPathSatisfying("$._links.self.href", uri -> assertThat(uri).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..4aef045e --- /dev/null +++ b/docs/modules/ROOT/pages/data-rest-composite-id.adoc @@ -0,0 +1,283 @@ += Spring Data REST with Composite ID +:source-highlighter: highlight.js +Rashidi Zin +1.0, July 13, 2025 +:nofooter: +:icons: font +:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/data-rest-composite-id + +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` and `Author`. 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)); + } + + // equals, hashCode, and toString methods omitted + } +} +---- + +The `Isbn` class breaks down an ISBN string into its components (prefix, group, registrant, publication, and check digit) and stores them as separate fields. This allows for more granular querying and validation. + +=== Author Entity + +The `Author` entity uses an embedded `Id` class and an embedded `Name` class: + +[source,java] +---- +@Entity +class Author { + + @EmbeddedId + private Id id = new Id(); + + @Embedded + private Name name; + + // Getters and setters omitted + + @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; + } + + // equals and hashCode methods omitted + } + + @Embeddable + record Name(@Column(name = "first_name") String first, @Column(name = "last_name") String last) { + + public Name() { + this(null, null); + } + } +} +---- + +The `Id` class wraps a single `Long` value, demonstrating a simpler approach to composite IDs. The `Name` class is implemented as a Java record, showing a modern approach to embedded classes. + +== Repository Interfaces + +The repository interfaces extend `JpaRepository` and are annotated with `@RepositoryRestResource` to expose them as REST resources: + +[source,java] +---- +@RepositoryRestResource +interface BookRepository extends JpaRepository { +} + +@RepositoryRestResource +interface AuthorRepository extends JpaRepository { +} +---- + +== ID Converters + +To handle the conversion between the string representation of IDs in the REST API and the composite ID objects used in the entities, we need to implement `BackendIdConverter` for each entity: + +=== BookIdConverter + +[source,java] +---- +@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); + } +} +---- + +The `BookIdConverter` converts between the string representation of an ISBN in the REST API and the `Isbn` object used in the `Book` entity. + +=== AuthorIdConverter + +[source,java] +---- +@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); + } +} +---- + +The `AuthorIdConverter` converts between the string representation of an Author ID in the REST API and the `Author.Id` object used in the `Author` entity. + +== Additional Configuration + +For handling references to entities with composite IDs, we need to register additional converters: + +[source,java] +---- +@Configuration +class BookRepositoryRestConfigurer implements RepositoryRestConfigurer { + + @Override + public void configureConversionService(ConfigurableConversionService conversionService) { + conversionService.addConverter(new AuthorIdReferencedConverter()); + } +} + +@ReadingConverter +class AuthorIdReferencedConverter implements Converter { + + @Override + public Author.Id convert(String source) { + return new Author.Id().id(Long.parseLong(source)); + } +} +---- + +The `BookRepositoryRestConfigurer` registers the `AuthorIdReferencedConverter` with the conversion service, allowing Spring Data REST to convert string representations of Author IDs to `Author.Id` objects when handling references. + +== Usage Examples + +=== Creating an Author + +[source,http] +---- +POST /authors +Content-Type: application/json + +{ + "name": { + "first": "Rudyard", + "last": "Kipling" + } +} +---- + +The response will include a `Location` header with the URL of the created Author, e.g., `/authors/1`. + +=== Creating a Book + +[source,http] +---- +POST /books +Content-Type: application/json + +{ + "isbn": "9781509827829", + "title": "The Jungle Book", + "author": "http://localhost/authors/1" +} +---- + +The response will include a `Location` header with the URL of the created Book, e.g., `/books/9781509827829`. + +=== Retrieving a Book + +[source,http] +---- +GET /books/9781509827829 +---- + +The response will include the Book's details and links to related resources: + +[source,json] +---- +{ + "title": "The Jungle Book", + "_links": { + "self": { + "href": "http://localhost/books/9781509827829" + }, + "author": { + "href": "http://localhost/books/9781509827829/author" + } + } +} +---- + +== Conclusion + +Working with composite IDs in Spring Data REST requires: + +1. Defining entities with embedded ID classes +2. Implementing `BackendIdConverter` for each entity to handle the conversion between string IDs and composite ID objects +3. Registering additional converters for handling references to entities with composite IDs + +With these components in place, Spring Data REST can properly expose entities with composite IDs as REST resources. \ 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") From e81614c204e6236142ba18982398914ad1389fcc Mon Sep 17 00:00:00 2001 From: Rashidi Zin Date: Sun, 13 Jul 2025 15:29:58 +0800 Subject: [PATCH 2/9] Add tutorial using composite Id with Data REST --- data-jdbc-schema-generation/user.yaml | 278 -------------------------- 1 file changed, 278 deletions(-) delete mode 100644 data-jdbc-schema-generation/user.yaml diff --git a/data-jdbc-schema-generation/user.yaml b/data-jdbc-schema-generation/user.yaml deleted file mode 100644 index 509a7e3f..00000000 --- a/data-jdbc-schema-generation/user.yaml +++ /dev/null @@ -1,278 +0,0 @@ -databaseChangeLog: -- changeSet: - id: '1748148271572' - author: Spring Data Relational - objectQuotingStrategy: LEGACY - changes: - - createTable: - columns: - - column: - autoIncrement: true - constraints: - nullable: true - primaryKey: true - name: id - type: BIGINT - - column: - constraints: - nullable: true - name: name - type: VARCHAR(255 BYTE) - - column: - constraints: - nullable: false - name: book - type: BIGINT - tableName: author - - createTable: - columns: - - column: - autoIncrement: true - constraints: - nullable: true - primaryKey: true - name: isbn - type: BIGINT - - column: - constraints: - nullable: true - name: title - type: VARCHAR(255 BYTE) - tableName: book - - addForeignKeyConstraint: - baseColumnNames: book - baseTableName: author - constraintName: book_isbn_fk - referencedColumnNames: isbn - referencedTableName: book -- changeSet: - id: '1748149478922' - author: Spring Data Relational - objectQuotingStrategy: LEGACY - changes: - - createTable: - columns: - - column: - autoIncrement: true - constraints: - nullable: true - primaryKey: true - name: id - type: BIGINT - - column: - constraints: - nullable: true - name: name - type: VARCHAR(255 BYTE) - - column: - constraints: - nullable: false - name: book - type: BIGINT - tableName: author - - createTable: - columns: - - column: - autoIncrement: true - constraints: - nullable: true - primaryKey: true - name: isbn - type: BIGINT - - column: - constraints: - nullable: true - name: title - type: VARCHAR(255 BYTE) - tableName: book - - addForeignKeyConstraint: - baseColumnNames: book - baseTableName: author - constraintName: book_isbn_fk - referencedColumnNames: isbn - referencedTableName: book -- changeSet: - id: '1748153682287' - author: Spring Data Relational - objectQuotingStrategy: LEGACY - changes: - - createTable: - columns: - - column: - autoIncrement: true - constraints: - nullable: true - primaryKey: true - name: isbn - type: BIGINT - - column: - constraints: - nullable: true - name: title - type: VARCHAR(255 BYTE) - tableName: book - - createTable: - columns: - - column: - autoIncrement: true - constraints: - nullable: true - primaryKey: true - name: id - type: BIGINT - - column: - constraints: - nullable: true - name: name - type: VARCHAR(255 BYTE) - - column: - constraints: - nullable: false - name: book - type: BIGINT - tableName: author - - addForeignKeyConstraint: - baseColumnNames: book - baseTableName: author - constraintName: book_isbn_fk - referencedColumnNames: isbn - referencedTableName: book -- changeSet: - id: '1748171516774' - author: Spring Data Relational - objectQuotingStrategy: LEGACY - changes: - - createTable: - columns: - - column: - autoIncrement: true - constraints: - nullable: true - primaryKey: true - name: isbn - type: BIGINT - - column: - constraints: - nullable: true - name: title - type: VARCHAR(255 BYTE) - tableName: book - - createTable: - columns: - - column: - autoIncrement: true - constraints: - nullable: true - primaryKey: true - name: id - type: BIGINT - - column: - constraints: - nullable: true - name: name - type: VARCHAR(255 BYTE) - - column: - constraints: - nullable: false - name: book - type: BIGINT - tableName: author - - addForeignKeyConstraint: - baseColumnNames: book - baseTableName: author - constraintName: book_isbn_fk - referencedColumnNames: isbn - referencedTableName: book -- changeSet: - id: '1748171956993' - author: Spring Data Relational - objectQuotingStrategy: LEGACY - changes: - - createTable: - columns: - - column: - autoIncrement: true - constraints: - nullable: true - primaryKey: true - name: isbn - type: BIGINT - - column: - constraints: - nullable: true - name: title - type: VARCHAR(255 BYTE) - tableName: book - - createTable: - columns: - - column: - autoIncrement: true - constraints: - nullable: true - primaryKey: true - name: id - type: BIGINT - - column: - constraints: - nullable: true - name: name - type: VARCHAR(255 BYTE) - - column: - constraints: - nullable: false - name: book - type: BIGINT - tableName: author - - addForeignKeyConstraint: - baseColumnNames: book - baseTableName: author - constraintName: book_isbn_fk - referencedColumnNames: isbn - referencedTableName: book -- changeSet: - id: '1748172435101' - author: Spring Data Relational - objectQuotingStrategy: LEGACY - changes: - - createTable: - columns: - - column: - autoIncrement: true - constraints: - nullable: true - primaryKey: true - name: id - type: BIGINT - - column: - constraints: - nullable: true - name: name - type: VARCHAR(255 BYTE) - - column: - constraints: - nullable: false - name: book - type: BIGINT - tableName: author - - createTable: - columns: - - column: - autoIncrement: true - constraints: - nullable: true - primaryKey: true - name: isbn - type: BIGINT - - column: - constraints: - nullable: true - name: title - type: VARCHAR(255 BYTE) - tableName: book - - addForeignKeyConstraint: - baseColumnNames: book - baseTableName: author - constraintName: book_isbn_fk - referencedColumnNames: isbn - referencedTableName: book - From 0381f6c13389c8d218d037ef0b1ba59acc062bf7 Mon Sep 17 00:00:00 2001 From: Rashidi Zin Date: Sun, 13 Jul 2025 15:33:25 +0800 Subject: [PATCH 3/9] Add tutorial using composite Id with Data REST --- .../datarest/compositeid/book/Author.java | 22 +------------------ .../datarest/compositeid/book/Book.java | 16 -------------- 2 files changed, 1 insertion(+), 37 deletions(-) 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 index 2b2d7795..ba75e7d8 100644 --- 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 @@ -16,10 +16,6 @@ class Author { @Embedded private Name name; - public Name getName() { - return name; - } - @Embeddable static class Id implements Serializable { @@ -35,25 +31,9 @@ public Id id(Long id) { return this; } - @Override - public final boolean equals(Object o) { - return o instanceof Id another && this.id.equals(another.id); - } - - @Override - public int hashCode() { - return id.hashCode(); - } - } @Embeddable - record Name(@Column(name = "first_name") String first, @Column(name = "last_name") String last) { - - public Name() { - this(null, null); - } - - } + 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/Book.java b/data-rest-composite-id/src/main/java/zin/rashidi/datarest/compositeid/book/Book.java index 02e12a08..8f4924f0 100644 --- 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 @@ -3,7 +3,6 @@ import jakarta.persistence.*; import java.io.Serializable; -import java.util.Objects; /** * @author Rashidi Zin @@ -55,21 +54,6 @@ public Isbn(String isbn) { this.check = Integer.parseInt(isbn.substring(12)); } - @Override - public final boolean equals(Object o) { - return o instanceof Isbn isbn - && prefix.equals(isbn.prefix) - && group.equals(isbn.group) - && registrant.equals(isbn.registrant) - && publication.equals(isbn.publication) - && check.equals(isbn.check); - } - - @Override - public int hashCode() { - return Objects.hash(prefix, group, registrant, publication, check); - } - @Override public String toString() { return String.format("%d%d%d%d%d", prefix, group, registrant, publication, check); From 6cd85c7d5aa6c802df17ea57768907ae18c3b571 Mon Sep 17 00:00:00 2001 From: Rashidi Zin Date: Sun, 13 Jul 2025 18:44:27 +0800 Subject: [PATCH 4/9] Add tutorial using composite Id with Data REST --- .../compositeid/book/GetBookTests.java | 8 +- .../ROOT/pages/data-rest-composite-id.adoc | 219 +++++++++--------- 2 files changed, 112 insertions(+), 115 deletions(-) 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 index a33eb7ba..79ca94da 100644 --- 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 @@ -19,7 +19,7 @@ */ @Import(TestcontainersConfiguration.class) @Testcontainers -@SpringBootTest(webEnvironment = RANDOM_PORT, properties = "spring.jpa.hibernate.Ïddl-auto=create-drop") +@SpringBootTest(webEnvironment = RANDOM_PORT, properties = "spring.jpa.hibernate.ddl-auto=create-drop") @AutoConfigureMockMvc class GetBookTests { @@ -36,9 +36,9 @@ void get() { mvc .get().uri("/books/9781509827829") .assertThat().bodyJson() - .hasPathSatisfying("$.title", title -> assertThat(title).isEqualTo("The Jungle Book")) - .hasPathSatisfying("$._links.author.href", authorUri -> assertThat(authorUri).isEqualTo("http://localhost/books/9781509827829/author")) - .hasPathSatisfying("$._links.self.href", uri -> assertThat(uri).isEqualTo("http://localhost/books/9781509827829")); + .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/pages/data-rest-composite-id.adoc b/docs/modules/ROOT/pages/data-rest-composite-id.adoc index 4aef045e..e1d2b319 100644 --- a/docs/modules/ROOT/pages/data-rest-composite-id.adoc +++ b/docs/modules/ROOT/pages/data-rest-composite-id.adoc @@ -2,21 +2,27 @@ :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. +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` and `Author`. Both use composite IDs implemented as embedded 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 @@ -61,16 +67,20 @@ class Book { this.check = Integer.parseInt(isbn.substring(12)); } - // equals, hashCode, and toString methods omitted + @Override + public String toString() { + return String.format("%d%d%d%d%d", prefix, group, registrant, publication, check); + } + } } ---- -The `Isbn` class breaks down an ISBN string into its components (prefix, group, registrant, publication, and check digit) and stores them as separate fields. This allows for more granular querying and validation. +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 and an embedded `Name` class: +The `Author` entity uses an embedded `Id` class as its ID: [source,java] ---- @@ -83,8 +93,6 @@ class Author { @Embedded private Name name; - // Getters and setters omitted - @Embeddable static class Id implements Serializable { @@ -100,95 +108,66 @@ class Author { return this; } - // equals and hashCode methods omitted } @Embeddable - record Name(@Column(name = "first_name") String first, @Column(name = "last_name") String last) { + record Name(@Column(name = "first_name") String first, @Column(name = "last_name") String last) { } - public Name() { - this(null, null); - } - } } ---- -The `Id` class wraps a single `Long` value, demonstrating a simpler approach to composite IDs. The `Name` class is implemented as a Java record, showing a modern approach to embedded classes. +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 -The repository interfaces extend `JpaRepository` and are annotated with `@RepositoryRestResource` to expose them as REST resources: +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 { -} - -@RepositoryRestResource -interface AuthorRepository extends JpaRepository { +interface BookRepository extends JpaRepository { } ---- -== ID Converters - -To handle the conversion between the string representation of IDs in the REST API and the composite ID objects used in the entities, we need to implement `BackendIdConverter` for each entity: +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. -=== BookIdConverter +=== AuthorRepository [source,java] ---- -@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); - } +@RepositoryRestResource +interface AuthorRepository extends JpaRepository { } ---- -The `BookIdConverter` converts between the string representation of an ISBN in the REST API and the `Isbn` object used in the `Book` entity. +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 -=== AuthorIdConverter +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] ---- -@Component -class AuthorIdConverter implements BackendIdConverter { - - @Override - public Serializable fromRequestId(String id, Class entityType) { - return new Author.Id().id(parseLong(id)); - } +@ReadingConverter +class AuthorIdReferencedConverter implements Converter { @Override - public String toRequestId(Serializable id, Class entityType) { - return ((Author.Id) id).id().toString(); + public Author.Id convert(String source) { + return new Author.Id().id(Long.parseLong(source)); } - @Override - public boolean supports(Class aClass) { - return Author.class.isAssignableFrom(aClass); - } } ---- -The `AuthorIdConverter` converts between the string representation of an Author ID in the REST API and the `Author.Id` object used in the `Author` entity. +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. -== Additional Configuration +=== Configuring Converters -For handling references to entities with composite IDs, we need to register additional converters: +To register the custom converters, we need to implement `RepositoryRestConfigurer`: [source,java] ---- @@ -199,85 +178,103 @@ class BookRepositoryRestConfigurer implements RepositoryRestConfigurer { public void configureConversionService(ConfigurableConversionService conversionService) { conversionService.addConverter(new AuthorIdReferencedConverter()); } -} - -@ReadingConverter -class AuthorIdReferencedConverter implements Converter { - @Override - public Author.Id convert(String source) { - return new Author.Id().id(Long.parseLong(source)); - } } ---- -The `BookRepositoryRestConfigurer` registers the `AuthorIdReferencedConverter` with the conversion service, allowing Spring Data REST to convert string representations of Author IDs to `Author.Id` objects when handling references. +This configuration class adds the `AuthorIdReferencedConverter` to the conversion service, allowing Spring Data REST to convert between string representations and `Author.Id` objects. + +== Testing -== Usage Examples +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,http] +[source,java] ---- -POST /authors -Content-Type: application/json - -{ - "name": { - "first": "Rudyard", - "last": "Kipling" - } +@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); } ---- -The response will include a `Location` header with the URL of the created Author, e.g., `/authors/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,http] +[source,java] ---- -POST /books -Content-Type: application/json - -{ - "isbn": "9781509827829", - "title": "The Jungle Book", - "author": "http://localhost/authors/1" +@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"); } ---- -The response will include a `Location` header with the URL of the created Book, e.g., `/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,http] ----- -GET /books/9781509827829 ----- - -The response will include the Book's details and links to related resources: - -[source,json] +[source,java] ---- -{ - "title": "The Jungle Book", - "_links": { - "self": { - "href": "http://localhost/books/9781509827829" - }, - "author": { - "href": "http://localhost/books/9781509827829/author" - } - } +@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 -Working with composite IDs in Spring Data REST requires: +In this article, we've demonstrated how to implement and expose entities with composite IDs through Spring Data REST. The key points are: -1. Defining entities with embedded ID classes -2. Implementing `BackendIdConverter` for each entity to handle the conversion between string IDs and composite ID objects -3. Registering additional converters for handling references to entities with composite IDs +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 components in place, Spring Data REST can properly expose entities with composite IDs as REST resources. \ No newline at end of file +With these steps, you can successfully work with composite IDs in your Spring Data REST applications. \ No newline at end of file From 976f593268dd8028898ed1267f95e9dd8ff033a0 Mon Sep 17 00:00:00 2001 From: Rashidi Zin Date: Sun, 13 Jul 2025 18:47:32 +0800 Subject: [PATCH 5/9] Add tutorial using composite Id with Data REST --- data-rest-composite-id/README.adoc | 218 ++++++++++++++--------------- 1 file changed, 107 insertions(+), 111 deletions(-) diff --git a/data-rest-composite-id/README.adoc b/data-rest-composite-id/README.adoc index e979aaf9..e1d2b319 100644 --- a/data-rest-composite-id/README.adoc +++ b/data-rest-composite-id/README.adoc @@ -6,18 +6,23 @@ Rashidi Zin :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. +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` and `Author`. Both use composite IDs implemented as embedded 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 @@ -62,16 +67,20 @@ class Book { this.check = Integer.parseInt(isbn.substring(12)); } - // equals, hashCode, and toString methods omitted + @Override + public String toString() { + return String.format("%d%d%d%d%d", prefix, group, registrant, publication, check); + } + } } ---- -The `Isbn` class breaks down an ISBN string into its components (prefix, group, registrant, publication, and check digit) and stores them as separate fields. This allows for more granular querying and validation. +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 and an embedded `Name` class: +The `Author` entity uses an embedded `Id` class as its ID: [source,java] ---- @@ -84,8 +93,6 @@ class Author { @Embedded private Name name; - // Getters and setters omitted - @Embeddable static class Id implements Serializable { @@ -101,95 +108,66 @@ class Author { return this; } - // equals and hashCode methods omitted } @Embeddable - record Name(@Column(name = "first_name") String first, @Column(name = "last_name") String last) { + record Name(@Column(name = "first_name") String first, @Column(name = "last_name") String last) { } - public Name() { - this(null, null); - } - } } ---- -The `Id` class wraps a single `Long` value, demonstrating a simpler approach to composite IDs. The `Name` class is implemented as a Java record, showing a modern approach to embedded classes. +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 -The repository interfaces extend `JpaRepository` and are annotated with `@RepositoryRestResource` to expose them as REST resources: +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 { -} - -@RepositoryRestResource -interface AuthorRepository extends JpaRepository { +interface BookRepository extends JpaRepository { } ---- -== ID Converters - -To handle the conversion between the string representation of IDs in the REST API and the composite ID objects used in the entities, we need to implement `BackendIdConverter` for each entity: +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. -=== BookIdConverter +=== AuthorRepository [source,java] ---- -@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); - } +@RepositoryRestResource +interface AuthorRepository extends JpaRepository { } ---- -The `BookIdConverter` converts between the string representation of an ISBN in the REST API and the `Isbn` object used in the `Book` entity. +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 -=== AuthorIdConverter +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] ---- -@Component -class AuthorIdConverter implements BackendIdConverter { - - @Override - public Serializable fromRequestId(String id, Class entityType) { - return new Author.Id().id(parseLong(id)); - } +@ReadingConverter +class AuthorIdReferencedConverter implements Converter { @Override - public String toRequestId(Serializable id, Class entityType) { - return ((Author.Id) id).id().toString(); + public Author.Id convert(String source) { + return new Author.Id().id(Long.parseLong(source)); } - @Override - public boolean supports(Class aClass) { - return Author.class.isAssignableFrom(aClass); - } } ---- -The `AuthorIdConverter` converts between the string representation of an Author ID in the REST API and the `Author.Id` object used in the `Author` entity. +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. -== Additional Configuration +=== Configuring Converters -For handling references to entities with composite IDs, we need to register additional converters: +To register the custom converters, we need to implement `RepositoryRestConfigurer`: [source,java] ---- @@ -200,85 +178,103 @@ class BookRepositoryRestConfigurer implements RepositoryRestConfigurer { public void configureConversionService(ConfigurableConversionService conversionService) { conversionService.addConverter(new AuthorIdReferencedConverter()); } -} - -@ReadingConverter -class AuthorIdReferencedConverter implements Converter { - @Override - public Author.Id convert(String source) { - return new Author.Id().id(Long.parseLong(source)); - } } ---- -The `BookRepositoryRestConfigurer` registers the `AuthorIdReferencedConverter` with the conversion service, allowing Spring Data REST to convert string representations of Author IDs to `Author.Id` objects when handling references. +This configuration class adds the `AuthorIdReferencedConverter` to the conversion service, allowing Spring Data REST to convert between string representations and `Author.Id` objects. + +== Testing -== Usage Examples +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,http] +[source,java] ---- -POST /authors -Content-Type: application/json - -{ - "name": { - "first": "Rudyard", - "last": "Kipling" - } +@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); } ---- -The response will include a `Location` header with the URL of the created Author, e.g., `/authors/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,http] +[source,java] ---- -POST /books -Content-Type: application/json - -{ - "isbn": "9781509827829", - "title": "The Jungle Book", - "author": "http://localhost/authors/1" +@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"); } ---- -The response will include a `Location` header with the URL of the created Book, e.g., `/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,http] ----- -GET /books/9781509827829 ----- - -The response will include the Book's details and links to related resources: - -[source,json] +[source,java] ---- -{ - "title": "The Jungle Book", - "_links": { - "self": { - "href": "http://localhost/books/9781509827829" - }, - "author": { - "href": "http://localhost/books/9781509827829/author" - } - } +@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 -Working with composite IDs in Spring Data REST requires: +In this article, we've demonstrated how to implement and expose entities with composite IDs through Spring Data REST. The key points are: -1. Defining entities with embedded ID classes -2. Implementing `BackendIdConverter` for each entity to handle the conversion between string IDs and composite ID objects -3. Registering additional converters for handling references to entities with composite IDs +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 components in place, Spring Data REST can properly expose entities with composite IDs as REST resources. \ No newline at end of file +With these steps, you can successfully work with composite IDs in your Spring Data REST applications. \ No newline at end of file From f906f620c685e485b8b042b93b8891260bce52b9 Mon Sep 17 00:00:00 2001 From: Rashidi Zin Date: Sun, 13 Jul 2025 18:51:32 +0800 Subject: [PATCH 6/9] Add tutorial using composite Id with Data REST --- .../rashidi/datarest/compositeid/book/CreateBookTests.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 83280e26..bf9a2637 100644 --- 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 @@ -34,13 +34,13 @@ void create() { .post().uri("/books") .content(""" { - "isbn": "9781509827829", + "isbn": "9781402745777", "title": "The Jungle Book", "author": "http://localhost/authors/100" } """) .assertThat().headers() - .extracting(LOCATION).asString().isEqualTo("http://localhost/books/9781509827829"); + .extracting(LOCATION).asString().isEqualTo("http://localhost/books/9781402745777"); } } From 7bfc6cf6bd3d0073dc46ca6ea3f3383e740ab7a6 Mon Sep 17 00:00:00 2001 From: Rashidi Zin Date: Sun, 13 Jul 2025 18:55:05 +0800 Subject: [PATCH 7/9] Add tutorial using composite Id with Data REST --- .../zin/rashidi/datarest/compositeid/book/GetBookTests.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 79ca94da..cf0a3ca2 100644 --- 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 @@ -28,7 +28,7 @@ class GetBookTests { @Test @Sql(statements = { - "INSERT INTO author (id, first_name, last_name) VALUES (100, 'Rudyard', 'Kipling')", + "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, 100, 'The Jungle Book')" }) @DisplayName("Given a book is available When I request by its ISBN Then its information should be returned") From 0428921ac68caa1210b7cedd4a2144c346921b23 Mon Sep 17 00:00:00 2001 From: Rashidi Zin Date: Sun, 13 Jul 2025 18:56:04 +0800 Subject: [PATCH 8/9] Add tutorial using composite Id with Data REST --- .../zin/rashidi/datarest/compositeid/book/GetBookTests.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index cf0a3ca2..da81f84d 100644 --- 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 @@ -29,7 +29,7 @@ class GetBookTests { @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, 100, 'The Jungle Book')" + "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() { From 8aa7ba0d53f383eeffce25a0137f77dea63d452c Mon Sep 17 00:00:00 2001 From: Rashidi Zin Date: Sun, 13 Jul 2025 18:57:17 +0800 Subject: [PATCH 9/9] Add tutorial using composite Id with Data REST --- data-rest-composite-id/README.adoc | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/data-rest-composite-id/README.adoc b/data-rest-composite-id/README.adoc index e1d2b319..ec9cdf83 100644 --- a/data-rest-composite-id/README.adoc +++ b/data-rest-composite-id/README.adoc @@ -233,13 +233,13 @@ void create() { .post().uri("/books") .content(""" { - "isbn": "9781509827829", + "isbn": "9781402745777", "title": "The Jungle Book", "author": "http://localhost/authors/100" } """) .assertThat().headers() - .extracting(LOCATION).asString().isEqualTo("http://localhost/books/9781509827829"); + .extracting(LOCATION).asString().isEqualTo("http://localhost/books/9781402745777"); } ---- @@ -251,8 +251,8 @@ This test creates a Book with an ISBN, title, and author reference, then verifie ---- @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')" + "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() {