From b08d2563528ef999a33398705919933c6a09c822 Mon Sep 17 00:00:00 2001 From: Rashidi Zin Date: Sun, 6 Apr 2025 13:59:39 +0800 Subject: [PATCH 01/11] Add example for Spring Modulith --- modulith/.gitattributes | 3 + modulith/.gitignore | 37 +++ modulith/README.adoc | 241 ++++++++++++++++++ modulith/build.gradle | 45 ++++ modulith/docs/all-docs.adoc | 15 ++ modulith/docs/components.puml | 21 ++ modulith/docs/module-course.adoc | 27 ++ modulith/docs/module-course.puml | 17 ++ modulith/docs/module-student.adoc | 27 ++ modulith/docs/module-student.puml | 17 ++ modulith/docs/module-subscription.adoc | 16 ++ modulith/docs/module-subscription.puml | 21 ++ modulith/settings.gradle | 1 + .../boot/modulith/ModulithApplication.java | 13 + .../rashidi/boot/modulith/course/Course.java | 39 +++ .../boot/modulith/course/CourseEnded.java | 9 + .../course/CourseEventsConfiguration.java | 26 ++ .../modulith/course/CourseManagement.java | 24 ++ .../modulith/course/CourseRepository.java | 9 + .../boot/modulith/student/Student.java | 39 +++ .../student/StudentEventsConfiguration.java | 25 ++ .../modulith/student/StudentInactivated.java | 9 + .../modulith/student/StudentManagement.java | 25 ++ .../modulith/student/StudentRepository.java | 8 + .../modulith/subscription/Subscription.java | 28 ++ .../subscription/SubscriptionManagement.java | 30 +++ .../subscription/SubscriptionRepository.java | 20 ++ .../src/main/resources/application.properties | 2 + .../rashidi/boot/modulith/ModuleTests.java | 31 +++ .../modulith/TestModulithApplication.java | 11 + .../modulith/TestcontainersConfiguration.java | 19 ++ .../modulith/course/CourseEndedTests.java | 48 ++++ .../course/CourseManagementTests.java | 38 +++ .../student/StudentInactivatedTests.java | 45 ++++ .../student/StudentManagementTests.java | 35 +++ .../SubscriptionManagementTests.java | 45 ++++ modulith/src/test/resources/schema-data.sql | 23 ++ modulith/src/test/resources/schema.sql | 24 ++ settings.gradle | 1 + 39 files changed, 1114 insertions(+) create mode 100644 modulith/.gitattributes create mode 100644 modulith/.gitignore create mode 100644 modulith/README.adoc create mode 100644 modulith/build.gradle create mode 100644 modulith/docs/all-docs.adoc create mode 100644 modulith/docs/components.puml create mode 100644 modulith/docs/module-course.adoc create mode 100644 modulith/docs/module-course.puml create mode 100644 modulith/docs/module-student.adoc create mode 100644 modulith/docs/module-student.puml create mode 100644 modulith/docs/module-subscription.adoc create mode 100644 modulith/docs/module-subscription.puml create mode 100644 modulith/settings.gradle create mode 100644 modulith/src/main/java/zin/rashidi/boot/modulith/ModulithApplication.java create mode 100644 modulith/src/main/java/zin/rashidi/boot/modulith/course/Course.java create mode 100644 modulith/src/main/java/zin/rashidi/boot/modulith/course/CourseEnded.java create mode 100644 modulith/src/main/java/zin/rashidi/boot/modulith/course/CourseEventsConfiguration.java create mode 100644 modulith/src/main/java/zin/rashidi/boot/modulith/course/CourseManagement.java create mode 100644 modulith/src/main/java/zin/rashidi/boot/modulith/course/CourseRepository.java create mode 100644 modulith/src/main/java/zin/rashidi/boot/modulith/student/Student.java create mode 100644 modulith/src/main/java/zin/rashidi/boot/modulith/student/StudentEventsConfiguration.java create mode 100644 modulith/src/main/java/zin/rashidi/boot/modulith/student/StudentInactivated.java create mode 100644 modulith/src/main/java/zin/rashidi/boot/modulith/student/StudentManagement.java create mode 100644 modulith/src/main/java/zin/rashidi/boot/modulith/student/StudentRepository.java create mode 100644 modulith/src/main/java/zin/rashidi/boot/modulith/subscription/Subscription.java create mode 100644 modulith/src/main/java/zin/rashidi/boot/modulith/subscription/SubscriptionManagement.java create mode 100644 modulith/src/main/java/zin/rashidi/boot/modulith/subscription/SubscriptionRepository.java create mode 100644 modulith/src/main/resources/application.properties create mode 100644 modulith/src/test/java/zin/rashidi/boot/modulith/ModuleTests.java create mode 100644 modulith/src/test/java/zin/rashidi/boot/modulith/TestModulithApplication.java create mode 100644 modulith/src/test/java/zin/rashidi/boot/modulith/TestcontainersConfiguration.java create mode 100644 modulith/src/test/java/zin/rashidi/boot/modulith/course/CourseEndedTests.java create mode 100644 modulith/src/test/java/zin/rashidi/boot/modulith/course/CourseManagementTests.java create mode 100644 modulith/src/test/java/zin/rashidi/boot/modulith/student/StudentInactivatedTests.java create mode 100644 modulith/src/test/java/zin/rashidi/boot/modulith/student/StudentManagementTests.java create mode 100644 modulith/src/test/java/zin/rashidi/boot/modulith/subscription/SubscriptionManagementTests.java create mode 100644 modulith/src/test/resources/schema-data.sql create mode 100644 modulith/src/test/resources/schema.sql diff --git a/modulith/.gitattributes b/modulith/.gitattributes new file mode 100644 index 00000000..8af972cd --- /dev/null +++ b/modulith/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/modulith/.gitignore b/modulith/.gitignore new file mode 100644 index 00000000..c2065bc2 --- /dev/null +++ b/modulith/.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/modulith/README.adoc b/modulith/README.adoc new file mode 100644 index 00000000..93598424 --- /dev/null +++ b/modulith/README.adoc @@ -0,0 +1,241 @@ += Spring Modulith: Building Modular Monolithic Applications + +== Introduction + +This tutorial demonstrates how to use Spring Modulith to build a modular monolithic application. Spring Modulith is a framework that helps structure applications into well-defined modules with clear boundaries while still deploying as a single unit. + +In this example, we've built a simple student course management system with three modules: + +* *Course*: Manages course information and lifecycle +* *Student*: Manages student information and status +* *Subscription*: Manages the relationship between students and courses + +== What is Spring Modulith? + +Spring Modulith is an extension to the Spring ecosystem that provides: + +* Clear module boundaries through package conventions +* Explicit module dependencies +* Event-based communication between modules +* Testing support for modules in isolation +* Documentation generation for module structure + +== Project Structure + +The project follows the Spring Modulith package convention: + +[source] +---- +zin.rashidi.boot.modulith +├── ModulithApplication.java +├── course +│ ├── Course.java +│ ├── CourseEnded.java +│ ├── CourseEventsConfiguration.java +│ ├── CourseManagement.java +│ └── CourseRepository.java +├── student +│ ├── Student.java +│ ├── StudentEventsConfiguration.java +│ ├── StudentInactivated.java +│ ├── StudentManagement.java +│ └── StudentRepository.java +└── subscription + ├── Subscription.java + ├── SubscriptionManagement.java + └── SubscriptionRepository.java +---- + +Each module is a separate package under the application's base package. + +== Module Interactions + +The modules interact with each other through events: + +1. When a course ends, the Course module publishes a `CourseEnded` event +2. When a student is inactivated, the Student module publishes a `StudentInactivated` event +3. The Subscription module listens for these events and cancels the relevant subscriptions + +This event-based communication ensures loose coupling between modules. + +== Key Components + +=== Domain Entities + +Each module has its own domain entity: + +* `Course`: Represents a course with a name and status (ACTIVE, DORMANT, ENDED) +* `Student`: Represents a student with a name and status (ACTIVE, INACTIVE) +* `Subscription`: Represents a relationship between a student and a course with a status (ACTIVE, COMPLETED, DORMANT, CANCELLED) + +=== Repositories + +Each module has its own repository for data access: + +* `CourseRepository`: Basic CRUD operations for courses +* `StudentRepository`: Basic CRUD operations for students +* `SubscriptionRepository`: CRUD operations plus custom methods for cancelling subscriptions by course or student + +=== Services + +Each module has a service class for business logic: + +* `CourseManagement`: Updates course information +* `StudentManagement`: Manages student status +* `SubscriptionManagement`: Listens for events and manages subscriptions accordingly + +=== Events + +The application uses domain events for communication between modules: + +* `CourseEnded`: Published when a course status is set to ENDED +* `StudentInactivated`: Published when a student status is set to INACTIVE + +== Testing + +Spring Modulith provides excellent testing support: + +* `ModuleTests`: Verifies the modulith architecture and generates documentation +* Module-specific tests: Test each module in isolation or with its dependencies + +=== Architecture Verification + +The `ModuleTests` class includes a test that verifies the modulith architecture: + +[source,java] +---- +@Test +@DisplayName("Verify architecture") +void verify() { + modules.verify(); +} +---- + +This test ensures that module dependencies are correctly defined and that there are no unwanted dependencies between modules. + +=== Documentation Generation + +The `ModuleTests` class also includes a test that generates documentation: + +[source,java] +---- +@Test +@DisplayName("Generate documentation") +void document() { + new Documenter(modules, defaults().withOutputFolder("docs")) + .writeModulesAsPlantUml() + .writeDocumentation(Documenter.DiagramOptions.defaults(), Documenter.CanvasOptions.defaults().revealInternals()); +} +---- + +This test generates documentation in the `docs` folder, including PlantUML diagrams and AsciiDoc files for each module. + +=== Testing Event-Based Communication + +Spring Modulith provides excellent support for testing event-based communication between modules. Here are examples from our test classes: + +==== Publishing Events + +The `CourseManagementTests` class demonstrates how to test event publishing: + +[source,java] +---- +@ApplicationModuleTest +class CourseManagementTests { + + @Autowired + private CourseManagement courses; + + @Test + @DisplayName("When a course is ENDED Then CourseEnded event will be triggered with the course Id") + void courseEnded(Scenario scenario) { + var course = new Course("Advanced Java Programming").status(ENDED); + ReflectionTestUtils.setField(course, "id", 2L); + + scenario.stimulate(() -> courses.updateCourse(course)) + .andWaitAtMost(ofMillis(101)) + .andWaitForEventOfType(CourseEnded.class) + .toArriveAndVerify(event -> assertThat(event).extracting("id").isEqualTo(2L)); + } +} +---- + +.This test: +. Uses `@ApplicationModuleTest` to test the Course module +. Uses `Scenario.stimulate()` to trigger an action (updating a course) +. Uses `andWaitAtMost()` to specify a maximum wait time +. Uses `andWaitForEventOfType()` to wait for a specific event type +. Uses `toArriveAndVerify()` to verify the event's properties + +Similarly, the `StudentManagementTests` class tests event publishing from the Student module: + +[source,java] +---- +@ApplicationModuleTest +class StudentManagementTests { + + @Autowired + private StudentManagement students; + + @Test + @DisplayName("When the student with id 4 is inactivated Then StudentInactivated event will be triggered with student id 4") + void inactive(Scenario scenario) { + var student = new Student("Bob Johnson"); + ReflectionTestUtils.setField(student, "id", 4L); + + scenario.stimulate(() -> students.inactive(student)) + .andWaitForEventOfType(StudentInactivated.class) + .toArriveAndVerify(inActivatedStudent -> assertThat(inActivatedStudent).extracting("id").isEqualTo(4L)); + } +} +---- + +==== Consuming Events + +The `SubscriptionManagementTests` class demonstrates how to test event consumption: + +[source,java] +---- +@ApplicationModuleTest +class SubscriptionManagementTests { + + @Autowired + private SubscriptionRepository subscriptions; + + @Test + @DisplayName("When CourseEnded is triggered with id 5 Then all subscriptions for the course will be CANCELLED") + void courseEnded(Scenario scenario) { + var event = new CourseEnded(5L); + + scenario.publish(event) + .andWaitForStateChange(() -> subscriptions.cancelByCourseId(5L)) + .andVerify(updatedRows -> assertThat(updatedRows).isEqualTo(2)); + } + + @Test + @DisplayName("When StudentInactivated is triggered with id 5 Then all subscriptions for the student will be CANCELLED") + void studentInactivated(Scenario scenario) { + var event = new StudentInactivated(5L); + + scenario.publish(event) + .andWaitForStateChange(() -> subscriptions.cancelByStudentId(5L)) + .andVerify(updatedRows -> assertThat(updatedRows).isEqualTo(2)); + } +} +---- + +.This test: +. Uses `@ApplicationModuleTest` to test the Subscription module +. Uses `Scenario.publish()` to publish an event +. Uses `andWaitForStateChange()` to wait for a state change in the system +. Uses `andVerify()` to verify the result of the state change + +== Generated Documentation + +Spring Modulith automatically generates documentation for your modules. You can view the generated documentation in the link:docs/all-docs.adoc[docs/all-docs.adoc] file. + +== Conclusion + +Spring Modulith provides a powerful way to structure your Spring Boot applications into well-defined modules while still deploying as a single unit. By following package conventions and using event-based communication, you can build modular monolithic applications that are easier to understand, test, and maintain. + +For more information, visit the https://spring.io/projects/spring-modulith[Spring Modulith website]. diff --git a/modulith/build.gradle b/modulith/build.gradle new file mode 100644 index 00000000..b95089d3 --- /dev/null +++ b/modulith/build.gradle @@ -0,0 +1,45 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.4.4' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'zin.rashidi.boot' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +ext { + set('springModulithVersion', "1.3.4") +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' + implementation 'org.springframework.modulith:spring-modulith-starter-core' + implementation 'org.springframework.modulith:spring-modulith-starter-jdbc' + runtimeOnly 'org.postgresql:postgresql' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.boot:spring-boot-testcontainers' + testImplementation 'org.springframework.modulith:spring-modulith-starter-test' + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.testcontainers:postgresql' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +dependencyManagement { + imports { + mavenBom "org.springframework.modulith:spring-modulith-bom:${springModulithVersion}" + } +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/modulith/docs/all-docs.adoc b/modulith/docs/all-docs.adoc new file mode 100644 index 00000000..bcf9d320 --- /dev/null +++ b/modulith/docs/all-docs.adoc @@ -0,0 +1,15 @@ +== ModulithApplication +plantuml::components.puml[,,format=svg] + +== Course +plantuml::module-course.puml[,,format=svg] +include::module-course.adoc[] + +== Student +plantuml::module-student.puml[,,format=svg] +include::module-student.adoc[] + +== Subscription +plantuml::module-subscription.puml[,,format=svg] +include::module-subscription.adoc[] + diff --git a/modulith/docs/components.puml b/modulith/docs/components.puml new file mode 100644 index 00000000..001e3281 --- /dev/null +++ b/modulith/docs/components.puml @@ -0,0 +1,21 @@ +@startuml +set separator none +title ModulithApplication + +top to bottom direction + +!include +!include +!include + +Container_Boundary("ModulithApplication.ModulithApplication_boundary", "ModulithApplication", $tags="") { + Component(ModulithApplication.ModulithApplication.Course, "Course", $techn="Module", $descr="", $tags="", $link="") + Component(ModulithApplication.ModulithApplication.Student, "Student", $techn="Module", $descr="", $tags="", $link="") + Component(ModulithApplication.ModulithApplication.Subscription, "Subscription", $techn="Module", $descr="", $tags="", $link="") +} + +Rel(ModulithApplication.ModulithApplication.Subscription, ModulithApplication.ModulithApplication.Course, "listens to", $techn="", $tags="", $link="") +Rel(ModulithApplication.ModulithApplication.Subscription, ModulithApplication.ModulithApplication.Student, "listens to", $techn="", $tags="", $link="") + +SHOW_LEGEND(true) +@enduml \ No newline at end of file diff --git a/modulith/docs/module-course.adoc b/modulith/docs/module-course.adoc new file mode 100644 index 00000000..929b230f --- /dev/null +++ b/modulith/docs/module-course.adoc @@ -0,0 +1,27 @@ +[%autowidth.stretch, cols="h,a"] +|=== +|Base package +|`zin.rashidi.boot.modulith.course` +|Spring components +|_Services_ + +* `z.r.b.m.c.CourseManagement` + +_Repositories_ + +* `z.r.b.m.c.CourseRepository` + +_Event listeners_ + +* `o.s.c.ApplicationListener` + +_Others_ + +* `z.r.b.m.c.CourseEventsConfiguration` +|Published events +|* `z.r.b.m.c.CourseEnded` created by: +** `z.r.b.m.c.CourseEventsConfiguration.courseEnded(…)` + +|Events listened to +|* `org.springframework.context.ApplicationEvent` +|=== diff --git a/modulith/docs/module-course.puml b/modulith/docs/module-course.puml new file mode 100644 index 00000000..c55ecda2 --- /dev/null +++ b/modulith/docs/module-course.puml @@ -0,0 +1,17 @@ +@startuml +set separator none +title Course + +top to bottom direction + +!include +!include +!include + +Container_Boundary("ModulithApplication.ModulithApplication_boundary", "ModulithApplication", $tags="") { + Component(ModulithApplication.ModulithApplication.Course, "Course", $techn="Module", $descr="", $tags="", $link="") +} + + +SHOW_LEGEND(true) +@enduml \ No newline at end of file diff --git a/modulith/docs/module-student.adoc b/modulith/docs/module-student.adoc new file mode 100644 index 00000000..00dacffd --- /dev/null +++ b/modulith/docs/module-student.adoc @@ -0,0 +1,27 @@ +[%autowidth.stretch, cols="h,a"] +|=== +|Base package +|`zin.rashidi.boot.modulith.student` +|Spring components +|_Services_ + +* `z.r.b.m.s.StudentManagement` + +_Repositories_ + +* `z.r.b.m.s.StudentRepository` + +_Event listeners_ + +* `o.s.c.ApplicationListener` + +_Others_ + +* `z.r.b.m.s.StudentEventsConfiguration` +|Published events +|* `z.r.b.m.s.StudentInactivated` created by: +** `z.r.b.m.s.StudentEventsConfiguration.studentInactivated(…)` + +|Events listened to +|* `org.springframework.context.ApplicationEvent` +|=== diff --git a/modulith/docs/module-student.puml b/modulith/docs/module-student.puml new file mode 100644 index 00000000..882e78c1 --- /dev/null +++ b/modulith/docs/module-student.puml @@ -0,0 +1,17 @@ +@startuml +set separator none +title Student + +top to bottom direction + +!include +!include +!include + +Container_Boundary("ModulithApplication.ModulithApplication_boundary", "ModulithApplication", $tags="") { + Component(ModulithApplication.ModulithApplication.Student, "Student", $techn="Module", $descr="", $tags="", $link="") +} + + +SHOW_LEGEND(true) +@enduml \ No newline at end of file diff --git a/modulith/docs/module-subscription.adoc b/modulith/docs/module-subscription.adoc new file mode 100644 index 00000000..7b4f8691 --- /dev/null +++ b/modulith/docs/module-subscription.adoc @@ -0,0 +1,16 @@ +[%autowidth.stretch, cols="h,a"] +|=== +|Base package +|`zin.rashidi.boot.modulith.subscription` +|Spring components +|_Repositories_ + +* `z.r.b.m.s.SubscriptionRepository` + +_Event listeners_ + +* `z.r.b.m.s.SubscriptionManagement` +|Events listened to +|* `z.r.b.m.c.CourseEnded` (async) +* `z.r.b.m.s.StudentInactivated` (async) +|=== diff --git a/modulith/docs/module-subscription.puml b/modulith/docs/module-subscription.puml new file mode 100644 index 00000000..f8bb4309 --- /dev/null +++ b/modulith/docs/module-subscription.puml @@ -0,0 +1,21 @@ +@startuml +set separator none +title Subscription + +top to bottom direction + +!include +!include +!include + +Container_Boundary("ModulithApplication.ModulithApplication_boundary", "ModulithApplication", $tags="") { + Component(ModulithApplication.ModulithApplication.Course, "Course", $techn="Module", $descr="", $tags="", $link="") + Component(ModulithApplication.ModulithApplication.Student, "Student", $techn="Module", $descr="", $tags="", $link="") + Component(ModulithApplication.ModulithApplication.Subscription, "Subscription", $techn="Module", $descr="", $tags="", $link="") +} + +Rel(ModulithApplication.ModulithApplication.Subscription, ModulithApplication.ModulithApplication.Course, "listens to", $techn="", $tags="", $link="") +Rel(ModulithApplication.ModulithApplication.Subscription, ModulithApplication.ModulithApplication.Student, "listens to", $techn="", $tags="", $link="") + +SHOW_LEGEND(true) +@enduml \ No newline at end of file diff --git a/modulith/settings.gradle b/modulith/settings.gradle new file mode 100644 index 00000000..0fb5f69a --- /dev/null +++ b/modulith/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'modulith' diff --git a/modulith/src/main/java/zin/rashidi/boot/modulith/ModulithApplication.java b/modulith/src/main/java/zin/rashidi/boot/modulith/ModulithApplication.java new file mode 100644 index 00000000..c80548c7 --- /dev/null +++ b/modulith/src/main/java/zin/rashidi/boot/modulith/ModulithApplication.java @@ -0,0 +1,13 @@ +package zin.rashidi.boot.modulith; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ModulithApplication { + + public static void main(String[] args) { + SpringApplication.run(ModulithApplication.class, args); + } + +} diff --git a/modulith/src/main/java/zin/rashidi/boot/modulith/course/Course.java b/modulith/src/main/java/zin/rashidi/boot/modulith/course/Course.java new file mode 100644 index 00000000..d81b6e69 --- /dev/null +++ b/modulith/src/main/java/zin/rashidi/boot/modulith/course/Course.java @@ -0,0 +1,39 @@ +package zin.rashidi.boot.modulith.course; + +import org.springframework.data.annotation.Id; + +import static zin.rashidi.boot.modulith.course.Course.Status.ACTIVE; + +/** + * @author Rashidi Zin + */ +class Course { + + @Id + private Long id; + private final String name; + private Status status; + + Course(String name) { + this.name = name; + this.status = ACTIVE; + } + + public Long id() { + return id; + } + + public Course status(Status status) { + this.status = status; + return this; + } + + public Status status() { + return status; + } + + enum Status { + ACTIVE, DORMANT, ENDED + } + +} diff --git a/modulith/src/main/java/zin/rashidi/boot/modulith/course/CourseEnded.java b/modulith/src/main/java/zin/rashidi/boot/modulith/course/CourseEnded.java new file mode 100644 index 00000000..f7a2b2f4 --- /dev/null +++ b/modulith/src/main/java/zin/rashidi/boot/modulith/course/CourseEnded.java @@ -0,0 +1,9 @@ +package zin.rashidi.boot.modulith.course; + +import org.jmolecules.event.annotation.DomainEvent; + +/** + * @author Rashidi Zin + */ +@DomainEvent +public record CourseEnded(Long id) {} diff --git a/modulith/src/main/java/zin/rashidi/boot/modulith/course/CourseEventsConfiguration.java b/modulith/src/main/java/zin/rashidi/boot/modulith/course/CourseEventsConfiguration.java new file mode 100644 index 00000000..65bb0655 --- /dev/null +++ b/modulith/src/main/java/zin/rashidi/boot/modulith/course/CourseEventsConfiguration.java @@ -0,0 +1,26 @@ +package zin.rashidi.boot.modulith.course; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationListener; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.relational.core.mapping.event.AfterSaveEvent; + +import static zin.rashidi.boot.modulith.course.Course.Status.ENDED; + +/** + * @author Rashidi Zin + */ +@Configuration +class CourseEventsConfiguration { + + @Bean + public ApplicationListener> courseEnded(ApplicationEventPublisher publisher) { + return event -> { + if (ENDED == event.getEntity().status()) { + publisher.publishEvent(new CourseEnded(event.getEntity().id())); + } + }; + } + +} diff --git a/modulith/src/main/java/zin/rashidi/boot/modulith/course/CourseManagement.java b/modulith/src/main/java/zin/rashidi/boot/modulith/course/CourseManagement.java new file mode 100644 index 00000000..c7228d61 --- /dev/null +++ b/modulith/src/main/java/zin/rashidi/boot/modulith/course/CourseManagement.java @@ -0,0 +1,24 @@ +package zin.rashidi.boot.modulith.course; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * @author Rashidi Zin + */ +@Service +@Transactional(readOnly = true) +class CourseManagement { + + private final CourseRepository courses; + + CourseManagement(CourseRepository courses) { + this.courses = courses; + } + + @Transactional + void updateCourse(Course course) { + courses.save(course); + } + +} diff --git a/modulith/src/main/java/zin/rashidi/boot/modulith/course/CourseRepository.java b/modulith/src/main/java/zin/rashidi/boot/modulith/course/CourseRepository.java new file mode 100644 index 00000000..713faf40 --- /dev/null +++ b/modulith/src/main/java/zin/rashidi/boot/modulith/course/CourseRepository.java @@ -0,0 +1,9 @@ +package zin.rashidi.boot.modulith.course; + +import org.springframework.data.repository.CrudRepository; + +/** + * @author Rashidi Zin + */ +interface CourseRepository extends CrudRepository { +} diff --git a/modulith/src/main/java/zin/rashidi/boot/modulith/student/Student.java b/modulith/src/main/java/zin/rashidi/boot/modulith/student/Student.java new file mode 100644 index 00000000..0c1ba41b --- /dev/null +++ b/modulith/src/main/java/zin/rashidi/boot/modulith/student/Student.java @@ -0,0 +1,39 @@ +package zin.rashidi.boot.modulith.student; + +import org.springframework.data.annotation.Id; + +import static zin.rashidi.boot.modulith.student.Student.Status.ACTIVE; + +/** + * @author Rashidi Zin + */ +class Student { + + @Id + private Long id; + private final String name; + private Status status; + + public Student(String name) { + this.name = name; + this.status = ACTIVE; + } + + public Long id() { + return id; + } + + public Status status() { + return status; + } + + public Student status(Status status) { + this.status = status; + return this; + } + + enum Status { + ACTIVE, INACTIVE + } + +} diff --git a/modulith/src/main/java/zin/rashidi/boot/modulith/student/StudentEventsConfiguration.java b/modulith/src/main/java/zin/rashidi/boot/modulith/student/StudentEventsConfiguration.java new file mode 100644 index 00000000..5531c6a4 --- /dev/null +++ b/modulith/src/main/java/zin/rashidi/boot/modulith/student/StudentEventsConfiguration.java @@ -0,0 +1,25 @@ +package zin.rashidi.boot.modulith.student; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationListener; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.relational.core.mapping.event.AfterSaveEvent; + +import static zin.rashidi.boot.modulith.student.Student.Status.INACTIVE; + +/** + * @author Rashidi Zin + */ +@Configuration +class StudentEventsConfiguration { + + @Bean + public ApplicationListener> studentInactivated(ApplicationEventPublisher publisher) { + return event -> { + if (INACTIVE == event.getEntity().status()) + publisher.publishEvent(new StudentInactivated(event.getEntity().id())); + }; + } + +} diff --git a/modulith/src/main/java/zin/rashidi/boot/modulith/student/StudentInactivated.java b/modulith/src/main/java/zin/rashidi/boot/modulith/student/StudentInactivated.java new file mode 100644 index 00000000..7997749c --- /dev/null +++ b/modulith/src/main/java/zin/rashidi/boot/modulith/student/StudentInactivated.java @@ -0,0 +1,9 @@ +package zin.rashidi.boot.modulith.student; + +import org.jmolecules.event.annotation.DomainEvent; + +/** + * @author Rashidi Zin + */ +@DomainEvent +public record StudentInactivated(Long id) {} \ No newline at end of file diff --git a/modulith/src/main/java/zin/rashidi/boot/modulith/student/StudentManagement.java b/modulith/src/main/java/zin/rashidi/boot/modulith/student/StudentManagement.java new file mode 100644 index 00000000..f65b33b5 --- /dev/null +++ b/modulith/src/main/java/zin/rashidi/boot/modulith/student/StudentManagement.java @@ -0,0 +1,25 @@ +package zin.rashidi.boot.modulith.student; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static zin.rashidi.boot.modulith.student.Student.Status.INACTIVE; + +/** + * @author Rashidi Zin + */ +@Service +class StudentManagement { + + private final StudentRepository students; + + StudentManagement(StudentRepository students) { + this.students = students; + } + + @Transactional + public Student inactive(Student student) { + return students.save(student.status(INACTIVE)); + } + +} diff --git a/modulith/src/main/java/zin/rashidi/boot/modulith/student/StudentRepository.java b/modulith/src/main/java/zin/rashidi/boot/modulith/student/StudentRepository.java new file mode 100644 index 00000000..8a69a954 --- /dev/null +++ b/modulith/src/main/java/zin/rashidi/boot/modulith/student/StudentRepository.java @@ -0,0 +1,8 @@ +package zin.rashidi.boot.modulith.student; + +import org.springframework.data.repository.CrudRepository; + +/** + * @author Rashidi Zin + */ +interface StudentRepository extends CrudRepository {} diff --git a/modulith/src/main/java/zin/rashidi/boot/modulith/subscription/Subscription.java b/modulith/src/main/java/zin/rashidi/boot/modulith/subscription/Subscription.java new file mode 100644 index 00000000..7c451e22 --- /dev/null +++ b/modulith/src/main/java/zin/rashidi/boot/modulith/subscription/Subscription.java @@ -0,0 +1,28 @@ +package zin.rashidi.boot.modulith.subscription; + +import org.springframework.data.annotation.Id; + +import static zin.rashidi.boot.modulith.subscription.Subscription.Status.ACTIVE; + +/** + * @author Rashidi Zin + */ +class Subscription { + + @Id + private Long id; + private final Long studentId; + private final Long courseId; + private Status status; + + public Subscription(Long studentId, Long courseId) { + this.studentId = studentId; + this.courseId = courseId; + this.status = ACTIVE; + } + + enum Status { + ACTIVE, COMPLETED, DORMANT, CANCELLED + } + +} diff --git a/modulith/src/main/java/zin/rashidi/boot/modulith/subscription/SubscriptionManagement.java b/modulith/src/main/java/zin/rashidi/boot/modulith/subscription/SubscriptionManagement.java new file mode 100644 index 00000000..3530dcaa --- /dev/null +++ b/modulith/src/main/java/zin/rashidi/boot/modulith/subscription/SubscriptionManagement.java @@ -0,0 +1,30 @@ +package zin.rashidi.boot.modulith.subscription; + +import org.springframework.modulith.events.ApplicationModuleListener; +import org.springframework.stereotype.Component; +import zin.rashidi.boot.modulith.course.CourseEnded; +import zin.rashidi.boot.modulith.student.StudentInactivated; + +/** + * @author Rashidi Zin + */ +@Component +class SubscriptionManagement { + + private final SubscriptionRepository subscriptions; + + SubscriptionManagement(SubscriptionRepository subscriptions) { + this.subscriptions = subscriptions; + } + + @ApplicationModuleListener + void cancelByCourse(CourseEnded course) { + subscriptions.cancelByCourseId(course.id()); + } + + @ApplicationModuleListener + void cancelByStudent(StudentInactivated student) { + subscriptions.cancelByStudentId(student.id()); + } + +} diff --git a/modulith/src/main/java/zin/rashidi/boot/modulith/subscription/SubscriptionRepository.java b/modulith/src/main/java/zin/rashidi/boot/modulith/subscription/SubscriptionRepository.java new file mode 100644 index 00000000..3d9ca3f8 --- /dev/null +++ b/modulith/src/main/java/zin/rashidi/boot/modulith/subscription/SubscriptionRepository.java @@ -0,0 +1,20 @@ +package zin.rashidi.boot.modulith.subscription; + +import org.springframework.data.jdbc.repository.query.Modifying; +import org.springframework.data.jdbc.repository.query.Query; +import org.springframework.data.repository.CrudRepository; + +/** + * @author Rashidi Zin + */ +interface SubscriptionRepository extends CrudRepository { + + @Modifying + @Query("UPDATE subscription SET status = 'CANCELLED' WHERE course_id = :courseId") + int cancelByCourseId(Long courseId); + + @Modifying + @Query("UPDATE subscription SET status = 'CANCELLED' WHERE student_id = :studentId") + int cancelByStudentId(Long studentId); + +} diff --git a/modulith/src/main/resources/application.properties b/modulith/src/main/resources/application.properties new file mode 100644 index 00000000..80ff89a3 --- /dev/null +++ b/modulith/src/main/resources/application.properties @@ -0,0 +1,2 @@ +spring.application.name=modulith +spring.modulith.events.jdbc.schema-initialization.enabled=true \ No newline at end of file diff --git a/modulith/src/test/java/zin/rashidi/boot/modulith/ModuleTests.java b/modulith/src/test/java/zin/rashidi/boot/modulith/ModuleTests.java new file mode 100644 index 00000000..b8c68402 --- /dev/null +++ b/modulith/src/test/java/zin/rashidi/boot/modulith/ModuleTests.java @@ -0,0 +1,31 @@ +package zin.rashidi.boot.modulith; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.modulith.core.ApplicationModules; +import org.springframework.modulith.docs.Documenter; + +import static org.springframework.modulith.docs.Documenter.Options.defaults; + +/** + * @author Rashidi Zin + */ +class ModuleTests { + + private final ApplicationModules modules = ApplicationModules.of(ModulithApplication.class); + + @Test + @DisplayName("Verify architecture") + void verify() { + modules.verify(); + } + + @Test + @DisplayName("Generate documentation") + void document() { + new Documenter(modules, defaults().withOutputFolder("docs")) + .writeModulesAsPlantUml() + .writeDocumentation(Documenter.DiagramOptions.defaults(), Documenter.CanvasOptions.defaults().revealInternals()); + } + +} diff --git a/modulith/src/test/java/zin/rashidi/boot/modulith/TestModulithApplication.java b/modulith/src/test/java/zin/rashidi/boot/modulith/TestModulithApplication.java new file mode 100644 index 00000000..a4e218bc --- /dev/null +++ b/modulith/src/test/java/zin/rashidi/boot/modulith/TestModulithApplication.java @@ -0,0 +1,11 @@ +package zin.rashidi.boot.modulith; + +import org.springframework.boot.SpringApplication; + +public class TestModulithApplication { + + public static void main(String[] args) { + SpringApplication.from(ModulithApplication::main).with(TestcontainersConfiguration.class).run(args); + } + +} diff --git a/modulith/src/test/java/zin/rashidi/boot/modulith/TestcontainersConfiguration.java b/modulith/src/test/java/zin/rashidi/boot/modulith/TestcontainersConfiguration.java new file mode 100644 index 00000000..87c21c0c --- /dev/null +++ b/modulith/src/test/java/zin/rashidi/boot/modulith/TestcontainersConfiguration.java @@ -0,0 +1,19 @@ +package zin.rashidi.boot.modulith; + +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")) + .withInitScripts("schema.sql", "schema-data.sql"); + } + +} diff --git a/modulith/src/test/java/zin/rashidi/boot/modulith/course/CourseEndedTests.java b/modulith/src/test/java/zin/rashidi/boot/modulith/course/CourseEndedTests.java new file mode 100644 index 00000000..c73bb5f4 --- /dev/null +++ b/modulith/src/test/java/zin/rashidi/boot/modulith/course/CourseEndedTests.java @@ -0,0 +1,48 @@ +package zin.rashidi.boot.modulith.course; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; +import zin.rashidi.boot.modulith.TestcontainersConfiguration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.springframework.test.jdbc.JdbcTestUtils.countRowsInTableWhere; +import static zin.rashidi.boot.modulith.course.Course.Status.ENDED; + +/** + * @author Rashidi Zin + */ +@Import(TestcontainersConfiguration.class) +@SpringBootTest +class CourseEndedTests { + + @Autowired + private CourseManagement courses; + + @Autowired + private JdbcTemplate jdbc; + + @Test + @DisplayName("Given there are subscriptions with course Id 1 When the course ENDED Then all subscriptions for the course will be CANCELLED") + void end() { + var course = new Course("Introduction to Spring Boot").status(ENDED); + ReflectionTestUtils.setField(course, "id", 1L); + + courses.updateCourse(course); + + await() + .untilAsserted(() -> + assertThat(cancelledSubscriptionsByCourseId(1L)).isEqualTo(2) + ); + } + + private int cancelledSubscriptionsByCourseId(Long courseId) { + return countRowsInTableWhere(jdbc, "subscription", "course_id = %d AND status = 'CANCELLED'".formatted(courseId)); + } + +} diff --git a/modulith/src/test/java/zin/rashidi/boot/modulith/course/CourseManagementTests.java b/modulith/src/test/java/zin/rashidi/boot/modulith/course/CourseManagementTests.java new file mode 100644 index 00000000..9790be40 --- /dev/null +++ b/modulith/src/test/java/zin/rashidi/boot/modulith/course/CourseManagementTests.java @@ -0,0 +1,38 @@ +package zin.rashidi.boot.modulith.course; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.modulith.test.ApplicationModuleTest; +import org.springframework.modulith.test.Scenario; +import org.springframework.test.util.ReflectionTestUtils; +import zin.rashidi.boot.modulith.TestcontainersConfiguration; + +import static java.time.Duration.ofMillis; +import static org.assertj.core.api.Assertions.assertThat; +import static zin.rashidi.boot.modulith.course.Course.Status.ENDED; + +/** + * @author Rashidi Zin + */ +@Import(TestcontainersConfiguration.class) +@ApplicationModuleTest +class CourseManagementTests { + + @Autowired + private CourseManagement courses; + + @Test + @DisplayName("When a course is ENDED Then CourseEnded event will be triggered with the course Id") + void courseEnded(Scenario scenario) { + var course = new Course("Advanced Java Programming").status(ENDED); + ReflectionTestUtils.setField(course, "id", 2L); + + scenario.stimulate(() -> courses.updateCourse(course)) + .andWaitAtMost(ofMillis(101)) + .andWaitForEventOfType(CourseEnded.class) + .toArriveAndVerify(event -> assertThat(event).extracting("id").isEqualTo(2L)); + } + +} \ No newline at end of file diff --git a/modulith/src/test/java/zin/rashidi/boot/modulith/student/StudentInactivatedTests.java b/modulith/src/test/java/zin/rashidi/boot/modulith/student/StudentInactivatedTests.java new file mode 100644 index 00000000..a930b6c6 --- /dev/null +++ b/modulith/src/test/java/zin/rashidi/boot/modulith/student/StudentInactivatedTests.java @@ -0,0 +1,45 @@ +package zin.rashidi.boot.modulith.student; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; +import zin.rashidi.boot.modulith.TestcontainersConfiguration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.springframework.test.jdbc.JdbcTestUtils.countRowsInTableWhere; + +/** + * @author Rashidi Zin + */ +@Import(TestcontainersConfiguration.class) +@SpringBootTest +class StudentInactivatedTests { + + @Autowired + private StudentManagement students; + + @Autowired + private JdbcTemplate jdbc; + + @Test + @DisplayName("Given there are subscriptions with student Id 5 When the student is inactivated Then all subscriptions for the student will be CANCELLED") + void inactivated() { + var student = new Student("Charlie Brown"); + ReflectionTestUtils.setField(student, "id", 5L); + + students.inactive(student); + + await().untilAsserted(() -> + assertThat(inactiveSubscriptionsByStudentId(5L)).isEqualTo(2) + ); + } + + private int inactiveSubscriptionsByStudentId(Long id) { + return countRowsInTableWhere(jdbc, "subscription", "student_id = %d AND status = 'CANCELLED'".formatted(id)); + } +} diff --git a/modulith/src/test/java/zin/rashidi/boot/modulith/student/StudentManagementTests.java b/modulith/src/test/java/zin/rashidi/boot/modulith/student/StudentManagementTests.java new file mode 100644 index 00000000..c9f9eb46 --- /dev/null +++ b/modulith/src/test/java/zin/rashidi/boot/modulith/student/StudentManagementTests.java @@ -0,0 +1,35 @@ +package zin.rashidi.boot.modulith.student; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.modulith.test.ApplicationModuleTest; +import org.springframework.modulith.test.Scenario; +import org.springframework.test.util.ReflectionTestUtils; +import zin.rashidi.boot.modulith.TestcontainersConfiguration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Rashidi Zin + */ +@Import(TestcontainersConfiguration.class) +@ApplicationModuleTest +class StudentManagementTests { + + @Autowired + private StudentManagement students; + + @Test + @DisplayName("When the student with id 4 is inactivated Then StudentInactivated event will be triggered with student id 4") + void inactive(Scenario scenario) { + var student = new Student("Bob Johnson"); + ReflectionTestUtils.setField(student, "id", 4L); + + scenario.stimulate(() -> students.inactive(student)) + .andWaitForEventOfType(StudentInactivated.class) + .toArriveAndVerify(inActivatedStudent -> assertThat(inActivatedStudent).extracting("id").isEqualTo(4L)); + } + +} diff --git a/modulith/src/test/java/zin/rashidi/boot/modulith/subscription/SubscriptionManagementTests.java b/modulith/src/test/java/zin/rashidi/boot/modulith/subscription/SubscriptionManagementTests.java new file mode 100644 index 00000000..5c3d8fdb --- /dev/null +++ b/modulith/src/test/java/zin/rashidi/boot/modulith/subscription/SubscriptionManagementTests.java @@ -0,0 +1,45 @@ +package zin.rashidi.boot.modulith.subscription; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.modulith.test.ApplicationModuleTest; +import org.springframework.modulith.test.Scenario; +import zin.rashidi.boot.modulith.TestcontainersConfiguration; +import zin.rashidi.boot.modulith.course.CourseEnded; +import zin.rashidi.boot.modulith.student.StudentInactivated; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Rashidi Zin + */ +@Import(TestcontainersConfiguration.class) +@ApplicationModuleTest +class SubscriptionManagementTests { + + @Autowired + private SubscriptionRepository subscriptions; + + @Test + @DisplayName("When CourseEnded is triggered with id 5 Then all subscriptions for the course will be CANCELLED") + void courseEnded(Scenario scenario) { + var event = new CourseEnded(5L); + + scenario.publish(event) + .andWaitForStateChange(() -> subscriptions.cancelByCourseId(5L)) + .andVerify(updatedRows -> assertThat(updatedRows).isEqualTo(2)); + } + + @Test + @DisplayName("When StudentInactivated is triggered with id 5 Then all subscriptions for the student will be CANCELLED") + void studentInactivated(Scenario scenario) { + var event = new StudentInactivated(5L); + + scenario.publish(event) + .andWaitForStateChange(() -> subscriptions.cancelByStudentId(5L)) + .andVerify(updatedRows -> assertThat(updatedRows).isEqualTo(2)); + } + +} \ No newline at end of file diff --git a/modulith/src/test/resources/schema-data.sql b/modulith/src/test/resources/schema-data.sql new file mode 100644 index 00000000..603cd16c --- /dev/null +++ b/modulith/src/test/resources/schema-data.sql @@ -0,0 +1,23 @@ +-- Course data +INSERT INTO course (id, name, status) VALUES (1, 'Introduction to Spring Boot', 'ACTIVE'); +INSERT INTO course (id, name, status) VALUES (2, 'Advanced Java Programming', 'ACTIVE'); +INSERT INTO course (id, name, status) VALUES (3, 'Database Design', 'DORMANT'); +INSERT INTO course (id, name, status) VALUES (4, 'Web Development Fundamentals', 'ENDED'); +INSERT INTO course (id, name, status) VALUES (5, 'Cloud Computing', 'ACTIVE'); + +-- Student data +INSERT INTO student (id, name, status) VALUES (1, 'John Doe', 'ACTIVE'); +INSERT INTO student (id, name, status) VALUES (2, 'Jane Smith', 'ACTIVE'); +INSERT INTO student (id, name, status) VALUES (3, 'Bob Johnson', 'INACTIVE'); +INSERT INTO student (id, name, status) VALUES (4, 'Alice Williams', 'ACTIVE'); +INSERT INTO student (id, name, status) VALUES (5, 'Charlie Brown', 'ACTIVE'); + +-- Subscription data +INSERT INTO subscription (id, student_id, course_id, status) VALUES (1, 1, 1, 'ACTIVE'); +INSERT INTO subscription (id, student_id, course_id, status) VALUES (2, 1, 2, 'ACTIVE'); +INSERT INTO subscription (id, student_id, course_id, status) VALUES (3, 2, 1, 'COMPLETED'); +INSERT INTO subscription (id, student_id, course_id, status) VALUES (4, 2, 3, 'DORMANT'); +INSERT INTO subscription (id, student_id, course_id, status) VALUES (5, 3, 4, 'CANCELLED'); +INSERT INTO subscription (id, student_id, course_id, status) VALUES (6, 4, 5, 'ACTIVE'); +INSERT INTO subscription (id, student_id, course_id, status) VALUES (7, 5, 2, 'ACTIVE'); +INSERT INTO subscription (id, student_id, course_id, status) VALUES (8, 5, 5, 'DORMANT'); diff --git a/modulith/src/test/resources/schema.sql b/modulith/src/test/resources/schema.sql new file mode 100644 index 00000000..a6041f2b --- /dev/null +++ b/modulith/src/test/resources/schema.sql @@ -0,0 +1,24 @@ +-- Course table +CREATE TABLE course ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + status VARCHAR(20) NOT NULL +); + +-- Student table +CREATE TABLE student ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + status VARCHAR(20) NOT NULL +); + +-- Subscription table +CREATE TABLE subscription ( + id SERIAL PRIMARY KEY, + student_id BIGINT NOT NULL, + course_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL, + UNIQUE (student_id, course_id), + FOREIGN KEY (student_id) REFERENCES student(id), + FOREIGN KEY (course_id) REFERENCES course(id) +); diff --git a/settings.gradle b/settings.gradle index 16f9465c..b7f1124f 100644 --- a/settings.gradle +++ b/settings.gradle @@ -17,6 +17,7 @@ include('data-repository-definition') include('data-rest-validation') include('graphql') include('jooq') +include('modulith') include('test-execution-listeners') include('test-rest-assured') include('test-slice-tests-rest') From 1227914ea0af40e41aa356b840f8489aebec6c97 Mon Sep 17 00:00:00 2001 From: Rashidi Zin Date: Sun, 6 Apr 2025 14:03:03 +0800 Subject: [PATCH 02/11] Update platform usage --- modulith/build.gradle | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/modulith/build.gradle b/modulith/build.gradle index b95089d3..a6682b0f 100644 --- a/modulith/build.gradle +++ b/modulith/build.gradle @@ -17,11 +17,9 @@ repositories { mavenCentral() } -ext { - set('springModulithVersion', "1.3.4") -} - dependencies { + implementation platform('org.springframework.modulith:spring-modulith-bom:1.3.4') + implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' implementation 'org.springframework.modulith:spring-modulith-starter-core' implementation 'org.springframework.modulith:spring-modulith-starter-jdbc' @@ -34,12 +32,6 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } -dependencyManagement { - imports { - mavenBom "org.springframework.modulith:spring-modulith-bom:${springModulithVersion}" - } -} - tasks.named('test') { useJUnitPlatform() } From 397d29169c75dccab5c5b3b33e488f2e29dc299c Mon Sep 17 00:00:00 2001 From: Rashidi Zin Date: Sun, 6 Apr 2025 15:24:46 +0800 Subject: [PATCH 03/11] Add link to antora --- README.adoc | 1 + docs/modules/ROOT/nav.adoc | 4 +- .../ROOT/pages/batch-rest-repository.adoc | 115 +---- docs/modules/ROOT/pages/batch-skip-step.adoc | 130 +----- .../ROOT/pages/cloud-jdbc-env-repo.adoc | 167 +------ .../ROOT/pages/data-domain-events.adoc | 147 +----- .../modules/ROOT/pages/data-envers-audit.adoc | 141 +----- docs/modules/ROOT/pages/data-jdbc-audit.adoc | 140 +----- docs/modules/ROOT/pages/data-jpa-audit.adoc | 167 +------ docs/modules/ROOT/pages/data-jpa-event.adoc | 147 +----- .../ROOT/pages/data-jpa-filtered-query.adoc | 123 +---- .../ROOT/pages/data-mongodb-audit.adoc | 103 +---- .../pages/data-mongodb-full-text-search.adoc | 157 +------ .../ROOT/pages/data-mongodb-tc-data-load.adoc | 284 +----------- .../pages/data-mongodb-transactional.adoc | 106 +---- .../pages/data-repository-definition.adoc | 86 +--- .../ROOT/pages/data-rest-validation.adoc | 109 +---- docs/modules/ROOT/pages/graphql.adoc | 164 +------ docs/modules/ROOT/pages/jooq.adoc | 162 +------ docs/modules/ROOT/pages/modulith.adoc | 4 + .../ROOT/pages/test-execution-listeners.adoc | 109 +---- .../modules/ROOT/pages/test-rest-assured.adoc | 428 +----------------- .../ROOT/pages/test-slice-tests-rest.adoc | 296 +----------- docs/modules/ROOT/pages/web-rest-client.adoc | 355 +-------------- generate-antora-pages.sh | 5 +- 25 files changed, 53 insertions(+), 3597 deletions(-) create mode 100644 docs/modules/ROOT/pages/modulith.adoc diff --git a/README.adoc b/README.adoc index 862b794a..60d3b57a 100644 --- a/README.adoc +++ b/README.adoc @@ -62,4 +62,5 @@ All tutorials are documented in AsciiDoc format and published as an https://anto |link:test-rest-assured[Spring Test: Integration with RestAssured] | Implement Behaviour Driven Development with https://rest-assured.io/[RestAssured] |link:test-slice-tests-rest[Spring Test: Implementing Slice Tests for REST application] | Dive into available options to implement tests with Spring Boot's test components |link:web-rest-client[Spring Web: REST Clients for calling Synchronous API] | Implement REST client to perform synchronous API calls +|link:modulith[Spring Modulith: Building Modular Monolithic Applications] | Structure Spring Boot applications into well-defined modules with clear boundaries |=== diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index 110d1920..12754f0e 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -18,6 +18,8 @@ ** xref:data-rest-validation.adoc[REST Validation] * Spring GraphQL ** xref:graphql.adoc[GraphQL Server] +* Spring Modulith +** xref:modulith.adoc[Building Modular Monolithic Applications] * jOOQ ** xref:jooq.adoc[jOOQ] * Spring Test @@ -26,4 +28,4 @@ ** xref:test-rest-assured.adoc[Integration with RestAssured] ** xref:test-slice-tests-rest.adoc[Implementing Slice Tests for REST application] * Spring Web -** xref:web-rest-client.adoc[REST Clients for calling Synchronous API] \ No newline at end of file +** xref:web-rest-client.adoc[REST Clients for calling Synchronous API] diff --git a/docs/modules/ROOT/pages/batch-rest-repository.adoc b/docs/modules/ROOT/pages/batch-rest-repository.adoc index 8e86bfab..e650a66e 100644 --- a/docs/modules/ROOT/pages/batch-rest-repository.adoc +++ b/docs/modules/ROOT/pages/batch-rest-repository.adoc @@ -1,115 +1,4 @@ = Spring Batch: Working With REST Resources -:source-highlighter: highlight.js -:nofooter: -:icons: font -:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/batch-rest-repository +:page-aliases: batch-rest-repository.adoc -Implement batch operation for REST resources with https://spring.io/projects/spring-batch[Spring Batch] - - -== Background -Spring Batch allows us to perform large volumes of records from several resources such as https://docs.spring.io/spring-batch/docs/current/api/org/springframework/batch/item/file/FlatFileItemReader.html[File], -https://docs.spring.io/spring-batch/docs/current/api/org/springframework/batch/item/database/JpaPagingItemReader.html[Relational Database], and, -https://docs.spring.io/spring-batch/docs/current/api/org/springframework/batch/item/json/JsonItemReader.html[JSON file] to name a few. - -In this article, we will explore how to implement batch operation that reads from REST resources with Spring Batch through `JsonItemReader`. We will retrieve a list of users from https://jsonplaceholder.typicode.com/users[JSON Placeholder] and save them into a database. - -== Job Configuration -Next is to implement the job that will be responsible to read from REST resource and save them into a database. `Job` consists of `Step` and `Step` -consists of `ItemReader` and `ItemWriter`. We will implement all of them in link:{url-quickref}/src/main/java/zin/rashidi/boot/batch/rest/user/UserJobConfiguration.java[UserJobConfiguration]. - -[source,java] ----- -@Configuration -class UserJobConfiguration { - private final JobRepository jobRepository; - private final PlatformTransactionManager transactionManager; - private final MongoOperations mongo; - UserJobConfiguration(JobRepository jobRepository, PlatformTransactionManager transactionManager, MongoOperations mongo) { - this.jobRepository = jobRepository; - this.transactionManager = transactionManager; - this.mongo = mongo; - } - @Bean - public Job userJob() throws MalformedURLException { - return new JobBuilder("userJob", jobRepository).start(step()).build(); - } - private Step step() throws MalformedURLException { - return new StepBuilder("userStep", jobRepository) - .chunk(10, transactionManager) - .reader(reader()) - .writer(writer()) - .build(); - } - private JsonItemReader reader() throws MalformedURLException { - JacksonJsonObjectReader jsonObjectReader = new JacksonJsonObjectReader<>(User.class); - jsonObjectReader.setMapper(new ObjectMapper()); - return new JsonItemReaderBuilder() - .name("userReader") - .jsonObjectReader(jsonObjectReader) - .resource(new UrlResource("https://jsonplaceholder.typicode.com/users")) - .build(); - } - private MongoItemWriter writer() { - return new MongoItemWriterBuilder() - .template(mongo) - .build(); - } -} ----- - -From the code above, we can see that a `URL` form of `Resource` is assigned to `JsonItemReader`. We will depend on `JacksonJsonObjectRader` to convert response from link:https://jsonplaceholder.typicode.com/users[JSON Placeholder] to `User` object. - -[source,java] ----- -@Configuration -class UserJobConfiguration { - private JsonItemReader reader() throws MalformedURLException { - JacksonJsonObjectReader jsonObjectReader = new JacksonJsonObjectReader<>(User.class); - jsonObjectReader.setMapper(new ObjectMapper()); - return new JsonItemReaderBuilder() - .name("userReader") - .jsonObjectReader(jsonObjectReader) - .resource(new UrlResource("https://jsonplaceholder.typicode.com/users")) - .build(); - } -} ----- - -Now that we have implemented the `Job`, we can verify that it is working by executing an integration test. - -== Verification -We will launch `userJob` which will retrieve list of `User` from https://jsonplaceholder.typicode.com/users[JSON Placeholder] and save them into a database. -Once completed then we will verify that the database contains the expected number of users. - -[source,java] ----- -@Testcontainers -@SpringBatchTest -@SpringBootTest(classes = { BatchTestConfiguration.class, MongoTestConfiguration.class, UserJobConfiguration.class }, webEnvironment = NONE) -class UserBatchJobTests { - @Container - @ServiceConnection - private final static MySQLContainer MYSQL_CONTAINER = new MySQLContainer<>("mysql:latest") - .withInitScript("org/springframework/batch/core/schema-mysql.sql"); - @Container - @ServiceConnection - private final static MongoDBContainer MONGO_DB_CONTAINER = new MongoDBContainer("mongo:latest"); - @Autowired - private JobLauncherTestUtils launcher; - @Autowired - private MongoOperations mongoOperations; - @Test - @DisplayName("Given there are 10 users returned from REST Service When the job is COMPLETED Then all users should be saved to MongoDB") - void launch() { - await().atMost(ofSeconds(30)).untilAsserted(() -> { - var execution = launcher.launchJob(); - assertThat(execution.getExitStatus()).isEqualTo(COMPLETED); - }); - var persistedUsers = mongoOperations.findAll(User.class); - assertThat(persistedUsers).hasSize(10); - } -} ----- - -Full implementation can be found in link:{url-quickref}/src/test/java/zin/rashidi/boot/batch/rest/user/UserBatchJobTests.java[UserBatchJobTests]. +include::../../../../batch-rest-repository/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/batch-skip-step.adoc b/docs/modules/ROOT/pages/batch-skip-step.adoc index 126c3f18..876567da 100644 --- a/docs/modules/ROOT/pages/batch-skip-step.adoc +++ b/docs/modules/ROOT/pages/batch-skip-step.adoc @@ -1,130 +1,4 @@ = Spring Batch: Skip Specific Data -:source-highlighter: highlight.js -:nofooter: -:icons: font -:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/batch-skip-step +:page-aliases: batch-skip-step.adoc -Skip processing specific data through business logic in Spring Batch. - - -== Background -Spring Batch is a framework for batch processing – execution of a series of jobs. A job is composed of a series of steps. -Each step consists of a reader, a processor, and a writer. The reader reads data from a data source, the processor -processes the data, and the writer writes the processed data to a data source. - -There are scenarios where we want to skip processing specific data through business logic. For example, in this guide, -we want to skip data where `username` are either `Elwyn.Skiles` or `Maxime_Nienow`. We will look into two approaches; -one by returning `null` and another by throwing `RuntimeException`. Both approaches will be implemented in the same -`ItemProcessor`. - -Our application will process data from link:{url-quickref}/src/main/resources/users.json[users.json] file and write the processed data -into MySQL database. Content of `users.json` is taken from link:https://jsonplaceholder.typicode.com/users[JSONPlaceholder]. - -== Implement Logics to Skip Data -=== Returning `null` -First approach is to return `null` from `ItemProcessor` implementation. This approach is straight forward and does not -require additional configuration. - -[source,java] ----- -@Configuration -class UserJobConfiguration { - private ItemProcessor processor() { - return item -> switch (item.username()) { - case "Elwyn.Skiles" -> null; - }; - } -} ----- - -With that, when the `Job` detected that the `ItemProcessor` returns `null`, it will skip the data and continue to the next. - -=== Throwing `RuntimeException` -Second approach is to throw `RuntimeException` from `ItemProcessor` implementation. This approach requires additional -configuration to be done when defining `Step`. - -Implementation in `ItemProcessor` is as follows: - -[source,java] ----- -@Configuration -class UserJobConfiguration { - private ItemProcessor processor() { - return item -> switch (item.username()) { - case "Maxime_Nienow" -> throw new UsernameNotAllowedException(item.username()); - }; - } - static class UsernameNotAllowedException extends RuntimeException { - public UsernameNotAllowedException(String username) { - super("Username " + username + " is not allowed"); - } - } -} ----- - -Next is to inform `Step` to skip `UsernameNotAllowedException`: - -[source,java] ----- -@Configuration -class UserJobConfiguration { - private Step step(JobRepository jobRepository, PlatformTransactionManager transactionManager, DataSource dataSource) { - return new StepBuilder("userStep", jobRepository) - .chunk(10, transactionManager) - .faultTolerant() - .skip(UsernameNotAllowedException.class) - .skipLimit(1) - .build(); - } -} ----- - -With that, when the `Job` detected that the `ItemProcessor` throws `UsernameNotAllowedException`, it will skip the data. - -Full definition of the `Job` can be found in link:{url-quickref}/src/main/java/zin/rashidi/boot/batch/user/UserJobConfiguration.java[UserJobConfiguration.java]. - -== Verification -We will implement integration tests to verify that our implementation is working as intended whereby `Elwyn.Skiles` and -`Maxime_Nienow` are skipped thus will not be available in the database. - -[source,java] ----- -@Testcontainers -@SpringBatchTest -@SpringBootTest(classes = { - BatchTestConfiguration.class, - JdbcTestConfiguration.class, - UserJobConfiguration.class -}, webEnvironment = NONE) -@Sql( - scripts = { - "classpath:org/springframework/batch/core/schema-drop-mysql.sql", - "classpath:org/springframework/batch/core/schema-mysql.sql" - }, - statements = "CREATE TABLE IF NOT EXISTS users (id BIGINT PRIMARY KEY, name text, username text)" -) -class UserBatchJobTests { - @Container - @ServiceConnection - private final static MySQLContainer MYSQL_CONTAINER = new MySQLContainer<>("mysql:latest"); - @Autowired - private JobLauncherTestUtils launcher; - @Autowired - private JdbcTemplate jdbc; - @Test - @DisplayName("Given the username Elwyn.Skiles and Maxime_Nienow are skipped, When job is executed, Then users are not inserted into database") - void findAll() { - await().atMost(10, SECONDS).untilAsserted(() -> { - var execution = launcher.launchJob(); - assertThat(execution.getExitStatus()).isEqualTo(COMPLETED); - }); - var users = jdbc.query("SELECT * FROM users", (rs, rowNum) -> - new User(rs.getLong("id"), rs.getString("name"), rs.getString("username")) - ); - assertThat(users).extracting("username").doesNotContain("Elwyn.Skiles", "Maxime_Nienow"); - } -} ----- - -By executing our tests in link:{url-quickref}src/test/java/zin/rashidi/boot/batch/user/UserBatchJobTests.java[UserBatchJobTests.java], -we will see that all users are processed except `Elwyn.Skiles` and `Maxime_Nienow`. +include::../../../../batch-skip-step/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/cloud-jdbc-env-repo.adoc b/docs/modules/ROOT/pages/cloud-jdbc-env-repo.adoc index ff83291d..5c6b8940 100644 --- a/docs/modules/ROOT/pages/cloud-jdbc-env-repo.adoc +++ b/docs/modules/ROOT/pages/cloud-jdbc-env-repo.adoc @@ -1,167 +1,4 @@ = Spring Cloud: JDBCEnvironmentRepository Sample Application -:source-highlighter: highlight.js -:nofooter: -:icons: font -:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/cloud-jdbc-env-repo +:page-aliases: cloud-jdbc-env-repo.adoc -Sample of https://cloud.spring.io/spring-cloud-config/single/spring-cloud-config.html#_spring_cloud_config_server[Spring Cloud Config Server] embedded application that uses database as backend for configuration properties. - - -== Background -https://cloud.spring.io/spring-cloud-config/single/spring-cloud-config.html#_spring_cloud_config_server[Spring Cloud Config Server] provides several options to store configuration for an application. In general it is handled -by https://cloud.spring.io/spring-cloud-config/single/spring-cloud-config.html#_environment_repository[Environment Repository]. -Available options are git, file system, vault, svn, and database. This application demonstrates usage of https://cloud.spring.io/spring-cloud-config/single/spring-cloud-config.html#_jdbc_backend[JdbcEnvironmentRepository] -which allows an application to store its configuration in database. - -== Configuration -In order to enable this feature we will include `spring-boot-starter-jdbc` as one of the dependencies for the application and -include `jdbc` as one of its active profiles. - -=== Include JDBC as dependency -This can be seen in link:{url-quickref}/build.gradle[build.gradle]. - -[source,groovy] ----- -implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' ----- - -=== Include `jdbc` as active profile -This can be seen in link:{url-quickref}/src/main/resources/bootstrap.yml[bootstrap.yml]. - -[source,yaml] ----- -spring: - profiles: - active: jdbc ----- - -== Creating table and populating data -By default the https://cloud.spring.io/spring-cloud-config/single/spring-cloud-config.html#_jdbc_backend[JdbcEnvironmentRepository] will look into a table called `PROPERTIES` which contains the following columns: -* KEY -* VALUE -* APPLICATION -* PROFILE -* LABEL - -=== Create table schema -Schema to create the table can found in link:{url-quickref}/src/test/resources/init-script.sql[init-script.sql]: - -[source,sql] ----- -CREATE TABLE `PROPERTIES` -( - `KEY` VARCHAR(128), - `VALUE` VARCHAR(128), - `APPLICATION` VARCHAR(128), - `PROFILE` VARCHAR(128), - `LABEL` VARCHAR(128), - PRIMARY KEY (`KEY`, `APPLICATION`, `LABEL`) -); ----- - -In the script above KEY, APPLICATION, PROFILE, and LABEL are marked as composite key in order to avoid duplicated entry. - -=== Populate table -For this demonstration will we have a configuration called `app.greet.name` and this will be populated upon start-up. -Its script can be found in link:{url-quickref}/src/test/resources/init-script.sql[init-script.sql]. - -[source,sql] ----- -INSERT INTO PROPERTIES (`APPLICATION`, `PROFILE`, `LABEL`, `KEY`, `VALUE`) -VALUES ('demo', 'default', 'master', 'app.greet.name', 'Demo'); ----- - -The script above explains that the configuration `app.greet.name` belongs to: -* an application called _demo_ -* with profile called _default_ -* and labelled _master_ - -== Configure Application Properties -In order for https://cloud.spring.io/spring-cloud-config/single/spring-cloud-config.html#_jdbc_backend[JdbcEnvironmentRepository] to retrieve properties for this application it will need to be informed on -its name, profile, and label. This configurations can be found in link:{url-quickref}/src/main/resources/bootstrap.yml[bootstrap.yml] - -[source,yaml] ----- -spring: - application: - name: demo ----- - -We are not configuring `spring.cloud.profile` because its default value is `default`. - -== Create Bootstrap Application Context -Finally, we will need to inform Spring Cloud on what are the classes needed in order to build the -bootstrap application context. This can found in link:{url-quickref}/src/main/resources/META-INF/spring.factories[spring.factories]: - -[source,text] ----- -org.springframework.cloud.bootstrap.BootstrapConfiguration=\ - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration,\ - org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration ----- - -These two classes will help us to build https://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/jdbc/core/JdbcTemplate.html[JdbcTemplate] which is needed to construct https://github.com/spring-cloud/spring-cloud-config/blob/master/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/JdbcEnvironmentRepository.java[JdbcEnvironmentRepository]. - -== Verify Configuration Properties -In order to ensure that the application will use configurations from database we will create same configuration in link:{url-quickref}/src/main/resources/application.yml[application.yml]: - -[source,yaml] ----- -app: - greet: - name: Default ----- - -== Configure Environment Properties Retrieval SQL -By default, https://github.com/spring-cloud/spring-cloud-config/blob/main/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/JdbcEnvironmentProperties.java#L30[there are two SQL statements that are used to retrieve properties from database]. -However, these queries need to be modified to follow MySQL requirement and implemented in link:{url-quickref}/src/main/resources/bootstrap.yml[bootstrap.yml]: - -[source,yaml] ----- -spring: - cloud: - config: - server: - jdbc: - sql: SELECT `KEY`, `VALUE` from PROPERTIES where APPLICATION=? and PROFILE=? and LABEL=? - sql-without-profile: SELECT `KEY`, `VALUE` from PROPERTIES where APPLICATION=? and PROFILE='default' and LABEL=? ----- - -We will have link:{url-quickref}/src/main/java/zin/rashidi/boot/cloud/jdbcenvrepo/greet/GreetResource.java[GreetResource] which will retrieve the value of `app.greet.name` from link:{url-quickref}/src/main/java/zin/rashidi/boot/cloud/jdbcenvrepo/greet/GreetProperties.java[GreetProperties]. - -[source,java] ----- -@RestController -class GreetResource { - private final GreetProperties properties; - GreetResource(GreetProperties properties) { - this.properties = properties; - } - @GetMapping("/greet") - public String greet(@RequestParam String greeting) { - return String.format("%s, my name is %s", greeting, properties.name()); - } -} ----- - -Next we will have link:{url-quickref}/src/test/java/zin/rashidi/boot/cloud/jdbcenvrepo/CloudJdbcEnvRepoApplicationTests.java[CloudJdbcEnvRepoApplicationTests] class that verifies that the value for `app.greet.name` is *Demo* and not *Default*: - -[source,java] ----- -@Testcontainers -@SpringBootTest(properties = "spring.datasource.url=jdbc:tc:mysql:8:///test?TC_INITSCRIPT=init-script.sql", webEnvironment = RANDOM_PORT) -class CloudJdbcEnvRepoApplicationTests { - @Container - private static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8"); - @Autowired - private TestRestTemplate restClient; - @Test - @DisplayName("Given app.greet.name is configured to Demo in the database When I call greet Then I should get Hello, my name is Demo") - void greet() { - var response = restClient.getForEntity("/greet?greeting={0}", String.class, "Hello"); - assertThat(response.getBody()).isEqualTo("Hello, my name is Demo"); - } -} ----- - -By executing `greet()` we verify that the returned response is *Hello, my name is Demo* and not *Hello, my name is Default*. +include::../../../../cloud-jdbc-env-repo/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/data-domain-events.adoc b/docs/modules/ROOT/pages/data-domain-events.adoc index e6560146..f73dc833 100644 --- a/docs/modules/ROOT/pages/data-domain-events.adoc +++ b/docs/modules/ROOT/pages/data-domain-events.adoc @@ -1,147 +1,4 @@ = Spring Data: Domain Events Example -:source-highlighter: highlight.js -:nofooter: -:icons: font -:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/data-domain-events +:page-aliases: data-domain-events.adoc -Reduce method complexity by utilising `@DomainEvents` from link:https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#core.domain-events[Spring Data JPA]. - - -== Background -In this repository we will explore Spring Data JPA helps us to adhere to link:https://en.wikipedia.org/wiki/Single-responsibility_principle[Single Responsibility], a component of link:https://en.wikipedia.org/wiki/SOLID[SOLID Principles]. -We will reduce responsibilities of a method that does more than one thing. - -== Scenario -This repository demonstrates a scenario where once a book is purchased, its total availability will be reduced. - -== Implementation -=== Integration End-to-end Test -In the spirit of TDD, we will start by implementing an integration end-to-end test. - -[source,java] ----- -@SpringBootTest( - classes = TestDataDomainEventsApplication.class, - properties = "spring.jpa.hibernate.ddl-auto=create", - webEnvironment = RANDOM_PORT -) -class BookPurchaseTests { - @Autowired - private BookAvailabilityRepository availabilities; - @Autowired - private BookRepository books; - @Autowired - private TestRestTemplate client; - private Book book; - @BeforeEach - void setup() { - book = books.save(book()); - availabilities.save(availability()); - } - @Test - @DisplayName("Given total book availability is 100 When a book is purchased Then total book availability should be 99") - void purchase() { - client.delete("/books/{id}/purchase", book.getId()); - var availability = availabilities.findByIsbn(book.getIsbn()); - assertThat(availability).get() - .extracting("total") - .isEqualTo(99); - } - private Book book() { - var book = new Book(); - book.setTitle("Say Nothing: A True Story of Murder and Memory in Northern Ireland"); - book.setAuthor("Patrick Radden Keefe"); - book.setIsbn(9780385543378L); - return book; - } - private BookAvailability availability() { - var availability = new BookAvailability(); - availability.setIsbn(9780385543378L); - availability.setTotal(100); - return availability; - } -} ----- - -Full implementation can be found in link:{url-quickref}/src/test/java/zin/rashidi/boot/data/de/availability/BookPurchaseTests.java[BookPurchaseTests.java]. - -=== Domain and Repository class -Our domain class, link:{url-quickref}/src/main/java/zin/rashidi/boot/data/de/book/Book.java[Book.java], will hold information about the event that will be published. - -[source,java] ----- -@Entity -public class Book extends AbstractAggregateRoot { - @Id - @GeneratedValue - private Long id; - private String title; - private String author; - private Long isbn; - // getter & setter are omitted for brevity - public Book purchase() { - registerEvent(new BookPurchaseEvent(this)); - return this; - } -} ----- - -The class will publish link:{url-quickref}/src/main/java/zin/rashidi/boot/data/de/book/BookPurchaseEvent.java[BookPurchaseEvent.java] when a book is purchased. -Next is to implement a repository classes for `Book` and link:{url-quickref}/src/main/java/zin/rashidi/boot/data/de/availability/BookAvailability.java[BookAvailability.java]. - -[source,java] ----- -public interface BookRepository extends JpaRepository { -} ----- - -[source,java] ----- -interface BookAvailabilityRepository extends JpaRepository { - Optional findByIsbn(Long isbn); -} ----- - -==== REST Resource Class -link:{url-quickref}/src/main/java/zin/rashidi/boot/data/de/book/BookResource.java[BookResource] is a typical `@RestController` class which will trigger `Book.purchase`. - -[source,java] ----- -@RestController -class BookResource { - private final BookRepository repository; - BookResource(BookRepository repository) { - this.repository = repository; - } - @Transactional - @DeleteMapping("/books/{id}/purchase") - public void purchase(@PathVariable Long id) { - repository.findById(id).map(Book::purchase).ifPresent(repository::delete); - } -} ----- - -==== Event Listener Class -Finally, we will implement a `@Service` class that will observe link:{url-quickref}/src/main/java/zin/rashidi/boot/data/de/book/BookPurchaseEvent.java[BookPurchaseEvent] and reduce the total availability of the book. - -[source,java] ----- -@Service -class BookAvailabilityManagement { - private final BookAvailabilityRepository repository; - BookAvailabilityManagement(BookAvailabilityRepository repository) { - this.repository = repository; - } - @TransactionalEventListener - @Transactional(propagation = REQUIRES_NEW) - public void updateTotal(BookPurchaseEvent event) { - var book = event.getSource(); - repository.findByIsbn(book.getIsbn()) - .map(BookAvailability::reduceTotal) - .ifPresent(repository::save); - } -} ----- - -== Verification -By executing `BookPurchaseTests.purchase`, we will see that the test passes. +include::../../../../data-domain-events/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/data-envers-audit.adoc b/docs/modules/ROOT/pages/data-envers-audit.adoc index 717044c4..1da6ad0d 100644 --- a/docs/modules/ROOT/pages/data-envers-audit.adoc +++ b/docs/modules/ROOT/pages/data-envers-audit.adoc @@ -1,141 +1,4 @@ = Spring Data Envers: Audit With Entity Revisions -:source-highlighter: highlight.js -:nofooter: -:icons: font -:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/data-envers-audit +:page-aliases: data-envers-audit.adoc -Sample application that demonstrates entity revisions with http://projects.spring.io/spring-data-envers/[Spring Data Envers]. - - -== Background -https://projects.spring.io/spring-data-jpa/[Spring Data Jpa] provides rough audit information. However, if you are looking for what are the exact changes being -made to an entity you can do so with http://projects.spring.io/spring-data-envers/[Spring Data Envers]. -As the name has suggested http://projects.spring.io/spring-data-envers/[Spring Data Envers] utilises and simplifies the usage of http://hibernate.org/orm/envers/[Hibernate Envers]. - -== Dependency and Configuration -In order to enable Envers features we will first include *spring-data-envers* as dependency. - -[source,groovy] ----- -implementation 'org.springframework.data:spring-data-envers' ----- - -=== Enable Entity Audit -By annotating an `@Entity` with `@Audited`, we are informing Spring that we would like respective entity to be audited. -The following example shows that we want all activities related to link:{url-quickref}/src/main/java/zin/rashidi/boot/data/envers/book/Book.java[Book] to be audited: - -[source,java] ----- -@Entity -@Audited -public class Book { - @Id - @GeneratedValue - private Long id; - private String author; - private String title; -} ----- - -Next is to extend a `Repository` class in order to allow us to utilise audit revision features. This can be done by extending -https://github.com/spring-projects/spring-data-commons/blob/master/src/main/java/org/springframework/data/repository/history/RevisionRepository.java[RevisionRepository] interface to our `Repository` class. An example can be seen in link:{url-quickref}/src/main/java/zin/rashidi/boot/data/envers/book/BookRepository.java[BookRepository]: - -[source,java] ----- -public interface BookRepository extends JpaRepository, RevisionRepository { -} ----- - -== Verification -We will be utilising on `@SpringBootTest` to verify that our implementation works. - -=== Upon Creation an Initial Revision is Created - -[source,java] ----- -@Testcontainers -@SpringBootTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop") -class BookAuditRevisionTests { - @Container - @ServiceConnection - private static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:latest"); - @Autowired - private BookRepository repository; - @Test - @DisplayName("When a book is created, then a revision information is available with revision number 1") - void create() { - var book = new Book(); - book.setTitle("The Jungle Book"); - book.setAuthor("Rudyard Kipling"); - var createdBook = repository.save(book); - var revisions = repository.findRevisions(createdBook.getId()); - assertThat(revisions) - .hasSize(1) - .first() - .extracting(Revision::getRevisionNumber) - .returns(1, Optional::get); - } -} ----- - -=== Revision Number Will Be Increase and Latest Revision is Available - -[source,java] ----- -@Testcontainers -@SpringBootTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop") -class BookAuditRevisionTests { - @Container - @ServiceConnection - private static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:latest"); - @Autowired - private BookRepository repository; - @Test - @DisplayName("When a book is modified, then a revision number will increase") - void modify() { - var book = new Book(); - book.setTitle("The Jungle Book"); - book.setAuthor("Rudyard Kipling"); - var createdBook = repository.save(book); - createdBook.setTitle("If"); - repository.save(createdBook); - var revisions = repository.findRevisions(createdBook.getId()); - assertThat(revisions) - .hasSize(2) - .last() - .extracting(Revision::getRevisionNumber) - .extracting(Optional::get).is(matching(greaterThan(1))); - } -} ----- - -=== Upon Deletion All Entity Information Will be Removed Except its ID - -[source,java] ----- -@Testcontainers -@SpringBootTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop") -class BookAuditRevisionTests { - @Container - @ServiceConnection - private static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:latest"); - @Autowired - private BookRepository repository; - @Test - @DisplayName("When a book is removed, then only ID information is available") - void remove() { - var book = new Book(); - book.setTitle("The Jungle Book"); - book.setAuthor("Rudyard Kipling"); - var createdBook = repository.save(book); - repository.delete(createdBook); - var revision = repository.findLastChangeRevision(createdBook.getId()); - assertThat(revision).get() - .extracting(Revision::getEntity) - .extracting("id", "title", "author") - .containsOnly(createdBook.getId(), null, null); - } -} ----- - -All tests above can be found in link:{url-quickref}/src/test/java/zin/rashidi/boot/data/envers/BookAuditRevisionTests.java[BookAuditRevisionTests]. +include::../../../../data-envers-audit/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/data-jdbc-audit.adoc b/docs/modules/ROOT/pages/data-jdbc-audit.adoc index 5bb777dc..aee433a2 100644 --- a/docs/modules/ROOT/pages/data-jdbc-audit.adoc +++ b/docs/modules/ROOT/pages/data-jdbc-audit.adoc @@ -1,140 +1,4 @@ = Spring Data JDBC: Implement Auditing -:icons: font -:source-highlighter: highlight.js -:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/data-jdbc-audit -:source-main: {url-quickref}/src/main/java/zin/rashidi/boot/data/jdbc -:source-test: {url-quickref}/src/test/java/zin/rashidi/boot/data/jdbc +:page-aliases: data-jdbc-audit.adoc -We have explored on how to implement auditing with link:../data-jpa-audit/[Spring Data Jpa], link:../data-envers-audit/[Spring Data Enver], and link:../data-mongodb-audit/[Spring Data Mongo]. In this tutorial, we will implement auditing with https://spring.io/projects/spring-data-jdbc[Spring Data JDBC]. - - -== Background -Spring Data JDBC provides the convenience to enable auditing for a class. This can be achieved by using the following annotations: - - `@EnableJdbcAuditing` - - `@Created` - - `@CreatedBy` - - `@LastModified` - - `@LastModifiedBy` - -== Configuration -In order to enable auditing we will create a `@Configuration` class that is also annotated with `@EnableJdbcAuditing` and expose -a `@Bean` of `AuditorAware`: - -[source, java] ----- -@Configuration -@EnableJdbcAuditing -class AuditConfiguration { - @Bean - public AuditorAware auditorAware() { - return () -> Optional.of("Mr. Auditor"); - } -} ----- - -link:{source-main}/audit/AuditConfiguration.java[`AuditConfiguration`] demonstrates a simple implementation of `AuditorAware` which returns `Mr. Auditor`. This will be assigned to fields that are annotated with `@CreatedBy` and `@LastModifiedBy`. - -== Audited Class -Next, we will implement link:{source-main}/user/User.java[`User`] which stores audit information: - -[source,java] ----- -@Table("users") -class User { - @Id - private Long id; - @CreatedDate - private Instant created; - @CreatedBy - private String createdBy; - @LastModifiedDate - private Instant lastModified; - @LastModifiedBy - private String lastModifiedBy; - private final String name; - private String username; - User(String name, String username) { - this.name = name; - this.username = username; - } - public User username(String username) { - this.username = username; - return this; - } -} ----- - -Values for the fields `created`, `createdBy`, `lastModified`, and `lastModifiedBy` will be handled by the framework. - -== Repository Class -Finally, we will implement the repository class - link:{source-main}/user/UserRepository.java[`UserRepository] which extends `CrudRepository` class from Spring Data: - -[source, java] ----- -interface UserRepository extends CrudRepository { -} ----- - -== Verification -As always, we will verify our implementation through database integration test. We will utilise `@Testcontainers` and `@DataJdbcTest` annotations. - -Unlike Spring Data JPA / Hibernate, Spring Data JDBC does not support automatic creation of a table. Therefore, we will use `@Sql` in our test to create the table `users` before running our tests. The setup will look as follows: - -[source, java] ----- -@Testcontainers -@DataJdbcTest(includeFilters = @Filter(EnableJdbcAuditing.class)) -@Sql( - executionPhase = BEFORE_TEST_CLASS, - statements = "CREATE TABLE users (id BIGSERIAL PRIMARY KEY, created TIMESTAMP WITH TIME ZONE NOT NULL, created_by TEXT NOT NULL, last_modified TIMESTAMP WITH TIME ZONE NOT NULL, last_modified_by TEXT NOT NULL, name TEXT NOT NULL, username TEXT NOT NULL)" -) -class UserAuditTests { - @Container - @ServiceConnection - private static final PostgreSQLContainer postgres = new PostgreSQLContainer<>(DockerImageName.parse("postgres:latest")); - @Autowired - private UserRepository repository; -} ----- - -`@DataJdbcTest` is not aware about our `AuditConfiguration` class. For that, we are using `includeFilters` to inform it about the class. - -=== Create new User -Upon the creation of a new `User`, the annotated fields should be automatically assigned. Whereby `created` and `lastModified` are assigned with current time while `createdBy` and `lastModifiedBy` are assigned with `Mr. Auditor`: - -[source,java] ----- -class UserAuditTests { - @Autowired - private UserRepository repository; - @Test - @DisplayName("When a user is persisted Then created and lastModified fields are set And createdBy and lastModifiedBy fields are set to Mr. Auditor") - void create() { - var user = repository.save(new User("Rashidi Zin", "rashidi")); - assertThat(user).extracting("created", "lastModified").doesNotContainNull(); - assertThat(user).extracting("createdBy", "lastModifiedBy").containsOnly("Mr. Auditor"); - } -} ----- - -=== Update an existing User -When updating an existing `User`, the field `lastModified` should be updated. The following test demonstrates that there is a `User` created seven days ago and once updated, its `lastModified` field should be later than `created` field: - -[source,java] ----- -class UserAuditTests { - @Autowired - private UserRepository repository; - @Test - @DisplayName("Given there is a user When I update its username Then lastModified field should be updated") - @Sql(statements = "INSERT INTO users (id, created, created_by, last_modified, last_modified_by, name, username) VALUES (84, CURRENT_TIMESTAMP - INTERVAL '7 days', 'Mr. Auditor', CURRENT_TIMESTAMP - INTERVAL '7 days', 'Mr. Auditor', 'Rashidi Zin', 'rashidi');") - void update() { - var modifiedUser = repository.findById(84L).map(user -> { user.username("rashidi.zin"); return user; }).map(repository::save).orElseThrow(); - var created = (Instant) ReflectionTestUtils.getField(modifiedUser, "created"); - var modified = (Instant) ReflectionTestUtils.getField(modifiedUser, "lastModified"); - assertThat(modified).isAfter(created); - } -} ----- - -Full implementation of the test can be found in link:{source-test}/user/UserAuditTests.java[`UserAuditTests`]. +include::../../../../data-jdbc-audit/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/data-jpa-audit.adoc b/docs/modules/ROOT/pages/data-jpa-audit.adoc index 6b1f5ef2..04fff412 100644 --- a/docs/modules/ROOT/pages/data-jpa-audit.adoc +++ b/docs/modules/ROOT/pages/data-jpa-audit.adoc @@ -1,167 +1,4 @@ = Spring Data Jpa Audit Example -:source-highlighter: highlight.js -:nofooter: -:icons: font -:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/data-jpa-audit +:page-aliases: data-jpa-audit.adoc -Enable auditing with Spring Data Jpa's `@CreatedDate` and `@LastModified`. For example with Spring Data MongoDB, please check out link:../data-mongodb-audit[Spring Data MongoDB Audit Example]. - - -== Background -http://docs.spring.io/spring-data/jpa/docs/current/reference/html/[Spring Data Jpa] provides auditing feature which includes `@CreateDate`, `@CreatedBy`, `@LastModifiedDate`, -and `@LastModifiedBy`. In this example we will see how it can be implemented with very little configurations. - -== Entity Class -In this example we have an entity class, link:{url-quickref}/src/main/java/zin/rashidi/boot/data/user/User.java[User] which contains information about the table structure. Initial -structure is as follows: - -[source,java] ----- -@Entity -class User { - @Id - @GeneratedValue - private Long id; - private String name; - private String username; - @CreatedBy - private String createdBy; - @CreatedDate - private Instant created; - @LastModifiedBy - private String modifiedBy; - @LastModifiedDate - private Instant modified; - // omitted getter / setter -} ----- - -As you can see it is a standard implementation of `@Entity` JPA class. We would like to keep track when an entry is -created with `created` column and when it is modified with `modified` column. - -== Enable JpaAudit -In order to enable http://docs.spring.io/spring-data/jpa/docs/current/reference/html/#jpa.auditing[JPA Auditing] for this project will need to apply three annotations and a configuration class. -Those annotations are; `@EntityListener`, `@CreatedDate`, and `@LastModifiedDate`. - -`@EntityListener` will be the one that is responsible to listen to any create or update activity. It requires -`Listeners` to be defined. In this example we will use the default class, `EntityListeners`. - -By annotating a column with `@CreatedDate` we will inform Spring that we need this column to have information on -when the entity is created. While `@LastModifiedDate` column will be defaulted to `@CreatedDate` and will be updated -to the current time when the entry is updated. - -The final look of `User` class: - -[source,java] ----- -@Entity -@EntityListeners(AuditingEntityListener.class) -class User { - @Id - @GeneratedValue - private Long id; - private String name; - private String username; - @CreatedBy - private String createdBy; - @CreatedDate - private Instant created; - @LastModifiedBy - private String modifiedBy; - @LastModifiedDate - private Instant modified; - // omitted getter / setter -} ----- - -As you can see `User` is now annotated with `@EntityListeners` while `created`, `createdBy`, `modified`, and `modifiedBy` columns are annotated -with `@CreatedDate`, `@CreatedBy`, `@LastModifiedDate`, and `@LastModifiedBy`. `createdBy` and `modifiedBy` fields will be automatically populated -if https://projects.spring.io/spring-security/[Spring Security] is available in the project path. Alternatively we wil implement our own https://docs.spring.io/spring-data/commons/docs/current/api/org/springframework/data/domain/AuditorAware.html[AuditorAware] in order to inform Spring who -is the current auditor. - -We will do so in link:{url-quickref}/src/main/java/zin/rashidi/boot/data/audit/AuditConfiguration.java[AuditConfiguration] class. In this class, we will also inform Spring to enable JPA auditing by annotating it with -`@EnableJpaAuditing` annotation. - -[source,java] ----- -@Configuration -@EnableJpaAuditing -class AuditConfiguration { - @Bean - public AuditorAware auditorAwareRef() { - return () -> Optional.of("Mr. Auditor"); - } -} ----- - -That's it! Our application has JPA Auditing feature enabled. The result can be seen in link:{url-quickref}/src/test/java/zin/rashidi/boot/data/user/UserAuditTests.java[UserAuditTests]. - -== Verify Audit Implementation -There is no better way to verify an implementation other than running some tests. In our test class we have to scenario: -* Create an entity which will have `created` and `modified` fields has values without us assigning them -* Update created entity and `created` field will remain to have the same value while `modified` values will be updated - -=== Create an entity -In the following test we will see that values for `created` and `modified` are assigned by Spring itself: - -[source,java] ----- -@Testcontainers -@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop", includeFilters = @Filter(type = ANNOTATION, classes = EnableJpaAuditing.class)) -class UserAuditTests { - @Container - @ServiceConnection - private final static MySQLContainer MYSQL = new MySQLContainer("mysql:latest"); - @Autowired - private UserRepository repository; - @Test - @DisplayName("When a user is saved Then created and modified fields are set And createdBy and modifiedBy fields are set to Mr. Auditor") - void create() { - var user = new User("Rashidi Zin", "rashidi"); - var createdUser = repository.save(user); - assertThat(createdUser).extracting("created", "modified").isNotNull(); - assertThat(createdUser).extracting("createdBy", "modifiedBy").containsOnly("Mr. Auditor"); - } -} ----- - -As mentioned earlier, we did not assign values for `created` and `modified` fields but Spring will assign them for us. -Same goes with when we are updating an entry. - -=== Update an entity -In the following test we will change the `username` without changing `modified` field. We will expect that `modified` -field will have a recent time as compare to when it was created: - -[source,java] ----- -@Testcontainers -@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop", includeFilters = @Filter(type = ANNOTATION, classes = EnableJpaAuditing.class)) -class UserAuditTests { - @Container - @ServiceConnection - private final static MySQLContainer MYSQL = new MySQLContainer("mysql:latest"); - @Autowired - private UserRepository repository; - @Test - @DisplayName("When a user is updated Then modified field should be updated") - @Sql(statements = "INSERT INTO users (id, name, username, created, modified) VALUES ('84', 'Rashidi Zin', 'rashidi', now() - INTERVAL 7 DAY, now() - INTERVAL 7 DAY)") - void update() { - var modifiedUser = repository.findById(84L).map(user -> { user.setUsername("rashidi.zin"); return user; }).map(repository::saveAndFlush).orElseThrow(); - var created = (Instant) ReflectionTestUtils.getField(modifiedUser, "created"); - var modified = (Instant) ReflectionTestUtils.getField(modifiedUser, "modified"); - assertThat(modified).isAfter(created); - } -} ----- - -As you can see at our final verification we assert that `modified` field should have a greater value than it -previously had. - -== Conclusion -To recap. All we need in order to enable JPA auditing feature in this project are: -* `@EnableJpaAuditing` -* `@EntityListeners` -* `@CreatedBy` -* `@CreatedDate` -* `@LastModifiedBy` -* `@LastModifiedDate` +include::../../../../data-jpa-audit/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/data-jpa-event.adoc b/docs/modules/ROOT/pages/data-jpa-event.adoc index 865ce87f..0a2ef0f4 100644 --- a/docs/modules/ROOT/pages/data-jpa-event.adoc +++ b/docs/modules/ROOT/pages/data-jpa-event.adoc @@ -1,147 +1,4 @@ = Spring Data JPA: Perform Entity Validation at Repository Level through Event Driven -:source-highlighter: highlight.js -:highlightjs-languages: java, groovy -:nofooter: -:icons: font -:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/data-jpa-event +:page-aliases: data-jpa-event.adoc -In this tutorial, we will look into how we can utilise events to perform validation at repository level. - - -== Background -It is common that we implement validation at a service layer (`@Service`). For example, we want to ensure that a username is unique. The common practice -is to perform the validation in a service layer before calling our repository method, such as `repository.save`. While this work, it breaks the principle of single responsibility by the method. -The service method is called `save()`. But, it performs a validation which is not part of its name. Alternatively, we may create a method `saveIfUsernameAvailable`. We will ended up with a long method name -and worse if we have several validations. - -In this tutorial we will implement the validation at `repository` level while following single responsibility principle. - -== Entity and Repository Classes -We will start by implementing link:{url-quickref}/src/main/java/zin/rashidi/data/event/user/User.java[User] and link:{url-quickref}/src/main/java/zin/rashidi/data/event/user/UserRepository.java[UserRepository] classes: - -[source, java] ----- -@Entity -@Table(name = "users") -class User { - @Id - @GeneratedValue - private Long id; - private String username; - protected User() { - } - public User(String username) { - this.username = username; - } - public String getUsername() { - return username; - } -} ----- - -[source,java] ----- -interface UserRepository extends JpaRepository { - boolean existsByUsername(String username); -} ----- - -The method `existsByUsername` will be used to perform validation against newly created `User` account. - -== Event Classes -We will create a custom `ApplicationEvent` class, link:{url-quickref}/src/main/java/zin/rashidi/data/event/user/UserBeforeSaveEvent.java[UserBeforeSaveEvent], -that will be triggered before `repository.save` is executed. - -[source, java] ----- -class UserBeforeSaveEvent extends ApplicationEvent { - public UserBeforeSaveEvent(User source) { - super(source); - } - @Override - public User getSource() { - return (User) super.getSource(); - } -} ----- - -Next is to implement an event publisher class that will be responsible to publish `UserBeforeSaveEvent` prior to saving the `User` into the database. - -[source, java] ----- -@Component -class UserEventPublisher { - private final ApplicationEventPublisher publisher; - UserEventPublisher(ApplicationEventPublisher publisher) { - this.publisher = publisher; - } - @PrePersist - public void beforeSave(User user) { - publisher.publishEvent(new UserBeforeSaveEvent(user)); - } -} ----- - -The method `beforeSave` is marked with `PrePersist` which is our way of telling JPA to trigger this method before persisting the `Entity` into the database. We will also need to update our `User` class -so that JPA is aware about `UserEventPublisher`. We can do this by using `@EntityListeners`. - -[source, java] ----- -@Entity -@Table(name = "users") -@EntityListeners(UserEventPublisher.class) -class User { - // remove for brevity -} ----- - -== Validator Class -`User.username` unique validation will be implemented in link:{url-quickref}/src/main/java/zin/rashidi/data/event/user/UserValidation.java[UserValidation]. This will be done by utilising `@EventListener` -that will be observing `UserBeforeSaveEvent`. - -[source, java] ----- -@Component -class UserValidation { - private final UserRepository repository; - UserValidation(UserRepository repository) { - this.repository = repository; - } - @EventListener - void usernameIsUnique(UserBeforeSaveEvent event) { - var usernameExisted = repository.existsByUsername(event.getSource().getUsername()); - if (usernameExisted) { - throw new IllegalArgumentException("Username is already taken"); - } - } -} ----- - -== Verify the Implementation -We will verify our implementation through `@DataJpaTest`, which does not require the whole application to run. Instead, only relevant classes will be used. Our intention is to ensure that -the username `rashidi.zin` is unique. Therefore, if a new `User` being created with the same `username`, an error that reads `Username is already taken` will be thrown. - -[source, java] ----- -@Testcontainers -@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop", includeFilters = @Filter(type = ASSIGNABLE_TYPE, classes = { UserEventPublisher.class, UserValidation.class })) -class UserRepositoryTests { - @Container - @ServiceConnection - private static final PostgreSQLContainer postgresql = new PostgreSQLContainer<>(DockerImageName.parse("postgres:latest")); - @Autowired - private TestEntityManager em; - @Autowired - private UserRepository repository; - @Test - @DisplayName("Given username rashidi.zin is exist When I create a new user with username rashidi.zin Then error with a message Username is already taken will be thrown") - void saveWithExistingUsername() { - em.persistAndFlush(new User("rashidi.zin")); - assertThatThrownBy(() -> repository.save(new User("rashidi.zin"))) - .hasCauseInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Username is already taken"); - } -} ----- - -Once done, execute the test in link:{url-quickref}/src/test/java/zin/rashidi/data/event/user/UserRepositoryTests.java[UserRepositoryTests] to ensure our implementation is working as expected. The full implementation can be found in {url-quickref}[Github]. +include::../../../../data-jpa-event/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/data-jpa-filtered-query.adoc b/docs/modules/ROOT/pages/data-jpa-filtered-query.adoc index de6b8b2b..3b3fdea1 100644 --- a/docs/modules/ROOT/pages/data-jpa-filtered-query.adoc +++ b/docs/modules/ROOT/pages/data-jpa-filtered-query.adoc @@ -1,123 +1,4 @@ = Spring Data Jpa: Global Filter Query -:source-highlighter: highlight.js -:nofooter: -:icons: font -:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/data-jpa-filtered-query +:page-aliases: data-jpa-filtered-query.adoc -Implement global filter query which involves several `Entity` with Spring Data Jpa. - - -== Background -It is common that we need to implement similar filter query in our application. For example, we have `User` and `Post` entities whereby both -contains `status` field. We would want to ensure that when these `Entity` is retrieved, only `ACTIVE` ones will be returned. - -In this tutorial we will implement a global filter by utilising Spring Data Jpa `repositoryBaseClass`. - -== Integration Tests -There are two tests which involves two entities - `User` and `Country`. `User` contains a `status` field while `Country` does not. -When `findAll` is triggered for `User` then only `ACTIVE` users will be returned. - -[source,java] ----- -@Testcontainers -@DataJpaTest( - properties = "spring.jpa.hibernate.ddl-auto=create-drop", - includeFilters = @Filter(type = ANNOTATION, classes = EnableJpaRepositories.class) -) -class UserRepositoryTests { - @Container - @ServiceConnection - private static final MySQLContainer mysql = new MySQLContainer<>("mysql:latest"); - @Autowired - private UserRepository users; - @BeforeEach - void setup() { - users.saveAll(List.of( - new User("Rashidi Zin", ACTIVE), - new User("John Doe", INACTIVE) - )); - } - @Test - @DisplayName("Given there are two users with status ACTIVE and INACTIVE, when findAll is invoked, then only ACTIVE users are returned") - void findAll() { - assertThat(users.findAll()) - .extracting("status") - .containsOnly(ACTIVE); - } -} ----- - -However, when `findAll` is triggered for `Country` then all countries will be returned. - -[source,java] ----- -@Testcontainers -@DataJpaTest( - properties = "spring.jpa.hibernate.ddl-auto=create-drop", - includeFilters = @Filter(type = ANNOTATION, classes = EnableJpaRepositories.class) -) -class CountryRepositoryTests { - @Container - @ServiceConnection - private static final MySQLContainer mysql = new MySQLContainer<>("mysql:latest"); - @Autowired - private CountryRepository countries; - @BeforeEach - void setup() { - countries.saveAll(List.of( - new Country("DE", "Germany"), - new Country("MY", "Malaysia") - )); - } - @Test - @DisplayName("Given there are two countries, when findAll is invoked, then both countries are returned") - void findAll() { - assertThat(countries.findAll()) - .hasSize(2) - .extracting("isoCode") - .containsOnly("DE", "MY"); - } -} ----- - -== Configuration Class -We will start by defining `repositoryBaseClass` - -[source,java] ----- -class JpaCustomBaseRepository extends SimpleJpaRepository { - public JpaCustomBaseRepository(JpaEntityInformation entityInformation, EntityManager entityManager) { - super(entityInformation, entityManager); - } - @Override - public List findAll() { - var hasStatusField = Stream.of(ReflectionUtils.getDeclaredMethods(getDomainClass())).anyMatch(field -> field.getName().equals("status")); - return hasStatusField ? findAll(where((root, query, criteriaBuilder) -> root.get("status").in(ACTIVE))) : super.findAll(); - } -} ----- - -In `JpaCustomBaseRepository` we will define a method `findAll` which will be used by all `Entity` to retrieve data. This method will filter -any `Entity` with `status` field and return only `ACTIVE` ones. - -To recap, link:{url-quickref}/src/main/java/zin/rashidi/boot/data/jpa/user/User.java[`User`] contains `status` field while -link:{url-quickref}/src/main/java/zin/rashidi/boot/data/jpa/country/Country.java[`Country`] does not. Therefore, when `findAll` is triggered for `User` then -only `ACTIVE` users will be returned. However, when `findAll` is triggered for `Country` then all countries will be returned. - -Next we will inform Spring Data Jpa to use `JpaCustomBaseRepository` as the base class for all `Entity` by defining `@EnableJpaRepositories` -in link:{url-quickref}/src/main/java/zin/rashidi/boot/data/jpa/jpa/JpaConfiguration.java[`JpaConfiguration`]. - -[source,java] ----- -@Configuration -@EnableJpaRepositories( - basePackages = "zin.rashidi.boot.data.jpa", - repositoryBaseClass = JpaCustomBaseRepository.class -) -class JpaConfiguration { -} ----- - -== Verification -To ensure that our implementation is working as expected, we will execute tests defined in link:{url-quickref}/src/test/java/zin/rashidi/boot/data/jpa/user/UserRepositoryTests.java[`UserRepositoryTests`] and link:{url-quickref}/src/test/java/zin/rashidi/boot/data/jpa/country/CountryRepositoryTests.java[`CountryRepositoryTests`]. -Both tests should pass. +include::../../../../data-jpa-filtered-query/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/data-mongodb-audit.adoc b/docs/modules/ROOT/pages/data-mongodb-audit.adoc index 79b7c151..8ae35499 100644 --- a/docs/modules/ROOT/pages/data-mongodb-audit.adoc +++ b/docs/modules/ROOT/pages/data-mongodb-audit.adoc @@ -1,103 +1,4 @@ = Spring Data MongoDB Audit Example -:source-highlighter: highlight.js -:nofooter: -:icons: font -:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/data-mongodb-audit +:page-aliases: data-mongodb-audit.adoc -Enable auditing with Spring Data MongoDB. For example with Spring Data JPA, visit link:../data-jpa-audit/[Spring Data JPA Audit Example]. - - -== Background -https://spring.io/projects/spring-data-mongodb[Spring Data MongoDB] provides auditing support for MongoDB. Auditing is a common requirement for most applications. It is used to track changes to entities, such as who created or modified an entity and when the change occurred. - -In this example, we will create a simple Spring Boot application that uses Spring Data MongoDB to persist and retrieve data from MongoDB. We will also enable auditing to track changes to entities. - -== Document Class -We will have a `Document` called link:{url-quickref}/src/main/java/zin/rashidi/boot/data/mongodb/user/User.java[User]: - -[source,java] ----- -@Document -class User { - @Id - private ObjectId id; - private String name; - private String username; - @CreatedBy - private String createdBy; - @CreatedDate - private Instant created; - @LastModifiedBy - private String modifiedBy; - @LastModifiedDate - private Instant modified; - // getters and setters -} ----- - -Fields that are marked with `@CreatedBy`, `@CreatedDate`, `@LastModifiedBy` and `@LastModifiedDate` are meant for auditing and will be populated by Spring Data MongoDB. - -== Enable Mongo Audit -To enable auditing, we need to add `@EnableMongoAuditing` annotation to our `@Configuration` class - link:{url-quickref}/src/main/java/zin/rashidi/boot/data/mongodb/audit/MongoAuditConfiguration.java[MongoAuditConfiguration]: - -[source,java] ----- -@Configuration -@EnableMongoAuditing -class MongoAuditConfiguration { - @Bean - public AuditorAware auditorAwareRef() { - return () -> Optional.of("Mr. Auditor"); - } -} ----- - -== Verify Audit Implementation -We will verify that the auditing is working by creating a test case that will create a new `User` and verify that the `created`, `createdBy`, `modified`, and `modifiedBy` fields are populated. - -[source,java] ----- -@Testcontainers -@DataMongoTest(includeFilters = @Filter(type = ANNOTATION, classes = EnableMongoAuditing.class)) -class UserAuditTests { - @Container - @ServiceConnection - private final static MongoDBContainer mongo = new MongoDBContainer("mongo:latest"); - @Autowired - private UserRepository repository; - @Test - @DisplayName("When a user is saved Then created and modified fields are set And createdBy and modifiedBy fields are set to Mr. Auditor") - void create() { - var createdUser = repository.save(new User().name("Rashidi Zin").username("rashidi")); - assertThat(createdUser).extracting("created", "modified").isNotNull(); - assertThat(createdUser).extracting("createdBy", "modifiedBy").containsOnly("Mr. Auditor"); - } -} ----- - -Next we will verify that the `modified` field are updated when we update the `User`: - -[source,java] ----- -@Testcontainers -@DataMongoTest(includeFilters = @Filter(type = ANNOTATION, classes = EnableMongoAuditing.class)) -class UserAuditTests { - @Container - @ServiceConnection - private final static MongoDBContainer mongo = new MongoDBContainer("mongo:latest"); - @Autowired - private UserRepository repository; - @Test - @DisplayName("When a user is updated Then modified field should be updated") - void update() { - var createdUser = repository.save(new User().name("Rashidi Zin").username("rashidi")); - await().atMost(ofSeconds(1)).untilAsserted(() -> { - var persistedUser = repository.findById(createdUser.id()).orElseThrow(); - var modifiedUser = repository.save(persistedUser.username("rashidi.zin")); - assertThat(modifiedUser.modified()).isAfter(createdUser.modified()); - }); - } -} ----- - -Full implementation can be found in link:{url-quickref}/src/test/java/zin/rashidi/boot/data/mongodb/user/UserAuditTests.java[UserAuditTests]. +include::../../../../data-mongodb-audit/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/data-mongodb-full-text-search.adoc b/docs/modules/ROOT/pages/data-mongodb-full-text-search.adoc index 042a70d8..42c0c5fa 100644 --- a/docs/modules/ROOT/pages/data-mongodb-full-text-search.adoc +++ b/docs/modules/ROOT/pages/data-mongodb-full-text-search.adoc @@ -1,157 +1,4 @@ = Spring Data MongoDB: Full Text Search -:source-highlighter: highlight.js -:nofooter: -:icons: font -:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/data-mongodb-full-text-search +:page-aliases: data-mongodb-full-text-search.adoc -Implement link:https://docs.mongodb.com/manual/text-search/[MongoDB Full Text Search] with link:https://spring.io/projects/spring-data-mongodb[Spring Data MongoDB]. - - -== Background -MongoDB full text search provides the flexibility to perform search entries through multiple fields. In this example we will explore how to implement full text search with Spring Data MongoDB. - -== Verification -Given we have the following entries in link:{url-quickref}/src/main/java/zin/rashidi/boot/data/mongodb/character/Character.java[Character]: - -.Characters -|=== -|Name |Publisher -|Captain Marvel -|Marvel -|Joker -|DC -|Thanos -|Marvel -|=== - -When searching for `captain marvel` then the following results should be returned - -.Characters that contains the keyword `captain` or `marvel` -|=== -|Name |Publisher -|Captain Marvel -|Marvel -|Thanos -|Marvel -|=== - -This is demonstrated in link:{url-quickref}/src/test/java/zin/rashidi/boot/data/mongodb/character/CharacterRepositoryTests.java[CharacterRepositoryTests]. - -== Implementation -We will start by defining the `Character` entity. - -[source,java] ----- -@Document -class Character { - @Id - private ObjectId id; - @TextIndexed - private final String name; - @TextIndexed - private final String publisher; - public Character(String name, String publisher) { - this.name = name; - this.publisher = publisher; - } -} ----- - -=== With Predefined Index -If respective fields are already `indexed` then we can utilise Spring Data query generation to perform full text search. -This can be done by creating a method that takes `TextCriteria` as parameter: - -[source,java] ----- -interface CharacterRepository extends MongoRepository, CharacterSearchRepository { - List findAllBy(TextCriteria criteria, Sort sort); -} ----- - -This method can then be used in the following manner: - -[source,java] ----- -@Testcontainers -@DataMongoTest -class CharacterRepositoryTests { - @Container - @ServiceConnection - private final static MongoDBContainer mongo = new MongoDBContainer("mongo:latest"); - @Autowired - private MongoOperations operations; - @Autowired - private CharacterRepository repository; - @Test - @DisplayName("Generated query: Search for 'captain marvel' should return 'Captain Marvel' and 'Thanos'") - void withGeneratedQuery() { - // Simulate predefined index - operations.indexOps(Character.class).ensureIndex(new TextIndexDefinitionBuilder().onFields("name", "publisher").build()); - var characters = repository.findAllBy(new TextCriteria().matchingAny("captain", "marvel"), Sort.by("name")); - assertThat(characters) - .hasSize(2) - .extracting("name") - .containsOnly("Captain Marvel", "Thanos") - .doesNotContain("Joker"); - } -} ----- - -=== Without Predefined Index -Without predefined index, we will need to implement a custom repository implementation. We will start by defining a custom repository interface, link:{url-quickref}/src/main/java/zin/rashidi/boot/data/mongodb/character/CharacterSearchRepository.java[CharacterSearchRepository]: - -[source,java] ----- -interface CharacterSearchRepository { - List findByText(String text, Sort sort); -} ----- - -Next, implement the custom repository interface in link:{url-quickref}/src/main/java/zin/rashidi/boot/data/mongodb/character/CharacterSearchRepositoryImpl.java[CharacterSearchRepositoryImpl]: - -[source,java] ----- -class CharacterSearchRepositoryImpl implements CharacterSearchRepository { - private final MongoOperations operations; - CharacterSearchRepositoryImpl(MongoOperations operations) { - this.operations = operations; - } - @Override - public List findByText(String text, Sort sort) { - operations.indexOps(Character.class) - .ensureIndex(new TextIndexDefinitionBuilder().onFields("name", "publisher").build()); - var parameters = text.split(" "); - var query = TextQuery.queryText(new TextCriteria().matchingAny(parameters)).with(sort); - return operations.find(query, Character.class); - } -} ----- - -This implementation will `indexed` searchable fields, i.e. `name` and `publisher` before searching the `Document`. - -Finally, we will verify our custom implementation through integration test: - -[source,java] ----- -@Testcontainers -@DataMongoTest -class CharacterRepositoryTests { - @Container - @ServiceConnection - private final static MongoDBContainer mongo = new MongoDBContainer("mongo:latest"); - @Autowired - private MongoOperations operations; - @Autowired - private CharacterRepository repository; - @Test - @DisplayName("Custom implementation: Search for 'captain marvel' should return 'Captain Marvel' and 'Thanos'") - void findByText() { - var characters = repository.findByText("captain marvel", Sort.by("name")); - assertThat(characters) - .hasSize(2) - .extracting("name") - .containsOnly("Captain Marvel", "Thanos") - .doesNotContain("Joker"); - } -} ----- +include::../../../../data-mongodb-full-text-search/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/data-mongodb-tc-data-load.adoc b/docs/modules/ROOT/pages/data-mongodb-tc-data-load.adoc index 71d5d5f5..dea18c39 100644 --- a/docs/modules/ROOT/pages/data-mongodb-tc-data-load.adoc +++ b/docs/modules/ROOT/pages/data-mongodb-tc-data-load.adoc @@ -1,284 +1,4 @@ = Spring Data MongoDb with Testcontainers -:source-highlighter: highlight.js -:nofooter: -:icons: font -:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/data-mongodb-tc-data-load +:page-aliases: data-mongodb-tc-data-load.adoc -Preloaded data for testing. - - -== Background -It is common to have preloaded data when we are testing our delete, read, and update operations. One of the most common approaches is to -load them programmatically. In this example, we will see how we can use Testcontainers to load data into our MongoDB instance. - -== Load Data Programmatically -It is common that we load data programmatically. There are several approaches to do this. - -=== Using `@BeforeEach` and `@AfterEach` -`@BeforeEach` will be executed before each test method execution while `@AfterEach` will be executed after test method executed. In this example, -we will use `@BeforeEach` to load data and `@AfterEach` to delete data. - -[source,java] ----- -@DataMongoTest -@Testcontainers -class UserRepositoryTests { - - @Container - @ServiceConnection - private static final MongoDBContainer mongo = new MongoDBContainer("mongo:latest"); - - @Autowired - private UserRepository repository; - - @BeforeEach - void create() { - repository.save(new User(null, "rashidi.zin", "Rashidi Zin")); - } - - @AfterEach - void delete() { - repository.deleteAll(); - } - - @Test - @DisplayName("Given there is a user with username rashidi.zin and name Rashidi Zin When I search for username rashidi.zin Then user with provided username should be returned") - void findByUsername() { - var user = repository.findByUsername("rashidi.zin"); - - assertThat(user) - .extracting("name") - .isEqualTo("Rashidi Zin"); - } - - @Test - @DisplayName("Given there is no user with username zaid.zin When I search for username zaid.zin Then null should be returned") - void findByUsernameWithNonExistingUsername() { - var user = repository.findByUsername("zaid.zin"); - - assertThat(user).isNull(); - } -} ----- - -While this approach works, it might be time-consuming when we have many methods to be executed. Another approach is to use `@TestExecutionListeners` - -=== Using `@TestExecutionListeners` -`@TestExecutionListeners` allows us to register implemented https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/test/context/TestExecutionListener.html[`TestExecutionListener`] -which can be used to execute code before and after test execution. - -The following `TestExecutionListener` is used to load data before executing the test class and remove all data after test class has been executed. - -[source,java] ----- -class UserTestExecutionListener extends AbstractTestExecutionListener { - - private User user; - - - @Override - public void beforeTestClass(TestContext testContext) { - var mongo = testContext.getApplicationContext().getBean(MongoOperations.class); - - user = mongo.insert(new User(null, "rashidi.zin", "Rashidi Zin")); - } - - @Override - public void afterTestClass(TestContext testContext) { - var mongo = testContext.getApplicationContext().getBean(MongoOperations.class); - - mongo.remove(user); - } - -} ----- - -Then we will include it in our test class: - -[source,java] ----- -@DataMongoTest -@Import(UserRepositoryTests.TestcontainersConfiguration.class) -@TestExecutionListeners(listeners = UserTestExecutionListener.class, mergeMode = MERGE_WITH_DEFAULTS) -class UserRepositoryTests { - - @Autowired - private UserRepository repository; - - @Test - @DisplayName("Given there is a user with username rashidi.zin and name Rashidi Zin When I search for username rashidi.zin Then user with provided username should be returned") - void findByUsername() { - var user = repository.findByUsername("rashidi.zin"); - - assertThat(user) - .extracting("name") - .isEqualTo("Rashidi Zin"); - } - - @Test - @DisplayName("Given there is no user with username zaid.zin When I search for username zaid.zin Then null should be returned") - void findByUsernameWithNonExistingUsername() { - var user = repository.findByUsername("zaid.zin"); - - assertThat(user).isNull(); - } - - @TestConfiguration(proxyBeanMethods = false) - @ImportAutoConfiguration(TestcontainersPropertySourceAutoConfiguration.class) - static class TestcontainersConfiguration { - - @Bean - MongoDBContainer mongoDbContainer(DynamicPropertyRegistry registry) { - var mongo = new MongoDBContainer("mongo:latest"); - - registry.add("spring.data.mongodb.uri", mongo::getReplicaSetUrl); - - return mongo; - } - - } -} ----- - -In this example, we are using `@TestExecutionListeners` to register `UserTestExecutionListener` which will be executed before and after test class execution. Alternatively, we also no longer utilise on -helpful annotations - `@Testcontainers`, `@Container`, and `@ServiceConnection`. - -== Load Data Using RepositoryPopulators -Next approach is to load data using https://docs.spring.io/spring-data/mongodb/reference/repositories/core-extensions.html#core.repository-populators[RepositoryPopulators] and Testcontainers. -We will start by creating link:{url-quickref}/src/test/resources/users.json[users.json] and populate it with the following content. - -[source,json] ----- -[{ - "_class": "zin.rashidi.data.mongodb.tc.dataload.user.User", - "name": "Rashidi Zin", - "username": "rashidi.zin" -}] ----- - -First, we will have to add `jackson-databind` as our dependency in link:${url-quickref}/build.gradle[build.gradle]. - -[source,groovy] ----- -dependencies { - testImplementation "com.fasterxml.jackson.core:jackson-databind" -} ----- - -Next we will create a `@TestConfiguration` class which will define `RepositoryPopulator`. - -[source,java] ----- -class UserRepositoryTests { - - @TestConfiguration - static class RepositoryPopulatorTestConfiguration { - - @Bean - public Jackson2RepositoryPopulatorFactoryBean jacksonRepositoryPopulator() { - var populator = new Jackson2RepositoryPopulatorFactoryBean(); - populator.setResources(new Resource[] { new ClassPathResource("users.json") }); - return populator; - } - } - -} ----- - -Then we will inform link:${url-quickref}/src/test/java/zin/rashidi/data/mongodb/tc/dataload/user/UserRepositoryTests.java[UserRepositoryTests] to include `RepositoryPopulatorTestConfiguration`. - -[source,java] ----- -@DataMongoTest(includeFilters = @Filter(type = ASSIGNABLE_TYPE, classes = UserRepositoryTests.RepositoryPopulatorTestConfiguration.class)) -class UserRepositoryTests { - - @TestConfiguration - static class RepositoryPopulatorTestConfiguration { - - @Bean - public Jackson2RepositoryPopulatorFactoryBean jacksonRepositoryPopulator() { - var populator = new Jackson2RepositoryPopulatorFactoryBean(); - populator.setResources(new Resource[] { new ClassPathResource("users.json") }); - return populator; - } - } - -} ----- - -Finally, the usual setup to include `@TestContainers` and `MongoDBContainer`. - -[source,java] ----- -@Testcontainers -@DataMongoTest(includeFilters = @Filter(type = ASSIGNABLE_TYPE, classes = UserRepositoryTests.RepositoryPopulatorTestConfiguration.class)) -class UserRepositoryTests { - - @Container - @ServiceConnection - private static final MongoDBContainer mongo = new MongoDBContainer("mongo:latest"); - - @TestConfiguration - static class RepositoryPopulatorTestConfiguration { - - @Bean - public Jackson2RepositoryPopulatorFactoryBean jacksonRepositoryPopulator() { - var populator = new Jackson2RepositoryPopulatorFactoryBean(); - populator.setResources(new Resource[] { new ClassPathResource("users.json") }); - return populator; - } - } - -} ----- - -Once everything is ready, we will add our tests. - -[source,java] ----- -@Testcontainers -@DataMongoTest(includeFilters = @Filter(type = ASSIGNABLE_TYPE, classes = UserRepositoryTests.RepositoryPopulatorTestConfiguration.class)) -class UserRepositoryTests { - - @Container - @ServiceConnection - private static final MongoDBContainer mongo = new MongoDBContainer("mongo:latest"); - - @Autowired - private UserRepository repository; - - @Test - @DisplayName("Given there is a user with username rashidi.zin and name Rashidi Zin When I search for username rashidi.zin Then user with provided username should be returned") - void findByUsername() { - var user = repository.findByUsername("rashidi.zin"); - - assertThat(user) - .extracting("name") - .isEqualTo("Rashidi Zin"); - } - - @Test - @DisplayName("Given there is no user with username zaid.zin When I search for username zaid.zin Then null should be returned") - void findByUsernameWithNonExistingUsername() { - var user = repository.findByUsername("zaid.zin"); - - assertThat(user).isNull(); - } - - @TestConfiguration - static class RepositoryPopulatorTestConfiguration { - - @Bean - public Jackson2RepositoryPopulatorFactoryBean jacksonRepositoryPopulator() { - var populator = new Jackson2RepositoryPopulatorFactoryBean(); - populator.setResources(new Resource[] { new ClassPathResource("users.json") }); - return populator; - } - } - -} ----- - -With that, data will be loaded into MongoDB before the test execution. Full implementation of link:{url-quickref}/src/test/java/zin/rashidi/data/mongodb/tc/dataload/user/UserRepositoryTests.java[`UserRepositoryTests`]: - -This also allows us to have a single source of truth in managing data for our tests. +include::../../../../data-mongodb-tc-data-load/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/data-mongodb-transactional.adoc b/docs/modules/ROOT/pages/data-mongodb-transactional.adoc index 0ec7955e..aa2bd4b6 100644 --- a/docs/modules/ROOT/pages/data-mongodb-transactional.adoc +++ b/docs/modules/ROOT/pages/data-mongodb-transactional.adoc @@ -1,106 +1,4 @@ = @Transactional with Spring Data MongoDB -:source-highlighter: highlight.js -:nofooter: -:icons: font -:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/data-mongodb-transactional +:page-aliases: data-mongodb-transactional.adoc -Guide to utilise `@Transactional` with Spring Data MongoDB. - - -== Background -Unlike Spring Data JPA, Spring Data MongoDB does not support `@Transactional` out of the box. In this guide, we will explore how to implement `@Transactional` with Spring Data MongoDB. - -== Scenario -We will implement based on the following scenario: - -[,text] ----- -When a new User is created -Then the status should be ACTIVE ----- - -== Implementation -=== Integration Test -We will verify our implementation through integration test whereby we will create a new `User` and verify that the status is `ACTIVE`. - -[source,java] ----- -@SpringBootTest(webEnvironment = RANDOM_PORT) -@Testcontainers -class CreateUserTests { - @Container - @ServiceConnection - private static final MongoDBContainer mongo = new MongoDBContainer("mongo:latest"); - @Autowired - private TestRestTemplate restTemplate; - @Test - void create() { - var headers = new HttpHeaders() {{ setContentType(APPLICATION_JSON); }}; - var body = """ - { - "username": "rashidi.zin", - "name": "Rashidi Zin" - } - """; - var response = restTemplate.exchange("/users", POST, new HttpEntity<>(body, headers), User.class); - var createdUser = response.getBody(); - assertThat(createdUser).extracting("status").isEqualTo(ACTIVE); - } -} ----- - -=== Event Listener -We will start by implementing an `EventListener` that will be responsible to assign the status to `ACTIVE` when a new `User` is created. - -[source,java] ----- -@Component -class UpdateUserStatus { - @TransactionalEventListener - public void onBeforeSave(BeforeSaveEvent event) { - var user = event.getSource(); - user.status(ACTIVE); - } -} ----- - -As we can see, `onBeforeSave` is annotated with `@TransactionalEventListener`. This annotation will ensure that the event listener will be -executed within a transaction. - -=== Transactional Method -Next, we will implement a transactional method that will create a new `User`. - -[source,java] ----- -@RestController -class UserResource { - private final UserRepository repository; - UserResource(UserRepository repository) { - this.repository = repository; - } - @PostMapping("/users") - @ResponseStatus(CREATED) - @Transactional - public User add(@RequestBody User user) { - return repository.save(user); - } -} ----- - -=== Configuration Class -Finally, we will configure `MongoTransactionManager` to enable transaction support for MongoDB. - -[source,java] ----- -@Configuration -@EnableTransactionManagement -class MongoTransactionManagerConfiguration { - @Bean - public PlatformTransactionManager transactionManager(MongoDatabaseFactory factory) { - return new MongoTransactionManager(factory); - } -} ----- - -=== Verification -In order to ensure that our implementation is working as expected, the test implemented in link:{url-quickref}/src/test/java/zin/rashidi/boot/data/mongodb/tm/user/CreateUserTests.java[CreateUserTests] should pass. +include::../../../../data-mongodb-transactional/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/data-repository-definition.adoc b/docs/modules/ROOT/pages/data-repository-definition.adoc index 7a712753..68f3b278 100644 --- a/docs/modules/ROOT/pages/data-repository-definition.adoc +++ b/docs/modules/ROOT/pages/data-repository-definition.adoc @@ -1,86 +1,4 @@ = Spring Data: Repository Definition -:source-highlighter: highlight.js -:nofooter: -:icons: font -:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/data-repository-definition +:page-aliases: data-repository-definition.adoc -Implement custom repository interfaces with @RepositoryDefinition annotation. - - -== Background -link:https://spring.io/projects/spring-data[Spring Data] provides a consistent programming model for data access while still retaining the special traits of the underlying data store. It makes it easy to use data access technologies, relational and non-relational databases, map-reduce frameworks, and cloud-based data services. - -When working with Spring Data, we typically create repository interfaces by extending one of the provided base interfaces such as `CrudRepository`, `JpaRepository`, or `MongoRepository`. However, sometimes we may want to define a repository with only specific methods, without inheriting all the methods from these base interfaces. - -This is where the `@RepositoryDefinition` annotation comes in. It allows us to define a repository interface with only the methods we need, providing more control over the repository's API. - -== Domain Class -We have a simple domain class, link:{url-quickref}/src/main/java/zin/rashidi/data/repositorydefinition/note/Note.java[Note], which is a Java record with three fields: `id`, `title`, and `content`. - -[source,java] ----- -record Note(@Id Long id, String title, String content) { -} ----- - -The `@Id` annotation from Spring Data marks the `id` field as the primary key. - -== Repository Definition -Instead of extending a base repository interface, we use the `@RepositoryDefinition` annotation to define our repository interface, link:{url-quickref}/src/main/java/zin/rashidi/data/repositorydefinition/note/NoteRepository.java[NoteRepository]. - -[source,java] ----- -@RepositoryDefinition(domainClass = Note.class, idClass = Long.class) -interface NoteRepository { - List findByTitleContainingIgnoreCase(String title); -} ----- - -The `@RepositoryDefinition` annotation takes two parameters: -- `domainClass`: The entity class that this repository manages (in this case, `Note.class`) -- `idClass`: The type of the entity's ID field (in this case, `Long.class`) - -With this annotation, Spring Data will create a repository implementation for us, just like it would for a repository that extends a base interface. The difference is that our repository only has the methods we explicitly define, in this case, just `findByTitleContainingIgnoreCase`. - -== Benefits of @RepositoryDefinition -Using `@RepositoryDefinition` offers several benefits: - -1. **Minimalist API**: You only expose the methods you need, making the API cleaner and more focused. -2. **Explicit Contract**: The repository interface clearly shows what operations are supported. -3. **Reduced Surface Area**: By not inheriting methods from base interfaces, you reduce the risk of unintended operations being performed. -4. **Flexibility**: You can define repositories for any domain class without being tied to a specific persistence technology's base interface. - -== Testing -We can link:{url-quickref}/src/test/java/zin/rashidi/data/repositorydefinition/note/NoteRepositoryTests.java[test our repository] using Spring Boot's testing support with Testcontainers for PostgreSQL. - -[source,java] ----- -@Import(TestcontainersConfiguration.class) -@DataJdbcTest -@SqlMergeMode(MERGE) -@Sql(statements = "CREATE TABLE note (id BIGINT PRIMARY KEY, title VARCHAR(50), content TEXT);", executionPhase = BEFORE_TEST_CLASS) -class NoteRepositoryTests { - @Autowired - private NoteRepository notes; - @Test - @Sql(statements = { - "INSERT INTO note (id, title, content) VALUES ('1', 'Right Turn', 'Step forward. Step forward and turn right. Collect.')", - "INSERT INTO note (id, title, content) VALUES ('2', 'Left Turn', 'Step forward. Reverse and turn left. Collect.')", - "INSERT INTO note (id, title, content) VALUES ('3', 'Double Spin', 'Syncopated. Double spin. Collect.')" - }) - @DisplayName("Given there are two entries with the word 'turn' in the title When I search by 'turn' in title Then Right Turn And Left Turn should be returned") - void findByTitleContainingIgnoreCase() { - var turns = notes.findByTitleContainingIgnoreCase("turn"); - assertThat(turns) - .extracting("title") - .containsOnly("Right Turn", "Left Turn"); - } -} ----- - -The test verifies that our repository method `findByTitleContainingIgnoreCase` correctly finds notes with titles containing the word "turn", ignoring case. - -== Conclusion -The `@RepositoryDefinition` annotation provides a way to create custom repository interfaces with only the methods you need, without inheriting all the methods from base interfaces. This gives you more control over your repository's API and makes your code more explicit about what operations are supported. - -While extending base interfaces like `CrudRepository` or `JpaRepository` is convenient for most cases, using `@RepositoryDefinition` can be a good choice when you want to limit the operations that can be performed on your entities or when you want to create a more focused and explicit API. +include::../../../../data-repository-definition/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/data-rest-validation.adoc b/docs/modules/ROOT/pages/data-rest-validation.adoc index aeed92ed..093929a3 100644 --- a/docs/modules/ROOT/pages/data-rest-validation.adoc +++ b/docs/modules/ROOT/pages/data-rest-validation.adoc @@ -1,109 +1,4 @@ = Spring Data REST: Validation -:source-highlighter: highlight.js -:nofooter: -:icons: font -:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/data-rest-validation +:page-aliases: data-rest-validation.adoc -Implement validation in Spring Data REST. - - -== Background -link:https://spring.io/projects/spring-data-rest[Spring Data REST] is a framework that helps developers to build hypermedia-driven REST web services. It is built on top of the Spring Data project and makes it easy to build hypermedia-driven REST web services that connect to Spring Data repositories – all using HAL as the driving hypermedia type. - -In this article, we will look at how to implement validation in Spring Data REST. - -== Domain Classes -There are two `@Entity` classes, link:{url-quickref}/src/main/java/zin/rashidi/boot/data/rest/book/Author.java[Author] and link:{url-quickref}/src/main/java/zin/rashidi/boot/data/rest/book/Book.java[Book]. Both classes are accompanied by `JpaRepository` classes - link:{url-quickref}/src/main/java/zin/rashidi/boot/data/rest/book/AuthorRepository.java[AuthorRepository] and link:{url-quickref}/src/main/java/zin/rashidi/boot/data/rest/book/BookRepository.java[BookRepository]. - -While `Author` and `Book` are standard JPA entities, their repositories are annotated with `@RepositoryRestResource` to expose them as REST resources. - -[source,java] ----- -@RepositoryRestResource -interface AuthorRepository extends JpaRepository { -} ----- - -[source,java] ----- -@RepositoryRestResource -interface BookRepository extends JpaRepository { -} ----- - -== Validation -We will implement a validation that ensures that `Author` in `Book` is not `INACTIVE`. To do this, we will create a custom validator class, link:{url-quickref}/src/main/java/zin/rashidi/boot/data/rest/book/BeforeCreateBookValidator.java[BeforeCreateBookValidator]. - -[source,java] ----- -class BeforeCreateBookValidator implements Validator { - @Override - public boolean supports(Class clazz) { - return Book.class.isAssignableFrom(clazz); - } - @Override - public void validate(Object target, Errors errors) { - Book book = (Book) target; - if (book.getAuthor().getStatus() == INACTIVE) { - errors.rejectValue("author", "author.inactive", "Author is inactive"); - } - } -} ----- - -As we can see, the validator class implements `Validator` interface and overrides `supports` and `validate` methods. The `supports` method checks if the class is `Book` and the `validate` method checks if the `Author` is `INACTIVE`. Next we will inform Spring about our `Validator` through link:{url-quickref}/src/main/java/zin/rashidi/boot/data/rest/book/BookValidatorConfiguration.java[BookValidatorConfiguration]. - -[source,java] ----- -@Configuration -class BookValidatorConfiguration implements RepositoryRestConfigurer { - @Override - public void configureValidatingRepositoryEventListener(ValidatingRepositoryEventListener validatingListener) { - validatingListener.addValidator("beforeCreate", new BeforeCreateBookValidator()); - } -} ----- - -Now, Spring is aware that the `Validator` will be executed before creating a `Book`. - -== Verify Implementation -We will perform a `POST` request to create a `Book` with an `Author` that is `INACTIVE`. The request will be rejected with `400 Bad Request` response. - -[source,java] ----- -@Import(TestDataRestValidationApplication.class) -@SpringBootTest(webEnvironment = RANDOM_PORT, properties = "spring.jpa.hibernate.ddl-auto=create-drop") -class CreateBookTests { - @Autowired - private TestRestTemplate restClient; - @Test - @DisplayName("When I create a Book with an inactive Author, I should get a Bad Request response") - void inactiveAuthor() { - var body = """ - { - "title": "If", - "author": "%s" - } - """.formatted(authorUri()); - var response = restClient.exchange("/books", POST, new HttpEntity<>(body, headers()), RepositoryRestErrorResponse.class); - assertThat(response.getStatusCode()).isEqualTo(BAD_REQUEST); - assertThat(response.getBody().getErrors()) - .hasSize(1) - .extracting(ValidationError::getMessage) - .containsExactly("Author is inactive"); - } - private URI authorUri() { - var body = """ - { - "name": "Rudyard Kipling", - "status": "INACTIVE" - } - """; - return restClient.exchange("/authors", POST, new HttpEntity<>(body, headers()), Void.class) - .getHeaders() - .getLocation(); - } -} ----- - -Full implementation of the test can be found in link:{url-quickref}/src/test/java/zin/rashidi/boot/data/rest/book/CreateBookTests.java[CreateBookTests]. +include::../../../../data-rest-validation/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/graphql.adoc b/docs/modules/ROOT/pages/graphql.adoc index 1be1c4aa..b8b80884 100644 --- a/docs/modules/ROOT/pages/graphql.adoc +++ b/docs/modules/ROOT/pages/graphql.adoc @@ -1,164 +1,4 @@ = GraphQL With Spring Boot -:source-highlighter: highlight.js -:nofooter: -:icons: font -:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/graphql +:page-aliases: graphql.adoc -Implementing https://graphql.org/[GraphQL] server with https://spring.io/guides/gs/graphql-server/[Spring Boot GraphQL Server]. - - -== Background -GraphQL provides the flexibility for clients to retrieve fields that are only relevant to them. In this tutorial we will -explore how we can implement GraphQL server with Spring Boot. - -== Server Implementation -=== schema.graphqls -[source,graphql] ----- -scalar Long -type Query { - findAll: [Book] - findByTitle(title: String): Book -} -type Book { - isbn: Isbn - title: String - author: Author -} -type Isbn { - ean: Long - registrationGroup: Int - registrant: Int - publication: Int - digit: Int -} -type Author { - name: Name -} -type Name { - first: String - last: String -} ----- -This is the schema that inform our clients on available fields and queries. Next is to implement a `@Controller` that -handle all requests. - -=== Controller -[source, java] ----- -@Controller -class BookResource { - private final BookRepository repository; - BookResource(BookRepository repository) { - this.repository = repository; - } - @QueryMapping - public List findAll() { - return repository.findAll(); - } - @QueryMapping - public Book findByTitle(@Argument String title) { - return repository.findByTitle(title); - } -} ----- -link:{url-quickref}/src/main/java/zin/rashidi/boot/graphql/book/BookResource.java[BookResource] implements two types of services - -`findAll`, a service without parameter and `findByTitle`, a service with a parameter. Both services are annotated with -`@QueryMapping` to indicate that they are GraphQL queries. - -== Verification -We will utilise `@GraphQlTest` to verify our implementation. - -=== Integration Test -[source, java] ----- -@GraphQlTest( - controllers = BookResource.class, - includeFilters = @Filter(type = ANNOTATION, classes = { Configuration.class, Repository.class }) -) -class BookResourceTests { - @Autowired - private GraphQlTester client; - @Test - @DisplayName("Given there are 3 books, when findAll is invoked, then return all books") - void findAll() { - client.documentName("books") - .execute() - .path("findAll") - .matchesJson( - """ - [ - { - "title": "Clean Code" - }, - { - "title": "Design Patterns" - }, - { - "title": "The Hobbit" - } - ] - """ - ) - ; - } - @Test - @DisplayName("Given there is a book titled The Hobbit, when findByTitle is invoked, then return the book") - void findByTitle() { - client.documentName("books") - .variable("title", "The Hobbit") - .execute() - .path("findByTitle") - .matchesJson( - """ - { - "title": "The Hobbit", - "isbn": { - "ean": 9780132350884, - "registrationGroup": 978, - "registrant": 0, - "publication": 13235088, - "digit": 4 - }, - "author": { - "name": { - "first": "J.R.R.", - "last": "Tolkien" - } - } - } - """ - ); - } -} ----- - -=== Client schema definition -In order to map the response to a Java object, we need to define the schema of the response. This is done in link:{url-quickref}/src/test/resources/graphql-test/books.graphql[books.graphl]. - -[source, graphql] ----- -query books($title: String) { - findByTitle(title: $title) { - title - isbn { - ean - registrationGroup - registrant - publication - digit - } - author { - name { - first - last - } - } - } - findAll { - title - } -} ----- - -By executing tests implemented in link:{url-quickref}/src/test/java/zin/rashidi/boot/graphql/book/BookResourceTests.java[BookResourceTests], we can verify that our implementation is working as expected. +include::../../../../graphql/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/jooq.adoc b/docs/modules/ROOT/pages/jooq.adoc index 0715cb14..fac977f1 100644 --- a/docs/modules/ROOT/pages/jooq.adoc +++ b/docs/modules/ROOT/pages/jooq.adoc @@ -1,162 +1,4 @@ = jOOQ: Implement jOOQ with Spring Boot and Gradle -:source-highlighter: highlight.js -:highlightjs-languages: java, groovy -:nofooter: -:icons: font -:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/jooq +:page-aliases: jooq.adoc -While JPA is commonly used in Java applications, it is not the only option. In this tutorial, we will look at how to use jOOQ with Spring Boot and Gradle. - - -== Background -https://www.jooq.org/[jOOQ] is a Java library that allows for fluent SQL query construction and typesafe database querying. It supports a -wide range of databases including MySQL, PostgreSQL, Oracle, Microsoft SQL Server, H2, HSQLDB, and SQLite. -It is also a good alternative to JPA / Hibernate. For more information on that, please refer to an article by Lukas Eder - -https://blog.jooq.org/jooq-vs-hibernate-when-to-choose-which/[jOOQ vs. Hibernate: When to Choose Which]. - -While most jOOQ examples and tutorials are using maven, we will explore the option of using Gradle by utilising -https://github.com/etiennestuder/gradle-jooq-plugin[Gradle jOOQ Plugin]. - -== Gradle Configuration -In order to enable jOOQ code generation with gradle we will need to include `nu.studer.jooq` plugin in link:{url-quickref}/build.gradle[build.gradle]. - -[source, groovy] ----- -plugins { - id 'nu.studer.jooq' version '8.2' -} ----- - -Instead of generating the code based on existing database, we will https://www.jooq.org/doc/latest/manual/code-generation/codegen-ddl/[generate them based on schema] -defined in link:{url-quickref}/src/main/resources/mysql-schema.sql[mysql-schema.sql] file. In order to do so, we will need to include `org.jooq.meta.extensions.ddl.DDLDatabase` - -[source, groovy] ----- -dependencies { - jooqGenerator 'org.jooq:jooq-meta-extensions:' + dependencyManagement.importedProperties['jooq.version'] - jooqGenerator 'com.mysql:mysql-connector-j' -} ----- - -Next we will use `org.jooq.meta.extensions.ddl.DDLDatabase` in our configuration - -[source, groovy] ----- -jooq { - version = dependencyManagement.importedProperties['jooq.version'] - edition = JooqEdition.OSS - configurations { - main { - generationTool { - generator { - database { - name = 'org.jooq.meta.extensions.ddl.DDLDatabase' - properties { - property { - key = 'scripts' - value = 'src/main/resources/mysql-schema.sql' - } - } - } - target { - packageName = 'zin.rashidi.boot.jooq' - } - strategy.name = 'org.jooq.codegen.DefaultGeneratorStrategy' - } - } - } - } -} ----- - -Now our Gradle setup is completed, we will proceed to `Repository` implementation. - -== Repository Implementation -We will start by defining an `interface` which will be used by the clients such as `Service` and `Controller` class. - -[source, java] ----- -interface UserRepository { - Optional findByUsername(String username); -} ----- - -As you can see, link:{url-quickref}/src/main/java/zin/rashidi/boot/jooq/user/UserRepository.java[UserRepository] implementation is similar -to standard Spring Data's implementation. We will implement this interface using jOOQ in link:{url-quickref}/src/main/java/zin/rashidi/boot/jooq/user/UserJooqRepository.java[UserJooqRepository]. - -[source, java] ----- -@Repository -class UserJooqRepository implements UserRepository { - private final DSLContext dsl; - UserJooqRepository(DSLContext dsl) { - this.dsl = dsl; - } - @Override - public Optional findByUsername(String username) { - return dsl.selectFrom(USERS).where(USERS.USERNAME.eq(username)) - .withReadOnly() - .maxRows(1) - .stream() - .map(record -> new User(record.getId(), record.getName(), record.getUsername())) - .findFirst(); - } -} ----- - -Now that is done, let's write a test to validate our implementation. - -== Integration Test -We will utilise `Testcontainers` and `JooqTest` to write an integration test for our `UserRepository` implementation. There will be two -scenarios: -* `findByUsername` returns `Optional` when user exists -* `findByUsername` returns empty `Optional` when user does not exist - -We will create `USERS` table and populate with test data prior to test execution. This is done by using `@Sql` annotation. - -=== Find by username with existing username -[source, java] ----- -@Testcontainers -@Sql(scripts = "classpath:mysql-schema.sql", statements = "INSERT INTO USERS (name, username) VALUES ('Rashidi Zin', 'rashidi')") -@JooqTest(includeFilters = @Filter(classes = Repository.class)) -class UserRepositoryTests { - @Container - @ServiceConnection - private static final MySQLContainer container = new MySQLContainer<>("mysql:latest"); - @Autowired - private UserRepository repository; - @Test - @DisplayName("Given username rashidi is available, when findByUsername, then return User") - void findByUsername() { - var user = repository.findByUsername("rashidi"); - assertThat(user).get() - .extracting("name", "username") - .containsOnly("Rashidi Zin", "rashidi"); - } -} ----- - -=== Find by username with non-existing username -[source, java] ----- -@Testcontainers -@Sql(scripts = "classpath:mysql-schema.sql", statements = "INSERT INTO USERS (name, username) VALUES ('Rashidi Zin', 'rashidi')") -@JooqTest(includeFilters = @Filter(classes = Repository.class)) -class UserRepositoryTests { - @Container - @ServiceConnection - private static final MySQLContainer container = new MySQLContainer<>("mysql:latest"); - @Autowired - private UserRepository repository; - @Test - @DisplayName("Given there is no user with username zaid.zin, when findByUsername, then return empty Optional") - void findByUsernameWithNonExistingUsername() { - var user = repository.findByUsername("zaid.zin"); - assertThat(user).isEmpty(); - } -} ----- - -Once done, execute the tests in link:{url-quickref}/src/test/java/zin/rashidi/boot/jooq/user/UserRepositoryTests.java[UserRepositoryTests] -to ensure our implementation is working as expected. +include::../../../../jooq/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/modulith.adoc b/docs/modules/ROOT/pages/modulith.adoc new file mode 100644 index 00000000..eb098c20 --- /dev/null +++ b/docs/modules/ROOT/pages/modulith.adoc @@ -0,0 +1,4 @@ += Spring Modulith: Building Modular Monolithic Applications +:page-aliases: modulith.adoc + +include::../../../../modulith/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/test-execution-listeners.adoc b/docs/modules/ROOT/pages/test-execution-listeners.adoc index 81b853ea..d2708805 100644 --- a/docs/modules/ROOT/pages/test-execution-listeners.adoc +++ b/docs/modules/ROOT/pages/test-execution-listeners.adoc @@ -1,109 +1,4 @@ = Spring Test: Managing Test Data with `TestExecutionListener` -:source-highlighter: highlight.js -:nofooter: -:icons: font -:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/test-execution-listeners +:page-aliases: test-execution-listeners.adoc -We often make use of `@BeforeEach` and `@AfterEach` methods to prepare and clean up test data. However, this approach is not scalable and can be difficult to maintain. In this article, we will look at how we can use `TestExecutionListener` to manage test data. - - -== Background -Spring provides link:https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/test/context/TestExecutionListener.html[`TestExecutionListener`] interface that we can implement to hook into the test execution lifecycle. This helps us in ensuring that our test classes are concise and not cluttered with test data preparation and clean up code. - -In this article, we will look at how we can use `TestExecutionListener` to manage test data. We will implement listeners to create initial data, update relevant data, and clean up data after the test execution. - -== `TestExecutionListener` classes -=== Creating initial data -We will start by creating initial link:{url-quickref}/src/main/java/zin/rashidi/boot/test/user/User.java[User] data which consists of `name` and `username` fields. - -[source,java] ----- -class UserCreationTestExecutionListener extends AbstractTestExecutionListener { - @Override - public void beforeTestClass(TestContext testContext) { - var mongo = testContext.getApplicationContext().getBean(MongoOperations.class); - mongo.save(new User("Rashidi Zin", "rashidi.zin")); - } - @Override - public int getOrder() { - return HIGHEST_PRECEDENCE; - } -} ----- - -By default `getOrder` method returns `LOWEST_PRECEDENCE` which means that this listener will be executed last. Since we want this listener to always be executed first, we will set the order to `HIGHEST_PRECEDENCE`. - -=== Update relevant data -Our test will focus on finding `User` with `status` `INACTIVE`. We will create a listener to update the `status` of the `User` to `INACTIVE` after the test execution. - -[source,java] ----- -class UserStatusUpdateTestExecutionListener extends AbstractTestExecutionListener { - @Override - public void beforeTestClass(TestContext testContext) { - var mongo = testContext.getApplicationContext().getBean(MongoOperations.class); - var findByUsername = mongo.findOne(query(where("username").is("rashidi.zin")), User.class); - mongo.save(findByUsername.status(INACTIVE)); - } - @Override - public int getOrder() { - return 1; - } -} ----- - -Given that we are expecting a `User` with `username` `rashidi.zin` to be returned, we will update the `status` of the `User` with `username` `rashidi.zin` to `INACTIVE`. - -In this listener, we will set the order to `1` as we want to ensure it will not be the last one to be executed. - -=== Clean up data -Both listeners above will create and update `User` data. They will be executed _before_ test class. For data cleanup it will be executed _after_ test class is executed. - -[source,java] ----- -class UserDeletionTestExecutionListener extends AbstractTestExecutionListener { - private static Logger log = LoggerFactory.getLogger(UserDeletionTestExecutionListener.class); - @Override - public void afterTestClass(TestContext testContext) { - var mongo = testContext.getApplicationContext().getBean(MongoOperations.class); - mongo.dropCollection(User.class); - log.info("user collection dropped"); - } -} ----- - -== Registering `TestExecutionListener` classes -Finally, we will implement a test class to test the link:{url-quickref}/src/main/java/zin/rashidi/boot/test/user/UserRepository.java[UserRepository] which will be executed with the listeners above. - -We will define necessary `TestExecutionListeners` using `@TestExecutionListeners` annotation and we will also set the `mergeMode` to `MERGE_WITH_DEFAULTS` to ensure that the default listeners are also executed. - -[source,java] ----- -@Testcontainers -@DataMongoTest -@TestExecutionListeners( - listeners = { UserCreationTestExecutionListener.class, UserStatusUpdateTestExecutionListener.class, UserDeletionTestExecutionListener.class }, - mergeMode = MERGE_WITH_DEFAULTS -) -class UserRepositoryTests { - @Container - @ServiceConnection - private static final MongoDBContainer mongo = new MongoDBContainer("mongo:latest"); - @Autowired - private UserRepository repository; - @Test - @DisplayName("Given there are users with status INACTIVE, When I search for users with status INACTIVE, Then I should get users with status INACTIVE") - void findByStatus() { - var inactiveUsers = repository.findByStatus(INACTIVE); - assertThat(inactiveUsers) - .hasSize(1) - .extracting("username") - .containsOnly("rashidi.zin"); - } -} ----- - -While `findByStatus` will validate our implementation in `UserCreationTestExecutionListener` and `UserStatusUpdateTestExecutionListener`, a log message will be printed to indicate that `UserDeletionTestExecutionListener` is executed. - -== Conclusion -With `TestExecutionListener` data can be reused across test classes. This helps us in ensuring that our test classes are concise and not cluttered with test data preparation and clean up code. +include::../../../../test-execution-listeners/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/test-rest-assured.adoc b/docs/modules/ROOT/pages/test-rest-assured.adoc index 0577d238..1fa96e53 100644 --- a/docs/modules/ROOT/pages/test-rest-assured.adoc +++ b/docs/modules/ROOT/pages/test-rest-assured.adoc @@ -1,428 +1,4 @@ = Spring Test: Implement BDD with RestAssured -:source-highlighter: highlight.js -:nofooter: -:icons: font -:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/test-rest-assured +:page-aliases: test-rest-assured.adoc -Verify API implementation through integration tests with https://www.browserstack.com/guide/what-is-bdd[Behaviour Driven Development (BDD)] -using Spring Boot, https://testcontainers.com/[Testcontainers], and https://rest-assured.io/[RestAssured]. - - -== Background - -RestAssured provide the convenience to test REST API in BDD style. It is very useful to test API implementation in Spring Boot application. -Provided that its API involved common BDD keywords such as `given`, `when` and `then`. - -In this example we will implement three features: - -[start=1] -. User creation -. User retrieval by username -. User deletion - -We will implement test scenarios before implementing the actual API. - -== User Creation - -We will implement two scenarios - create with an available username and create with an unavailable username. - -[source,java] ----- -@Testcontainers -@TestExecutionListeners(listeners = UserCreationTestExecutionListener.class, mergeMode = MERGE_WITH_DEFAULTS) -@SpringBootTest(webEnvironment = RANDOM_PORT) -class CreateUserTests { - - @Container - @ServiceConnection - private static final MongoDBContainer mongo = new MongoDBContainer("mongo:latest"); - - @BeforeAll - static void port(@LocalServerPort int port) { - RestAssured.port = port; - } - - @Test - @DisplayName("Given provided username is available When I create a User Then response status should be Created") - void availableUsername() { - var content = """ - { - "name": "Rashidi Zin", - "username": "rashidi.zin" - } - """; - - given() - .contentType(JSON) - .body(content) - .when() - .post("/users") - .then().assertThat() - .statusCode(equalTo(SC_CREATED)); - } - - @Test - @DisplayName("Given the username zaid.zin is unavailable When I create a User Then response status should be Bad Request") - void unavailableUsername() { - var content = """ - { - "name": "Zaid Zin", - "username": "zaid.zin" - } - """; - - given() - .contentType(JSON) - .body(content) - .when() - .post("/users") - .then().assertThat() - .statusCode(equalTo(SC_BAD_REQUEST)); - } - -} ----- - -In the implementation above. Testcontainers is used to simulate actual MongoDB and -link:{url-quickref}/src/test/java/zin/rashidi/boot/test/restassured/user/UserCreationTestExecutionListener.java[UserCreationTestExecutionListener] will load data into the database. -The data will be used to validate the second scenario. - -Next we will implement the API which will ensure that scenarios above will pass. We will start our implementation to fix the first failing -scenario - create with an available username. - -[source,java] ----- -@RestController -class UserResource { - - private final UserRepository repository; - - UserResource(UserRepository repository) { - this.repository = repository; - } - - @PostMapping("/users") - @ResponseStatus(CREATED) - public void create(@RequestBody UserRequest request) { - repository.save(new User(request.name(), request.username())); - } - -} ----- - -The implementation above should be sufficient to fix our first scenario. We will run the test again to ensure that it passes. Next is to -fix our second scenario - create with an unavailable username. - -Given that we do not have any validation in place, the second scenario will fail. We will add validation to ensure that the username is -unique. We will start by implementing a `Repository` method to validate if the username exists. - -[source,java] ----- -interface UserRepository extends MongoRepository { - - boolean existsByUsername(String username); - -} ----- - -Next, we will use `existsByUsername` to validate if the username exists before saving the user. - -[source,java] ----- -@RestController -class UserResource { - - private final UserRepository repository; - - UserResource(UserRepository repository) { - this.repository = repository; - } - - @PostMapping("/users") - @ResponseStatus(CREATED) - public void create(@RequestBody UserRequest request) { - if (repository.existsByUsername(request.username())) { - throw new IllegalArgumentException("Username already exists"); - } - - repository.save(new User(request.name(), request.username())); - } - -} ----- - -This, however, is insufficient as the server will throw `500 Internal Server Error` when the username already exists. We will add -`@ExceptionHandler` to handle the exception which converts it to `BAD REQUEST`. - -[source,java] ----- -@RestController -class UserResource { - - private final UserRepository repository; - - UserResource(UserRepository repository) { - this.repository = repository; - } - - @PostMapping("/users") - @ResponseStatus(CREATED) - public void create(@RequestBody UserRequest request) { - if (repository.existsByUsername(request.username())) { - throw new IllegalArgumentException("Username already exists"); - } - - repository.save(new User(request.name(), request.username())); - } - - @ExceptionHandler - @ResponseStatus(BAD_REQUEST) - public void handleIllegalArgumentException(IllegalArgumentException ignored) { - } - -} ----- - -Now we will run link:{url-quickref}/src/test/java/zin/rashidi/boot/test/restassured/user/CreateUserTests.java[CreateUserTests] again to ensure that both scenarios pass. Next, we will follow the same approach to implement the API for user retrieval by username. - -== User Retrieval by Username - -In link:{url-quickref}/src/test/java/zin/rashidi/boot/test/restassured/user/FindUserByUsernameTests.java[FindUserByUsernameTests], we will implement two scenarios - find with an available username and find with an unavailable username. - -[source,java] ----- -@Testcontainers -@TestExecutionListeners(listeners = UserCreationTestExecutionListener.class, mergeMode = MERGE_WITH_DEFAULTS) -@SpringBootTest(webEnvironment = RANDOM_PORT) -class FindUserByUsernameTests { - - @Container - @ServiceConnection - private static final MongoDBContainer mongo = new MongoDBContainer("mongo:latest"); - - @BeforeAll - static void port(@LocalServerPort int port) { - RestAssured.port = port; - } - - @Test - @DisplayName("Given username zaid.zin exists When I find a User Then response status should be OK and User should be returned") - void findByExistingUsername() { - given() - .contentType(JSON) - .when() - .get("/users/{username}", "zaid.zin") - .then().assertThat() - .statusCode(equalTo(SC_OK)) - .body("name", equalTo("Zaid Zin")) - .body("username", equalTo("zaid.zin")); - } - - @Test - @DisplayName("Given there is no User with username rashidi.zin When I find a User Then response status should be Not Found") - void findByNonExistingUsername() { - given() - .contentType(JSON) - .when() - .get("/users/{username}", "rashidi.zin") - .then().assertThat() - .statusCode(equalTo(SC_NOT_FOUND)); - } - -} ----- - -As you can see, `findByExistingUsername` validates the response body as well as HTTP response. Given that the user exists then the response body should contain the user's name and username. The HTTP response should be `200 OK`. - -While in the event requested `username` does not exist then the HTTP response should be `404 Not Found`. - -We will start by implementing a `Repository` method which will retrieve requested username. - -[source,java] ----- -interface UserRepository extends MongoRepository { - - Optional findByUsername(String username); - -} ----- - -link:{url-quickref}/src/main/java/zin/rashidi/boot/test/restassured/user/UserReadOnly.java[UserReadOnly] is a read-only projection of -`User` which will be used to retrieve the user's name and username. - -Then we will implement the API to fix the scenarios above. We will start with the first scenario - find with an available username. - -[source,java] ----- -@RestController -class UserResource { - - private final UserRepository repository; - - UserResource(UserRepository repository) { - this.repository = repository; - } - - @GetMapping("/users/{username}") - public UserReadOnly findByUsername(@PathVariable String username) { - return repository.findByUsername(username).orElseThrow(); - } - -} ----- - -The implementation above should be sufficient to fix our first scenario. We will run the test again to ensure that it passes. -Next is to fix our second scenario - find with an unavailable username. - -As for now, the second scenario will fail. We will add `@ExceptionHandler` to handle the exception which converts it to `NOT FOUND`. - -[source,java] ----- -@RestController -class UserResource { - - private final UserRepository repository; - - UserResource(UserRepository repository) { - this.repository = repository; - } - - @GetMapping("/users/{username}") - public UserReadOnly findByUsername(@PathVariable String username) { - return repository.findByUsername(username).orElseThrow(); - } - - @ExceptionHandler - @ResponseStatus(NOT_FOUND) - public void handleNoSuchElementException(NoSuchElementException ignored) { - } - -} ----- - -Now we will run link:{url-quickref}/src/test/java/zin/rashidi/boot/test/restassured/user/FindUserByUsernameTests.java[FindUserByUsernameTests] -again to ensure that both scenarios pass. Next, we will follow the same approach to implement the API for user deletion. - -== User Deletion - -For User Deletion, the action requires a valid `id`. However, since we are going to utilise data stored by `Testcontainers`, we are required -to retrieve the existing user's `id` first. Then we will perform the deletion. - -We will implement two scenarios - delete with an available `id` and delete with an non-existing `id`. - -[source,java] ----- -@Testcontainers -@TestExecutionListeners(listeners = UserCreationTestExecutionListener.class, mergeMode = MERGE_WITH_DEFAULTS) -@SpringBootTest(webEnvironment = RANDOM_PORT) -class DeleteUserTests { - - @Container - @ServiceConnection - private static final MongoDBContainer mongo = new MongoDBContainer("mongo:latest"); - - @BeforeAll - static void port(@LocalServerPort int port) { - RestAssured.port = port; - } - - @Test - @DisplayName("Given username zaid.zin exists When I delete with its id Then response status should be No Content") - void deleteWithValidId() { - String id = get("/users/{username}", "zaid.zin").path("id"); - - when() - .delete("/users/{id}", id) - .then().assertThat() - .statusCode(equalTo(SC_NO_CONTENT)); - } - - @Test - @DisplayName("When I trigger delete with a non-existing ID Then response status should be Not Found") - void deleteWithNonExistingId() { - when() - .delete("/users/{id}", "5f9b0a9b9d9b4a0a9d9b4a0a") - .then().assertThat() - .statusCode(equalTo(SC_NOT_FOUND)); - } - -} ----- - -As you can see, in `deleteWithValidId` we are retrieving the existing user's `id` first. - -[source,java] ----- -@Testcontainers -@TestExecutionListeners(listeners = UserCreationTestExecutionListener.class, mergeMode = MERGE_WITH_DEFAULTS) -@SpringBootTest(webEnvironment = RANDOM_PORT) -class DeleteUserTests { - - @Test - @DisplayName("Given username zaid.zin exists When I delete with its id Then response status should be No Content") - void deleteWithValidId() { - String id = get("/users/{username}", "zaid.zin").path("id"); - - when() - .delete("/users/{id}", id) - .then().assertThat() - .statusCode(equalTo(SC_NO_CONTENT)); - } - -} ----- - -Once we have the `id`, we will perform the deletion. Next, we will implement the API to fix the scenarios above. We will start with the first scenario - delete with an available `id`. - -[source,java] ----- -@RestController -class UserResource { - - private final UserRepository repository; - - UserResource(UserRepository repository) { - this.repository = repository; - } - - @DeleteMapping("/users/{id}") - @ResponseStatus(NO_CONTENT) - public void deleteById(@PathVariable ObjectId id) { - repository.findById(id).ifPresent(repository::delete); - } - -} ----- - -The implementation above should be sufficient to fix our first scenario. We will run the test again to ensure that it passes. Next is to fix our second scenario - delete with an non-existing `id`. We're expecting `404 Not Found` in this scenario. We can achieve this with slight modification to `deleteById` method. - -[source,java] ----- -@RestController -class UserResource { - - private final UserRepository repository; - - UserResource(UserRepository repository) { - this.repository = repository; - } - - @DeleteMapping("/users/{id}") - @ResponseStatus(NO_CONTENT) - public void deleteById(@PathVariable ObjectId id) { - repository.findById(id).ifPresentOrElse(repository::delete, () -> { throw new NoSuchElementException(); }); - } - -} ----- - -Since we have already implement `@ExceptionHandler` to handle `NoSuchElementException`, this implementation should be sufficient to fix our -second scenario. We will run the test again to ensure that it passes. - -== Conclusion - -I have always preferred RestAssured as it allows me to test API implementation in BDD style. Given that I can decouple my tests with the -production code, I can ensure that my tests are not affected by the implementation details. - -As you can see from tests above. None of the tests uses production code. This is very useful when I need to refactor my -code. I can refactor my code without worrying that my tests will break. As long as the API contract remains the same, my tests will pass. +include::../../../../test-rest-assured/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/test-slice-tests-rest.adoc b/docs/modules/ROOT/pages/test-slice-tests-rest.adoc index c2448084..476c84f2 100644 --- a/docs/modules/ROOT/pages/test-slice-tests-rest.adoc +++ b/docs/modules/ROOT/pages/test-slice-tests-rest.adoc @@ -1,296 +1,4 @@ = Spring Test: Slice Testing a REST Application -:icons: font -:source-highlighter: highlight.js -:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/test-slice-tests-rest -:source-main: {url-quickref}/src/main/java/zin/rashidi/boot/test/slices -:source-test: {url-quickref}/src/test/java/zin/rashidi/boot/test/slices +:page-aliases: test-slice-tests-rest.adoc -Testing has become a critical component in today's software development world. It helps us in ensuring high quality product -that provides stability and scalability. In this article, we will explore about implementing tests for Spring Boot Web application. - - -== Background - -https://docs.spring.io/spring-boot/reference/testing/index.html[Spring Boot Testing] components provides great convenience -for us to test our implementation. In my experience, I found projects are still relying on mocks. The reason behind it is integration tests -usually takes too long and expensive. - -Such opinion was true in the past. However, today with Spring's Test component and https://testcontainers.com/[Testcontainers], integration -tests no longer being a burden. We will look into the options in implementing tests using Spring Boot for a standard REST application. - -== The Application - -link:{url-quickref}[The application] is rather a simple REST application which consists of Spring Data JPA, Spring Security, and Spring Web. -The typical components used in most Spring Boot applications. - -We will start by implementing the repository component. - -== Entity & Repository - -Given that we have the entity link:{source-main}/user/User.java[`User`], we will implement a `Repository` class that -extends `JpaRepository`. - -[source, java] ----- -interface UserRepository extends JpaRepository {} ----- - -We want to allow the users to retrieve a `User` by `username`. However, we want to hide their `id` information and to simplify -`name` - instead of having `first` and `last` name, we will just return their full name. For this we will use a -https://docs.spring.io/spring-data/jpa/reference/repositories/projections.html[Projections] called link:{source-main}/user/UserWithoutId.java[`UserWithoutId`]. - -[source, java] ----- -interface UserRepository extends JpaRepository { - - @NativeQuery( - name = "User.findByUsername", - value = "SELECT CONCAT_WS(' ', first, last) as name, username, status FROM users WHERE username = ?1", - sqlResultSetMapping = "User.WithoutId" - ) - Optional findByUsername(String username); - -} ----- - -Since we have a custom implementation in `UserRepository`, we will implement a test to ensure that it is behaving as expected. For this we will -use `@DataJpaTest` which will load sufficient components for us to run a `JpaRepository` test. - -We will implement two scenarios in link:{source-test}/user/UserRepositoryTests.java[`UserRepositoryTests`] - find by username with an existing username and find by username with a non-existing username. - -[source, java] ----- -@Testcontainers -@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop") -class UserRepositoryTests { - - @Container - @ServiceConnection - private static final PostgreSQLContainer postgresql = new PostgreSQLContainer<>(DockerImageName.parse("postgres:latest")); - - @Autowired - private UserRepository repository; - - @Test - @DisplayName("Given there the username rashidi.zin exists When I find by the username Then I should receive a summary of the user") - @Sql(statements = "INSERT INTO users (id, first, last, username, status) VALUES (1, 'Rashidi', 'Zin', 'rashidi.zin', 0)") - void findByUsername() { - var user = repository.findByUsername("rashidi.zin"); - - assertThat(user).get() - .extracting("name", "username", "status") - .containsExactly("Rashidi Zin", "rashidi.zin", ACTIVE); - } - - @Test - @DisplayName("Given there the username zaid.zin does not exist When I find by the username Then I should receive empty optional") - void findByNonExistingUsername() { - var user = repository.findByUsername("zaid.zin"); - - assertThat(user).isEmpty(); - } - -} ----- - -.Annotations being used in the test above are: -* `@Testcontainers` - Enabling Testcontainers support for this test -* `DataJpaTest` - Load Spring Data JPA's related components -* `@Container` - Allow Testcontainers manage the lifecycle of `PostgreSQLContainer` -* `@ServiceConnection` - Automatically assign `DataSource` related properties -* `@Sql` - Load test data - -With that, we have verified that `UserRepository.findByUsername` is behaving as expected. Full implementation can be found in -link:{source-main}/user[`user`] package. For other database types, I wrote articles on link:../data-jdbc-audit/[using `@DataJdbcTest`] -and link:../data-mongodb-audit/[using `@DataMongoTest`] - -Next, we will implement the web components. - -== Web Implementation - -.Our web components involves: -* link:{source-main}/security/WebSecurityConfiguration.java[`WebSecurityConfiguration`] - contains a simple HTTP Basic authentication -* link:{source-main}/user/UserResource.java[`UserResource`] - implements a `GET` method to a retrieve user information and a `ExceptionHandler` that will return `NOT_FOUND` for non-existing `username`: - -[source, java] ----- -@RestController -class UserResource { - - private final UserRepository repository; - - UserResource(UserRepository repository) { - this.repository = repository; - } - - @GetMapping(value = "/users/{username}", produces = APPLICATION_JSON_VALUE) - public UserWithoutId findByUsername(@PathVariable String username) { - return repository.findByUsername(username).orElseThrow(InvalidUserException::new); - } - - @ExceptionHandler(InvalidUserException.class) - @ResponseStatus(NOT_FOUND) - public void handleInvalidUserException() {} - - static class InvalidUserException extends RuntimeException {} - -} ----- - -=== Testing with `@WebMvcTest` - -If long-running time is a concern, `@WebMvcTest` would be a suitable approach as it will only load web related components. It allows us to mock -any of its dependencies and arrange suitable behaviour for them. In the following implementation, we will mock (or arrange) the behaviour of `UserRepository.findByUsername`: - -In `findByUsername`, we will arrange that it will return `Optional` containing `UserWithoutId`. We will expect that the response will be `HTTP OK`. While in `findByNonExistingUsername`, we -arrange that it will return an empty `Optional`. This will lead to `InvalidUserException` being thrown and translated to `HTTP NOT_FOUND`. - -[source, java] ----- -@WebMvcTest(controllers = UserResource.class, includeFilters = @Filter(EnableWebSecurity.class)) -class UserResourceTests { - - private static MockMvcTester mvc; - - @MockitoBean - private UserRepository repository; - - @BeforeAll - static void setup(@Autowired WebApplicationContext context) { - mvc = from(context, builder -> builder.apply(springSecurity()).build()); - } - - @Test - @WithMockUser - @DisplayName("Given username rashidi.zin exists When when I request for the username Then the response status should be OK") - void findByUsername() { - var fakeUser = Optional.of(new UserWithoutId("Rashidi Zin", "rashidi.zin", ACTIVE)); - - doReturn(fakeUser).when(repository).findByUsername("rashidi.zin"); - - mvc - .get().uri("/users/{username}", "rashidi.zin") - .assertThat() - .hasStatus(OK); - - verify(repository).findByUsername("rashidi.zin"); - } - - @Test - @WithMockUser - @DisplayName("Given username rashidi.zin does not exist When when I request for the username Then the response status should be NOT_FOUND") - void findByNonExistingUsername() { - doReturn(empty()).when(repository).findByUsername("rashidi.zin"); - - mvc - .get().uri("/users/{username}", "rashidi.zin") - .assertThat() - .hasStatus(NOT_FOUND); - - verify(repository).findByUsername("rashidi.zin"); - } - - @Test - @DisplayName("Given there is no authentication When I request for the username Then the response status should be UNAUTHORIZED") - void findByUsernameWithoutAuthentication() { - mvc - .get().uri("/users/{username}", "rashidi.zin") - .assertThat().hasStatus(UNAUTHORIZED); - - verify(repository, never()).findByUsername("rashidi.zin"); - } - -} ----- - -.Methods and annotations used in the test above: -* `@WebMvcTest` - Our test will only focus on `UserResource` and we will load security configuration from `WebSecurityConfiguration` -* `SecurityMockMvcConfigurers.springSecurity()` - Enable Spring Security support for `MockMvcTester` -* `@WithMockUser` - Mocks user authentication. Without it the response will be `UNAUTHORIZED` as demonstrated in `findByUsernameWithoutAuthentication` -* `@MockitoBean` - Mocks `UserRepository` since we have verified that it works correctly in link:{source-test}/user/UserRepositoryTests.java[`UserRepositoryTests`] -* `Mockito.verify` - Verifies that `UserRepository.findByUsername` was either triggered (when user is authenticated) or not - -Given that link:{source-test}/user/UserResourceTests.java[`UserResourceTests`] is specifically for `UserResource` and only necessary components are loaded, its execution -should be fast. - -=== Testing with `@SpringBootTest` - -`@SpringBootTest`, by default, will load all components. In our case, it will expect there is a running PostgreSQL and the properties are assigned. -This is handled by {source-test}/TestcontainersConfiguration.java[`TestcontainersConfiguration`] and -we will import it into our test - link:{source-test}/user/FindByUsernameTests.java[`FindByUsernameTests`]. - -We will implement the same test scenarios as we did in link:{source-test}/user/UserResourceTests.java[`UserResourceTests`]: - -[source, java] ----- -@Import(TestcontainersConfiguration.class) -@SpringBootTest(webEnvironment = RANDOM_PORT, properties = { - "spring.jpa.hibernate.ddl-auto=create-drop", - "spring.security.user.name=rashidi.zin", - "spring.security.user.password=jU$7d3m0pL3a$eRe|ax" -}) -@Sql(executionPhase = BEFORE_TEST_CLASS, statements = "INSERT INTO users (id, first, last, username, status) VALUES (1, 'Rashidi', 'Zin', 'rashidi.zin', 0)") -class FindByUsernameTests { - - @Autowired - private TestRestTemplate restClient; - - @Test - @DisplayName("Given username rashidi.zin exists When I request for the username Then response status should be OK and it should contain the summary of the user") - void withExistingUsername() { - var response = restClient - .withBasicAuth("rashidi.zin", "jU$7d3m0pL3a$eRe|ax") - .getForEntity("/users/{username}", UserWithoutId.class, "rashidi.zin"); - - assertThat(response.getStatusCode()).isEqualTo(OK); - - var user = response.getBody(); - - assertThat(user) - .extracting("name", "username", "status") - .containsExactly("Rashidi Zin", "rashidi.zin", ACTIVE); - } - - @Test - @DisplayName("Given username zaid.zin does not exist When I request for the username Then response status should be NOT_FOUND") - void withNonExistingUsername() { - var response = restClient - .withBasicAuth("rashidi.zin", "jU$7d3m0pL3a$eRe|ax") - .getForEntity("/users/{username}", UserWithoutId.class, "zaid.zin"); - - assertThat(response.getStatusCode()).isEqualTo(NOT_FOUND); - } - - @Test - @DisplayName("Given there is no authentication When I request for the username Then response status should be UNAUTHORIZED") - void withoutAuthentication() { - var response = restClient.getForEntity("/users/{username}", UserWithoutId.class, "rashidi.zin"); - - assertThat(response.getStatusCode()).isEqualTo(UNAUTHORIZED); - } - -} ----- - -.In `FindByUsernameTests`, we have: -* Import `PostgreSQLContainer` from `Testcontainers` that is defined in `TestcontainersConfiguration` -* Define default username and password through `spring.security.user.name` and `spring.security.user.password` -* Insert test data prior to running the class - -In `withExistingUsername`, we implement the same verification in `UserResourceTests.findByUsername()` and `UserRepositoryTests.findByUsername()`. The same goes to -`withNonExistingUsername` and `withoutAuthentication` whereby its verification is the same as -`UserResourceTests.findByNonExistingUsername()`, `UserRepositoryTests.findByNonExistingUsername()`, and `UserResourceTests.findByUsernameWithoutAuthentication()` - -If you find this is redundant, you are right. Given that `FindByUsernameTests` is an end-to-end integration test class, we could rely on solely on it. As for -implementations in `UserResourceTests` and `UserRepositoryTests` can be removed. - -== Conclusion - -Wherever possible, I will always favour using `@SpringBootTest` as it allows me to ensure that the whole application is behaving accordingly. However, as mentioned earlier, -if the `@SpringBootTest` class takes too long to run then I'd go with `@WebMvcTest`. It is less desire as the test will -be affected should the production code implementation changes. For example, a refactoring. - -With `@SpringBootTest`, I am able to implement my tests following link:../test-rest-assured/[Behaviour Driven Development] easily as -opposed to using `@WebMvcTest` as I don't have to be concerned about the feature's implementation. - -In the end, choose the ones that provide you with the efficiency to maintain and to run your tests. Either with `@SpringBootTest` or the combination of `@WebMvcTest` and `@DataJpaTest`. +include::../../../../test-slice-tests-rest/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/web-rest-client.adoc b/docs/modules/ROOT/pages/web-rest-client.adoc index f0689f7c..f0299b52 100644 --- a/docs/modules/ROOT/pages/web-rest-client.adoc +++ b/docs/modules/ROOT/pages/web-rest-client.adoc @@ -1,355 +1,4 @@ = Spring Web: REST Clients for calling Synchronous API -:icons: font -:source-highlighter: highlight.js -:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/web-rest-client -:source-main: {url-quickref}/src/main/java/zin/rashidi/boot/web/restclient -:source-test: {url-quickref}/src/test/java/zin/rashidi/boot/web/restclient +:page-aliases: web-rest-client.adoc - -== Background - -Historically, `RestTemplate` has been the main choice as the REST client to call synchronous API. Since Spring 6, https://docs.spring.io/spring-framework/reference/integration/rest-clients.html[there are two other -options being provided - `RestClient` and `@HttpExchange` as the alternatives]. - -== RestClient - -https://docs.spring.io/spring-framework/reference/integration/rest-clients.html#rest-restclient[`RestClient`] provides fluent API which makes it more readable. -`RestClient` can be constructed through two options - `RestClient.create` and `RestClient.Builder`. In this tutorial we will use `RestClient.Builder` as it is more -convenient for us to utilise `RestClientTest`. - -We will start by creating a repository interface for link:{source-main}/user/User.java[`User`]: - -[source,java] ----- -interface UserRepository { - - List findAll(); - - User findById(Long id); - -} ----- - -For those who are familiar with Spring Data, these methods name are following Spring Data's standard method naming. Next we will write tests and its respective implementation. -Our implementation for `UserRepository` will be done in `UserRestRepository`. This is to align with standard Spring's repository practices. - -=== Declaring RestClient - -As mentioned earlier, we will use `RestClient.Builder` to construct `RestClient`: - -[source, java] ----- -@Repository -class UserRestRepository implements UserRepository { - - private final RestClient restClient; - - UserRestRepository(RestClient.Builder restClientBuilder) { - this.restClient = restClientBuilder - .baseUrl("https://jsonplaceholder.typicode.com/users") - .defaultHeader(ACCEPT, APPLICATION_JSON_VALUE) - .build(); - } - -} ----- - -Our `RestClient` will communicate with https://jsonplaceholder.typicode.com/[{JSON} Placeholder] to retrieve all `User` and all requests -will be equipped with `application/json` as the expected response header. - -=== Get all users - -[source,java] ----- -@Repository -class UserRestRepository implements UserRepository { - - private final RestClient restClient; - - @Override - public List findAll() { - return restClient.get() - .retrieve() - .body(new ParameterizedTypeReference<>() {}); - } - -} ----- - -Our first implementation is fairly simple. We will retrieve a `List` of `User` and we use `ParamterizedTypeReference` to convert it. In our test, we will verify that -the `restClient` will trigger a call to `https://jsonplaceholder.typicode.com/users` and we will mock the responses. As our intention is to ensure we are calling the right endpoint. - -[source,java] ----- -@RestClientTest(UserRestRepository.class) -class UserRepositoryTests { - - @Autowired - private UserRepository repository; - - @Autowired - private MockRestServiceServer mockServer; - - @Autowired - private ObjectMapper mapper; - - @Test - @DisplayName("When findAll Then all users should be returned") - void findAll() throws JsonProcessingException { - var response = mapper.writeValueAsString(List.of( - new User(84L, "Rashidi Zin", "rashidi.zin", "rashidi@zin.my", URI.create("rashidi.zin.my")), - new User(87L, "Zaid Zin", "zaid.zin", "zaid@zin.my", URI.create("zaid.zin.my")) - )); - - mockServer.expect(requestTo("https://jsonplaceholder.typicode.com/users")).andRespond(withSuccess(response, APPLICATION_JSON)); - - assertThat(repository.findAll()).hasSize(2); - - mockServer.verify(); - } - -} ----- - -.There are three dependencies declared: -* `UserRepository` - the class that we want to test -* `MockRestServiceServer` - the class that we will use to mock responses from JSONPlaceholder -* `ObjectMapper` - the class that we will use to convert an `Object` to `String` to be used as the mocked response - -In the test above, we mocked the response from `https://jsonplaceholder.typicode.com/users` and we verify that when `UserRepository.findAll()` is called then -a request to `https://jsonplaceholder.typicode.com/users` is triggered. - -Next, let's simulate a situation where an error is returned in the response. - -=== Get a user by id - -We are going to implement a method that will return a particular `User` based on provided `id`: - -[source,java] ----- -@Repository -class UserRestRepository implements UserRepository { - - private final RestClient restClient; - - @Override - public User findById(Long id) { - return restClient.get().uri("/{id}", id) - .retrieve() - .onStatus(HttpStatusCode::is4xxClientError, ((request, response) -> { - throw new UserNotFoundException(); - })) - .body(User.class); - } - - static class UserNotFoundException extends RuntimeException {} - -} - ----- - -In the implementation above, `UserNotFoundException` will be thrown when client error is returned as the response. In our test we will -simulate a situation where error resource not found is returned (`404`): - -[source,java] ----- -@RestClientTest(UserRestRepository.class) -class UserRepositoryTests { - - @Autowired - private UserRepository repository; - - @Autowired - private MockRestServiceServer mockServer; - - @Autowired - private ObjectMapper mapper; - - @Test - @DisplayName("When an invalid user id is provided Then UserNotFoundException will be thrown") - void findByInvalidId() { - mockServer.expect(requestTo("https://jsonplaceholder.typicode.com/users/84")).andRespond(withResourceNotFound()); - - assertThatThrownBy(() -> repository.findById(84L)).isInstanceOf(UserNotFoundException.class); - - mockServer.verify(); - } - -} ----- - -Full implementation of the test and its production code can be found in link:{source-main}/user/UserRestRepository.java[UserRepository] and link:{source-test}/user/UserRepositoryTests.java[UserRepositoryTests]. - -== HTTP Interface - -Spring allows us to define HTTP service as Java interface with `@HttpExchange` methods - `@DeleteExchange`, `@GetExchange`, `@PatchExchange`, `@PostExchange`, and `@PutExchange`. -In this tutorial we will use `@GetExchange` to retrieve all `Post` and to retrieve one link:{source-main}/post/main/Post.java[`Post`] by its `id`. - -=== PostRepository interface - -These methods are implemented in link:{source-main}/post/PostRepository.java[`PostRepository`]: - -[source,java] ----- -@HttpExchange(url = "/posts", accept = APPLICATION_JSON_VALUE) -interface PostRepository { - - @GetExchange - List findAll(); - - @GetExchange("/{id}") - Post findById(@PathVariable Long id); - -} ----- - -.In the implementation above we have defined the following: -* All methods in this class will call an endpoint that ends with `/posts` -* Each REST calls accepts `application/json` in the response -* `findAll` will return all `Post` -* `findById` will return `Post` that belongs to the requested `id` - -=== PostRepository configuration class - -Spring requires us to define which REST Client to use for API calls in `PostRepository`. In this tutorial, our choice will be `RestClient`. Our aim is to have -same outcome as `UserRepository`. - -[source,java] ----- -@Configuration -class PostRepositoryConfiguration { - - @Bean - public PostRepository postRepository(RestClient.Builder restClientBuilder) { - var restClient = restClientBuilder - .baseUrl("https://jsonplaceholder.typicode.com") - .defaultStatusHandler(HttpStatusCode::is4xxClientError, new PostErrorResponseHandler()) - .build(); - - return builderFor(create(restClient)) - .build() - .createClient(PostRepository.class); - } - - static class PostErrorResponseHandler implements ErrorHandler { - - @Override - public void handle(HttpRequest request, ClientHttpResponse response) throws IOException { - - if (response.getStatusCode() == NOT_FOUND) { throw new PostNotFoundException(); } - - } - - static class PostNotFoundException extends RuntimeException {} - } -} ----- - -.In link:{source-main}/post/PostRepositoryConfiguration.java[`PostRepositoryConfiguration`], we have defined: -* Our `RestClient` will trigger calls to `https://jsonplaceholder.typicode.com` -* When error `404` is returned then `PostNotFoundException` will be thrown -* `@HttpExchange` in `PostRepository` will use the `RestClient` that we have defined in `postRepository` - -=== Verify PostRepository implementation - -We will write same tests as `UserRepositoryTests` where we will validate retrieving all `Post` and an error will be thrown when invalid `id` is provided. - -==== Test configuration - -Given that we have a `@Configuration` class, the class need to be included in our test when defining `RestClientTest`: - -[source, java] ----- -@RestClientTest(components = PostRepository.class, includeFilters = @Filter(type = ASSIGNABLE_TYPE, classes = PostRepositoryConfiguration.class)) -class PostRepositoryTests { - - @Autowired - private PostRepository repository; - - @Autowired - private MockRestServiceServer mockServer; - - @Autowired - private ObjectMapper mapper; - -} ----- - -Now our test is aware about `PostRepositoryConfiguration`. The dependencies are the same as `UserRepositoryTests` except for our target repository - `PostRepository`. - -==== Get all posts - -In this test, we are expecting a HTTP call to `https://jsonplaceholder.typicode.com/posts` will be made when we trigger `PostRepository.findAll()`: - -[source,java] ----- -@RestClientTest(components = PostRepository.class, includeFilters = @Filter(type = ASSIGNABLE_TYPE, classes = PostRepositoryConfiguration.class)) -class PostRepositoryTests { - - @Autowired - private PostRepository repository; - - @Autowired - private MockRestServiceServer mockServer; - - @Autowired - private ObjectMapper mapper; - - @Test - @DisplayName("When requesting for all posts then response should contain all posts available") - void findAll() throws JsonProcessingException { - var content = mapper.writeValueAsString(posts()); - - mockServer.expect(requestTo("https://jsonplaceholder.typicode.com/posts")).andRespond(withSuccess(content, APPLICATION_JSON)); - - repository.findAll(); - - mockServer.verify(); - } - - private List posts() { - return List.of( - new Post(1L, 84L, "Spring Web: REST Clients Example with RESTClient", "An example of using RESTClient"), - new Post(2L, 84L, "Spring Web: REST Clients Example with HTTPExchange", "An example of using HttpExchange interface") - ); - } - -} ----- - -==== Get a post with invalid id - -Next, we want to validate that when we provide an invalid id to `PostRepository.findById()` the error `PostNotFoundException` will be thrown. To simulate this, -we will mock a response that returns `404`: - -[source,java] ----- -@RestClientTest(components = PostRepository.class, includeFilters = @Filter(type = ASSIGNABLE_TYPE, classes = PostRepositoryConfiguration.class)) -class PostRepositoryTests { - - @Autowired - private PostRepository repository; - - @Autowired - private MockRestServiceServer mockServer; - - @Autowired - private ObjectMapper mapper; - - @Test - @DisplayName("When requesting with an invalid post id Then an error PostNotFoundException will be thrown") - void findByInvalidId() { - mockServer.expect(requestTo("https://jsonplaceholder.typicode.com/posts/10101011")).andRespond(withResourceNotFound()); - - assertThatThrownBy(() -> repository.findById(10101011L)).isInstanceOf(PostNotFoundException.class); - } - -} ----- - -All the tests can be found in link:{source-test}/post/PostRepositoryTests.java[PostRepository]. - -== Conclusion - -`@HttpExchange` provides a cleaner implementation and the flexibility to choose which REST Client to be used. In this example, we are dealing with a synchronous API and we -chose `RestClient` over `RestTemplate`. If you are dealing with asynchronous API then `WebClient` should be your choice. +include::../../../../web-rest-client/README.adoc[lines=2..-1] diff --git a/generate-antora-pages.sh b/generate-antora-pages.sh index be03c250..a265be9d 100755 --- a/generate-antora-pages.sh +++ b/generate-antora-pages.sh @@ -17,6 +17,7 @@ SUBMODULES=( "data-repository-definition" "data-rest-validation" "graphql" + "modulith" "jooq" "data-mongodb-tc-data-load" "test-execution-listeners" @@ -29,7 +30,7 @@ SUBMODULES=( for submodule in "${SUBMODULES[@]}"; do # Extract the title from the README.adoc file title=$(head -n 1 "$submodule/README.adoc" | sed 's/^= //') - + # Create the Antora page cat > "docs/modules/ROOT/pages/$submodule.adoc" << EOF = $title @@ -41,4 +42,4 @@ EOF echo "Created Antora page for $submodule" done -echo "All Antora pages have been created." \ No newline at end of file +echo "All Antora pages have been created." From 38bb53a777d9b410898478d450a72a5020f18f4d Mon Sep 17 00:00:00 2001 From: Rashidi Zin Date: Sun, 6 Apr 2025 15:28:23 +0800 Subject: [PATCH 04/11] Fix sonar --- .../java/zin/rashidi/boot/modulith/course/CourseManagement.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modulith/src/main/java/zin/rashidi/boot/modulith/course/CourseManagement.java b/modulith/src/main/java/zin/rashidi/boot/modulith/course/CourseManagement.java index c7228d61..02c0a287 100644 --- a/modulith/src/main/java/zin/rashidi/boot/modulith/course/CourseManagement.java +++ b/modulith/src/main/java/zin/rashidi/boot/modulith/course/CourseManagement.java @@ -17,7 +17,7 @@ class CourseManagement { } @Transactional - void updateCourse(Course course) { + public void updateCourse(Course course) { courses.save(course); } From 32e6e2dd81ad0c2243bf43425b3d7ebb40305bbe Mon Sep 17 00:00:00 2001 From: Rashidi Zin Date: Sun, 6 Apr 2025 15:31:38 +0800 Subject: [PATCH 05/11] Fix sonar --- .github/workflows/build-and-publish-antora.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-publish-antora.yml b/.github/workflows/build-and-publish-antora.yml index f83b7003..217fc6fe 100644 --- a/.github/workflows/build-and-publish-antora.yml +++ b/.github/workflows/build-and-publish-antora.yml @@ -4,7 +4,7 @@ on: push: branches: - master - - docs/** + - modulith paths: - 'docs/**' - 'antora-playbook.yml' From e83599dfb0f81848a1b749ac2b2ac353db49750e Mon Sep 17 00:00:00 2001 From: Rashidi Zin Date: Sun, 6 Apr 2025 15:42:38 +0800 Subject: [PATCH 06/11] Fix sonar --- docs/modules/ROOT/pages/batch-rest-repository.adoc | 1 - docs/modules/ROOT/pages/batch-skip-step.adoc | 1 - docs/modules/ROOT/pages/cloud-jdbc-env-repo.adoc | 1 - docs/modules/ROOT/pages/data-domain-events.adoc | 1 - docs/modules/ROOT/pages/data-envers-audit.adoc | 1 - docs/modules/ROOT/pages/data-jdbc-audit.adoc | 1 - docs/modules/ROOT/pages/data-jpa-audit.adoc | 1 - docs/modules/ROOT/pages/data-jpa-event.adoc | 1 - docs/modules/ROOT/pages/data-jpa-filtered-query.adoc | 1 - docs/modules/ROOT/pages/data-mongodb-audit.adoc | 1 - docs/modules/ROOT/pages/data-mongodb-full-text-search.adoc | 1 - docs/modules/ROOT/pages/data-mongodb-tc-data-load.adoc | 1 - docs/modules/ROOT/pages/data-mongodb-transactional.adoc | 1 - docs/modules/ROOT/pages/data-repository-definition.adoc | 1 - docs/modules/ROOT/pages/data-rest-validation.adoc | 1 - docs/modules/ROOT/pages/graphql.adoc | 1 - docs/modules/ROOT/pages/jooq.adoc | 1 - docs/modules/ROOT/pages/modulith.adoc | 1 - docs/modules/ROOT/pages/test-execution-listeners.adoc | 1 - docs/modules/ROOT/pages/test-rest-assured.adoc | 1 - 20 files changed, 20 deletions(-) diff --git a/docs/modules/ROOT/pages/batch-rest-repository.adoc b/docs/modules/ROOT/pages/batch-rest-repository.adoc index e650a66e..5436582f 100644 --- a/docs/modules/ROOT/pages/batch-rest-repository.adoc +++ b/docs/modules/ROOT/pages/batch-rest-repository.adoc @@ -1,4 +1,3 @@ = Spring Batch: Working With REST Resources -:page-aliases: batch-rest-repository.adoc include::../../../../batch-rest-repository/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/batch-skip-step.adoc b/docs/modules/ROOT/pages/batch-skip-step.adoc index 876567da..ae8d43ca 100644 --- a/docs/modules/ROOT/pages/batch-skip-step.adoc +++ b/docs/modules/ROOT/pages/batch-skip-step.adoc @@ -1,4 +1,3 @@ = Spring Batch: Skip Specific Data -:page-aliases: batch-skip-step.adoc include::../../../../batch-skip-step/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/cloud-jdbc-env-repo.adoc b/docs/modules/ROOT/pages/cloud-jdbc-env-repo.adoc index 5c6b8940..1be4a262 100644 --- a/docs/modules/ROOT/pages/cloud-jdbc-env-repo.adoc +++ b/docs/modules/ROOT/pages/cloud-jdbc-env-repo.adoc @@ -1,4 +1,3 @@ = Spring Cloud: JDBCEnvironmentRepository Sample Application -:page-aliases: cloud-jdbc-env-repo.adoc include::../../../../cloud-jdbc-env-repo/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/data-domain-events.adoc b/docs/modules/ROOT/pages/data-domain-events.adoc index f73dc833..e3717092 100644 --- a/docs/modules/ROOT/pages/data-domain-events.adoc +++ b/docs/modules/ROOT/pages/data-domain-events.adoc @@ -1,4 +1,3 @@ = Spring Data: Domain Events Example -:page-aliases: data-domain-events.adoc include::../../../../data-domain-events/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/data-envers-audit.adoc b/docs/modules/ROOT/pages/data-envers-audit.adoc index 1da6ad0d..9f7a2868 100644 --- a/docs/modules/ROOT/pages/data-envers-audit.adoc +++ b/docs/modules/ROOT/pages/data-envers-audit.adoc @@ -1,4 +1,3 @@ = Spring Data Envers: Audit With Entity Revisions -:page-aliases: data-envers-audit.adoc include::../../../../data-envers-audit/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/data-jdbc-audit.adoc b/docs/modules/ROOT/pages/data-jdbc-audit.adoc index aee433a2..f2462e54 100644 --- a/docs/modules/ROOT/pages/data-jdbc-audit.adoc +++ b/docs/modules/ROOT/pages/data-jdbc-audit.adoc @@ -1,4 +1,3 @@ = Spring Data JDBC: Implement Auditing -:page-aliases: data-jdbc-audit.adoc include::../../../../data-jdbc-audit/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/data-jpa-audit.adoc b/docs/modules/ROOT/pages/data-jpa-audit.adoc index 04fff412..6edf2992 100644 --- a/docs/modules/ROOT/pages/data-jpa-audit.adoc +++ b/docs/modules/ROOT/pages/data-jpa-audit.adoc @@ -1,4 +1,3 @@ = Spring Data Jpa Audit Example -:page-aliases: data-jpa-audit.adoc include::../../../../data-jpa-audit/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/data-jpa-event.adoc b/docs/modules/ROOT/pages/data-jpa-event.adoc index 0a2ef0f4..dd8a4654 100644 --- a/docs/modules/ROOT/pages/data-jpa-event.adoc +++ b/docs/modules/ROOT/pages/data-jpa-event.adoc @@ -1,4 +1,3 @@ = Spring Data JPA: Perform Entity Validation at Repository Level through Event Driven -:page-aliases: data-jpa-event.adoc include::../../../../data-jpa-event/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/data-jpa-filtered-query.adoc b/docs/modules/ROOT/pages/data-jpa-filtered-query.adoc index 3b3fdea1..b1fbad4f 100644 --- a/docs/modules/ROOT/pages/data-jpa-filtered-query.adoc +++ b/docs/modules/ROOT/pages/data-jpa-filtered-query.adoc @@ -1,4 +1,3 @@ = Spring Data Jpa: Global Filter Query -:page-aliases: data-jpa-filtered-query.adoc include::../../../../data-jpa-filtered-query/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/data-mongodb-audit.adoc b/docs/modules/ROOT/pages/data-mongodb-audit.adoc index 8ae35499..fccd67a7 100644 --- a/docs/modules/ROOT/pages/data-mongodb-audit.adoc +++ b/docs/modules/ROOT/pages/data-mongodb-audit.adoc @@ -1,4 +1,3 @@ = Spring Data MongoDB Audit Example -:page-aliases: data-mongodb-audit.adoc include::../../../../data-mongodb-audit/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/data-mongodb-full-text-search.adoc b/docs/modules/ROOT/pages/data-mongodb-full-text-search.adoc index 42c0c5fa..d9ecb203 100644 --- a/docs/modules/ROOT/pages/data-mongodb-full-text-search.adoc +++ b/docs/modules/ROOT/pages/data-mongodb-full-text-search.adoc @@ -1,4 +1,3 @@ = Spring Data MongoDB: Full Text Search -:page-aliases: data-mongodb-full-text-search.adoc include::../../../../data-mongodb-full-text-search/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/data-mongodb-tc-data-load.adoc b/docs/modules/ROOT/pages/data-mongodb-tc-data-load.adoc index dea18c39..c441d2b9 100644 --- a/docs/modules/ROOT/pages/data-mongodb-tc-data-load.adoc +++ b/docs/modules/ROOT/pages/data-mongodb-tc-data-load.adoc @@ -1,4 +1,3 @@ = Spring Data MongoDb with Testcontainers -:page-aliases: data-mongodb-tc-data-load.adoc include::../../../../data-mongodb-tc-data-load/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/data-mongodb-transactional.adoc b/docs/modules/ROOT/pages/data-mongodb-transactional.adoc index aa2bd4b6..efcee742 100644 --- a/docs/modules/ROOT/pages/data-mongodb-transactional.adoc +++ b/docs/modules/ROOT/pages/data-mongodb-transactional.adoc @@ -1,4 +1,3 @@ = @Transactional with Spring Data MongoDB -:page-aliases: data-mongodb-transactional.adoc include::../../../../data-mongodb-transactional/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/data-repository-definition.adoc b/docs/modules/ROOT/pages/data-repository-definition.adoc index 68f3b278..77b75552 100644 --- a/docs/modules/ROOT/pages/data-repository-definition.adoc +++ b/docs/modules/ROOT/pages/data-repository-definition.adoc @@ -1,4 +1,3 @@ = Spring Data: Repository Definition -:page-aliases: data-repository-definition.adoc include::../../../../data-repository-definition/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/data-rest-validation.adoc b/docs/modules/ROOT/pages/data-rest-validation.adoc index 093929a3..72877445 100644 --- a/docs/modules/ROOT/pages/data-rest-validation.adoc +++ b/docs/modules/ROOT/pages/data-rest-validation.adoc @@ -1,4 +1,3 @@ = Spring Data REST: Validation -:page-aliases: data-rest-validation.adoc include::../../../../data-rest-validation/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/graphql.adoc b/docs/modules/ROOT/pages/graphql.adoc index b8b80884..6a760a59 100644 --- a/docs/modules/ROOT/pages/graphql.adoc +++ b/docs/modules/ROOT/pages/graphql.adoc @@ -1,4 +1,3 @@ = GraphQL With Spring Boot -:page-aliases: graphql.adoc include::../../../../graphql/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/jooq.adoc b/docs/modules/ROOT/pages/jooq.adoc index fac977f1..1acd80f4 100644 --- a/docs/modules/ROOT/pages/jooq.adoc +++ b/docs/modules/ROOT/pages/jooq.adoc @@ -1,4 +1,3 @@ = jOOQ: Implement jOOQ with Spring Boot and Gradle -:page-aliases: jooq.adoc include::../../../../jooq/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/modulith.adoc b/docs/modules/ROOT/pages/modulith.adoc index eb098c20..7369b7d4 100644 --- a/docs/modules/ROOT/pages/modulith.adoc +++ b/docs/modules/ROOT/pages/modulith.adoc @@ -1,4 +1,3 @@ = Spring Modulith: Building Modular Monolithic Applications -:page-aliases: modulith.adoc include::../../../../modulith/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/test-execution-listeners.adoc b/docs/modules/ROOT/pages/test-execution-listeners.adoc index d2708805..f8e4a0bf 100644 --- a/docs/modules/ROOT/pages/test-execution-listeners.adoc +++ b/docs/modules/ROOT/pages/test-execution-listeners.adoc @@ -1,4 +1,3 @@ = Spring Test: Managing Test Data with `TestExecutionListener` -:page-aliases: test-execution-listeners.adoc include::../../../../test-execution-listeners/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/test-rest-assured.adoc b/docs/modules/ROOT/pages/test-rest-assured.adoc index 1fa96e53..160295eb 100644 --- a/docs/modules/ROOT/pages/test-rest-assured.adoc +++ b/docs/modules/ROOT/pages/test-rest-assured.adoc @@ -1,4 +1,3 @@ = Spring Test: Implement BDD with RestAssured -:page-aliases: test-rest-assured.adoc include::../../../../test-rest-assured/README.adoc[lines=2..-1] From 2376a40b0504d3845f0ef034ac3592265e7b8da8 Mon Sep 17 00:00:00 2001 From: Rashidi Zin Date: Sun, 6 Apr 2025 15:45:23 +0800 Subject: [PATCH 07/11] Fix sonar --- docs/modules/ROOT/pages/test-slice-tests-rest.adoc | 1 - docs/modules/ROOT/pages/web-rest-client.adoc | 1 - 2 files changed, 2 deletions(-) diff --git a/docs/modules/ROOT/pages/test-slice-tests-rest.adoc b/docs/modules/ROOT/pages/test-slice-tests-rest.adoc index 476c84f2..e3887939 100644 --- a/docs/modules/ROOT/pages/test-slice-tests-rest.adoc +++ b/docs/modules/ROOT/pages/test-slice-tests-rest.adoc @@ -1,4 +1,3 @@ = Spring Test: Slice Testing a REST Application -:page-aliases: test-slice-tests-rest.adoc include::../../../../test-slice-tests-rest/README.adoc[lines=2..-1] diff --git a/docs/modules/ROOT/pages/web-rest-client.adoc b/docs/modules/ROOT/pages/web-rest-client.adoc index f0299b52..53376a31 100644 --- a/docs/modules/ROOT/pages/web-rest-client.adoc +++ b/docs/modules/ROOT/pages/web-rest-client.adoc @@ -1,4 +1,3 @@ = Spring Web: REST Clients for calling Synchronous API -:page-aliases: web-rest-client.adoc include::../../../../web-rest-client/README.adoc[lines=2..-1] From 446498ac67c4d2c05b34e75f55102c4266f9bfe9 Mon Sep 17 00:00:00 2001 From: Rashidi Zin Date: Sun, 6 Apr 2025 16:23:07 +0800 Subject: [PATCH 08/11] Fix sonar --- .../ROOT/pages/batch-rest-repository.adoc | 139 +++++- docs/modules/ROOT/pages/batch-skip-step.adoc | 150 +++++- .../ROOT/pages/cloud-jdbc-env-repo.adoc | 191 +++++++- .../ROOT/pages/data-domain-events.adoc | 185 +++++++- .../modules/ROOT/pages/data-envers-audit.adoc | 179 +++++++- docs/modules/ROOT/pages/data-jdbc-audit.adoc | 169 ++++++- docs/modules/ROOT/pages/data-jpa-audit.adoc | 206 ++++++++- docs/modules/ROOT/pages/data-jpa-event.adoc | 181 +++++++- .../ROOT/pages/data-jpa-filtered-query.adoc | 143 +++++- .../ROOT/pages/data-mongodb-audit.adoc | 126 +++++- .../pages/data-mongodb-full-text-search.adoc | 192 +++++++- .../ROOT/pages/data-mongodb-tc-data-load.adoc | 285 +++++++++++- .../pages/data-mongodb-transactional.adoc | 130 +++++- .../pages/data-repository-definition.adoc | 95 +++- .../ROOT/pages/data-rest-validation.adoc | 128 +++++- docs/modules/ROOT/pages/graphql.adoc | 189 +++++++- docs/modules/ROOT/pages/index.adoc | 1 - docs/modules/ROOT/pages/jooq.adoc | 187 +++++++- docs/modules/ROOT/pages/modulith.adoc | 246 +++++++++- .../ROOT/pages/test-execution-listeners.adoc | 128 +++++- .../modules/ROOT/pages/test-rest-assured.adoc | 428 +++++++++++++++++- .../ROOT/pages/test-slice-tests-rest.adoc | 294 +++++++++++- docs/modules/ROOT/pages/web-rest-client.adoc | 355 ++++++++++++++- generate-antora-pages.sh | 25 +- modulith/README.adoc | 6 + 25 files changed, 4332 insertions(+), 26 deletions(-) diff --git a/docs/modules/ROOT/pages/batch-rest-repository.adoc b/docs/modules/ROOT/pages/batch-rest-repository.adoc index 5436582f..0ed6bfd4 100644 --- a/docs/modules/ROOT/pages/batch-rest-repository.adoc +++ b/docs/modules/ROOT/pages/batch-rest-repository.adoc @@ -1,3 +1,140 @@ = Spring Batch: Working With REST Resources +:source-highlighter: highlight.js +Rashidi Zin +2.0, September 27, 2023 +:nofooter: +:icons: font +:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/batch-rest-repository -include::../../../../batch-rest-repository/README.adoc[lines=2..-1] +Implement batch operation for REST resources with https://spring.io/projects/spring-batch[Spring Batch] + + +== Background +Spring Batch allows us to perform large volumes of records from several resources such as https://docs.spring.io/spring-batch/docs/current/api/org/springframework/batch/item/file/FlatFileItemReader.html[File], +https://docs.spring.io/spring-batch/docs/current/api/org/springframework/batch/item/database/JpaPagingItemReader.html[Relational Database], and, +https://docs.spring.io/spring-batch/docs/current/api/org/springframework/batch/item/json/JsonItemReader.html[JSON file] to name a few. + +In this article, we will explore how to implement batch operation that reads from REST resources with Spring Batch through `JsonItemReader`. We will retrieve a list of users from https://jsonplaceholder.typicode.com/users[JSON Placeholder] and save them into a database. + +== Job Configuration +Next is to implement the job that will be responsible to read from REST resource and save them into a database. `Job` consists of `Step` and `Step` +consists of `ItemReader` and `ItemWriter`. We will implement all of them in link:{url-quickref}/src/main/java/zin/rashidi/boot/batch/rest/user/UserJobConfiguration.java[UserJobConfiguration]. + +[source,java] +---- +@Configuration +class UserJobConfiguration { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final MongoOperations mongo; + + UserJobConfiguration(JobRepository jobRepository, PlatformTransactionManager transactionManager, MongoOperations mongo) { + this.jobRepository = jobRepository; + this.transactionManager = transactionManager; + this.mongo = mongo; + } + + @Bean + public Job userJob() throws MalformedURLException { + return new JobBuilder("userJob", jobRepository).start(step()).build(); + } + + private Step step() throws MalformedURLException { + return new StepBuilder("userStep", jobRepository) + .chunk(10, transactionManager) + .reader(reader()) + .writer(writer()) + .build(); + } + + private JsonItemReader reader() throws MalformedURLException { + JacksonJsonObjectReader jsonObjectReader = new JacksonJsonObjectReader<>(User.class); + + jsonObjectReader.setMapper(new ObjectMapper()); + + return new JsonItemReaderBuilder() + .name("userReader") + .jsonObjectReader(jsonObjectReader) + .resource(new UrlResource("https://jsonplaceholder.typicode.com/users")) + .build(); + } + + private MongoItemWriter writer() { + return new MongoItemWriterBuilder() + .template(mongo) + .build(); + } + +} +---- + +From the code above, we can see that a `URL` form of `Resource` is assigned to `JsonItemReader`. We will depend on `JacksonJsonObjectRader` to convert response from link:https://jsonplaceholder.typicode.com/users[JSON Placeholder] to `User` object. + +[source,java] +---- +@Configuration +class UserJobConfiguration { + + private JsonItemReader reader() throws MalformedURLException { + JacksonJsonObjectReader jsonObjectReader = new JacksonJsonObjectReader<>(User.class); + + jsonObjectReader.setMapper(new ObjectMapper()); + + return new JsonItemReaderBuilder() + .name("userReader") + .jsonObjectReader(jsonObjectReader) + .resource(new UrlResource("https://jsonplaceholder.typicode.com/users")) + .build(); + } + +} +---- + +Now that we have implemented the `Job`, we can verify that it is working by executing an integration test. + +== Verification +We will launch `userJob` which will retrieve list of `User` from https://jsonplaceholder.typicode.com/users[JSON Placeholder] and save them into a database. +Once completed then we will verify that the database contains the expected number of users. + +[source,java] +---- +@Testcontainers +@SpringBatchTest +@SpringBootTest(classes = { BatchTestConfiguration.class, MongoTestConfiguration.class, UserJobConfiguration.class }, webEnvironment = NONE) +class UserBatchJobTests { + + @Container + @ServiceConnection + private final static MySQLContainer MYSQL_CONTAINER = new MySQLContainer<>("mysql:latest") + .withInitScript("org/springframework/batch/core/schema-mysql.sql"); + + @Container + @ServiceConnection + private final static MongoDBContainer MONGO_DB_CONTAINER = new MongoDBContainer("mongo:latest"); + + @Autowired + private JobLauncherTestUtils launcher; + + @Autowired + private MongoOperations mongoOperations; + + @Test + @DisplayName("Given there are 10 users returned from REST Service When the job is COMPLETED Then all users should be saved to MongoDB") + void launch() { + + await().atMost(ofSeconds(30)).untilAsserted(() -> { + var execution = launcher.launchJob(); + + assertThat(execution.getExitStatus()).isEqualTo(COMPLETED); + }); + + var persistedUsers = mongoOperations.findAll(User.class); + + assertThat(persistedUsers).hasSize(10); + } + +} +---- + +Full implementation can be found in link:{url-quickref}/src/test/java/zin/rashidi/boot/batch/rest/user/UserBatchJobTests.java[UserBatchJobTests]. diff --git a/docs/modules/ROOT/pages/batch-skip-step.adoc b/docs/modules/ROOT/pages/batch-skip-step.adoc index ae8d43ca..f28093c2 100644 --- a/docs/modules/ROOT/pages/batch-skip-step.adoc +++ b/docs/modules/ROOT/pages/batch-skip-step.adoc @@ -1,3 +1,151 @@ = Spring Batch: Skip Specific Data +:source-highlighter: highlight.js +Rashidi Zin +2.0, October 1, 2023 +:nofooter: +:icons: font +:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/batch-skip-step -include::../../../../batch-skip-step/README.adoc[lines=2..-1] +Skip processing specific data through business logic in Spring Batch. + +== Background + +Spring Batch is a framework for batch processing – execution of a series of jobs. A job is composed of a series of steps. +Each step consists of a reader, a processor, and a writer. The reader reads data from a data source, the processor +processes the data, and the writer writes the processed data to a data source. + +There are scenarios where we want to skip processing specific data through business logic. For example, in this guide, +we want to skip data where `username` are either `Elwyn.Skiles` or `Maxime_Nienow`. We will look into two approaches; +one by returning `null` and another by throwing `RuntimeException`. Both approaches will be implemented in the same +`ItemProcessor`. + +Our application will process data from link:{url-quickref}/src/main/resources/users.json[users.json] file and write the processed data +into MySQL database. Content of `users.json` is taken from link:https://jsonplaceholder.typicode.com/users[JSONPlaceholder]. + +== Implement Logics to Skip Data + +=== Returning `null` + +First approach is to return `null` from `ItemProcessor` implementation. This approach is straight forward and does not +require additional configuration. + +[source,java] +---- +@Configuration +class UserJobConfiguration { + + private ItemProcessor processor() { + return item -> switch (item.username()) { + case "Elwyn.Skiles" -> null; + }; + } + +} +---- + +With that, when the `Job` detected that the `ItemProcessor` returns `null`, it will skip the data and continue to the next. + +=== Throwing `RuntimeException` + +Second approach is to throw `RuntimeException` from `ItemProcessor` implementation. This approach requires additional +configuration to be done when defining `Step`. + +Implementation in `ItemProcessor` is as follows: + +[source,java] +---- +@Configuration +class UserJobConfiguration { + + private ItemProcessor processor() { + return item -> switch (item.username()) { + case "Maxime_Nienow" -> throw new UsernameNotAllowedException(item.username()); + }; + } + + static class UsernameNotAllowedException extends RuntimeException { + + public UsernameNotAllowedException(String username) { + super("Username " + username + " is not allowed"); + } + + } +} +---- + +Next is to inform `Step` to skip `UsernameNotAllowedException`: + +[source,java] +---- +@Configuration +class UserJobConfiguration { + + private Step step(JobRepository jobRepository, PlatformTransactionManager transactionManager, DataSource dataSource) { + return new StepBuilder("userStep", jobRepository) + .chunk(10, transactionManager) + .faultTolerant() + .skip(UsernameNotAllowedException.class) + .skipLimit(1) + .build(); + } + +} +---- + +With that, when the `Job` detected that the `ItemProcessor` throws `UsernameNotAllowedException`, it will skip the data. +Full definition of the `Job` can be found in link:{url-quickref}/src/main/java/zin/rashidi/boot/batch/user/UserJobConfiguration.java[UserJobConfiguration.java]. + +== Verification + +We will implement integration tests to verify that our implementation is working as intended whereby `Elwyn.Skiles` and +`Maxime_Nienow` are skipped thus will not be available in the database. + +[source,java] +---- +@Testcontainers +@SpringBatchTest +@SpringBootTest(classes = { + BatchTestConfiguration.class, + JdbcTestConfiguration.class, + UserJobConfiguration.class +}, webEnvironment = NONE) +@Sql( + scripts = { + "classpath:org/springframework/batch/core/schema-drop-mysql.sql", + "classpath:org/springframework/batch/core/schema-mysql.sql" + }, + statements = "CREATE TABLE IF NOT EXISTS users (id BIGINT PRIMARY KEY, name text, username text)" +) +class UserBatchJobTests { + + @Container + @ServiceConnection + private final static MySQLContainer MYSQL_CONTAINER = new MySQLContainer<>("mysql:latest"); + + @Autowired + private JobLauncherTestUtils launcher; + + @Autowired + private JdbcTemplate jdbc; + + @Test + @DisplayName("Given the username Elwyn.Skiles and Maxime_Nienow are skipped, When job is executed, Then users are not inserted into database") + void findAll() { + + await().atMost(10, SECONDS).untilAsserted(() -> { + var execution = launcher.launchJob(); + assertThat(execution.getExitStatus()).isEqualTo(COMPLETED); + }); + + var users = jdbc.query("SELECT * FROM users", (rs, rowNum) -> + new User(rs.getLong("id"), rs.getString("name"), rs.getString("username")) + ); + + assertThat(users).extracting("username").doesNotContain("Elwyn.Skiles", "Maxime_Nienow"); + } + +} +---- + +By executing our tests in link:{url-quickref}src/test/java/zin/rashidi/boot/batch/user/UserBatchJobTests.java[UserBatchJobTests.java], +we will see that all users are processed except `Elwyn.Skiles` and `Maxime_Nienow`. diff --git a/docs/modules/ROOT/pages/cloud-jdbc-env-repo.adoc b/docs/modules/ROOT/pages/cloud-jdbc-env-repo.adoc index 1be4a262..b00ec94c 100644 --- a/docs/modules/ROOT/pages/cloud-jdbc-env-repo.adoc +++ b/docs/modules/ROOT/pages/cloud-jdbc-env-repo.adoc @@ -1,3 +1,192 @@ = Spring Cloud: JDBCEnvironmentRepository Sample Application +:source-highlighter: highlight.js +Rashidi Zin +2.0, September 25, 2023 +:nofooter: +:icons: font +:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/cloud-jdbc-env-repo -include::../../../../cloud-jdbc-env-repo/README.adoc[lines=2..-1] +Sample of https://cloud.spring.io/spring-cloud-config/single/spring-cloud-config.html#_spring_cloud_config_server[Spring Cloud Config Server] embedded application that uses database as backend for configuration properties. + + +== Background + +https://cloud.spring.io/spring-cloud-config/single/spring-cloud-config.html#_spring_cloud_config_server[Spring Cloud Config Server] provides several options to store configuration for an application. In general it is handled +by https://cloud.spring.io/spring-cloud-config/single/spring-cloud-config.html#_environment_repository[Environment Repository]. + +Available options are git, file system, vault, svn, and database. This application demonstrates usage of https://cloud.spring.io/spring-cloud-config/single/spring-cloud-config.html#_jdbc_backend[JdbcEnvironmentRepository] +which allows an application to store its configuration in database. + +== Configuration + +In order to enable this feature we will include `spring-boot-starter-jdbc` as one of the dependencies for the application and +include `jdbc` as one of its active profiles. + +=== Include JDBC as dependency + +This can be seen in link:{url-quickref}/build.gradle[build.gradle]. + +[source,groovy] +---- +implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' +---- + +=== Include `jdbc` as active profile + +This can be seen in link:{url-quickref}/src/main/resources/bootstrap.yml[bootstrap.yml]. + +[source,yaml] +---- +spring: + profiles: + active: jdbc +---- + +== Creating table and populating data + +By default the https://cloud.spring.io/spring-cloud-config/single/spring-cloud-config.html#_jdbc_backend[JdbcEnvironmentRepository] will look into a table called `PROPERTIES` which contains the following columns: + +* KEY +* VALUE +* APPLICATION +* PROFILE +* LABEL + +=== Create table schema + +Schema to create the table can found in link:{url-quickref}/src/test/resources/init-script.sql[init-script.sql]: + +[source,sql] +---- +CREATE TABLE `PROPERTIES` +( + `KEY` VARCHAR(128), + `VALUE` VARCHAR(128), + `APPLICATION` VARCHAR(128), + `PROFILE` VARCHAR(128), + `LABEL` VARCHAR(128), + PRIMARY KEY (`KEY`, `APPLICATION`, `LABEL`) +); +---- + +In the script above KEY, APPLICATION, PROFILE, and LABEL are marked as composite key in order to avoid duplicated entry. + +=== Populate table + +For this demonstration will we have a configuration called `app.greet.name` and this will be populated upon start-up. +Its script can be found in link:{url-quickref}/src/test/resources/init-script.sql[init-script.sql]. + +[source,sql] +---- +INSERT INTO PROPERTIES (`APPLICATION`, `PROFILE`, `LABEL`, `KEY`, `VALUE`) +VALUES ('demo', 'default', 'master', 'app.greet.name', 'Demo'); +---- + +The script above explains that the configuration `app.greet.name` belongs to: + +* an application called _demo_ +* with profile called _default_ +* and labelled _master_ + +== Configure Application Properties + +In order for https://cloud.spring.io/spring-cloud-config/single/spring-cloud-config.html#_jdbc_backend[JdbcEnvironmentRepository] to retrieve properties for this application it will need to be informed on +its name, profile, and label. This configurations can be found in link:{url-quickref}/src/main/resources/bootstrap.yml[bootstrap.yml] + +[source,yaml] +---- +spring: + application: + name: demo +---- + +We are not configuring `spring.cloud.profile` because its default value is `default`. + +== Create Bootstrap Application Context + +Finally, we will need to inform Spring Cloud on what are the classes needed in order to build the +bootstrap application context. This can found in link:{url-quickref}/src/main/resources/META-INF/spring.factories[spring.factories]: + +[source,text] +---- +org.springframework.cloud.bootstrap.BootstrapConfiguration=\ + org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration,\ + org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration +---- + +These two classes will help us to build https://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/jdbc/core/JdbcTemplate.html[JdbcTemplate] which is needed to construct https://github.com/spring-cloud/spring-cloud-config/blob/master/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/JdbcEnvironmentRepository.java[JdbcEnvironmentRepository]. + +== Verify Configuration Properties + +In order to ensure that the application will use configurations from database we will create same configuration in link:{url-quickref}/src/main/resources/application.yml[application.yml]: + +[source,yaml] +---- +app: + greet: + name: Default +---- + +== Configure Environment Properties Retrieval SQL + +By default, https://github.com/spring-cloud/spring-cloud-config/blob/main/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/JdbcEnvironmentProperties.java#L30[there are two SQL statements that are used to retrieve properties from database]. +However, these queries need to be modified to follow MySQL requirement and implemented in link:{url-quickref}/src/main/resources/bootstrap.yml[bootstrap.yml]: + +[source,yaml] +---- +spring: + cloud: + config: + server: + jdbc: + sql: SELECT `KEY`, `VALUE` from PROPERTIES where APPLICATION=? and PROFILE=? and LABEL=? + sql-without-profile: SELECT `KEY`, `VALUE` from PROPERTIES where APPLICATION=? and PROFILE='default' and LABEL=? +---- + +We will have link:{url-quickref}/src/main/java/zin/rashidi/boot/cloud/jdbcenvrepo/greet/GreetResource.java[GreetResource] which will retrieve the value of `app.greet.name` from link:{url-quickref}/src/main/java/zin/rashidi/boot/cloud/jdbcenvrepo/greet/GreetProperties.java[GreetProperties]. + +[source,java] +---- +@RestController +class GreetResource { + + private final GreetProperties properties; + + GreetResource(GreetProperties properties) { + this.properties = properties; + } + + @GetMapping("/greet") + public String greet(@RequestParam String greeting) { + return String.format("%s, my name is %s", greeting, properties.name()); + } + +} +---- + +Next we will have link:{url-quickref}/src/test/java/zin/rashidi/boot/cloud/jdbcenvrepo/CloudJdbcEnvRepoApplicationTests.java[CloudJdbcEnvRepoApplicationTests] class that verifies that the value for `app.greet.name` is *Demo* and not *Default*: + +[source,java] +---- +@Testcontainers +@SpringBootTest(properties = "spring.datasource.url=jdbc:tc:mysql:8:///test?TC_INITSCRIPT=init-script.sql", webEnvironment = RANDOM_PORT) +class CloudJdbcEnvRepoApplicationTests { + + @Container + private static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8"); + + @Autowired + private TestRestTemplate restClient; + + @Test + @DisplayName("Given app.greet.name is configured to Demo in the database When I call greet Then I should get Hello, my name is Demo") + void greet() { + var response = restClient.getForEntity("/greet?greeting={0}", String.class, "Hello"); + + assertThat(response.getBody()).isEqualTo("Hello, my name is Demo"); + } + +} +---- + +By executing `greet()` we verify that the returned response is *Hello, my name is Demo* and not *Hello, my name is Default*. diff --git a/docs/modules/ROOT/pages/data-domain-events.adoc b/docs/modules/ROOT/pages/data-domain-events.adoc index e3717092..a5c6166e 100644 --- a/docs/modules/ROOT/pages/data-domain-events.adoc +++ b/docs/modules/ROOT/pages/data-domain-events.adoc @@ -1,3 +1,186 @@ = Spring Data: Domain Events Example +:source-highlighter: highlight.js +Rashidi Zin +1.0, July 30, 2023 +:nofooter: +:icons: font +:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/data-domain-events -include::../../../../data-domain-events/README.adoc[lines=2..-1] +Reduce method complexity by utilising `@DomainEvents` from link:https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#core.domain-events[Spring Data JPA]. + +== Background +In this repository we will explore Spring Data JPA helps us to adhere to link:https://en.wikipedia.org/wiki/Single-responsibility_principle[Single Responsibility], a component of link:https://en.wikipedia.org/wiki/SOLID[SOLID Principles]. + +We will reduce responsibilities of a method that does more than one thing. + +== Scenario +This repository demonstrates a scenario where once a book is purchased, its total availability will be reduced. + +== Implementation + +=== Integration End-to-end Test +In the spirit of TDD, we will start by implementing an integration end-to-end test. + +[source,java] +---- +@SpringBootTest( + classes = TestDataDomainEventsApplication.class, + properties = "spring.jpa.hibernate.ddl-auto=create", + webEnvironment = RANDOM_PORT +) +class BookPurchaseTests { + + @Autowired + private BookAvailabilityRepository availabilities; + + @Autowired + private BookRepository books; + + @Autowired + private TestRestTemplate client; + + private Book book; + + @BeforeEach + void setup() { + book = books.save(book()); + + availabilities.save(availability()); + } + + @Test + @DisplayName("Given total book availability is 100 When a book is purchased Then total book availability should be 99") + void purchase() { + client.delete("/books/{id}/purchase", book.getId()); + + var availability = availabilities.findByIsbn(book.getIsbn()); + + assertThat(availability).get() + .extracting("total") + .isEqualTo(99); + } + + private Book book() { + var book = new Book(); + + book.setTitle("Say Nothing: A True Story of Murder and Memory in Northern Ireland"); + book.setAuthor("Patrick Radden Keefe"); + book.setIsbn(9780385543378L); + + return book; + } + + private BookAvailability availability() { + var availability = new BookAvailability(); + + availability.setIsbn(9780385543378L); + availability.setTotal(100); + + return availability; + } + +} +---- + +Full implementation can be found in link:{url-quickref}/src/test/java/zin/rashidi/boot/data/de/availability/BookPurchaseTests.java[BookPurchaseTests.java]. + +=== Domain and Repository class + +Our domain class, link:{url-quickref}/src/main/java/zin/rashidi/boot/data/de/book/Book.java[Book.java], will hold information about the event that will be published. + +[source,java] +---- +@Entity +public class Book extends AbstractAggregateRoot { + + @Id + @GeneratedValue + private Long id; + private String title; + private String author; + private Long isbn; + + // getter & setter are omitted for brevity + + public Book purchase() { + registerEvent(new BookPurchaseEvent(this)); + return this; + } + +} +---- + +The class will publish link:{url-quickref}/src/main/java/zin/rashidi/boot/data/de/book/BookPurchaseEvent.java[BookPurchaseEvent.java] when a book is purchased. + +Next is to implement a repository classes for `Book` and link:{url-quickref}/src/main/java/zin/rashidi/boot/data/de/availability/BookAvailability.java[BookAvailability.java]. + +[source,java] +---- +public interface BookRepository extends JpaRepository { +} +---- + +[source,java] +---- +interface BookAvailabilityRepository extends JpaRepository { + + Optional findByIsbn(Long isbn); + +} +---- + +==== REST Resource Class + +link:{url-quickref}/src/main/java/zin/rashidi/boot/data/de/book/BookResource.java[BookResource] is a typical `@RestController` class which will trigger `Book.purchase`. + +[source,java] +---- +@RestController +class BookResource { + + private final BookRepository repository; + + BookResource(BookRepository repository) { + this.repository = repository; + } + + @Transactional + @DeleteMapping("/books/{id}/purchase") + public void purchase(@PathVariable Long id) { + repository.findById(id).map(Book::purchase).ifPresent(repository::delete); + } + +} +---- + +==== Event Listener Class + +Finally, we will implement a `@Service` class that will observe link:{url-quickref}/src/main/java/zin/rashidi/boot/data/de/book/BookPurchaseEvent.java[BookPurchaseEvent] and reduce the total availability of the book. + +[source,java] +---- +@Service +class BookAvailabilityManagement { + + private final BookAvailabilityRepository repository; + + BookAvailabilityManagement(BookAvailabilityRepository repository) { + this.repository = repository; + } + + @TransactionalEventListener + @Transactional(propagation = REQUIRES_NEW) + public void updateTotal(BookPurchaseEvent event) { + var book = event.getSource(); + + repository.findByIsbn(book.getIsbn()) + .map(BookAvailability::reduceTotal) + .ifPresent(repository::save); + } + +} +---- + +== Verification + +By executing `BookPurchaseTests.purchase`, we will see that the test passes. diff --git a/docs/modules/ROOT/pages/data-envers-audit.adoc b/docs/modules/ROOT/pages/data-envers-audit.adoc index 9f7a2868..9b5fa6e8 100644 --- a/docs/modules/ROOT/pages/data-envers-audit.adoc +++ b/docs/modules/ROOT/pages/data-envers-audit.adoc @@ -1,3 +1,180 @@ = Spring Data Envers: Audit With Entity Revisions +:source-highlighter: highlight.js +Rashidi Zin +2.1, November 26, 2024 +:nofooter: +:icons: font +:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/data-envers-audit -include::../../../../data-envers-audit/README.adoc[lines=2..-1] +Sample application that demonstrates entity revisions with http://projects.spring.io/spring-data-envers/[Spring Data Envers]. + + +== Background + +https://projects.spring.io/spring-data-jpa/[Spring Data Jpa] provides rough audit information. However, if you are looking for what are the exact changes being +made to an entity you can do so with http://projects.spring.io/spring-data-envers/[Spring Data Envers]. + +As the name has suggested http://projects.spring.io/spring-data-envers/[Spring Data Envers] utilises and simplifies the usage of http://hibernate.org/orm/envers/[Hibernate Envers]. + +== Dependency and Configuration + +In order to enable Envers features we will first include *spring-data-envers* as dependency. + +[source,groovy] +---- +implementation 'org.springframework.data:spring-data-envers' + +---- + +=== Enable Entity Audit + +By annotating an `@Entity` with `@Audited`, we are informing Spring that we would like respective entity to be audited. +The following example shows that we want all activities related to link:{url-quickref}/src/main/java/zin/rashidi/boot/data/envers/book/Book.java[Book] to be audited: + +[source,java] +---- +@Entity +@Audited +public class Book { + + @Id + @GeneratedValue + private Long id; + + private String author; + + private String title; +} +---- + +Next is to extend a `Repository` class in order to allow us to utilise audit revision features. This can be done by extending +https://github.com/spring-projects/spring-data-commons/blob/master/src/main/java/org/springframework/data/repository/history/RevisionRepository.java[RevisionRepository] interface to our `Repository` class. An example can be seen in link:{url-quickref}/src/main/java/zin/rashidi/boot/data/envers/book/BookRepository.java[BookRepository]: + +[source,java] +---- +public interface BookRepository extends JpaRepository, RevisionRepository { + +} +---- + +== Verification + +We will be utilising on `@SpringBootTest` to verify that our implementation works. + +=== Upon Creation an Initial Revision is Created + +[source,java] +---- +@Testcontainers +@SpringBootTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop") +class BookAuditRevisionTests { + + @Container + @ServiceConnection + private static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:latest"); + + @Autowired + private BookRepository repository; + + @Test + @DisplayName("When a book is created, then a revision information is available with revision number 1") + void create() { + var book = new Book(); + + book.setTitle("The Jungle Book"); + book.setAuthor("Rudyard Kipling"); + + var createdBook = repository.save(book); + + var revisions = repository.findRevisions(createdBook.getId()); + + assertThat(revisions) + .hasSize(1) + .first() + .extracting(Revision::getRevisionNumber) + .returns(1, Optional::get); + } + +} +---- + +=== Revision Number Will Be Increase and Latest Revision is Available + +[source,java] +---- +@Testcontainers +@SpringBootTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop") +class BookAuditRevisionTests { + + @Container + @ServiceConnection + private static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:latest"); + + @Autowired + private BookRepository repository; + + @Test + @DisplayName("When a book is modified, then a revision number will increase") + void modify() { + var book = new Book(); + + book.setTitle("The Jungle Book"); + book.setAuthor("Rudyard Kipling"); + + var createdBook = repository.save(book); + + createdBook.setTitle("If"); + + repository.save(createdBook); + + var revisions = repository.findRevisions(createdBook.getId()); + + assertThat(revisions) + .hasSize(2) + .last() + .extracting(Revision::getRevisionNumber) + .extracting(Optional::get).is(matching(greaterThan(1))); + } + +} +---- + +=== Upon Deletion All Entity Information Will be Removed Except its ID + +[source,java] +---- +@Testcontainers +@SpringBootTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop") +class BookAuditRevisionTests { + + @Container + @ServiceConnection + private static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:latest"); + + @Autowired + private BookRepository repository; + + @Test + @DisplayName("When a book is removed, then only ID information is available") + void remove() { + var book = new Book(); + + book.setTitle("The Jungle Book"); + book.setAuthor("Rudyard Kipling"); + + var createdBook = repository.save(book); + + repository.delete(createdBook); + + var revision = repository.findLastChangeRevision(createdBook.getId()); + + assertThat(revision).get() + .extracting(Revision::getEntity) + .extracting("id", "title", "author") + .containsOnly(createdBook.getId(), null, null); + } + +} +---- + +All tests above can be found in link:{url-quickref}/src/test/java/zin/rashidi/boot/data/envers/BookAuditRevisionTests.java[BookAuditRevisionTests]. diff --git a/docs/modules/ROOT/pages/data-jdbc-audit.adoc b/docs/modules/ROOT/pages/data-jdbc-audit.adoc index f2462e54..2e25843b 100644 --- a/docs/modules/ROOT/pages/data-jdbc-audit.adoc +++ b/docs/modules/ROOT/pages/data-jdbc-audit.adoc @@ -1,3 +1,170 @@ = Spring Data JDBC: Implement Auditing +Rashidi Zin +1.0, December 20, 2024: Initial version +:icons: font +:source-highlighter: highlight.js +:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/data-jdbc-audit +:source-main: {url-quickref}/src/main/java/zin/rashidi/boot/data/jdbc +:source-test: {url-quickref}/src/test/java/zin/rashidi/boot/data/jdbc -include::../../../../data-jdbc-audit/README.adoc[lines=2..-1] +We have explored on how to implement auditing with link:../data-jpa-audit/[Spring Data Jpa], link:../data-envers-audit/[Spring Data Enver], and link:../data-mongodb-audit/[Spring Data Mongo]. In this tutorial, we will implement auditing with https://spring.io/projects/spring-data-jdbc[Spring Data JDBC]. + +== Background + +Spring Data JDBC provides the convenience to enable auditing for a class. This can be achieved by using the following annotations: + + - `@EnableJdbcAuditing` + - `@Created` + - `@CreatedBy` + - `@LastModified` + - `@LastModifiedBy` + +== Configuration + +In order to enable auditing we will create a `@Configuration` class that is also annotated with `@EnableJdbcAuditing` and expose +a `@Bean` of `AuditorAware`: + +[source, java] +---- +@Configuration +@EnableJdbcAuditing +class AuditConfiguration { + + @Bean + public AuditorAware auditorAware() { + return () -> Optional.of("Mr. Auditor"); + } + +} +---- + +link:{source-main}/audit/AuditConfiguration.java[`AuditConfiguration`] demonstrates a simple implementation of `AuditorAware` which returns `Mr. Auditor`. This will be assigned to fields that are annotated with `@CreatedBy` and `@LastModifiedBy`. + +== Audited Class + +Next, we will implement link:{source-main}/user/User.java[`User`] which stores audit information: + +[source,java] +---- +@Table("users") +class User { + + @Id + private Long id; + + @CreatedDate + private Instant created; + + @CreatedBy + private String createdBy; + + @LastModifiedDate + private Instant lastModified; + + @LastModifiedBy + private String lastModifiedBy; + + private final String name; + private String username; + + User(String name, String username) { + this.name = name; + this.username = username; + } + + public User username(String username) { + this.username = username; + return this; + } + +} +---- + +Values for the fields `created`, `createdBy`, `lastModified`, and `lastModifiedBy` will be handled by the framework. + +== Repository Class + +Finally, we will implement the repository class - link:{source-main}/user/UserRepository.java[`UserRepository] which extends `CrudRepository` class from Spring Data: + +[source, java] +---- +interface UserRepository extends CrudRepository { +} +---- + +== Verification + +As always, we will verify our implementation through database integration test. We will utilise `@Testcontainers` and `@DataJdbcTest` annotations. + +Unlike Spring Data JPA / Hibernate, Spring Data JDBC does not support automatic creation of a table. Therefore, we will use `@Sql` in our test to create the table `users` before running our tests. The setup will look as follows: + +[source, java] +---- +@Testcontainers +@DataJdbcTest(includeFilters = @Filter(EnableJdbcAuditing.class)) +@Sql( + executionPhase = BEFORE_TEST_CLASS, + statements = "CREATE TABLE users (id BIGSERIAL PRIMARY KEY, created TIMESTAMP WITH TIME ZONE NOT NULL, created_by TEXT NOT NULL, last_modified TIMESTAMP WITH TIME ZONE NOT NULL, last_modified_by TEXT NOT NULL, name TEXT NOT NULL, username TEXT NOT NULL)" +) +class UserAuditTests { + + @Container + @ServiceConnection + private static final PostgreSQLContainer postgres = new PostgreSQLContainer<>(DockerImageName.parse("postgres:latest")); + + @Autowired + private UserRepository repository; + +} +---- + +`@DataJdbcTest` is not aware about our `AuditConfiguration` class. For that, we are using `includeFilters` to inform it about the class. + +=== Create new User + +Upon the creation of a new `User`, the annotated fields should be automatically assigned. Whereby `created` and `lastModified` are assigned with current time while `createdBy` and `lastModifiedBy` are assigned with `Mr. Auditor`: + +[source,java] +---- +class UserAuditTests { + + @Autowired + private UserRepository repository; + + @Test + @DisplayName("When a user is persisted Then created and lastModified fields are set And createdBy and lastModifiedBy fields are set to Mr. Auditor") + void create() { + var user = repository.save(new User("Rashidi Zin", "rashidi")); + + assertThat(user).extracting("created", "lastModified").doesNotContainNull(); + assertThat(user).extracting("createdBy", "lastModifiedBy").containsOnly("Mr. Auditor"); + } + +} +---- + +=== Update an existing User + +When updating an existing `User`, the field `lastModified` should be updated. The following test demonstrates that there is a `User` created seven days ago and once updated, its `lastModified` field should be later than `created` field: + +[source,java] +---- +class UserAuditTests { + + @Autowired + private UserRepository repository; + + @Test + @DisplayName("Given there is a user When I update its username Then lastModified field should be updated") + @Sql(statements = "INSERT INTO users (id, created, created_by, last_modified, last_modified_by, name, username) VALUES (84, CURRENT_TIMESTAMP - INTERVAL '7 days', 'Mr. Auditor', CURRENT_TIMESTAMP - INTERVAL '7 days', 'Mr. Auditor', 'Rashidi Zin', 'rashidi');") + void update() { + var modifiedUser = repository.findById(84L).map(user -> { user.username("rashidi.zin"); return user; }).map(repository::save).orElseThrow(); + + var created = (Instant) ReflectionTestUtils.getField(modifiedUser, "created"); + var modified = (Instant) ReflectionTestUtils.getField(modifiedUser, "lastModified"); + + assertThat(modified).isAfter(created); + } + +} +---- diff --git a/docs/modules/ROOT/pages/data-jpa-audit.adoc b/docs/modules/ROOT/pages/data-jpa-audit.adoc index 6edf2992..9a6deb41 100644 --- a/docs/modules/ROOT/pages/data-jpa-audit.adoc +++ b/docs/modules/ROOT/pages/data-jpa-audit.adoc @@ -1,3 +1,207 @@ = Spring Data Jpa Audit Example +:source-highlighter: highlight.js +Rashidi Zin +2.0, October 12, 2023 +:nofooter: +:icons: font +:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/data-jpa-audit -include::../../../../data-jpa-audit/README.adoc[lines=2..-1] +Enable auditing with Spring Data Jpa's `@CreatedDate` and `@LastModified`. For example with Spring Data MongoDB, please check out link:../data-mongodb-audit[Spring Data MongoDB Audit Example]. + + +== Background + +http://docs.spring.io/spring-data/jpa/docs/current/reference/html/[Spring Data Jpa] provides auditing feature which includes `@CreateDate`, `@CreatedBy`, `@LastModifiedDate`, +and `@LastModifiedBy`. In this example we will see how it can be implemented with very little configurations. + +== Entity Class + +In this example we have an entity class, link:{url-quickref}/src/main/java/zin/rashidi/boot/data/user/User.java[User] which contains information about the table structure. Initial +structure is as follows: + +[source,java] +---- +@Entity +class User { + + @Id + @GeneratedValue + private Long id; + + private String name; + private String username; + + @CreatedBy + private String createdBy; + + @CreatedDate + private Instant created; + + @LastModifiedBy + private String modifiedBy; + + @LastModifiedDate + private Instant modified; + + // omitted getter / setter +} + +---- + +As you can see it is a standard implementation of `@Entity` JPA class. We would like to keep track when an entry is +created with `created` column and when it is modified with `modified` column. + +== Enable JpaAudit + +In order to enable http://docs.spring.io/spring-data/jpa/docs/current/reference/html/#jpa.auditing[JPA Auditing] for this project will need to apply three annotations and a configuration class. +Those annotations are; `@EntityListener`, `@CreatedDate`, and `@LastModifiedDate`. + +`@EntityListener` will be the one that is responsible to listen to any create or update activity. It requires +`Listeners` to be defined. In this example we will use the default class, `EntityListeners`. + +By annotating a column with `@CreatedDate` we will inform Spring that we need this column to have information on +when the entity is created. While `@LastModifiedDate` column will be defaulted to `@CreatedDate` and will be updated +to the current time when the entry is updated. + +The final look of `User` class: + +[source,java] +---- +@Entity +@EntityListeners(AuditingEntityListener.class) +class User { + + @Id + @GeneratedValue + private Long id; + + private String name; + private String username; + + @CreatedBy + private String createdBy; + + @CreatedDate + private Instant created; + + @LastModifiedBy + private String modifiedBy; + + @LastModifiedDate + private Instant modified; + + // omitted getter / setter +} +---- + +As you can see `User` is now annotated with `@EntityListeners` while `created`, `createdBy`, `modified`, and `modifiedBy` columns are annotated +with `@CreatedDate`, `@CreatedBy`, `@LastModifiedDate`, and `@LastModifiedBy`. `createdBy` and `modifiedBy` fields will be automatically populated +if https://projects.spring.io/spring-security/[Spring Security] is available in the project path. Alternatively we wil implement our own https://docs.spring.io/spring-data/commons/docs/current/api/org/springframework/data/domain/AuditorAware.html[AuditorAware] in order to inform Spring who +is the current auditor. + +We will do so in link:{url-quickref}/src/main/java/zin/rashidi/boot/data/audit/AuditConfiguration.java[AuditConfiguration] class. In this class, we will also inform Spring to enable JPA auditing by annotating it with +`@EnableJpaAuditing` annotation. + +[source,java] +---- +@Configuration +@EnableJpaAuditing +class AuditConfiguration { + + @Bean + public AuditorAware auditorAwareRef() { + return () -> Optional.of("Mr. Auditor"); + } + +} +---- + +That's it! Our application has JPA Auditing feature enabled. The result can be seen in link:{url-quickref}/src/test/java/zin/rashidi/boot/data/user/UserAuditTests.java[UserAuditTests]. + +== Verify Audit Implementation + +There is no better way to verify an implementation other than running some tests. In our test class we have to scenario: + +* Create an entity which will have `created` and `modified` fields has values without us assigning them +* Update created entity and `created` field will remain to have the same value while `modified` values will be updated + +=== Create an entity + +In the following test we will see that values for `created` and `modified` are assigned by Spring itself: + +[source,java] +---- +@Testcontainers +@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop", includeFilters = @Filter(type = ANNOTATION, classes = EnableJpaAuditing.class)) +class UserAuditTests { + + @Container + @ServiceConnection + private final static MySQLContainer MYSQL = new MySQLContainer("mysql:latest"); + + @Autowired + private UserRepository repository; + + @Test + @DisplayName("When a user is saved Then created and modified fields are set And createdBy and modifiedBy fields are set to Mr. Auditor") + void create() { + var user = new User("Rashidi Zin", "rashidi"); + + var createdUser = repository.save(user); + + assertThat(createdUser).extracting("created", "modified").isNotNull(); + assertThat(createdUser).extracting("createdBy", "modifiedBy").containsOnly("Mr. Auditor"); + } + +} +---- + +As mentioned earlier, we did not assign values for `created` and `modified` fields but Spring will assign them for us. +Same goes with when we are updating an entry. + +=== Update an entity + +In the following test we will change the `username` without changing `modified` field. We will expect that `modified` +field will have a recent time as compare to when it was created: + +[source,java] +---- +@Testcontainers +@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop", includeFilters = @Filter(type = ANNOTATION, classes = EnableJpaAuditing.class)) +class UserAuditTests { + + @Container + @ServiceConnection + private final static MySQLContainer MYSQL = new MySQLContainer("mysql:latest"); + + @Autowired + private UserRepository repository; + + @Test + @DisplayName("When a user is updated Then modified field should be updated") + @Sql(statements = "INSERT INTO users (id, name, username, created, modified) VALUES ('84', 'Rashidi Zin', 'rashidi', now() - INTERVAL 7 DAY, now() - INTERVAL 7 DAY)") + void update() { + var modifiedUser = repository.findById(84L).map(user -> { user.setUsername("rashidi.zin"); return user; }).map(repository::saveAndFlush).orElseThrow(); + + var created = (Instant) ReflectionTestUtils.getField(modifiedUser, "created"); + var modified = (Instant) ReflectionTestUtils.getField(modifiedUser, "modified"); + + assertThat(modified).isAfter(created); + } + +} +---- + +As you can see at our final verification we assert that `modified` field should have a greater value than it +previously had. + +== Conclusion + +To recap. All we need in order to enable JPA auditing feature in this project are: + +* `@EnableJpaAuditing` +* `@EntityListeners` +* `@CreatedBy` +* `@CreatedDate` +* `@LastModifiedBy` +* `@LastModifiedDate` diff --git a/docs/modules/ROOT/pages/data-jpa-event.adoc b/docs/modules/ROOT/pages/data-jpa-event.adoc index dd8a4654..05ec33e0 100644 --- a/docs/modules/ROOT/pages/data-jpa-event.adoc +++ b/docs/modules/ROOT/pages/data-jpa-event.adoc @@ -1,3 +1,182 @@ = Spring Data JPA: Perform Entity Validation at Repository Level through Event Driven +:source-highlighter: highlight.js +:highlightjs-languages: java, groovy +Rashidi Zin +1.0, November 10, 2024: Initial version +:nofooter: +:icons: font +:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/data-jpa-event -include::../../../../data-jpa-event/README.adoc[lines=2..-1] +In this tutorial, we will look into how we can utilise events to perform validation at repository level. + +== Background + +It is common that we implement validation at a service layer (`@Service`). For example, we want to ensure that a username is unique. The common practice +is to perform the validation in a service layer before calling our repository method, such as `repository.save`. While this work, it breaks the principle of single responsibility by the method. + +The service method is called `save()`. But, it performs a validation which is not part of its name. Alternatively, we may create a method `saveIfUsernameAvailable`. We will ended up with a long method name +and worse if we have several validations. + +In this tutorial we will implement the validation at `repository` level while following single responsibility principle. + +== Entity and Repository Classes + +We will start by implementing link:{url-quickref}/src/main/java/zin/rashidi/data/event/user/User.java[User] and link:{url-quickref}/src/main/java/zin/rashidi/data/event/user/UserRepository.java[UserRepository] classes: + +[source, java] +---- +@Entity +@Table(name = "users") +class User { + + @Id + @GeneratedValue + private Long id; + private String username; + + protected User() { + } + + public User(String username) { + this.username = username; + } + + public String getUsername() { + return username; + } + +} +---- + +[source,java] +---- +interface UserRepository extends JpaRepository { + + boolean existsByUsername(String username); + +} +---- + +The method `existsByUsername` will be used to perform validation against newly created `User` account. + +== Event Classes + +We will create a custom `ApplicationEvent` class, link:{url-quickref}/src/main/java/zin/rashidi/data/event/user/UserBeforeSaveEvent.java[UserBeforeSaveEvent], +that will be triggered before `repository.save` is executed. + +[source, java] +---- +class UserBeforeSaveEvent extends ApplicationEvent { + + public UserBeforeSaveEvent(User source) { + super(source); + } + + @Override + public User getSource() { + return (User) super.getSource(); + } + +} +---- + +Next is to implement an event publisher class that will be responsible to publish `UserBeforeSaveEvent` prior to saving the `User` into the database. + +[source, java] +---- +@Component +class UserEventPublisher { + + private final ApplicationEventPublisher publisher; + + UserEventPublisher(ApplicationEventPublisher publisher) { + this.publisher = publisher; + } + + @PrePersist + public void beforeSave(User user) { + publisher.publishEvent(new UserBeforeSaveEvent(user)); + } + +} +---- + +The method `beforeSave` is marked with `PrePersist` which is our way of telling JPA to trigger this method before persisting the `Entity` into the database. We will also need to update our `User` class +so that JPA is aware about `UserEventPublisher`. We can do this by using `@EntityListeners`. + +[source, java] +---- +@Entity +@Table(name = "users") +@EntityListeners(UserEventPublisher.class) +class User { + + // remove for brevity + +} +---- + +== Validator Class + +`User.username` unique validation will be implemented in link:{url-quickref}/src/main/java/zin/rashidi/data/event/user/UserValidation.java[UserValidation]. This will be done by utilising `@EventListener` +that will be observing `UserBeforeSaveEvent`. + +[source, java] +---- +@Component +class UserValidation { + + private final UserRepository repository; + + UserValidation(UserRepository repository) { + this.repository = repository; + } + + @EventListener + void usernameIsUnique(UserBeforeSaveEvent event) { + var usernameExisted = repository.existsByUsername(event.getSource().getUsername()); + + if (usernameExisted) { + throw new IllegalArgumentException("Username is already taken"); + } + + } + +} +---- + +== Verify the Implementation + +We will verify our implementation through `@DataJpaTest`, which does not require the whole application to run. Instead, only relevant classes will be used. Our intention is to ensure that +the username `rashidi.zin` is unique. Therefore, if a new `User` being created with the same `username`, an error that reads `Username is already taken` will be thrown. + +[source, java] +---- +@Testcontainers +@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop", includeFilters = @Filter(type = ASSIGNABLE_TYPE, classes = { UserEventPublisher.class, UserValidation.class })) +class UserRepositoryTests { + + @Container + @ServiceConnection + private static final PostgreSQLContainer postgresql = new PostgreSQLContainer<>(DockerImageName.parse("postgres:latest")); + + @Autowired + private TestEntityManager em; + + @Autowired + private UserRepository repository; + + @Test + @DisplayName("Given username rashidi.zin is exist When I create a new user with username rashidi.zin Then error with a message Username is already taken will be thrown") + void saveWithExistingUsername() { + em.persistAndFlush(new User("rashidi.zin")); + + assertThatThrownBy(() -> repository.save(new User("rashidi.zin"))) + .hasCauseInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Username is already taken"); + } + +} +---- + +Once done, execute the test in link:{url-quickref}/src/test/java/zin/rashidi/data/event/user/UserRepositoryTests.java[UserRepositoryTests] to ensure our implementation is working as expected. The full implementation can be found in {url-quickref}[Github]. diff --git a/docs/modules/ROOT/pages/data-jpa-filtered-query.adoc b/docs/modules/ROOT/pages/data-jpa-filtered-query.adoc index b1fbad4f..e73fd82e 100644 --- a/docs/modules/ROOT/pages/data-jpa-filtered-query.adoc +++ b/docs/modules/ROOT/pages/data-jpa-filtered-query.adoc @@ -1,3 +1,144 @@ = Spring Data Jpa: Global Filter Query +:source-highlighter: highlight.js +Rashidi Zin +1.0, October 12, 2023 +:nofooter: +:icons: font +:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/data-jpa-filtered-query -include::../../../../data-jpa-filtered-query/README.adoc[lines=2..-1] +Implement global filter query which involves several `Entity` with Spring Data Jpa. + +== Background + +It is common that we need to implement similar filter query in our application. For example, we have `User` and `Post` entities whereby both +contains `status` field. We would want to ensure that when these `Entity` is retrieved, only `ACTIVE` ones will be returned. + +In this tutorial we will implement a global filter by utilising Spring Data Jpa `repositoryBaseClass`. + +== Integration Tests + +There are two tests which involves two entities - `User` and `Country`. `User` contains a `status` field while `Country` does not. + +When `findAll` is triggered for `User` then only `ACTIVE` users will be returned. + +[source,java] +---- +@Testcontainers +@DataJpaTest( + properties = "spring.jpa.hibernate.ddl-auto=create-drop", + includeFilters = @Filter(type = ANNOTATION, classes = EnableJpaRepositories.class) +) +class UserRepositoryTests { + + @Container + @ServiceConnection + private static final MySQLContainer mysql = new MySQLContainer<>("mysql:latest"); + + @Autowired + private UserRepository users; + + @BeforeEach + void setup() { + users.saveAll(List.of( + new User("Rashidi Zin", ACTIVE), + new User("John Doe", INACTIVE) + )); + } + + @Test + @DisplayName("Given there are two users with status ACTIVE and INACTIVE, when findAll is invoked, then only ACTIVE users are returned") + void findAll() { + assertThat(users.findAll()) + .extracting("status") + .containsOnly(ACTIVE); + } + +} +---- + +However, when `findAll` is triggered for `Country` then all countries will be returned. + +[source,java] +---- +@Testcontainers +@DataJpaTest( + properties = "spring.jpa.hibernate.ddl-auto=create-drop", + includeFilters = @Filter(type = ANNOTATION, classes = EnableJpaRepositories.class) +) +class CountryRepositoryTests { + + @Container + @ServiceConnection + private static final MySQLContainer mysql = new MySQLContainer<>("mysql:latest"); + + @Autowired + private CountryRepository countries; + + @BeforeEach + void setup() { + countries.saveAll(List.of( + new Country("DE", "Germany"), + new Country("MY", "Malaysia") + )); + } + + @Test + @DisplayName("Given there are two countries, when findAll is invoked, then both countries are returned") + void findAll() { + assertThat(countries.findAll()) + .hasSize(2) + .extracting("isoCode") + .containsOnly("DE", "MY"); + } + +} +---- + +== Configuration Class + +We will start by defining `repositoryBaseClass` + +[source,java] +---- +class JpaCustomBaseRepository extends SimpleJpaRepository { + + public JpaCustomBaseRepository(JpaEntityInformation entityInformation, EntityManager entityManager) { + super(entityInformation, entityManager); + } + + @Override + public List findAll() { + var hasStatusField = Stream.of(ReflectionUtils.getDeclaredMethods(getDomainClass())).anyMatch(field -> field.getName().equals("status")); + return hasStatusField ? findAll(where((root, query, criteriaBuilder) -> root.get("status").in(ACTIVE))) : super.findAll(); + } + +} +---- + +In `JpaCustomBaseRepository` we will define a method `findAll` which will be used by all `Entity` to retrieve data. This method will filter +any `Entity` with `status` field and return only `ACTIVE` ones. + +To recap, link:{url-quickref}/src/main/java/zin/rashidi/boot/data/jpa/user/User.java[`User`] contains `status` field while +link:{url-quickref}/src/main/java/zin/rashidi/boot/data/jpa/country/Country.java[`Country`] does not. Therefore, when `findAll` is triggered for `User` then +only `ACTIVE` users will be returned. However, when `findAll` is triggered for `Country` then all countries will be returned. + +Next we will inform Spring Data Jpa to use `JpaCustomBaseRepository` as the base class for all `Entity` by defining `@EnableJpaRepositories` +in link:{url-quickref}/src/main/java/zin/rashidi/boot/data/jpa/jpa/JpaConfiguration.java[`JpaConfiguration`]. + +[source,java] +---- +@Configuration +@EnableJpaRepositories( + basePackages = "zin.rashidi.boot.data.jpa", + repositoryBaseClass = JpaCustomBaseRepository.class +) +class JpaConfiguration { + +} +---- + +== Verification + +To ensure that our implementation is working as expected, we will execute tests defined in link:{url-quickref}/src/test/java/zin/rashidi/boot/data/jpa/user/UserRepositoryTests.java[`UserRepositoryTests`] and link:{url-quickref}/src/test/java/zin/rashidi/boot/data/jpa/country/CountryRepositoryTests.java[`CountryRepositoryTests`]. + +Both tests should pass. diff --git a/docs/modules/ROOT/pages/data-mongodb-audit.adoc b/docs/modules/ROOT/pages/data-mongodb-audit.adoc index fccd67a7..2d9e1814 100644 --- a/docs/modules/ROOT/pages/data-mongodb-audit.adoc +++ b/docs/modules/ROOT/pages/data-mongodb-audit.adoc @@ -1,3 +1,127 @@ = Spring Data MongoDB Audit Example +:source-highlighter: highlight.js +Rashidi Zin +3.0, July 29, 2022 +:nofooter: +:icons: font +:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/data-mongodb-audit -include::../../../../data-mongodb-audit/README.adoc[lines=2..-1] +Enable auditing with Spring Data MongoDB. For example with Spring Data JPA, visit link:../data-jpa-audit/[Spring Data JPA Audit Example]. + + +== Background + +https://spring.io/projects/spring-data-mongodb[Spring Data MongoDB] provides auditing support for MongoDB. Auditing is a common requirement for most applications. It is used to track changes to entities, such as who created or modified an entity and when the change occurred. + +In this example, we will create a simple Spring Boot application that uses Spring Data MongoDB to persist and retrieve data from MongoDB. We will also enable auditing to track changes to entities. + +== Document Class +We will have a `Document` called link:{url-quickref}/src/main/java/zin/rashidi/boot/data/mongodb/user/User.java[User]: + +[source,java] +---- +@Document +class User { + + @Id + private ObjectId id; + + private String name; + + private String username; + + @CreatedBy + private String createdBy; + + @CreatedDate + private Instant created; + + @LastModifiedBy + private String modifiedBy; + + @LastModifiedDate + private Instant modified; + + // getters and setters +} +---- + +Fields that are marked with `@CreatedBy`, `@CreatedDate`, `@LastModifiedBy` and `@LastModifiedDate` are meant for auditing and will be populated by Spring Data MongoDB. + +== Enable Mongo Audit +To enable auditing, we need to add `@EnableMongoAuditing` annotation to our `@Configuration` class - link:{url-quickref}/src/main/java/zin/rashidi/boot/data/mongodb/audit/MongoAuditConfiguration.java[MongoAuditConfiguration]: + +[source,java] +---- +@Configuration +@EnableMongoAuditing +class MongoAuditConfiguration { + + @Bean + public AuditorAware auditorAwareRef() { + return () -> Optional.of("Mr. Auditor"); + } + +} +---- + +== Verify Audit Implementation +We will verify that the auditing is working by creating a test case that will create a new `User` and verify that the `created`, `createdBy`, `modified`, and `modifiedBy` fields are populated. + +[source,java] +---- +@Testcontainers +@DataMongoTest(includeFilters = @Filter(type = ANNOTATION, classes = EnableMongoAuditing.class)) +class UserAuditTests { + + @Container + @ServiceConnection + private final static MongoDBContainer mongo = new MongoDBContainer("mongo:latest"); + + @Autowired + private UserRepository repository; + + @Test + @DisplayName("When a user is saved Then created and modified fields are set And createdBy and modifiedBy fields are set to Mr. Auditor") + void create() { + var createdUser = repository.save(new User().name("Rashidi Zin").username("rashidi")); + + assertThat(createdUser).extracting("created", "modified").isNotNull(); + assertThat(createdUser).extracting("createdBy", "modifiedBy").containsOnly("Mr. Auditor"); + } + +} +---- + +Next we will verify that the `modified` field are updated when we update the `User`: + +[source,java] +---- +@Testcontainers +@DataMongoTest(includeFilters = @Filter(type = ANNOTATION, classes = EnableMongoAuditing.class)) +class UserAuditTests { + + @Container + @ServiceConnection + private final static MongoDBContainer mongo = new MongoDBContainer("mongo:latest"); + + @Autowired + private UserRepository repository; + + @Test + @DisplayName("When a user is updated Then modified field should be updated") + void update() { + var createdUser = repository.save(new User().name("Rashidi Zin").username("rashidi")); + + await().atMost(ofSeconds(1)).untilAsserted(() -> { + var persistedUser = repository.findById(createdUser.id()).orElseThrow(); + var modifiedUser = repository.save(persistedUser.username("rashidi.zin")); + + assertThat(modifiedUser.modified()).isAfter(createdUser.modified()); + }); + } + +} +---- + +Full implementation can be found in link:{url-quickref}/src/test/java/zin/rashidi/boot/data/mongodb/user/UserAuditTests.java[UserAuditTests]. diff --git a/docs/modules/ROOT/pages/data-mongodb-full-text-search.adoc b/docs/modules/ROOT/pages/data-mongodb-full-text-search.adoc index d9ecb203..65f50b35 100644 --- a/docs/modules/ROOT/pages/data-mongodb-full-text-search.adoc +++ b/docs/modules/ROOT/pages/data-mongodb-full-text-search.adoc @@ -1,3 +1,193 @@ = Spring Data MongoDB: Full Text Search +:source-highlighter: highlight.js +Rashidi Zin +1.0, September 25, 2023 +:nofooter: +:icons: font +:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/data-mongodb-full-text-search -include::../../../../data-mongodb-full-text-search/README.adoc[lines=2..-1] +Implement link:https://docs.mongodb.com/manual/text-search/[MongoDB Full Text Search] with link:https://spring.io/projects/spring-data-mongodb[Spring Data MongoDB]. + +== Background + +MongoDB full text search provides the flexibility to perform search entries through multiple fields. In this example we will explore how to implement full text search with Spring Data MongoDB. + +== Verification +Given we have the following entries in link:{url-quickref}/src/main/java/zin/rashidi/boot/data/mongodb/character/Character.java[Character]: + +.Characters +|=== +|Name |Publisher + +|Captain Marvel +|Marvel + +|Joker +|DC + +|Thanos +|Marvel +|=== + +When searching for `captain marvel` then the following results should be returned + +.Characters that contains the keyword `captain` or `marvel` +|=== +|Name |Publisher + +|Captain Marvel +|Marvel + +|Thanos +|Marvel +|=== + +This is demonstrated in link:{url-quickref}/src/test/java/zin/rashidi/boot/data/mongodb/character/CharacterRepositoryTests.java[CharacterRepositoryTests]. + +== Implementation +We will start by defining the `Character` entity. + +[source,java] +---- +@Document +class Character { + + @Id + private ObjectId id; + + @TextIndexed + private final String name; + + @TextIndexed + private final String publisher; + + public Character(String name, String publisher) { + this.name = name; + this.publisher = publisher; + } + +} +---- + +=== With Predefined Index +If respective fields are already `indexed` then we can utilise Spring Data query generation to perform full text search. + +This can be done by creating a method that takes `TextCriteria` as parameter: + +[source,java] +---- +interface CharacterRepository extends MongoRepository, CharacterSearchRepository { + + List findAllBy(TextCriteria criteria, Sort sort); + +} +---- + +This method can then be used in the following manner: + +[source,java] +---- +@Testcontainers +@DataMongoTest +class CharacterRepositoryTests { + + @Container + @ServiceConnection + private final static MongoDBContainer mongo = new MongoDBContainer("mongo:latest"); + + @Autowired + private MongoOperations operations; + + @Autowired + private CharacterRepository repository; + + @Test + @DisplayName("Generated query: Search for 'captain marvel' should return 'Captain Marvel' and 'Thanos'") + void withGeneratedQuery() { + // Simulate predefined index + operations.indexOps(Character.class).ensureIndex(new TextIndexDefinitionBuilder().onFields("name", "publisher").build()); + + var characters = repository.findAllBy(new TextCriteria().matchingAny("captain", "marvel"), Sort.by("name")); + + assertThat(characters) + .hasSize(2) + .extracting("name") + .containsOnly("Captain Marvel", "Thanos") + .doesNotContain("Joker"); + } + +} +---- + +=== Without Predefined Index +Without predefined index, we will need to implement a custom repository implementation. We will start by defining a custom repository interface, link:{url-quickref}/src/main/java/zin/rashidi/boot/data/mongodb/character/CharacterSearchRepository.java[CharacterSearchRepository]: + +[source,java] +---- +interface CharacterSearchRepository { + + List findByText(String text, Sort sort); + +} +---- + +Next, implement the custom repository interface in link:{url-quickref}/src/main/java/zin/rashidi/boot/data/mongodb/character/CharacterSearchRepositoryImpl.java[CharacterSearchRepositoryImpl]: + +[source,java] +---- +class CharacterSearchRepositoryImpl implements CharacterSearchRepository { + + private final MongoOperations operations; + + CharacterSearchRepositoryImpl(MongoOperations operations) { + this.operations = operations; + } + + @Override + public List findByText(String text, Sort sort) { + operations.indexOps(Character.class) + .ensureIndex(new TextIndexDefinitionBuilder().onFields("name", "publisher").build()); + + var parameters = text.split(" "); + var query = TextQuery.queryText(new TextCriteria().matchingAny(parameters)).with(sort); + + return operations.find(query, Character.class); + } + +} +---- + +This implementation will `indexed` searchable fields, i.e. `name` and `publisher` before searching the `Document`. + +Finally, we will verify our custom implementation through integration test: + +[source,java] +---- +@Testcontainers +@DataMongoTest +class CharacterRepositoryTests { + + @Container + @ServiceConnection + private final static MongoDBContainer mongo = new MongoDBContainer("mongo:latest"); + + @Autowired + private MongoOperations operations; + + @Autowired + private CharacterRepository repository; + + @Test + @DisplayName("Custom implementation: Search for 'captain marvel' should return 'Captain Marvel' and 'Thanos'") + void findByText() { + var characters = repository.findByText("captain marvel", Sort.by("name")); + + assertThat(characters) + .hasSize(2) + .extracting("name") + .containsOnly("Captain Marvel", "Thanos") + .doesNotContain("Joker"); + } + +} +---- diff --git a/docs/modules/ROOT/pages/data-mongodb-tc-data-load.adoc b/docs/modules/ROOT/pages/data-mongodb-tc-data-load.adoc index c441d2b9..ba9518c1 100644 --- a/docs/modules/ROOT/pages/data-mongodb-tc-data-load.adoc +++ b/docs/modules/ROOT/pages/data-mongodb-tc-data-load.adoc @@ -1,3 +1,286 @@ = Spring Data MongoDb with Testcontainers +:source-highlighter: highlight.js +Rashidi Zin +3.0, November 24, 2024: Replace usage of mongo-init.js with Spring's RepositoryPopulator +:nofooter: +:icons: font +:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/data-mongodb-tc-data-load -include::../../../../data-mongodb-tc-data-load/README.adoc[lines=2..-1] +Preloaded data for testing. + + +== Background +It is common to have preloaded data when we are testing our delete, read, and update operations. One of the most common approaches is to +load them programmatically. In this example, we will see how we can use Testcontainers to load data into our MongoDB instance. + +== Load Data Programmatically +It is common that we load data programmatically. There are several approaches to do this. + +=== Using `@BeforeEach` and `@AfterEach` +`@BeforeEach` will be executed before each test method execution while `@AfterEach` will be executed after test method executed. In this example, +we will use `@BeforeEach` to load data and `@AfterEach` to delete data. + +[source,java] +---- +@DataMongoTest +@Testcontainers +class UserRepositoryTests { + + @Container + @ServiceConnection + private static final MongoDBContainer mongo = new MongoDBContainer("mongo:latest"); + + @Autowired + private UserRepository repository; + + @BeforeEach + void create() { + repository.save(new User(null, "rashidi.zin", "Rashidi Zin")); + } + + @AfterEach + void delete() { + repository.deleteAll(); + } + + @Test + @DisplayName("Given there is a user with username rashidi.zin and name Rashidi Zin When I search for username rashidi.zin Then user with provided username should be returned") + void findByUsername() { + var user = repository.findByUsername("rashidi.zin"); + + assertThat(user) + .extracting("name") + .isEqualTo("Rashidi Zin"); + } + + @Test + @DisplayName("Given there is no user with username zaid.zin When I search for username zaid.zin Then null should be returned") + void findByUsernameWithNonExistingUsername() { + var user = repository.findByUsername("zaid.zin"); + + assertThat(user).isNull(); + } +} +---- + +While this approach works, it might be time-consuming when we have many methods to be executed. Another approach is to use `@TestExecutionListeners` + +=== Using `@TestExecutionListeners` +`@TestExecutionListeners` allows us to register implemented https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/test/context/TestExecutionListener.html[`TestExecutionListener`] +which can be used to execute code before and after test execution. + +The following `TestExecutionListener` is used to load data before executing the test class and remove all data after test class has been executed. + +[source,java] +---- +class UserTestExecutionListener extends AbstractTestExecutionListener { + + private User user; + + + @Override + public void beforeTestClass(TestContext testContext) { + var mongo = testContext.getApplicationContext().getBean(MongoOperations.class); + + user = mongo.insert(new User(null, "rashidi.zin", "Rashidi Zin")); + } + + @Override + public void afterTestClass(TestContext testContext) { + var mongo = testContext.getApplicationContext().getBean(MongoOperations.class); + + mongo.remove(user); + } + +} +---- + +Then we will include it in our test class: + +[source,java] +---- +@DataMongoTest +@Import(UserRepositoryTests.TestcontainersConfiguration.class) +@TestExecutionListeners(listeners = UserTestExecutionListener.class, mergeMode = MERGE_WITH_DEFAULTS) +class UserRepositoryTests { + + @Autowired + private UserRepository repository; + + @Test + @DisplayName("Given there is a user with username rashidi.zin and name Rashidi Zin When I search for username rashidi.zin Then user with provided username should be returned") + void findByUsername() { + var user = repository.findByUsername("rashidi.zin"); + + assertThat(user) + .extracting("name") + .isEqualTo("Rashidi Zin"); + } + + @Test + @DisplayName("Given there is no user with username zaid.zin When I search for username zaid.zin Then null should be returned") + void findByUsernameWithNonExistingUsername() { + var user = repository.findByUsername("zaid.zin"); + + assertThat(user).isNull(); + } + + @TestConfiguration(proxyBeanMethods = false) + @ImportAutoConfiguration(TestcontainersPropertySourceAutoConfiguration.class) + static class TestcontainersConfiguration { + + @Bean + MongoDBContainer mongoDbContainer(DynamicPropertyRegistry registry) { + var mongo = new MongoDBContainer("mongo:latest"); + + registry.add("spring.data.mongodb.uri", mongo::getReplicaSetUrl); + + return mongo; + } + + } +} +---- + +In this example, we are using `@TestExecutionListeners` to register `UserTestExecutionListener` which will be executed before and after test class execution. Alternatively, we also no longer utilise on +helpful annotations - `@Testcontainers`, `@Container`, and `@ServiceConnection`. + +== Load Data Using RepositoryPopulators +Next approach is to load data using https://docs.spring.io/spring-data/mongodb/reference/repositories/core-extensions.html#core.repository-populators[RepositoryPopulators] and Testcontainers. +We will start by creating link:{url-quickref}/src/test/resources/users.json[users.json] and populate it with the following content. + +[source,json] +---- +[{ + "_class": "zin.rashidi.data.mongodb.tc.dataload.user.User", + "name": "Rashidi Zin", + "username": "rashidi.zin" +}] +---- + +First, we will have to add `jackson-databind` as our dependency in link:${url-quickref}/build.gradle[build.gradle]. + +[source,groovy] +---- +dependencies { + testImplementation "com.fasterxml.jackson.core:jackson-databind" +} +---- + +Next we will create a `@TestConfiguration` class which will define `RepositoryPopulator`. + +[source,java] +---- +class UserRepositoryTests { + + @TestConfiguration + static class RepositoryPopulatorTestConfiguration { + + @Bean + public Jackson2RepositoryPopulatorFactoryBean jacksonRepositoryPopulator() { + var populator = new Jackson2RepositoryPopulatorFactoryBean(); + populator.setResources(new Resource[] { new ClassPathResource("users.json") }); + return populator; + } + } + +} +---- + +Then we will inform link:${url-quickref}/src/test/java/zin/rashidi/data/mongodb/tc/dataload/user/UserRepositoryTests.java[UserRepositoryTests] to include `RepositoryPopulatorTestConfiguration`. + +[source,java] +---- +@DataMongoTest(includeFilters = @Filter(type = ASSIGNABLE_TYPE, classes = UserRepositoryTests.RepositoryPopulatorTestConfiguration.class)) +class UserRepositoryTests { + + @TestConfiguration + static class RepositoryPopulatorTestConfiguration { + + @Bean + public Jackson2RepositoryPopulatorFactoryBean jacksonRepositoryPopulator() { + var populator = new Jackson2RepositoryPopulatorFactoryBean(); + populator.setResources(new Resource[] { new ClassPathResource("users.json") }); + return populator; + } + } + +} +---- + +Finally, the usual setup to include `@TestContainers` and `MongoDBContainer`. + +[source,java] +---- +@Testcontainers +@DataMongoTest(includeFilters = @Filter(type = ASSIGNABLE_TYPE, classes = UserRepositoryTests.RepositoryPopulatorTestConfiguration.class)) +class UserRepositoryTests { + + @Container + @ServiceConnection + private static final MongoDBContainer mongo = new MongoDBContainer("mongo:latest"); + + @TestConfiguration + static class RepositoryPopulatorTestConfiguration { + + @Bean + public Jackson2RepositoryPopulatorFactoryBean jacksonRepositoryPopulator() { + var populator = new Jackson2RepositoryPopulatorFactoryBean(); + populator.setResources(new Resource[] { new ClassPathResource("users.json") }); + return populator; + } + } + +} +---- + +Once everything is ready, we will add our tests. + +[source,java] +---- +@Testcontainers +@DataMongoTest(includeFilters = @Filter(type = ASSIGNABLE_TYPE, classes = UserRepositoryTests.RepositoryPopulatorTestConfiguration.class)) +class UserRepositoryTests { + + @Container + @ServiceConnection + private static final MongoDBContainer mongo = new MongoDBContainer("mongo:latest"); + + @Autowired + private UserRepository repository; + + @Test + @DisplayName("Given there is a user with username rashidi.zin and name Rashidi Zin When I search for username rashidi.zin Then user with provided username should be returned") + void findByUsername() { + var user = repository.findByUsername("rashidi.zin"); + + assertThat(user) + .extracting("name") + .isEqualTo("Rashidi Zin"); + } + + @Test + @DisplayName("Given there is no user with username zaid.zin When I search for username zaid.zin Then null should be returned") + void findByUsernameWithNonExistingUsername() { + var user = repository.findByUsername("zaid.zin"); + + assertThat(user).isNull(); + } + + @TestConfiguration + static class RepositoryPopulatorTestConfiguration { + + @Bean + public Jackson2RepositoryPopulatorFactoryBean jacksonRepositoryPopulator() { + var populator = new Jackson2RepositoryPopulatorFactoryBean(); + populator.setResources(new Resource[] { new ClassPathResource("users.json") }); + return populator; + } + } + +} +---- + +With that, data will be loaded into MongoDB before the test execution. Full implementation of link:{url-quickref}/src/test/java/zin/rashidi/data/mongodb/tc/dataload/user/UserRepositoryTests.java[`UserRepositoryTests`]: + +This also allows us to have a single source of truth in managing data for our tests. diff --git a/docs/modules/ROOT/pages/data-mongodb-transactional.adoc b/docs/modules/ROOT/pages/data-mongodb-transactional.adoc index efcee742..059b5586 100644 --- a/docs/modules/ROOT/pages/data-mongodb-transactional.adoc +++ b/docs/modules/ROOT/pages/data-mongodb-transactional.adoc @@ -1,3 +1,131 @@ = @Transactional with Spring Data MongoDB +:source-highlighter: highlight.js +Rashidi Zin +2.0, October 12, 2023 +:nofooter: +:icons: font +:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/data-mongodb-transactional -include::../../../../data-mongodb-transactional/README.adoc[lines=2..-1] +Guide to utilise `@Transactional` with Spring Data MongoDB. + + +== Background + +Unlike Spring Data JPA, Spring Data MongoDB does not support `@Transactional` out of the box. In this guide, we will explore how to implement `@Transactional` with Spring Data MongoDB. + +== Scenario + +We will implement based on the following scenario: + +[,text] +---- +When a new User is created +Then the status should be ACTIVE +---- + +== Implementation + +=== Integration Test + +We will verify our implementation through integration test whereby we will create a new `User` and verify that the status is `ACTIVE`. + +[source,java] +---- +@SpringBootTest(webEnvironment = RANDOM_PORT) +@Testcontainers +class CreateUserTests { + + @Container + @ServiceConnection + private static final MongoDBContainer mongo = new MongoDBContainer("mongo:latest"); + + @Autowired + private TestRestTemplate restTemplate; + + @Test + void create() { + var headers = new HttpHeaders() {{ setContentType(APPLICATION_JSON); }}; + var body = """ + { + "username": "rashidi.zin", + "name": "Rashidi Zin" + } + """; + + var response = restTemplate.exchange("/users", POST, new HttpEntity<>(body, headers), User.class); + var createdUser = response.getBody(); + + assertThat(createdUser).extracting("status").isEqualTo(ACTIVE); + } + +} +---- + +=== Event Listener + +We will start by implementing an `EventListener` that will be responsible to assign the status to `ACTIVE` when a new `User` is created. + +[source,java] +---- +@Component +class UpdateUserStatus { + + @TransactionalEventListener + public void onBeforeSave(BeforeSaveEvent event) { + var user = event.getSource(); + + user.status(ACTIVE); + } + +} +---- + +As we can see, `onBeforeSave` is annotated with `@TransactionalEventListener`. This annotation will ensure that the event listener will be +executed within a transaction. + +=== Transactional Method + +Next, we will implement a transactional method that will create a new `User`. + +[source,java] +---- +@RestController +class UserResource { + + private final UserRepository repository; + + UserResource(UserRepository repository) { + this.repository = repository; + } + + @PostMapping("/users") + @ResponseStatus(CREATED) + @Transactional + public User add(@RequestBody User user) { + return repository.save(user); + } + +} +---- + +=== Configuration Class + +Finally, we will configure `MongoTransactionManager` to enable transaction support for MongoDB. + +[source,java] +---- +@Configuration +@EnableTransactionManagement +class MongoTransactionManagerConfiguration { + + @Bean + public PlatformTransactionManager transactionManager(MongoDatabaseFactory factory) { + return new MongoTransactionManager(factory); + } + +} +---- + +=== Verification + +In order to ensure that our implementation is working as expected, the test implemented in link:{url-quickref}/src/test/java/zin/rashidi/boot/data/mongodb/tm/user/CreateUserTests.java[CreateUserTests] should pass. diff --git a/docs/modules/ROOT/pages/data-repository-definition.adoc b/docs/modules/ROOT/pages/data-repository-definition.adoc index 77b75552..c10000f0 100644 --- a/docs/modules/ROOT/pages/data-repository-definition.adoc +++ b/docs/modules/ROOT/pages/data-repository-definition.adoc @@ -1,3 +1,96 @@ = Spring Data: Repository Definition +:source-highlighter: highlight.js +Rashidi Zin +1.0, March 22, 2025 +:nofooter: +:icons: font +:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/data-repository-definition -include::../../../../data-repository-definition/README.adoc[lines=2..-1] +Implement custom repository interfaces with @RepositoryDefinition annotation. + +== Background + +link:https://spring.io/projects/spring-data[Spring Data] provides a consistent programming model for data access while still retaining the special traits of the underlying data store. It makes it easy to use data access technologies, relational and non-relational databases, map-reduce frameworks, and cloud-based data services. + +When working with Spring Data, we typically create repository interfaces by extending one of the provided base interfaces such as `CrudRepository`, `JpaRepository`, or `MongoRepository`. However, sometimes we may want to define a repository with only specific methods, without inheriting all the methods from these base interfaces. + +This is where the `@RepositoryDefinition` annotation comes in. It allows us to define a repository interface with only the methods we need, providing more control over the repository's API. + +== Domain Class + +We have a simple domain class, link:{url-quickref}/src/main/java/zin/rashidi/data/repositorydefinition/note/Note.java[Note], which is a Java record with three fields: `id`, `title`, and `content`. + +[source,java] +---- +record Note(@Id Long id, String title, String content) { +} +---- + +The `@Id` annotation from Spring Data marks the `id` field as the primary key. + +== Repository Definition + +Instead of extending a base repository interface, we use the `@RepositoryDefinition` annotation to define our repository interface, link:{url-quickref}/src/main/java/zin/rashidi/data/repositorydefinition/note/NoteRepository.java[NoteRepository]. + +[source,java] +---- +@RepositoryDefinition(domainClass = Note.class, idClass = Long.class) +interface NoteRepository { + + List findByTitleContainingIgnoreCase(String title); + +} +---- + +The `@RepositoryDefinition` annotation takes two parameters: +- `domainClass`: The entity class that this repository manages (in this case, `Note.class`) +- `idClass`: The type of the entity's ID field (in this case, `Long.class`) + +With this annotation, Spring Data will create a repository implementation for us, just like it would for a repository that extends a base interface. The difference is that our repository only has the methods we explicitly define, in this case, just `findByTitleContainingIgnoreCase`. + +== Benefits of @RepositoryDefinition + +Using `@RepositoryDefinition` offers several benefits: + +1. **Minimalist API**: You only expose the methods you need, making the API cleaner and more focused. +2. **Explicit Contract**: The repository interface clearly shows what operations are supported. +3. **Reduced Surface Area**: By not inheriting methods from base interfaces, you reduce the risk of unintended operations being performed. +4. **Flexibility**: You can define repositories for any domain class without being tied to a specific persistence technology's base interface. + +== Testing + +We can link:{url-quickref}/src/test/java/zin/rashidi/data/repositorydefinition/note/NoteRepositoryTests.java[test our repository] using Spring Boot's testing support with Testcontainers for PostgreSQL. + +[source,java] +---- +@Import(TestcontainersConfiguration.class) +@DataJdbcTest +@SqlMergeMode(MERGE) +@Sql(statements = "CREATE TABLE note (id BIGINT PRIMARY KEY, title VARCHAR(50), content TEXT);", executionPhase = BEFORE_TEST_CLASS) +class NoteRepositoryTests { + + @Autowired + private NoteRepository notes; + + @Test + @Sql(statements = { + "INSERT INTO note (id, title, content) VALUES ('1', 'Right Turn', 'Step forward. Step forward and turn right. Collect.')", + "INSERT INTO note (id, title, content) VALUES ('2', 'Left Turn', 'Step forward. Reverse and turn left. Collect.')", + "INSERT INTO note (id, title, content) VALUES ('3', 'Double Spin', 'Syncopated. Double spin. Collect.')" + }) + @DisplayName("Given there are two entries with the word 'turn' in the title When I search by 'turn' in title Then Right Turn And Left Turn should be returned") + void findByTitleContainingIgnoreCase() { + var turns = notes.findByTitleContainingIgnoreCase("turn"); + + assertThat(turns) + .extracting("title") + .containsOnly("Right Turn", "Left Turn"); + } +} +---- + +The test verifies that our repository method `findByTitleContainingIgnoreCase` correctly finds notes with titles containing the word "turn", ignoring case. + +== Conclusion + +The `@RepositoryDefinition` annotation provides a way to create custom repository interfaces with only the methods you need, without inheriting all the methods from base interfaces. This gives you more control over your repository's API and makes your code more explicit about what operations are supported. diff --git a/docs/modules/ROOT/pages/data-rest-validation.adoc b/docs/modules/ROOT/pages/data-rest-validation.adoc index 72877445..d24c0cbc 100644 --- a/docs/modules/ROOT/pages/data-rest-validation.adoc +++ b/docs/modules/ROOT/pages/data-rest-validation.adoc @@ -1,3 +1,129 @@ = Spring Data REST: Validation +:source-highlighter: highlight.js +Rashidi Zin +1.0, September 25, 2023 +:nofooter: +:icons: font +:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/data-rest-validation -include::../../../../data-rest-validation/README.adoc[lines=2..-1] +Implement validation in Spring Data REST. + +== Background + +link:https://spring.io/projects/spring-data-rest[Spring Data REST] is a framework that helps developers to build hypermedia-driven REST web services. It is built on top of the Spring Data project and makes it easy to build hypermedia-driven REST web services that connect to Spring Data repositories – all using HAL as the driving hypermedia type. + +In this article, we will look at how to implement validation in Spring Data REST. + +== Domain Classes + +There are two `@Entity` classes, link:{url-quickref}/src/main/java/zin/rashidi/boot/data/rest/book/Author.java[Author] and link:{url-quickref}/src/main/java/zin/rashidi/boot/data/rest/book/Book.java[Book]. Both classes are accompanied by `JpaRepository` classes - link:{url-quickref}/src/main/java/zin/rashidi/boot/data/rest/book/AuthorRepository.java[AuthorRepository] and link:{url-quickref}/src/main/java/zin/rashidi/boot/data/rest/book/BookRepository.java[BookRepository]. + +While `Author` and `Book` are standard JPA entities, their repositories are annotated with `@RepositoryRestResource` to expose them as REST resources. + +[source,java] +---- +@RepositoryRestResource +interface AuthorRepository extends JpaRepository { +} +---- + +[source,java] +---- +@RepositoryRestResource +interface BookRepository extends JpaRepository { +} +---- + +== Validation + +We will implement a validation that ensures that `Author` in `Book` is not `INACTIVE`. To do this, we will create a custom validator class, link:{url-quickref}/src/main/java/zin/rashidi/boot/data/rest/book/BeforeCreateBookValidator.java[BeforeCreateBookValidator]. + +[source,java] +---- +class BeforeCreateBookValidator implements Validator { + + @Override + public boolean supports(Class clazz) { + return Book.class.isAssignableFrom(clazz); + } + + @Override + public void validate(Object target, Errors errors) { + Book book = (Book) target; + + if (book.getAuthor().getStatus() == INACTIVE) { + errors.rejectValue("author", "author.inactive", "Author is inactive"); + } + + } + +} +---- + +As we can see, the validator class implements `Validator` interface and overrides `supports` and `validate` methods. The `supports` method checks if the class is `Book` and the `validate` method checks if the `Author` is `INACTIVE`. Next we will inform Spring about our `Validator` through link:{url-quickref}/src/main/java/zin/rashidi/boot/data/rest/book/BookValidatorConfiguration.java[BookValidatorConfiguration]. + +[source,java] +---- +@Configuration +class BookValidatorConfiguration implements RepositoryRestConfigurer { + + @Override + public void configureValidatingRepositoryEventListener(ValidatingRepositoryEventListener validatingListener) { + validatingListener.addValidator("beforeCreate", new BeforeCreateBookValidator()); + } + +} +---- + +Now, Spring is aware that the `Validator` will be executed before creating a `Book`. + +== Verify Implementation + +We will perform a `POST` request to create a `Book` with an `Author` that is `INACTIVE`. The request will be rejected with `400 Bad Request` response. + +[source,java] +---- +@Import(TestDataRestValidationApplication.class) +@SpringBootTest(webEnvironment = RANDOM_PORT, properties = "spring.jpa.hibernate.ddl-auto=create-drop") +class CreateBookTests { + + @Autowired + private TestRestTemplate restClient; + + @Test + @DisplayName("When I create a Book with an inactive Author, I should get a Bad Request response") + void inactiveAuthor() { + var body = """ + { + "title": "If", + "author": "%s" + } + """.formatted(authorUri()); + + var response = restClient.exchange("/books", POST, new HttpEntity<>(body, headers()), RepositoryRestErrorResponse.class); + + assertThat(response.getStatusCode()).isEqualTo(BAD_REQUEST); + + assertThat(response.getBody().getErrors()) + .hasSize(1) + .extracting(ValidationError::getMessage) + .containsExactly("Author is inactive"); + } + + private URI authorUri() { + var body = """ + { + "name": "Rudyard Kipling", + "status": "INACTIVE" + } + """; + + return restClient.exchange("/authors", POST, new HttpEntity<>(body, headers()), Void.class) + .getHeaders() + .getLocation(); + } + +} +---- + +Full implementation of the test can be found in link:{url-quickref}/src/test/java/zin/rashidi/boot/data/rest/book/CreateBookTests.java[CreateBookTests]. diff --git a/docs/modules/ROOT/pages/graphql.adoc b/docs/modules/ROOT/pages/graphql.adoc index 6a760a59..49482118 100644 --- a/docs/modules/ROOT/pages/graphql.adoc +++ b/docs/modules/ROOT/pages/graphql.adoc @@ -1,3 +1,190 @@ = GraphQL With Spring Boot +:source-highlighter: highlight.js +Rashidi Zin +1.0, September 3, 2023 +:nofooter: +:icons: font +:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/graphql -include::../../../../graphql/README.adoc[lines=2..-1] +Implementing https://graphql.org/[GraphQL] server with https://spring.io/guides/gs/graphql-server/[Spring Boot GraphQL Server]. + +== Background + +GraphQL provides the flexibility for clients to retrieve fields that are only relevant to them. In this tutorial we will +explore how we can implement GraphQL server with Spring Boot. + +== Server Implementation + +=== schema.graphqls + +[source,graphql] +---- +scalar Long + +type Query { + findAll: [Book] + findByTitle(title: String): Book +} + +type Book { + isbn: Isbn + title: String + author: Author +} + +type Isbn { + ean: Long + registrationGroup: Int + registrant: Int + publication: Int + digit: Int +} + +type Author { + name: Name +} + +type Name { + first: String + last: String +} +---- + +This is the schema that inform our clients on available fields and queries. Next is to implement a `@Controller` that +handle all requests. + +=== Controller + +[source, java] +---- +@Controller +class BookResource { + + private final BookRepository repository; + + BookResource(BookRepository repository) { + this.repository = repository; + } + + @QueryMapping + public List findAll() { + return repository.findAll(); + } + + @QueryMapping + public Book findByTitle(@Argument String title) { + return repository.findByTitle(title); + } + +} +---- + +link:{url-quickref}/src/main/java/zin/rashidi/boot/graphql/book/BookResource.java[BookResource] implements two types of services - +`findAll`, a service without parameter and `findByTitle`, a service with a parameter. Both services are annotated with +`@QueryMapping` to indicate that they are GraphQL queries. + +== Verification + +We will utilise `@GraphQlTest` to verify our implementation. + +=== Integration Test + +[source, java] +---- +@GraphQlTest( + controllers = BookResource.class, + includeFilters = @Filter(type = ANNOTATION, classes = { Configuration.class, Repository.class }) +) +class BookResourceTests { + + @Autowired + private GraphQlTester client; + + @Test + @DisplayName("Given there are 3 books, when findAll is invoked, then return all books") + void findAll() { + client.documentName("books") + .execute() + .path("findAll") + .matchesJson( + """ + [ + { + "title": "Clean Code" + }, + { + "title": "Design Patterns" + }, + { + "title": "The Hobbit" + } + ] + """ + ) + ; + } + + @Test + @DisplayName("Given there is a book titled The Hobbit, when findByTitle is invoked, then return the book") + void findByTitle() { + client.documentName("books") + .variable("title", "The Hobbit") + .execute() + .path("findByTitle") + .matchesJson( + """ + { + "title": "The Hobbit", + "isbn": { + "ean": 9780132350884, + "registrationGroup": 978, + "registrant": 0, + "publication": 13235088, + "digit": 4 + }, + "author": { + "name": { + "first": "J.R.R.", + "last": "Tolkien" + } + } + } + """ + ); + } + +} +---- + +=== Client schema definition + +In order to map the response to a Java object, we need to define the schema of the response. This is done in link:{url-quickref}/src/test/resources/graphql-test/books.graphql[books.graphl]. + +[source, graphql] +---- +query books($title: String) { + findByTitle(title: $title) { + title + isbn { + ean + registrationGroup + registrant + publication + digit + } + author { + name { + first + last + } + } + } + + findAll { + title + } + +} +---- + +By executing tests implemented in link:{url-quickref}/src/test/java/zin/rashidi/boot/graphql/book/BookResourceTests.java[BookResourceTests], we can verify that our implementation is working as expected. diff --git a/docs/modules/ROOT/pages/index.adoc b/docs/modules/ROOT/pages/index.adoc index e8145fc0..0ec4c612 100644 --- a/docs/modules/ROOT/pages/index.adoc +++ b/docs/modules/ROOT/pages/index.adoc @@ -21,4 +21,3 @@ and always will be, my primary goal. With the help of https://github.com/dependabot[Dependabot], each tutorial is also kept up-to-date with the latest dependencies. Currently, we are using Java https://adoptium.net/en-GB/temurin/releases/?version=21[Temurin 21] -with https://plugins.gradle.org/plugin/org.springframework.boot/3.4.4[Spring Boot 3.4.4].. \ No newline at end of file diff --git a/docs/modules/ROOT/pages/jooq.adoc b/docs/modules/ROOT/pages/jooq.adoc index 1acd80f4..b2aa762c 100644 --- a/docs/modules/ROOT/pages/jooq.adoc +++ b/docs/modules/ROOT/pages/jooq.adoc @@ -1,3 +1,188 @@ = jOOQ: Implement jOOQ with Spring Boot and Gradle +:source-highlighter: highlight.js +:highlightjs-languages: java, groovy +Rashidi Zin +1.0, November 08, 2023: Initial version +:nofooter: +:icons: font +:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/jooq -include::../../../../jooq/README.adoc[lines=2..-1] +While JPA is commonly used in Java applications, it is not the only option. In this tutorial, we will look at how to use jOOQ with Spring Boot and Gradle. + +== Background + +https://www.jooq.org/[jOOQ] is a Java library that allows for fluent SQL query construction and typesafe database querying. It supports a +wide range of databases including MySQL, PostgreSQL, Oracle, Microsoft SQL Server, H2, HSQLDB, and SQLite. + +It is also a good alternative to JPA / Hibernate. For more information on that, please refer to an article by Lukas Eder - +https://blog.jooq.org/jooq-vs-hibernate-when-to-choose-which/[jOOQ vs. Hibernate: When to Choose Which]. + +While most jOOQ examples and tutorials are using maven, we will explore the option of using Gradle by utilising +https://github.com/etiennestuder/gradle-jooq-plugin[Gradle jOOQ Plugin]. + +== Gradle Configuration + +In order to enable jOOQ code generation with gradle we will need to include `nu.studer.jooq` plugin in link:{url-quickref}/build.gradle[build.gradle]. + +[source, groovy] +---- +plugins { + id 'nu.studer.jooq' version '8.2' +} +---- + +Instead of generating the code based on existing database, we will https://www.jooq.org/doc/latest/manual/code-generation/codegen-ddl/[generate them based on schema] +defined in link:{url-quickref}/src/main/resources/mysql-schema.sql[mysql-schema.sql] file. In order to do so, we will need to include `org.jooq.meta.extensions.ddl.DDLDatabase` + +[source, groovy] +---- +dependencies { + jooqGenerator 'org.jooq:jooq-meta-extensions:' + dependencyManagement.importedProperties['jooq.version'] + jooqGenerator 'com.mysql:mysql-connector-j' +} +---- + +Next we will use `org.jooq.meta.extensions.ddl.DDLDatabase` in our configuration + +[source, groovy] +---- +jooq { + version = dependencyManagement.importedProperties['jooq.version'] + edition = JooqEdition.OSS + + configurations { + main { + generationTool { + generator { + database { + name = 'org.jooq.meta.extensions.ddl.DDLDatabase' + properties { + property { + key = 'scripts' + value = 'src/main/resources/mysql-schema.sql' + } + } + } + target { + packageName = 'zin.rashidi.boot.jooq' + } + strategy.name = 'org.jooq.codegen.DefaultGeneratorStrategy' + } + } + } + } +} +---- + +Now our Gradle setup is completed, we will proceed to `Repository` implementation. + +== Repository Implementation + +We will start by defining an `interface` which will be used by the clients such as `Service` and `Controller` class. + +[source, java] +---- +interface UserRepository { + + Optional findByUsername(String username); + +} +---- + +As you can see, link:{url-quickref}/src/main/java/zin/rashidi/boot/jooq/user/UserRepository.java[UserRepository] implementation is similar +to standard Spring Data's implementation. We will implement this interface using jOOQ in link:src/main/java/zin/rashidi/boot/jooq/user/UserJooqRepository.java[UserJooqRepository]. + +[source, java] +---- +@Repository +class UserJooqRepository implements UserRepository { + + private final DSLContext dsl; + + UserJooqRepository(DSLContext dsl) { + this.dsl = dsl; + } + + @Override + public Optional findByUsername(String username) { + return dsl.selectFrom(USERS).where(USERS.USERNAME.eq(username)) + .withReadOnly() + .maxRows(1) + .stream() + .map(record -> new User(record.getId(), record.getName(), record.getUsername())) + .findFirst(); + } + +} +---- + +Now that is done, let's write a test to validate our implementation. + +== Integration Test + +We will utilise `Testcontainers` and `JooqTest` to write an integration test for our `UserRepository` implementation. There will be two +scenarios: + +* `findByUsername` returns `Optional` when user exists +* `findByUsername` returns empty `Optional` when user does not exist + +We will create `USERS` table and populate with test data prior to test execution. This is done by using `@Sql` annotation. + +=== Find by username with existing username + +[source, java] +---- +@Testcontainers +@Sql(scripts = "classpath:mysql-schema.sql", statements = "INSERT INTO USERS (name, username) VALUES ('Rashidi Zin', 'rashidi')") +@JooqTest(includeFilters = @Filter(classes = Repository.class)) +class UserRepositoryTests { + + @Container + @ServiceConnection + private static final MySQLContainer container = new MySQLContainer<>("mysql:latest"); + + @Autowired + private UserRepository repository; + + @Test + @DisplayName("Given username rashidi is available, when findByUsername, then return User") + void findByUsername() { + var user = repository.findByUsername("rashidi"); + + assertThat(user).get() + .extracting("name", "username") + .containsOnly("Rashidi Zin", "rashidi"); + } + +} +---- + +=== Find by username with non-existing username + +[source, java] +---- +@Testcontainers +@Sql(scripts = "classpath:mysql-schema.sql", statements = "INSERT INTO USERS (name, username) VALUES ('Rashidi Zin', 'rashidi')") +@JooqTest(includeFilters = @Filter(classes = Repository.class)) +class UserRepositoryTests { + + @Container + @ServiceConnection + private static final MySQLContainer container = new MySQLContainer<>("mysql:latest"); + + @Autowired + private UserRepository repository; + + @Test + @DisplayName("Given there is no user with username zaid.zin, when findByUsername, then return empty Optional") + void findByUsernameWithNonExistingUsername() { + var user = repository.findByUsername("zaid.zin"); + + assertThat(user).isEmpty(); + } + +} +---- + +Once done, execute the tests in link:{url-quickref}/src/test/java/zin/rashidi/boot/jooq/user/UserRepositoryTests.java[UserRepositoryTests] +to ensure our implementation is working as expected. diff --git a/docs/modules/ROOT/pages/modulith.adoc b/docs/modules/ROOT/pages/modulith.adoc index 7369b7d4..ff2f87b2 100644 --- a/docs/modules/ROOT/pages/modulith.adoc +++ b/docs/modules/ROOT/pages/modulith.adoc @@ -1,3 +1,247 @@ = Spring Modulith: Building Modular Monolithic Applications +:source-highlighter: highlight.js +Rashidi Zin +1.0, April 6, 2025 +:nofooter: +:icons: font +:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/modulith -include::../../../../modulith/README.adoc[lines=2..-1] +== Introduction + +This tutorial demonstrates how to use Spring Modulith to build a modular monolithic application. Spring Modulith is a framework that helps structure applications into well-defined modules with clear boundaries while still deploying as a single unit. + +In this example, we've built a simple student course management system with three modules: + +* *Course*: Manages course information and lifecycle +* *Student*: Manages student information and status +* *Subscription*: Manages the relationship between students and courses + +== What is Spring Modulith? + +Spring Modulith is an extension to the Spring ecosystem that provides: + +* Clear module boundaries through package conventions +* Explicit module dependencies +* Event-based communication between modules +* Testing support for modules in isolation +* Documentation generation for module structure + +== Project Structure + +The project follows the Spring Modulith package convention: + +[source] +---- +zin.rashidi.boot.modulith +├── ModulithApplication.java +├── course +│ ├── Course.java +│ ├── CourseEnded.java +│ ├── CourseEventsConfiguration.java +│ ├── CourseManagement.java +│ └── CourseRepository.java +├── student +│ ├── Student.java +│ ├── StudentEventsConfiguration.java +│ ├── StudentInactivated.java +│ ├── StudentManagement.java +│ └── StudentRepository.java +└── subscription + ├── Subscription.java + ├── SubscriptionManagement.java + └── SubscriptionRepository.java +---- + +Each module is a separate package under the application's base package. + +== Module Interactions + +The modules interact with each other through events: + +1. When a course ends, the Course module publishes a `CourseEnded` event +2. When a student is inactivated, the Student module publishes a `StudentInactivated` event +3. The Subscription module listens for these events and cancels the relevant subscriptions + +This event-based communication ensures loose coupling between modules. + +== Key Components + +=== Domain Entities + +Each module has its own domain entity: + +* `Course`: Represents a course with a name and status (ACTIVE, DORMANT, ENDED) +* `Student`: Represents a student with a name and status (ACTIVE, INACTIVE) +* `Subscription`: Represents a relationship between a student and a course with a status (ACTIVE, COMPLETED, DORMANT, CANCELLED) + +=== Repositories + +Each module has its own repository for data access: + +* `CourseRepository`: Basic CRUD operations for courses +* `StudentRepository`: Basic CRUD operations for students +* `SubscriptionRepository`: CRUD operations plus custom methods for cancelling subscriptions by course or student + +=== Services + +Each module has a service class for business logic: + +* `CourseManagement`: Updates course information +* `StudentManagement`: Manages student status +* `SubscriptionManagement`: Listens for events and manages subscriptions accordingly + +=== Events + +The application uses domain events for communication between modules: + +* `CourseEnded`: Published when a course status is set to ENDED +* `StudentInactivated`: Published when a student status is set to INACTIVE + +== Testing + +Spring Modulith provides excellent testing support: + +* `ModuleTests`: Verifies the modulith architecture and generates documentation +* Module-specific tests: Test each module in isolation or with its dependencies + +=== Architecture Verification + +The `ModuleTests` class includes a test that verifies the modulith architecture: + +[source,java] +---- +@Test +@DisplayName("Verify architecture") +void verify() { + modules.verify(); +} +---- + +This test ensures that module dependencies are correctly defined and that there are no unwanted dependencies between modules. + +=== Documentation Generation + +The `ModuleTests` class also includes a test that generates documentation: + +[source,java] +---- +@Test +@DisplayName("Generate documentation") +void document() { + new Documenter(modules, defaults().withOutputFolder("docs")) + .writeModulesAsPlantUml() + .writeDocumentation(Documenter.DiagramOptions.defaults(), Documenter.CanvasOptions.defaults().revealInternals()); +} +---- + +This test generates documentation in the `docs` folder, including PlantUML diagrams and AsciiDoc files for each module. + +=== Testing Event-Based Communication + +Spring Modulith provides excellent support for testing event-based communication between modules. Here are examples from our test classes: + +==== Publishing Events + +The `CourseManagementTests` class demonstrates how to test event publishing: + +[source,java] +---- +@ApplicationModuleTest +class CourseManagementTests { + + @Autowired + private CourseManagement courses; + + @Test + @DisplayName("When a course is ENDED Then CourseEnded event will be triggered with the course Id") + void courseEnded(Scenario scenario) { + var course = new Course("Advanced Java Programming").status(ENDED); + ReflectionTestUtils.setField(course, "id", 2L); + + scenario.stimulate(() -> courses.updateCourse(course)) + .andWaitAtMost(ofMillis(101)) + .andWaitForEventOfType(CourseEnded.class) + .toArriveAndVerify(event -> assertThat(event).extracting("id").isEqualTo(2L)); + } +} +---- + +.This test: +. Uses `@ApplicationModuleTest` to test the Course module +. Uses `Scenario.stimulate()` to trigger an action (updating a course) +. Uses `andWaitAtMost()` to specify a maximum wait time +. Uses `andWaitForEventOfType()` to wait for a specific event type +. Uses `toArriveAndVerify()` to verify the event's properties + +Similarly, the `StudentManagementTests` class tests event publishing from the Student module: + +[source,java] +---- +@ApplicationModuleTest +class StudentManagementTests { + + @Autowired + private StudentManagement students; + + @Test + @DisplayName("When the student with id 4 is inactivated Then StudentInactivated event will be triggered with student id 4") + void inactive(Scenario scenario) { + var student = new Student("Bob Johnson"); + ReflectionTestUtils.setField(student, "id", 4L); + + scenario.stimulate(() -> students.inactive(student)) + .andWaitForEventOfType(StudentInactivated.class) + .toArriveAndVerify(inActivatedStudent -> assertThat(inActivatedStudent).extracting("id").isEqualTo(4L)); + } +} +---- + +==== Consuming Events + +The `SubscriptionManagementTests` class demonstrates how to test event consumption: + +[source,java] +---- +@ApplicationModuleTest +class SubscriptionManagementTests { + + @Autowired + private SubscriptionRepository subscriptions; + + @Test + @DisplayName("When CourseEnded is triggered with id 5 Then all subscriptions for the course will be CANCELLED") + void courseEnded(Scenario scenario) { + var event = new CourseEnded(5L); + + scenario.publish(event) + .andWaitForStateChange(() -> subscriptions.cancelByCourseId(5L)) + .andVerify(updatedRows -> assertThat(updatedRows).isEqualTo(2)); + } + + @Test + @DisplayName("When StudentInactivated is triggered with id 5 Then all subscriptions for the student will be CANCELLED") + void studentInactivated(Scenario scenario) { + var event = new StudentInactivated(5L); + + scenario.publish(event) + .andWaitForStateChange(() -> subscriptions.cancelByStudentId(5L)) + .andVerify(updatedRows -> assertThat(updatedRows).isEqualTo(2)); + } +} +---- + +.This test: +. Uses `@ApplicationModuleTest` to test the Subscription module +. Uses `Scenario.publish()` to publish an event +. Uses `andWaitForStateChange()` to wait for a state change in the system +. Uses `andVerify()` to verify the result of the state change + +== Generated Documentation + +Spring Modulith automatically generates documentation for your modules. You can view the generated documentation in the link:${url-quickref}/docs/all-docs.adoc[docs/all-docs.adoc] file. + +== Conclusion + +Spring Modulith provides a powerful way to structure your Spring Boot applications into well-defined modules while still deploying as a single unit. By following package conventions and using event-based communication, you can build modular monolithic applications that are easier to understand, test, and maintain. + +For more information, visit the https://spring.io/projects/spring-modulith[Spring Modulith website]. diff --git a/docs/modules/ROOT/pages/test-execution-listeners.adoc b/docs/modules/ROOT/pages/test-execution-listeners.adoc index f8e4a0bf..c7f1f437 100644 --- a/docs/modules/ROOT/pages/test-execution-listeners.adoc +++ b/docs/modules/ROOT/pages/test-execution-listeners.adoc @@ -1,3 +1,129 @@ = Spring Test: Managing Test Data with `TestExecutionListener` +:source-highlighter: highlight.js +Rashidi Zin +1.0, September 19, 2023 +:nofooter: +:icons: font +:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/test-execution-listeners -include::../../../../test-execution-listeners/README.adoc[lines=2..-1] +We often make use of `@BeforeEach` and `@AfterEach` methods to prepare and clean up test data. However, this approach is not scalable and can be difficult to maintain. In this article, we will look at how we can use `TestExecutionListener` to manage test data. + +== Background + +Spring provides link:https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/test/context/TestExecutionListener.html[`TestExecutionListener`] interface that we can implement to hook into the test execution lifecycle. This helps us in ensuring that our test classes are concise and not cluttered with test data preparation and clean up code. + +In this article, we will look at how we can use `TestExecutionListener` to manage test data. We will implement listeners to create initial data, update relevant data, and clean up data after the test execution. + +== `TestExecutionListener` classes + +=== Creating initial data +We will start by creating initial link:{url-quickref}/src/main/java/zin/rashidi/boot/test/user/User.java[User] data which consists of `name` and `username` fields. + +[source,java] +---- +class UserCreationTestExecutionListener extends AbstractTestExecutionListener { + + @Override + public void beforeTestClass(TestContext testContext) { + var mongo = testContext.getApplicationContext().getBean(MongoOperations.class); + + mongo.save(new User("Rashidi Zin", "rashidi.zin")); + } + + @Override + public int getOrder() { + return HIGHEST_PRECEDENCE; + } + +} +---- + +By default `getOrder` method returns `LOWEST_PRECEDENCE` which means that this listener will be executed last. Since we want this listener to always be executed first, we will set the order to `HIGHEST_PRECEDENCE`. + +=== Update relevant data +Our test will focus on finding `User` with `status` `INACTIVE`. We will create a listener to update the `status` of the `User` to `INACTIVE` after the test execution. + +[source,java] +---- +class UserStatusUpdateTestExecutionListener extends AbstractTestExecutionListener { + + @Override + public void beforeTestClass(TestContext testContext) { + var mongo = testContext.getApplicationContext().getBean(MongoOperations.class); + var findByUsername = mongo.findOne(query(where("username").is("rashidi.zin")), User.class); + + mongo.save(findByUsername.status(INACTIVE)); + } + + @Override + public int getOrder() { + return 1; + } + +} +---- + +Given that we are expecting a `User` with `username` `rashidi.zin` to be returned, we will update the `status` of the `User` with `username` `rashidi.zin` to `INACTIVE`. + +In this listener, we will set the order to `1` as we want to ensure it will not be the last one to be executed. + +=== Clean up data +Both listeners above will create and update `User` data. They will be executed _before_ test class. For data cleanup it will be executed _after_ test class is executed. + +[source,java] +---- +class UserDeletionTestExecutionListener extends AbstractTestExecutionListener { + + private static Logger log = LoggerFactory.getLogger(UserDeletionTestExecutionListener.class); + + @Override + public void afterTestClass(TestContext testContext) { + var mongo = testContext.getApplicationContext().getBean(MongoOperations.class); + + mongo.dropCollection(User.class); + + log.info("user collection dropped"); + } + +} +---- + +== Registering `TestExecutionListener` classes +Finally, we will implement a test class to test the link:{url-quickref}/src/main/java/zin/rashidi/boot/test/user/UserRepository.java[UserRepository] which will be executed with the listeners above. +We will define necessary `TestExecutionListeners` using `@TestExecutionListeners` annotation and we will also set the `mergeMode` to `MERGE_WITH_DEFAULTS` to ensure that the default listeners are also executed. + +[source,java] +---- +@Testcontainers +@DataMongoTest +@TestExecutionListeners( + listeners = { UserCreationTestExecutionListener.class, UserStatusUpdateTestExecutionListener.class, UserDeletionTestExecutionListener.class }, + mergeMode = MERGE_WITH_DEFAULTS +) +class UserRepositoryTests { + + @Container + @ServiceConnection + private static final MongoDBContainer mongo = new MongoDBContainer("mongo:latest"); + + @Autowired + private UserRepository repository; + + @Test + @DisplayName("Given there are users with status INACTIVE, When I search for users with status INACTIVE, Then I should get users with status INACTIVE") + void findByStatus() { + var inactiveUsers = repository.findByStatus(INACTIVE); + + assertThat(inactiveUsers) + .hasSize(1) + .extracting("username") + .containsOnly("rashidi.zin"); + } + +} +---- + +While `findByStatus` will validate our implementation in `UserCreationTestExecutionListener` and `UserStatusUpdateTestExecutionListener`, a log message will be printed to indicate that `UserDeletionTestExecutionListener` is executed. + +== Conclusion +With `TestExecutionListener` data can be reused across test classes. This helps us in ensuring that our test classes are concise and not cluttered with test data preparation and clean up code. diff --git a/docs/modules/ROOT/pages/test-rest-assured.adoc b/docs/modules/ROOT/pages/test-rest-assured.adoc index 160295eb..9a93c4ed 100644 --- a/docs/modules/ROOT/pages/test-rest-assured.adoc +++ b/docs/modules/ROOT/pages/test-rest-assured.adoc @@ -1,3 +1,429 @@ = Spring Test: Implement BDD with RestAssured +:source-highlighter: highlight.js +Rashidi Zin +1.1, November 16, 2024: Fix broken build +:nofooter: +:icons: font +:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/test-rest-assured -include::../../../../test-rest-assured/README.adoc[lines=2..-1] +Verify API implementation through integration tests with https://www.browserstack.com/guide/what-is-bdd[Behaviour Driven Development (BDD)] +using Spring Boot, https://testcontainers.com/[Testcontainers], and https://rest-assured.io/[RestAssured]. + +== Background + +RestAssured provide the convenience to test REST API in BDD style. It is very useful to test API implementation in Spring Boot application. +Provided that its API involved common BDD keywords such as `given`, `when` and `then`. + +In this example we will implement three features: + +[start=1] +. User creation +. User retrieval by username +. User deletion + +We will implement test scenarios before implementing the actual API. + +== User Creation + +We will implement two scenarios - create with an available username and create with an unavailable username. + +[source,java] +---- +@Testcontainers +@TestExecutionListeners(listeners = UserCreationTestExecutionListener.class, mergeMode = MERGE_WITH_DEFAULTS) +@SpringBootTest(webEnvironment = RANDOM_PORT) +class CreateUserTests { + + @Container + @ServiceConnection + private static final MongoDBContainer mongo = new MongoDBContainer("mongo:latest"); + + @BeforeAll + static void port(@LocalServerPort int port) { + RestAssured.port = port; + } + + @Test + @DisplayName("Given provided username is available When I create a User Then response status should be Created") + void availableUsername() { + var content = """ + { + "name": "Rashidi Zin", + "username": "rashidi.zin" + } + """; + + given() + .contentType(JSON) + .body(content) + .when() + .post("/users") + .then().assertThat() + .statusCode(equalTo(SC_CREATED)); + } + + @Test + @DisplayName("Given the username zaid.zin is unavailable When I create a User Then response status should be Bad Request") + void unavailableUsername() { + var content = """ + { + "name": "Zaid Zin", + "username": "zaid.zin" + } + """; + + given() + .contentType(JSON) + .body(content) + .when() + .post("/users") + .then().assertThat() + .statusCode(equalTo(SC_BAD_REQUEST)); + } + +} +---- + +In the implementation above. Testcontainers is used to simulate actual MongoDB and +link:{url-quickref}/src/test/java/zin/rashidi/boot/test/restassured/user/UserCreationTestExecutionListener.java[UserCreationTestExecutionListener] will load data into the database. +The data will be used to validate the second scenario. + +Next we will implement the API which will ensure that scenarios above will pass. We will start our implementation to fix the first failing +scenario - create with an available username. + +[source,java] +---- +@RestController +class UserResource { + + private final UserRepository repository; + + UserResource(UserRepository repository) { + this.repository = repository; + } + + @PostMapping("/users") + @ResponseStatus(CREATED) + public void create(@RequestBody UserRequest request) { + repository.save(new User(request.name(), request.username())); + } + +} +---- + +The implementation above should be sufficient to fix our first scenario. We will run the test again to ensure that it passes. Next is to +fix our second scenario - create with an unavailable username. + +Given that we do not have any validation in place, the second scenario will fail. We will add validation to ensure that the username is +unique. We will start by implementing a `Repository` method to validate if the username exists. + +[source,java] +---- +interface UserRepository extends MongoRepository { + + boolean existsByUsername(String username); + +} +---- + +Next, we will use `existsByUsername` to validate if the username exists before saving the user. + +[source,java] +---- +@RestController +class UserResource { + + private final UserRepository repository; + + UserResource(UserRepository repository) { + this.repository = repository; + } + + @PostMapping("/users") + @ResponseStatus(CREATED) + public void create(@RequestBody UserRequest request) { + if (repository.existsByUsername(request.username())) { + throw new IllegalArgumentException("Username already exists"); + } + + repository.save(new User(request.name(), request.username())); + } + +} +---- + +This, however, is insufficient as the server will throw `500 Internal Server Error` when the username already exists. We will add +`@ExceptionHandler` to handle the exception which converts it to `BAD REQUEST`. + +[source,java] +---- +@RestController +class UserResource { + + private final UserRepository repository; + + UserResource(UserRepository repository) { + this.repository = repository; + } + + @PostMapping("/users") + @ResponseStatus(CREATED) + public void create(@RequestBody UserRequest request) { + if (repository.existsByUsername(request.username())) { + throw new IllegalArgumentException("Username already exists"); + } + + repository.save(new User(request.name(), request.username())); + } + + @ExceptionHandler + @ResponseStatus(BAD_REQUEST) + public void handleIllegalArgumentException(IllegalArgumentException ignored) { + } + +} +---- + +Now we will run link:{url-quickref}/src/test/java/zin/rashidi/boot/test/restassured/user/CreateUserTests.java[CreateUserTests] again to ensure that both scenarios pass. Next, we will follow the same approach to implement the API for user retrieval by username. + +== User Retrieval by Username + +In link:{url-quickref}/src/test/java/zin/rashidi/boot/test/restassured/user/FindUserByUsernameTests.java[FindUserByUsernameTests], we will implement two scenarios - find with an available username and find with an unavailable username. + +[source,java] +---- +@Testcontainers +@TestExecutionListeners(listeners = UserCreationTestExecutionListener.class, mergeMode = MERGE_WITH_DEFAULTS) +@SpringBootTest(webEnvironment = RANDOM_PORT) +class FindUserByUsernameTests { + + @Container + @ServiceConnection + private static final MongoDBContainer mongo = new MongoDBContainer("mongo:latest"); + + @BeforeAll + static void port(@LocalServerPort int port) { + RestAssured.port = port; + } + + @Test + @DisplayName("Given username zaid.zin exists When I find a User Then response status should be OK and User should be returned") + void findByExistingUsername() { + given() + .contentType(JSON) + .when() + .get("/users/{username}", "zaid.zin") + .then().assertThat() + .statusCode(equalTo(SC_OK)) + .body("name", equalTo("Zaid Zin")) + .body("username", equalTo("zaid.zin")); + } + + @Test + @DisplayName("Given there is no User with username rashidi.zin When I find a User Then response status should be Not Found") + void findByNonExistingUsername() { + given() + .contentType(JSON) + .when() + .get("/users/{username}", "rashidi.zin") + .then().assertThat() + .statusCode(equalTo(SC_NOT_FOUND)); + } + +} +---- + +As you can see, `findByExistingUsername` validates the response body as well as HTTP response. Given that the user exists then the response body should contain the user's name and username. The HTTP response should be `200 OK`. + +While in the event requested `username` does not exist then the HTTP response should be `404 Not Found`. + +We will start by implementing a `Repository` method which will retrieve requested username. + +[source,java] +---- +interface UserRepository extends MongoRepository { + + Optional findByUsername(String username); + +} +---- + +link:{url-quickref}/src/main/java/zin/rashidi/boot/test/restassured/user/UserReadOnly.java[UserReadOnly] is a read-only projection of +`User` which will be used to retrieve the user's name and username. + +Then we will implement the API to fix the scenarios above. We will start with the first scenario - find with an available username. + +[source,java] +---- +@RestController +class UserResource { + + private final UserRepository repository; + + UserResource(UserRepository repository) { + this.repository = repository; + } + + @GetMapping("/users/{username}") + public UserReadOnly findByUsername(@PathVariable String username) { + return repository.findByUsername(username).orElseThrow(); + } + +} +---- + +The implementation above should be sufficient to fix our first scenario. We will run the test again to ensure that it passes. +Next is to fix our second scenario - find with an unavailable username. + +As for now, the second scenario will fail. We will add `@ExceptionHandler` to handle the exception which converts it to `NOT FOUND`. + +[source,java] +---- +@RestController +class UserResource { + + private final UserRepository repository; + + UserResource(UserRepository repository) { + this.repository = repository; + } + + @GetMapping("/users/{username}") + public UserReadOnly findByUsername(@PathVariable String username) { + return repository.findByUsername(username).orElseThrow(); + } + + @ExceptionHandler + @ResponseStatus(NOT_FOUND) + public void handleNoSuchElementException(NoSuchElementException ignored) { + } + +} +---- + +Now we will run link:{url-quickref}/src/test/java/zin/rashidi/boot/test/restassured/user/FindUserByUsernameTests.java[FindUserByUsernameTests] +again to ensure that both scenarios pass. Next, we will follow the same approach to implement the API for user deletion. + +== User Deletion + +For User Deletion, the action requires a valid `id`. However, since we are going to utilise data stored by `Testcontainers`, we are required +to retrieve the existing user's `id` first. Then we will perform the deletion. + +We will implement two scenarios - delete with an available `id` and delete with an non-existing `id`. + +[source,java] +---- +@Testcontainers +@TestExecutionListeners(listeners = UserCreationTestExecutionListener.class, mergeMode = MERGE_WITH_DEFAULTS) +@SpringBootTest(webEnvironment = RANDOM_PORT) +class DeleteUserTests { + + @Container + @ServiceConnection + private static final MongoDBContainer mongo = new MongoDBContainer("mongo:latest"); + + @BeforeAll + static void port(@LocalServerPort int port) { + RestAssured.port = port; + } + + @Test + @DisplayName("Given username zaid.zin exists When I delete with its id Then response status should be No Content") + void deleteWithValidId() { + String id = get("/users/{username}", "zaid.zin").path("id"); + + when() + .delete("/users/{id}", id) + .then().assertThat() + .statusCode(equalTo(SC_NO_CONTENT)); + } + + @Test + @DisplayName("When I trigger delete with a non-existing ID Then response status should be Not Found") + void deleteWithNonExistingId() { + when() + .delete("/users/{id}", "5f9b0a9b9d9b4a0a9d9b4a0a") + .then().assertThat() + .statusCode(equalTo(SC_NOT_FOUND)); + } + +} +---- + +As you can see, in `deleteWithValidId` we are retrieving the existing user's `id` first. + +[source,java] +---- +@Testcontainers +@TestExecutionListeners(listeners = UserCreationTestExecutionListener.class, mergeMode = MERGE_WITH_DEFAULTS) +@SpringBootTest(webEnvironment = RANDOM_PORT) +class DeleteUserTests { + + @Test + @DisplayName("Given username zaid.zin exists When I delete with its id Then response status should be No Content") + void deleteWithValidId() { + String id = get("/users/{username}", "zaid.zin").path("id"); + + when() + .delete("/users/{id}", id) + .then().assertThat() + .statusCode(equalTo(SC_NO_CONTENT)); + } + +} +---- + +Once we have the `id`, we will perform the deletion. Next, we will implement the API to fix the scenarios above. We will start with the first scenario - delete with an available `id`. + +[source,java] +---- +@RestController +class UserResource { + + private final UserRepository repository; + + UserResource(UserRepository repository) { + this.repository = repository; + } + + @DeleteMapping("/users/{id}") + @ResponseStatus(NO_CONTENT) + public void deleteById(@PathVariable ObjectId id) { + repository.findById(id).ifPresent(repository::delete); + } + +} +---- + +The implementation above should be sufficient to fix our first scenario. We will run the test again to ensure that it passes. Next is to fix our second scenario - delete with an non-existing `id`. We're expecting `404 Not Found` in this scenario. We can achieve this with slight modification to `deleteById` method. + +[source,java] +---- +@RestController +class UserResource { + + private final UserRepository repository; + + UserResource(UserRepository repository) { + this.repository = repository; + } + + @DeleteMapping("/users/{id}") + @ResponseStatus(NO_CONTENT) + public void deleteById(@PathVariable ObjectId id) { + repository.findById(id).ifPresentOrElse(repository::delete, () -> { throw new NoSuchElementException(); }); + } + +} +---- + +Since we have already implement `@ExceptionHandler` to handle `NoSuchElementException`, this implementation should be sufficient to fix our +second scenario. We will run the test again to ensure that it passes. + +== Conclusion + +I have always preferred RestAssured as it allows me to test API implementation in BDD style. Given that I can decouple my tests with the +production code, I can ensure that my tests are not affected by the implementation details. + +As you can see from tests above. None of the tests uses production code. This is very useful when I need to refactor my +code. I can refactor my code without worrying that my tests will break. As long as the API contract remains the same, my tests will pass. diff --git a/docs/modules/ROOT/pages/test-slice-tests-rest.adoc b/docs/modules/ROOT/pages/test-slice-tests-rest.adoc index e3887939..08820891 100644 --- a/docs/modules/ROOT/pages/test-slice-tests-rest.adoc +++ b/docs/modules/ROOT/pages/test-slice-tests-rest.adoc @@ -1,3 +1,295 @@ = Spring Test: Slice Testing a REST Application +Rashidi Zin +1.1, January 17, 2025: Replace MockMvc with MockMvcTester +:icons: font +:source-highlighter: highlight.js +:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/test-slice-tests-rest +:source-main: {url-quickref}/src/main/java/zin/rashidi/boot/test/slices +:source-test: {url-quickref}/src/test/java/zin/rashidi/boot/test/slices -include::../../../../test-slice-tests-rest/README.adoc[lines=2..-1] +Testing has become a critical component in today's software development world. It helps us in ensuring high quality product +that provides stability and scalability. In this article, we will explore about implementing tests for Spring Boot Web application. + +== Background + +https://docs.spring.io/spring-boot/reference/testing/index.html[Spring Boot Testing] components provides great convenience +for us to test our implementation. In my experience, I found projects are still relying on mocks. The reason behind it is integration tests +usually takes too long and expensive. + +Such opinion was true in the past. However, today with Spring's Test component and https://testcontainers.com/[Testcontainers], integration +tests no longer being a burden. We will look into the options in implementing tests using Spring Boot for a standard REST application. + +== The Application + +link:{url-quickref}[The application] is rather a simple REST application which consists of Spring Data JPA, Spring Security, and Spring Web. +The typical components used in most Spring Boot applications. + +We will start by implementing the repository component. + +== Entity & Repository + +Given that we have the entity link:{source-main}/user/User.java[`User`], we will implement a `Repository` class that +extends `JpaRepository`. + +[source, java] +---- +interface UserRepository extends JpaRepository {} +---- + +We want to allow the users to retrieve a `User` by `username`. However, we want to hide their `id` information and to simplify +`name` - instead of having `first` and `last` name, we will just return their full name. For this we will use a +https://docs.spring.io/spring-data/jpa/reference/repositories/projections.html[Projections] called link:{source-main}/user/UserWithoutId.java[`UserWithoutId`]. + +[source, java] +---- +interface UserRepository extends JpaRepository { + + @NativeQuery( + name = "User.findByUsername", + value = "SELECT CONCAT_WS(' ', first, last) as name, username, status FROM users WHERE username = ?1", + sqlResultSetMapping = "User.WithoutId" + ) + Optional findByUsername(String username); + +} +---- + +Since we have a custom implementation in `UserRepository`, we will implement a test to ensure that it is behaving as expected. For this we will +use `@DataJpaTest` which will load sufficient components for us to run a `JpaRepository` test. + +We will implement two scenarios in link:{source-test}/user/UserRepositoryTests.java[`UserRepositoryTests`] - find by username with an existing username and find by username with a non-existing username. + +[source, java] +---- +@Testcontainers +@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop") +class UserRepositoryTests { + + @Container + @ServiceConnection + private static final PostgreSQLContainer postgresql = new PostgreSQLContainer<>(DockerImageName.parse("postgres:latest")); + + @Autowired + private UserRepository repository; + + @Test + @DisplayName("Given there the username rashidi.zin exists When I find by the username Then I should receive a summary of the user") + @Sql(statements = "INSERT INTO users (id, first, last, username, status) VALUES (1, 'Rashidi', 'Zin', 'rashidi.zin', 0)") + void findByUsername() { + var user = repository.findByUsername("rashidi.zin"); + + assertThat(user).get() + .extracting("name", "username", "status") + .containsExactly("Rashidi Zin", "rashidi.zin", ACTIVE); + } + + @Test + @DisplayName("Given there the username zaid.zin does not exist When I find by the username Then I should receive empty optional") + void findByNonExistingUsername() { + var user = repository.findByUsername("zaid.zin"); + + assertThat(user).isEmpty(); + } + +} +---- + +.Annotations being used in the test above are: +* `@Testcontainers` - Enabling Testcontainers support for this test +* `DataJpaTest` - Load Spring Data JPA's related components +* `@Container` - Allow Testcontainers manage the lifecycle of `PostgreSQLContainer` +* `@ServiceConnection` - Automatically assign `DataSource` related properties +* `@Sql` - Load test data + +With that, we have verified that `UserRepository.findByUsername` is behaving as expected. Full implementation can be found in +link:{source-main}/user[`user`] package. For other database types, I wrote articles on link:../data-jdbc-audit/[using `@DataJdbcTest`] +and link:../data-mongodb-audit/[using `@DataMongoTest`] + +Next, we will implement the web components. + +== Web Implementation + +.Our web components involves: +* link:{source-main}/security/WebSecurityConfiguration.java[`WebSecurityConfiguration`] - contains a simple HTTP Basic authentication +* link:{source-main}/user/UserResource.java[`UserResource`] - implements a `GET` method to a retrieve user information and a `ExceptionHandler` that will return `NOT_FOUND` for non-existing `username`: + +[source, java] +---- +@RestController +class UserResource { + + private final UserRepository repository; + + UserResource(UserRepository repository) { + this.repository = repository; + } + + @GetMapping(value = "/users/{username}", produces = APPLICATION_JSON_VALUE) + public UserWithoutId findByUsername(@PathVariable String username) { + return repository.findByUsername(username).orElseThrow(InvalidUserException::new); + } + + @ExceptionHandler(InvalidUserException.class) + @ResponseStatus(NOT_FOUND) + public void handleInvalidUserException() {} + + static class InvalidUserException extends RuntimeException {} + +} +---- + +=== Testing with `@WebMvcTest` + +If long-running time is a concern, `@WebMvcTest` would be a suitable approach as it will only load web related components. It allows us to mock +any of its dependencies and arrange suitable behaviour for them. In the following implementation, we will mock (or arrange) the behaviour of `UserRepository.findByUsername`: + +In `findByUsername`, we will arrange that it will return `Optional` containing `UserWithoutId`. We will expect that the response will be `HTTP OK`. While in `findByNonExistingUsername`, we +arrange that it will return an empty `Optional`. This will lead to `InvalidUserException` being thrown and translated to `HTTP NOT_FOUND`. + +[source, java] +---- +@WebMvcTest(controllers = UserResource.class, includeFilters = @Filter(EnableWebSecurity.class)) +class UserResourceTests { + + private static MockMvcTester mvc; + + @MockitoBean + private UserRepository repository; + + @BeforeAll + static void setup(@Autowired WebApplicationContext context) { + mvc = from(context, builder -> builder.apply(springSecurity()).build()); + } + + @Test + @WithMockUser + @DisplayName("Given username rashidi.zin exists When when I request for the username Then the response status should be OK") + void findByUsername() { + var fakeUser = Optional.of(new UserWithoutId("Rashidi Zin", "rashidi.zin", ACTIVE)); + + doReturn(fakeUser).when(repository).findByUsername("rashidi.zin"); + + mvc + .get().uri("/users/{username}", "rashidi.zin") + .assertThat() + .hasStatus(OK); + + verify(repository).findByUsername("rashidi.zin"); + } + + @Test + @WithMockUser + @DisplayName("Given username rashidi.zin does not exist When when I request for the username Then the response status should be NOT_FOUND") + void findByNonExistingUsername() { + doReturn(empty()).when(repository).findByUsername("rashidi.zin"); + + mvc + .get().uri("/users/{username}", "rashidi.zin") + .assertThat() + .hasStatus(NOT_FOUND); + + verify(repository).findByUsername("rashidi.zin"); + } + + @Test + @DisplayName("Given there is no authentication When I request for the username Then the response status should be UNAUTHORIZED") + void findByUsernameWithoutAuthentication() { + mvc + .get().uri("/users/{username}", "rashidi.zin") + .assertThat().hasStatus(UNAUTHORIZED); + + verify(repository, never()).findByUsername("rashidi.zin"); + } + +} +---- + +.Methods and annotations used in the test above: +* `@WebMvcTest` - Our test will only focus on `UserResource` and we will load security configuration from `WebSecurityConfiguration` +* `SecurityMockMvcConfigurers.springSecurity()` - Enable Spring Security support for `MockMvcTester` +* `@WithMockUser` - Mocks user authentication. Without it the response will be `UNAUTHORIZED` as demonstrated in `findByUsernameWithoutAuthentication` +* `@MockitoBean` - Mocks `UserRepository` since we have verified that it works correctly in link:{source-test}/user/UserRepositoryTests.java[`UserRepositoryTests`] +* `Mockito.verify` - Verifies that `UserRepository.findByUsername` was either triggered (when user is authenticated) or not + +Given that link:{source-test}/user/UserResourceTests.java[`UserResourceTests`] is specifically for `UserResource` and only necessary components are loaded, its execution +should be fast. + +=== Testing with `@SpringBootTest` + +`@SpringBootTest`, by default, will load all components. In our case, it will expect there is a running PostgreSQL and the properties are assigned. +This is handled by {source-test}/TestcontainersConfiguration.java[`TestcontainersConfiguration`] and +we will import it into our test - link:{source-test}/user/FindByUsernameTests.java[`FindByUsernameTests`]. + +We will implement the same test scenarios as we did in link:{source-test}/user/UserResourceTests.java[`UserResourceTests`]: + +[source, java] +---- +@Import(TestcontainersConfiguration.class) +@SpringBootTest(webEnvironment = RANDOM_PORT, properties = { + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.security.user.name=rashidi.zin", + "spring.security.user.password=jU$7d3m0pL3a$eRe|ax" +}) +@Sql(executionPhase = BEFORE_TEST_CLASS, statements = "INSERT INTO users (id, first, last, username, status) VALUES (1, 'Rashidi', 'Zin', 'rashidi.zin', 0)") +class FindByUsernameTests { + + @Autowired + private TestRestTemplate restClient; + + @Test + @DisplayName("Given username rashidi.zin exists When I request for the username Then response status should be OK and it should contain the summary of the user") + void withExistingUsername() { + var response = restClient + .withBasicAuth("rashidi.zin", "jU$7d3m0pL3a$eRe|ax") + .getForEntity("/users/{username}", UserWithoutId.class, "rashidi.zin"); + + assertThat(response.getStatusCode()).isEqualTo(OK); + + var user = response.getBody(); + + assertThat(user) + .extracting("name", "username", "status") + .containsExactly("Rashidi Zin", "rashidi.zin", ACTIVE); + } + + @Test + @DisplayName("Given username zaid.zin does not exist When I request for the username Then response status should be NOT_FOUND") + void withNonExistingUsername() { + var response = restClient + .withBasicAuth("rashidi.zin", "jU$7d3m0pL3a$eRe|ax") + .getForEntity("/users/{username}", UserWithoutId.class, "zaid.zin"); + + assertThat(response.getStatusCode()).isEqualTo(NOT_FOUND); + } + + @Test + @DisplayName("Given there is no authentication When I request for the username Then response status should be UNAUTHORIZED") + void withoutAuthentication() { + var response = restClient.getForEntity("/users/{username}", UserWithoutId.class, "rashidi.zin"); + + assertThat(response.getStatusCode()).isEqualTo(UNAUTHORIZED); + } + +} +---- + +.In `FindByUsernameTests`, we have: +* Import `PostgreSQLContainer` from `Testcontainers` that is defined in `TestcontainersConfiguration` +* Define default username and password through `spring.security.user.name` and `spring.security.user.password` +* Insert test data prior to running the class + +In `withExistingUsername`, we implement the same verification in `UserResourceTests.findByUsername()` and `UserRepositoryTests.findByUsername()`. The same goes to +`withNonExistingUsername` and `withoutAuthentication` whereby its verification is the same as +`UserResourceTests.findByNonExistingUsername()`, `UserRepositoryTests.findByNonExistingUsername()`, and `UserResourceTests.findByUsernameWithoutAuthentication()` + +If you find this is redundant, you are right. Given that `FindByUsernameTests` is an end-to-end integration test class, we could rely on solely on it. As for +implementations in `UserResourceTests` and `UserRepositoryTests` can be removed. + +== Conclusion + +Wherever possible, I will always favour using `@SpringBootTest` as it allows me to ensure that the whole application is behaving accordingly. However, as mentioned earlier, +if the `@SpringBootTest` class takes too long to run then I'd go with `@WebMvcTest`. It is less desire as the test will +be affected should the production code implementation changes. For example, a refactoring. + +With `@SpringBootTest`, I am able to implement my tests following link:../test-rest-assured/[Behaviour Driven Development] easily as +opposed to using `@WebMvcTest` as I don't have to be concerned about the feature's implementation. diff --git a/docs/modules/ROOT/pages/web-rest-client.adoc b/docs/modules/ROOT/pages/web-rest-client.adoc index 53376a31..c1d4004a 100644 --- a/docs/modules/ROOT/pages/web-rest-client.adoc +++ b/docs/modules/ROOT/pages/web-rest-client.adoc @@ -1,3 +1,356 @@ = Spring Web: REST Clients for calling Synchronous API +Rashidi Zin +1.0, December 02, 2024: Initial version +:icons: font +:source-highlighter: highlight.js +:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/web-rest-client +:source-main: {url-quickref}/src/main/java/zin/rashidi/boot/web/restclient +:source-test: {url-quickref}/src/test/java/zin/rashidi/boot/web/restclient -include::../../../../web-rest-client/README.adoc[lines=2..-1] +== Background + +Historically, `RestTemplate` has been the main choice as the REST client to call synchronous API. Since Spring 6, https://docs.spring.io/spring-framework/reference/integration/rest-clients.html[there are two other +options being provided - `RestClient` and `@HttpExchange` as the alternatives]. + +== RestClient + +https://docs.spring.io/spring-framework/reference/integration/rest-clients.html#rest-restclient[`RestClient`] provides fluent API which makes it more readable. +`RestClient` can be constructed through two options - `RestClient.create` and `RestClient.Builder`. In this tutorial we will use `RestClient.Builder` as it is more +convenient for us to utilise `RestClientTest`. + +We will start by creating a repository interface for link:{source-main}/user/User.java[`User`]: + +[source,java] +---- +interface UserRepository { + + List findAll(); + + User findById(Long id); + +} +---- + +For those who are familiar with Spring Data, these methods name are following Spring Data's standard method naming. Next we will write tests and its respective implementation. +Our implementation for `UserRepository` will be done in `UserRestRepository`. This is to align with standard Spring's repository practices. + +=== Declaring RestClient + +As mentioned earlier, we will use `RestClient.Builder` to construct `RestClient`: + +[source, java] +---- +@Repository +class UserRestRepository implements UserRepository { + + private final RestClient restClient; + + UserRestRepository(RestClient.Builder restClientBuilder) { + this.restClient = restClientBuilder + .baseUrl("https://jsonplaceholder.typicode.com/users") + .defaultHeader(ACCEPT, APPLICATION_JSON_VALUE) + .build(); + } + +} +---- + +Our `RestClient` will communicate with https://jsonplaceholder.typicode.com/[{JSON} Placeholder] to retrieve all `User` and all requests +will be equipped with `application/json` as the expected response header. + +=== Get all users + +[source,java] +---- +@Repository +class UserRestRepository implements UserRepository { + + private final RestClient restClient; + + @Override + public List findAll() { + return restClient.get() + .retrieve() + .body(new ParameterizedTypeReference<>() {}); + } + +} +---- + +Our first implementation is fairly simple. We will retrieve a `List` of `User` and we use `ParamterizedTypeReference` to convert it. In our test, we will verify that +the `restClient` will trigger a call to `https://jsonplaceholder.typicode.com/users` and we will mock the responses. As our intention is to ensure we are calling the right endpoint. + +[source,java] +---- +@RestClientTest(UserRestRepository.class) +class UserRepositoryTests { + + @Autowired + private UserRepository repository; + + @Autowired + private MockRestServiceServer mockServer; + + @Autowired + private ObjectMapper mapper; + + @Test + @DisplayName("When findAll Then all users should be returned") + void findAll() throws JsonProcessingException { + var response = mapper.writeValueAsString(List.of( + new User(84L, "Rashidi Zin", "rashidi.zin", "rashidi@zin.my", URI.create("rashidi.zin.my")), + new User(87L, "Zaid Zin", "zaid.zin", "zaid@zin.my", URI.create("zaid.zin.my")) + )); + + mockServer.expect(requestTo("https://jsonplaceholder.typicode.com/users")).andRespond(withSuccess(response, APPLICATION_JSON)); + + assertThat(repository.findAll()).hasSize(2); + + mockServer.verify(); + } + +} +---- + +.There are three dependencies declared: +* `UserRepository` - the class that we want to test +* `MockRestServiceServer` - the class that we will use to mock responses from JSONPlaceholder +* `ObjectMapper` - the class that we will use to convert an `Object` to `String` to be used as the mocked response + +In the test above, we mocked the response from `https://jsonplaceholder.typicode.com/users` and we verify that when `UserRepository.findAll()` is called then +a request to `https://jsonplaceholder.typicode.com/users` is triggered. + +Next, let's simulate a situation where an error is returned in the response. + +=== Get a user by id + +We are going to implement a method that will return a particular `User` based on provided `id`: + +[source,java] +---- +@Repository +class UserRestRepository implements UserRepository { + + private final RestClient restClient; + + @Override + public User findById(Long id) { + return restClient.get().uri("/{id}", id) + .retrieve() + .onStatus(HttpStatusCode::is4xxClientError, ((request, response) -> { + throw new UserNotFoundException(); + })) + .body(User.class); + } + + static class UserNotFoundException extends RuntimeException {} + +} + +---- + +In the implementation above, `UserNotFoundException` will be thrown when client error is returned as the response. In our test we will +simulate a situation where error resource not found is returned (`404`): + +[source,java] +---- +@RestClientTest(UserRestRepository.class) +class UserRepositoryTests { + + @Autowired + private UserRepository repository; + + @Autowired + private MockRestServiceServer mockServer; + + @Autowired + private ObjectMapper mapper; + + @Test + @DisplayName("When an invalid user id is provided Then UserNotFoundException will be thrown") + void findByInvalidId() { + mockServer.expect(requestTo("https://jsonplaceholder.typicode.com/users/84")).andRespond(withResourceNotFound()); + + assertThatThrownBy(() -> repository.findById(84L)).isInstanceOf(UserNotFoundException.class); + + mockServer.verify(); + } + +} +---- + +Full implementation of the test and its production code can be found in link:{source-main}/user/UserRestRepository.java[UserRepository] and link:{source-test}/user/UserRepositoryTests.java[UserRepositoryTests]. + +== HTTP Interface + +Spring allows us to define HTTP service as Java interface with `@HttpExchange` methods - `@DeleteExchange`, `@GetExchange`, `@PatchExchange`, `@PostExchange`, and `@PutExchange`. +In this tutorial we will use `@GetExchange` to retrieve all `Post` and to retrieve one link:{source-main}/post/main/Post.java[`Post`] by its `id`. + +=== PostRepository interface + +These methods are implemented in link:{source-main}/post/PostRepository.java[`PostRepository`]: + +[source,java] +---- +@HttpExchange(url = "/posts", accept = APPLICATION_JSON_VALUE) +interface PostRepository { + + @GetExchange + List findAll(); + + @GetExchange("/{id}") + Post findById(@PathVariable Long id); + +} +---- + +.In the implementation above we have defined the following: +* All methods in this class will call an endpoint that ends with `/posts` +* Each REST calls accepts `application/json` in the response +* `findAll` will return all `Post` +* `findById` will return `Post` that belongs to the requested `id` + +=== PostRepository configuration class + +Spring requires us to define which REST Client to use for API calls in `PostRepository`. In this tutorial, our choice will be `RestClient`. Our aim is to have +same outcome as `UserRepository`. + +[source,java] +---- +@Configuration +class PostRepositoryConfiguration { + + @Bean + public PostRepository postRepository(RestClient.Builder restClientBuilder) { + var restClient = restClientBuilder + .baseUrl("https://jsonplaceholder.typicode.com") + .defaultStatusHandler(HttpStatusCode::is4xxClientError, new PostErrorResponseHandler()) + .build(); + + return builderFor(create(restClient)) + .build() + .createClient(PostRepository.class); + } + + static class PostErrorResponseHandler implements ErrorHandler { + + @Override + public void handle(HttpRequest request, ClientHttpResponse response) throws IOException { + + if (response.getStatusCode() == NOT_FOUND) { throw new PostNotFoundException(); } + + } + + static class PostNotFoundException extends RuntimeException {} + + } +} +---- + +.In link:{source-main}/post/PostRepositoryConfiguration.java[`PostRepositoryConfiguration`], we have defined: +* Our `RestClient` will trigger calls to `https://jsonplaceholder.typicode.com` +* When error `404` is returned then `PostNotFoundException` will be thrown +* `@HttpExchange` in `PostRepository` will use the `RestClient` that we have defined in `postRepository` + +=== Verify PostRepository implementation + +We will write same tests as `UserRepositoryTests` where we will validate retrieving all `Post` and an error will be thrown when invalid `id` is provided. + +==== Test configuration + +Given that we have a `@Configuration` class, the class need to be included in our test when defining `RestClientTest`: + +[source, java] +---- +@RestClientTest(components = PostRepository.class, includeFilters = @Filter(type = ASSIGNABLE_TYPE, classes = PostRepositoryConfiguration.class)) +class PostRepositoryTests { + + @Autowired + private PostRepository repository; + + @Autowired + private MockRestServiceServer mockServer; + + @Autowired + private ObjectMapper mapper; + +} +---- + +Now our test is aware about `PostRepositoryConfiguration`. The dependencies are the same as `UserRepositoryTests` except for our target repository - `PostRepository`. + +==== Get all posts + +In this test, we are expecting a HTTP call to `https://jsonplaceholder.typicode.com/posts` will be made when we trigger `PostRepository.findAll()`: + +[source,java] +---- +@RestClientTest(components = PostRepository.class, includeFilters = @Filter(type = ASSIGNABLE_TYPE, classes = PostRepositoryConfiguration.class)) +class PostRepositoryTests { + + @Autowired + private PostRepository repository; + + @Autowired + private MockRestServiceServer mockServer; + + @Autowired + private ObjectMapper mapper; + + @Test + @DisplayName("When requesting for all posts then response should contain all posts available") + void findAll() throws JsonProcessingException { + var content = mapper.writeValueAsString(posts()); + + mockServer.expect(requestTo("https://jsonplaceholder.typicode.com/posts")).andRespond(withSuccess(content, APPLICATION_JSON)); + + repository.findAll(); + + mockServer.verify(); + } + + private List posts() { + return List.of( + new Post(1L, 84L, "Spring Web: REST Clients Example with RESTClient", "An example of using RESTClient"), + new Post(2L, 84L, "Spring Web: REST Clients Example with HTTPExchange", "An example of using HttpExchange interface") + ); + } + +} +---- + +==== Get a post with invalid id + +Next, we want to validate that when we provide an invalid id to `PostRepository.findById()` the error `PostNotFoundException` will be thrown. To simulate this, +we will mock a response that returns `404`: + +[source,java] +---- +@RestClientTest(components = PostRepository.class, includeFilters = @Filter(type = ASSIGNABLE_TYPE, classes = PostRepositoryConfiguration.class)) +class PostRepositoryTests { + + @Autowired + private PostRepository repository; + + @Autowired + private MockRestServiceServer mockServer; + + @Autowired + private ObjectMapper mapper; + + @Test + @DisplayName("When requesting with an invalid post id Then an error PostNotFoundException will be thrown") + void findByInvalidId() { + mockServer.expect(requestTo("https://jsonplaceholder.typicode.com/posts/10101011")).andRespond(withResourceNotFound()); + + assertThatThrownBy(() -> repository.findById(10101011L)).isInstanceOf(PostNotFoundException.class); + } + +} +---- + +All the tests can be found in link:{source-test}/post/PostRepositoryTests.java[PostRepository]. + +== Conclusion + +`@HttpExchange` provides a cleaner implementation and the flexibility to choose which REST Client to be used. In this example, we are dealing with a synchronous API and we diff --git a/generate-antora-pages.sh b/generate-antora-pages.sh index a265be9d..48017d08 100755 --- a/generate-antora-pages.sh +++ b/generate-antora-pages.sh @@ -26,19 +26,38 @@ SUBMODULES=( "web-rest-client" ) +# Badges content is no longer needed as we're removing all badges + # Create Antora pages for each submodule for submodule in "${SUBMODULES[@]}"; do + echo "Processing $submodule..." + # Extract the title from the README.adoc file title=$(head -n 1 "$submodule/README.adoc" | sed 's/^= //') - # Create the Antora page + # Create a temporary file to store the processed content + temp_file=$(mktemp) + + # Process the README.adoc file line by line + while IFS= read -r line; do + # Skip the line if it contains the include directive for badges.adoc + if [[ "$line" == *"include::../docs/badges.adoc[]"* ]]; then + continue + else + echo "$line" >> "$temp_file" + fi + done < <(tail -n +2 "$submodule/README.adoc") + + # Create the Antora page with the actual content cat > "docs/modules/ROOT/pages/$submodule.adoc" << EOF = $title -:page-aliases: $submodule.adoc -include::../../../../$submodule/README.adoc[lines=2..-1] +$(cat "$temp_file") EOF + # Remove the temporary file + rm "$temp_file" + echo "Created Antora page for $submodule" done diff --git a/modulith/README.adoc b/modulith/README.adoc index 93598424..608e8961 100644 --- a/modulith/README.adoc +++ b/modulith/README.adoc @@ -1,4 +1,10 @@ = Spring Modulith: Building Modular Monolithic Applications +:source-highlighter: highlight.js +Rashidi Zin +1.0, April 6, 2025 +:nofooter: +:icons: font +:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/modulith == Introduction From c5dfea61f398b1f9ac9011caf7c59d1ef32cbcc8 Mon Sep 17 00:00:00 2001 From: Rashidi Zin Date: Sun, 6 Apr 2025 16:25:05 +0800 Subject: [PATCH 09/11] Fix sonar --- docs/modules/ROOT/pages/modulith.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/modules/ROOT/pages/modulith.adoc b/docs/modules/ROOT/pages/modulith.adoc index ff2f87b2..92b3de29 100644 --- a/docs/modules/ROOT/pages/modulith.adoc +++ b/docs/modules/ROOT/pages/modulith.adoc @@ -238,7 +238,7 @@ class SubscriptionManagementTests { == Generated Documentation -Spring Modulith automatically generates documentation for your modules. You can view the generated documentation in the link:${url-quickref}/docs/all-docs.adoc[docs/all-docs.adoc] file. +Spring Modulith automatically generates documentation for your modules. You can view the generated documentation in the ${url-quickref}/docs/all-docs.adoc[docs/all-docs.adoc] file. == Conclusion From 0d86f077730d611b79f3f648630120134ed98396 Mon Sep 17 00:00:00 2001 From: Rashidi Zin Date: Sun, 6 Apr 2025 16:26:20 +0800 Subject: [PATCH 10/11] Fix sonar --- docs/modules/ROOT/pages/modulith.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/modules/ROOT/pages/modulith.adoc b/docs/modules/ROOT/pages/modulith.adoc index 92b3de29..56a40db7 100644 --- a/docs/modules/ROOT/pages/modulith.adoc +++ b/docs/modules/ROOT/pages/modulith.adoc @@ -238,7 +238,7 @@ class SubscriptionManagementTests { == Generated Documentation -Spring Modulith automatically generates documentation for your modules. You can view the generated documentation in the ${url-quickref}/docs/all-docs.adoc[docs/all-docs.adoc] file. +Spring Modulith automatically generates documentation for your modules. You can view the generated documentation in the {url-quickref}/docs/all-docs.adoc[docs/all-docs.adoc] file. == Conclusion From db0324ed92d012774c07e014e59d1cf636575eee Mon Sep 17 00:00:00 2001 From: Rashidi Zin Date: Sun, 6 Apr 2025 16:29:06 +0800 Subject: [PATCH 11/11] Fix sonar --- .github/workflows/build-and-publish-antora.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build-and-publish-antora.yml b/.github/workflows/build-and-publish-antora.yml index 217fc6fe..23d12b87 100644 --- a/.github/workflows/build-and-publish-antora.yml +++ b/.github/workflows/build-and-publish-antora.yml @@ -4,7 +4,6 @@ on: push: branches: - master - - modulith paths: - 'docs/**' - 'antora-playbook.yml'