Skip to content

Commit 5d44a34

Browse files
committed
feat: spring-boot-4x support, includes spring-kafka 4x
1 parent cc8dde7 commit 5d44a34

File tree

43 files changed

+2633
-2
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+2633
-2
lines changed

build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ apiValidation {
2121
"ktor-example",
2222
"micronaut-example",
2323
"spring-example",
24+
"spring-4x-example",
2425
"spring-standalone-example",
2526
"spring-streams-example"
2627
)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
plugins {
2+
alias(libs.plugins.spring.plugin)
3+
alias(libs.plugins.spring.boot.four)
4+
idea
5+
application
6+
}
7+
8+
dependencies {
9+
implementation(libs.spring.boot.four)
10+
implementation(libs.spring.boot.four.autoconfigure)
11+
implementation(libs.spring.boot.four.webflux)
12+
implementation(libs.spring.boot.four.actuator)
13+
annotationProcessor(libs.spring.boot.four.annotationProcessor)
14+
implementation(libs.spring.boot.four.kafka)
15+
implementation(libs.kotlinx.reactor)
16+
implementation(libs.kotlinx.core)
17+
implementation(libs.kotlinx.reactive)
18+
implementation(libs.kotlinx.slf4j)
19+
}
20+
21+
dependencies {
22+
testImplementation(libs.jackson3.kotlin)
23+
testImplementation(projects.stove.lib.stoveTestingE2eHttp)
24+
testImplementation(projects.stove.lib.stoveTestingE2eWiremock)
25+
testImplementation(projects.stove.starters.spring.stoveSpring4xTestingE2e)
26+
testImplementation(projects.stove.starters.spring.stoveSpring4xTestingE2eKafka)
27+
}
28+
29+
application { mainClass.set("stove.spring.example4x.ExampleAppkt") }
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package stove.spring.example4x
2+
3+
import org.springframework.boot.SpringApplication
4+
import org.springframework.boot.autoconfigure.SpringBootApplication
5+
import org.springframework.boot.runApplication
6+
import org.springframework.context.ConfigurableApplicationContext
7+
8+
@SpringBootApplication
9+
class ExampleApp
10+
11+
fun main(args: Array<String>) {
12+
run(args)
13+
}
14+
15+
/**
16+
* This is the point where spring application gets run.
17+
* run(args, init) method is the important point for the testing configuration.
18+
* init allows us to override any dependency from the testing side that is being time related or configuration related.
19+
* Spring itself opens this configuration higher order function to the outside.
20+
*/
21+
fun run(
22+
args: Array<String>,
23+
init: SpringApplication.() -> Unit = {}
24+
): ConfigurableApplicationContext = runApplication<ExampleApp>(*args, init = init)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package stove.spring.example4x.application.handlers
2+
3+
import org.springframework.stereotype.Service
4+
import stove.spring.example4x.infrastructure.api.ProductCreateRequest
5+
6+
@Service
7+
class ProductCreator {
8+
suspend fun create(request: ProductCreateRequest) {
9+
// In a real application, this would persist the product
10+
println("Creating product: ${request.name} with id ${request.id}")
11+
}
12+
}
13+
14+
data class ProductCreatedEvent(
15+
val id: Long,
16+
val name: String,
17+
val supplierId: Long
18+
)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package stove.spring.example4x.infrastructure.api
2+
3+
import org.springframework.http.ResponseEntity
4+
import org.springframework.web.bind.annotation.*
5+
import stove.spring.example4x.application.handlers.*
6+
import stove.spring.example4x.infrastructure.messaging.kafka.KafkaProducer
7+
8+
@RestController
9+
@RequestMapping("/api")
10+
class ProductController(
11+
private val productCreator: ProductCreator,
12+
private val kafkaProducer: KafkaProducer
13+
) {
14+
@GetMapping("/index")
15+
suspend fun index(
16+
@RequestParam(required = false) keyword: String?
17+
): ResponseEntity<String> = ResponseEntity.ok("Hi from Stove framework with ${keyword ?: "no keyword"}")
18+
19+
@PostMapping("/product/create")
20+
suspend fun create(
21+
@RequestBody request: ProductCreateRequest
22+
): ResponseEntity<Any> {
23+
productCreator.create(request)
24+
kafkaProducer.send(ProductCreatedEvent(request.id, request.name, request.supplierId))
25+
return ResponseEntity.ok().build()
26+
}
27+
}
28+
29+
data class ProductCreateRequest(
30+
val id: Long,
31+
val name: String,
32+
val supplierId: Long
33+
)
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
@file:Suppress("DEPRECATION")
2+
3+
package stove.spring.example4x.infrastructure.messaging.kafka
4+
5+
import org.apache.kafka.clients.consumer.ConsumerConfig
6+
import org.apache.kafka.clients.producer.ProducerConfig
7+
import org.apache.kafka.common.serialization.*
8+
import org.springframework.boot.context.properties.*
9+
import org.springframework.context.annotation.*
10+
import org.springframework.kafka.annotation.EnableKafka
11+
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory
12+
import org.springframework.kafka.core.*
13+
import org.springframework.kafka.listener.RecordInterceptor
14+
import org.springframework.kafka.support.serializer.*
15+
16+
@Configuration
17+
@EnableKafka
18+
@EnableConfigurationProperties(KafkaProperties::class)
19+
class KafkaConfiguration {
20+
@Bean
21+
fun kafkaListenerContainerFactory(
22+
consumerFactory: ConsumerFactory<String, String>,
23+
interceptor: RecordInterceptor<String, String>?
24+
): ConcurrentKafkaListenerContainerFactory<String, String> {
25+
val factory = ConcurrentKafkaListenerContainerFactory<String, String>()
26+
factory.setConsumerFactory(consumerFactory)
27+
interceptor?.let { factory.setRecordInterceptor(it) }
28+
return factory
29+
}
30+
31+
@Bean
32+
@Suppress("MagicNumber")
33+
fun consumerFactory(
34+
config: KafkaProperties
35+
): ConsumerFactory<String, String> = DefaultKafkaConsumerFactory(
36+
mapOf(
37+
ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to config.bootstrapServers,
38+
ConsumerConfig.GROUP_ID_CONFIG to config.groupId,
39+
ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to config.offset,
40+
ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java,
41+
ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to ErrorHandlingDeserializer::class.java,
42+
ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS to StringDeserializer::class.java,
43+
ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG to config.heartbeatInSeconds * 1000,
44+
ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG to config.heartbeatInSeconds * 3000,
45+
ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG to config.heartbeatInSeconds * 3000
46+
)
47+
)
48+
49+
@Bean
50+
fun kafkaTemplate(
51+
config: KafkaProperties
52+
): KafkaTemplate<String, Any> = KafkaTemplate(
53+
DefaultKafkaProducerFactory(
54+
mapOf(
55+
ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to config.bootstrapServers,
56+
ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java,
57+
ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to JacksonJsonSerializer::class.java,
58+
ProducerConfig.ACKS_CONFIG to config.acks
59+
)
60+
)
61+
)
62+
}
63+
64+
@ConfigurationProperties(prefix = "kafka")
65+
data class KafkaProperties(
66+
val bootstrapServers: String,
67+
val groupId: String = "spring-4x-example",
68+
val offset: String = "earliest",
69+
val acks: String = "1",
70+
val heartbeatInSeconds: Int = 3,
71+
val topicPrefix: String = "trendyol.stove.service"
72+
)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package stove.spring.example4x.infrastructure.messaging.kafka
2+
3+
import org.springframework.kafka.core.KafkaTemplate
4+
import org.springframework.stereotype.Component
5+
import stove.spring.example4x.application.handlers.ProductCreatedEvent
6+
7+
@Component
8+
class KafkaProducer(
9+
private val kafkaTemplate: KafkaTemplate<String, Any>,
10+
private val kafkaProperties: KafkaProperties
11+
) {
12+
suspend fun send(event: ProductCreatedEvent) {
13+
val topic = "${kafkaProperties.topicPrefix}.productCreated.1"
14+
kafkaTemplate.send(topic, event.id.toString(), event)
15+
}
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package stove.spring.example4x.infrastructure.messaging.kafka
2+
3+
import org.slf4j.*
4+
import org.springframework.kafka.annotation.KafkaListener
5+
import org.springframework.messaging.handler.annotation.*
6+
import org.springframework.stereotype.Component
7+
import stove.spring.example4x.application.handlers.ProductCreator
8+
import stove.spring.example4x.infrastructure.api.ProductCreateRequest
9+
import tools.jackson.databind.json.JsonMapper
10+
11+
@Component
12+
class ProductCreateConsumer(
13+
private val productCreator: ProductCreator,
14+
private val jsonMapper: JsonMapper
15+
) {
16+
private val logger: Logger = LoggerFactory.getLogger(javaClass)
17+
18+
@KafkaListener(topics = ["trendyol.stove.service.product.create.0"], groupId = "\${kafka.groupId}")
19+
suspend fun consume(
20+
@Payload message: String,
21+
@Header("X-UserEmail", required = false) userEmail: String?
22+
) {
23+
logger.info("Received message: $message with userEmail: $userEmail")
24+
val command = jsonMapper.readValue(message, CreateProductCommand::class.java)
25+
productCreator.create(ProductCreateRequest(command.id, command.name, command.supplierId))
26+
}
27+
}
28+
29+
data class CreateProductCommand(
30+
val id: Long,
31+
val name: String,
32+
val supplierId: Long
33+
)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
spring:
2+
application:
3+
name: "stove-spring-4x-example"
4+
5+
server:
6+
port: 8001
7+
8+
kafka:
9+
bootstrapServers: localhost:9092
10+
topicPrefix: trendyol.stove.service
11+
acks: "1"
12+
offset: "latest"
13+
heartbeatInSeconds: 30
14+
groupId: spring-4x-example
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package com.stove.spring.example4x.e2e
2+
3+
import arrow.core.some
4+
import com.trendyol.stove.testing.e2e.http.*
5+
import com.trendyol.stove.testing.e2e.kafka.kafka
6+
import com.trendyol.stove.testing.e2e.system.*
7+
import io.kotest.core.spec.style.FunSpec
8+
import io.kotest.matchers.shouldBe
9+
import io.kotest.matchers.string.shouldContain
10+
import stove.spring.example4x.application.handlers.ProductCreatedEvent
11+
import stove.spring.example4x.infrastructure.api.ProductCreateRequest
12+
import stove.spring.example4x.infrastructure.messaging.kafka.CreateProductCommand
13+
import kotlin.time.Duration.Companion.seconds
14+
15+
class ExampleTest :
16+
FunSpec({
17+
test("index should be reachable") {
18+
TestSystem.validate {
19+
http {
20+
get<String>("/api/index", queryParams = mapOf("keyword" to testCase.name.name)) { actual ->
21+
actual shouldContain "Hi from Stove framework with ${testCase.name.name}"
22+
println(actual)
23+
}
24+
get<String>("/api/index") { actual ->
25+
actual shouldContain "Hi from Stove framework with"
26+
println(actual)
27+
}
28+
}
29+
}
30+
}
31+
32+
test("should create new product when send product create request from api") {
33+
TestSystem.validate {
34+
val productCreateRequest = ProductCreateRequest(1L, name = "product name", 99L)
35+
36+
http {
37+
postAndExpectBodilessResponse(uri = "/api/product/create", body = productCreateRequest.some()) { actual ->
38+
actual.status shouldBe 200
39+
}
40+
}
41+
42+
kafka {
43+
shouldBePublished<ProductCreatedEvent> {
44+
actual.id == productCreateRequest.id &&
45+
actual.name == productCreateRequest.name &&
46+
actual.supplierId == productCreateRequest.supplierId
47+
}
48+
}
49+
}
50+
}
51+
52+
test("should consume product create command from kafka") {
53+
TestSystem.validate {
54+
val createProductCommand = CreateProductCommand(2L, name = "product from kafka", 100L)
55+
56+
kafka {
57+
publish("trendyol.stove.service.product.create.0", createProductCommand)
58+
shouldBeConsumed<CreateProductCommand>(10.seconds) {
59+
actual.id == createProductCommand.id &&
60+
actual.name == createProductCommand.name &&
61+
actual.supplierId == createProductCommand.supplierId
62+
}
63+
}
64+
}
65+
}
66+
})

0 commit comments

Comments
 (0)