diff --git a/spring-boot-modules/spring-boot-libraries-3/README.md b/spring-boot-modules/spring-boot-libraries-3/README.md new file mode 100644 index 0000000000..1458e3ef39 --- /dev/null +++ b/spring-boot-modules/spring-boot-libraries-3/README.md @@ -0,0 +1,5 @@ +## Spring Boot Libraries + +This module contains articles about various Spring Boot libraries + +### Relevant Articles: \ No newline at end of file diff --git a/spring-boot-modules/spring-boot-libraries-3/pom.xml b/spring-boot-modules/spring-boot-libraries-3/pom.xml new file mode 100644 index 0000000000..43d8be84a4 --- /dev/null +++ b/spring-boot-modules/spring-boot-libraries-3/pom.xml @@ -0,0 +1,89 @@ + + + 4.0.0 + spring-boot-libraries-3 + + + spring-boot-modules + com.baeldung.spring-boot-modules + 1.0.0-SNAPSHOT + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.kafka + spring-kafka + + + + org.springframework.modulith + spring-modulith-events-api + ${spring-modulith-events-kafka.version} + + + org.springframework.modulith + spring-modulith-events-kafka + ${spring-modulith-events-kafka.version} + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-testcontainers + test + + + + org.testcontainers + kafka + ${testcontainers.version} + test + + + org.testcontainers + testcontainers + ${testcontainers.version} + test + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + + com.h2database + h2 + 2.2.224 + test + + + + org.awaitility + awaitility + ${awaitility.version} + test + + + + + + 17 + 3.1.5 + 1.1.2 + 1.19.3 + 4.2.0 + + + \ No newline at end of file diff --git a/spring-boot-modules/spring-boot-libraries-3/src/main/java/com/baeldung/springmodulith/Application.java b/spring-boot-modules/spring-boot-libraries-3/src/main/java/com/baeldung/springmodulith/Application.java new file mode 100644 index 0000000000..aeaf57becd --- /dev/null +++ b/spring-boot-modules/spring-boot-libraries-3/src/main/java/com/baeldung/springmodulith/Application.java @@ -0,0 +1,13 @@ +package com.baeldung.springmodulith; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + + public static void main(String[] args) { + SpringApplication.run( Application.class, args); + } + +} diff --git a/spring-boot-modules/spring-boot-libraries-3/src/main/java/com/baeldung/springmodulith/events/externalization/Article.java b/spring-boot-modules/spring-boot-libraries-3/src/main/java/com/baeldung/springmodulith/events/externalization/Article.java new file mode 100644 index 0000000000..d52ed5afe5 --- /dev/null +++ b/spring-boot-modules/spring-boot-libraries-3/src/main/java/com/baeldung/springmodulith/events/externalization/Article.java @@ -0,0 +1,45 @@ +package com.baeldung.springmodulith.events.externalization; + +import static jakarta.persistence.GenerationType.*; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; + +@Entity +public class Article { + @Id + @GeneratedValue(strategy = AUTO) + private Long id; + + private String slug; + private String title; + private String author; + private String content; + + public Article(String slug, String title, String author, String content) { + this.slug = slug; + this.title = title; + this.author = author; + this.content = content; + } + + public Article() { + } + + public String author() { + return author; + } + + public String content() { + return content; + } + + public String slug() { + return slug; + } + + public String title() { + return title; + } +} \ No newline at end of file diff --git a/spring-boot-modules/spring-boot-libraries-3/src/main/java/com/baeldung/springmodulith/events/externalization/ArticlePublishedEvent.java b/spring-boot-modules/spring-boot-libraries-3/src/main/java/com/baeldung/springmodulith/events/externalization/ArticlePublishedEvent.java new file mode 100644 index 0000000000..e12b6dafe5 --- /dev/null +++ b/spring-boot-modules/spring-boot-libraries-3/src/main/java/com/baeldung/springmodulith/events/externalization/ArticlePublishedEvent.java @@ -0,0 +1,7 @@ +package com.baeldung.springmodulith.events.externalization; + +import org.springframework.modulith.events.Externalized; + +@Externalized("baeldung.article.published::#{slug()}") +public record ArticlePublishedEvent(String slug, String title) { +} diff --git a/spring-boot-modules/spring-boot-libraries-3/src/main/java/com/baeldung/springmodulith/events/externalization/ArticleRepository.java b/spring-boot-modules/spring-boot-libraries-3/src/main/java/com/baeldung/springmodulith/events/externalization/ArticleRepository.java new file mode 100644 index 0000000000..f6351b6262 --- /dev/null +++ b/spring-boot-modules/spring-boot-libraries-3/src/main/java/com/baeldung/springmodulith/events/externalization/ArticleRepository.java @@ -0,0 +1,8 @@ +package com.baeldung.springmodulith.events.externalization; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +@Repository +interface ArticleRepository extends CrudRepository { +} diff --git a/spring-boot-modules/spring-boot-libraries-3/src/main/java/com/baeldung/springmodulith/events/externalization/Baeldung.java b/spring-boot-modules/spring-boot-libraries-3/src/main/java/com/baeldung/springmodulith/events/externalization/Baeldung.java new file mode 100644 index 0000000000..1d309a8653 --- /dev/null +++ b/spring-boot-modules/spring-boot-libraries-3/src/main/java/com/baeldung/springmodulith/events/externalization/Baeldung.java @@ -0,0 +1,37 @@ +package com.baeldung.springmodulith.events.externalization; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class Baeldung { + + private final ApplicationEventPublisher applicationEvents; + private final ArticleRepository articleRepository; + + + public Baeldung(ApplicationEventPublisher applicationEvents, ArticleRepository articleRepository) { + this.applicationEvents = applicationEvents; + this.articleRepository = articleRepository; + } + + @Transactional + public void createArticle(Article article) { + // ... business logic + validateArticle(article); + article = addArticleTags(article); + article = articleRepository.save(article); + + applicationEvents.publishEvent(new ArticlePublishedEvent(article.slug(), article.title())); + } + + + private Article addArticleTags(Article article) { + return article; + } + + private void validateArticle(Article article) { + } + +} diff --git a/spring-boot-modules/spring-boot-libraries-3/src/main/java/com/baeldung/springmodulith/events/externalization/EventExternalizationConfig.java b/spring-boot-modules/spring-boot-libraries-3/src/main/java/com/baeldung/springmodulith/events/externalization/EventExternalizationConfig.java new file mode 100644 index 0000000000..9564696d92 --- /dev/null +++ b/spring-boot-modules/spring-boot-libraries-3/src/main/java/com/baeldung/springmodulith/events/externalization/EventExternalizationConfig.java @@ -0,0 +1,41 @@ +package com.baeldung.springmodulith.events.externalization; + +import org.springframework.boot.autoconfigure.kafka.KafkaProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; +import org.springframework.kafka.core.KafkaOperations; +import org.springframework.modulith.events.EventExternalizationConfiguration; +import org.springframework.modulith.events.RoutingTarget; + +@Configuration +class EventExternalizationConfig { + + @Bean + EventExternalizationConfiguration eventExternalizationConfiguration() { + return EventExternalizationConfiguration.externalizing() + .select(EventExternalizationConfiguration.annotatedAsExternalized()) + .route( + ArticlePublishedEvent.class, + it -> RoutingTarget.forTarget("baeldung.articles.published").andKey(it.slug()) + ) + .mapping( + ArticlePublishedEvent.class, + it -> new ArticlePublishedKafkaEvent(it.slug(), it.title()) + ) + .build(); + } + + record ArticlePublishedKafkaEvent(String slug, String title) { + } + + + @Bean + KafkaOperations kafkaOperations(KafkaProperties kafkaProperties) { + ProducerFactory producerFactory = new DefaultKafkaProducerFactory<>(kafkaProperties.buildProducerProperties()); + return new KafkaTemplate<>(producerFactory); + } +} + diff --git a/spring-boot-modules/spring-boot-libraries-3/src/main/java/com/baeldung/springmodulith/events/externalization/infra/ArticlePublishedKafkaProducer.java b/spring-boot-modules/spring-boot-libraries-3/src/main/java/com/baeldung/springmodulith/events/externalization/infra/ArticlePublishedKafkaProducer.java new file mode 100644 index 0000000000..17a88a73f7 --- /dev/null +++ b/spring-boot-modules/spring-boot-libraries-3/src/main/java/com/baeldung/springmodulith/events/externalization/infra/ArticlePublishedKafkaProducer.java @@ -0,0 +1,34 @@ +package com.baeldung.springmodulith.events.externalization.infra; + +import org.springframework.context.event.EventListener; +import org.springframework.kafka.core.KafkaOperations; +import org.springframework.scheduling.annotation.Async; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.util.Assert; + +import com.baeldung.springmodulith.events.externalization.ArticlePublishedEvent; + +//@Component +// this is used in sections 3 and 4 of tha article +// but it will cause the tests to fail if it used together with the @Externalized annotation +class ArticlePublishedKafkaProducer { + + private final KafkaOperations messageProducer; + + public ArticlePublishedKafkaProducer(KafkaOperations messageProducer) { + this.messageProducer = messageProducer; + } + + @EventListener + public void publish(ArticlePublishedEvent event) { + Assert.notNull(event.slug(), "Article Slug must not be null!"); + messageProducer.send("baeldung.articles.published", event); + } + + @Async + @TransactionalEventListener + public void publishAsync(ArticlePublishedEvent article) { + Assert.notNull(article.slug(), "Article Slug must not be null!"); + messageProducer.send("baeldung.articles.published", article.slug(), article); + } +} diff --git a/spring-boot-modules/spring-boot-libraries-3/src/main/resources/application.yml b/spring-boot-modules/spring-boot-libraries-3/src/main/resources/application.yml new file mode 100644 index 0000000000..ad8f7ab4e6 --- /dev/null +++ b/spring-boot-modules/spring-boot-libraries-3/src/main/resources/application.yml @@ -0,0 +1,12 @@ +logging.level.org.springframework.orm.jpa: TRACE + +spring.kafka: + bootstrap-servers: localhost:9092 + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + type-mapping: com.baeldung.annotation.events.externalization.producer.ArticlePublished + consumer: + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer + auto-offset-reset: earliest diff --git a/spring-boot-modules/spring-boot-libraries-3/src/test/java/com/baeldung/springmodulith/events/externalization/EventsExternalizationLiveTest.java b/spring-boot-modules/spring-boot-libraries-3/src/test/java/com/baeldung/springmodulith/events/externalization/EventsExternalizationLiveTest.java new file mode 100644 index 0000000000..7f4f7a8224 --- /dev/null +++ b/spring-boot-modules/spring-boot-libraries-3/src/test/java/com/baeldung/springmodulith/events/externalization/EventsExternalizationLiveTest.java @@ -0,0 +1,89 @@ +package com.baeldung.springmodulith.events.externalization; + +import static java.time.Duration.ofMillis; +import static java.time.Duration.ofSeconds; +import static org.assertj.core.api.Assertions.assertThat; +import static org.testcontainers.shaded.org.awaitility.Awaitility.await; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.KafkaContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.shaded.org.awaitility.Awaitility; +import org.testcontainers.utility.DockerImageName; + +import com.baeldung.springmodulith.Application; +import com.baeldung.springmodulith.events.externalization.listener.TestKafkaListenerConfig; +import com.baeldung.springmodulith.events.externalization.listener.TestListener; + +@Testcontainers +@SpringBootTest(classes = { Application.class, TestKafkaListenerConfig.class }) +class EventsExternalizationLiveTest { + + @Autowired + private Baeldung baeldung; + @Autowired + private TestListener listener; + @Autowired + private ArticleRepository repository; + + @Container + static KafkaContainer kafkaContainer = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:latest")); + + @DynamicPropertySource + static void dynamicProperties(DynamicPropertyRegistry registry) { + registry.add("spring.kafka.bootstrap-servers", kafkaContainer::getBootstrapServers); + } + + static { + Awaitility.setDefaultTimeout(ofSeconds(3)); + Awaitility.setDefaultPollDelay(ofMillis(100)); + } + + @BeforeEach + void beforeEach() { + listener.reset(); + repository.deleteAll(); + } + + @Test + void whenArticleIsSavedToDB_thenItIsAlsoPublishedToKafka() { + var article = new Article("introduction-to-spring-boot", "Introduction to Spring Boot", "John Doe", "

Spring Boot is [...]

"); + + baeldung.createArticle(article); + + await().untilAsserted(() -> + assertThat(listener.getEvents()) + .hasSize(1) + .first().asString() + .contains("\"slug\":\"introduction-to-spring-boot\"") + .contains("\"title\":\"Introduction to Spring Boot\"")); + + assertThat(repository.findAll()) + .hasSize(1) + .first() + .extracting(Article::slug, Article::title) + .containsExactly("introduction-to-spring-boot", "Introduction to Spring Boot"); + } + + @Test + void whenPublishingMessageFails_thenArticleIsStillSavedToDB() { + var article = new Article(null, "Introduction to Spring Boot", "John Doe", "

Spring Boot is [...]

"); + + baeldung.createArticle(article); + + assertThat(listener.getEvents()) + .isEmpty(); + + assertThat(repository.findAll()) + .hasSize(1) + .first() + .extracting(Article::title, Article::author) + .containsExactly("Introduction to Spring Boot", "John Doe"); + } +} \ No newline at end of file diff --git a/spring-boot-modules/spring-boot-libraries-3/src/test/java/com/baeldung/springmodulith/events/externalization/listener/TestKafkaListenerConfig.java b/spring-boot-modules/spring-boot-libraries-3/src/test/java/com/baeldung/springmodulith/events/externalization/listener/TestKafkaListenerConfig.java new file mode 100644 index 0000000000..c2ee9b24a2 --- /dev/null +++ b/spring-boot-modules/spring-boot-libraries-3/src/test/java/com/baeldung/springmodulith/events/externalization/listener/TestKafkaListenerConfig.java @@ -0,0 +1,31 @@ +package com.baeldung.springmodulith.events.externalization.listener; + +import org.springframework.boot.autoconfigure.kafka.KafkaProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafka; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.config.KafkaListenerContainerFactory; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import org.springframework.kafka.listener.ConcurrentMessageListenerContainer; + +@EnableKafka +@Configuration +public class TestKafkaListenerConfig { + + @Bean + KafkaListenerContainerFactory> kafkaListenerContainerFactory( + ConsumerFactory consumerFactory + ) { + var factory = new ConcurrentKafkaListenerContainerFactory(); + factory.setConsumerFactory(consumerFactory); + return factory; + } + + @Bean + ConsumerFactory consumerFactory(KafkaProperties kafkaProperties) { + return new DefaultKafkaConsumerFactory<>(kafkaProperties.buildConsumerProperties()); + } + +} \ No newline at end of file diff --git a/spring-boot-modules/spring-boot-libraries-3/src/test/java/com/baeldung/springmodulith/events/externalization/listener/TestListener.java b/spring-boot-modules/spring-boot-libraries-3/src/test/java/com/baeldung/springmodulith/events/externalization/listener/TestListener.java new file mode 100644 index 0000000000..bf5a36f66f --- /dev/null +++ b/spring-boot-modules/spring-boot-libraries-3/src/test/java/com/baeldung/springmodulith/events/externalization/listener/TestListener.java @@ -0,0 +1,25 @@ +package com.baeldung.springmodulith.events.externalization.listener; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +@Component +public class TestListener { + private List events = new ArrayList<>(); + + @KafkaListener(id = "test-id", topics = "baeldung.articles.published") + public void listen(String event) { + events.add(event); + } + + public List getEvents() { + return events; + } + + public void reset() { + events = new ArrayList<>(); + } +} \ No newline at end of file