From bee0200d23a5ff19498b5f1dbbde755d15e1b0eb Mon Sep 17 00:00:00 2001 From: Oguzhan Soykan Date: Tue, 14 May 2024 18:40:15 +0200 Subject: [PATCH] (recipes): add ktor kotlin recipe with mongodb --- recipes/build.gradle.kts | 4 + recipes/gradle/libs.versions.toml | 51 +++++++-- .../java/spring/ExampleSpringBootApp.java | 3 +- .../command/ProductApplicationService.java | 6 +- .../java/spring/domain/EventPublisher.java | 7 -- ...ry.java => ProductReactiveRepository.java} | 2 +- .../kafka/KafkaBeanConfiguration.java | 17 ++- .../boilerplate/kafka/KafkaConfiguration.java | 2 - .../kafka/KafkaDomainEventPublisher.java | 6 +- .../boilerplate/kafka/TopicResolver.java | 3 - ...l.java => CouchbaseProductRepository.java} | 8 +- .../ktor-recipe/build.gradle.kts | 40 ++++++- .../kotlin/ktor/ExampleStoveKtorApp.kt | 70 +++++++++++- .../ktor/application/RecipeAppConfig.kt | 52 +++++++++ .../application/external/CategoryHttpApi.kt | 7 ++ .../external/CategoryHttpApiImpl.kt | 20 ++++ .../product/command/ProductCommandHandler.kt | 21 ++++ .../application/product/command/handling.kt | 12 ++ .../ktor/domain/product/ProductRepository.kt | 10 ++ .../ktor/infra/boilerplate/http/http.kt | 50 +++++++++ .../ktor/infra/boilerplate/kediatr/kediatr.kt | 27 +++++ .../ktor/infra/boilerplate/mongo/mongo.kt | 31 ++++++ .../serialization/JacksonConfiguration.kt | 34 ++++++ .../kotlin/ktor/infra/boilerplate/util.kt | 98 +++++++++++++++++ .../infra/components/external/category.kt | 12 ++ .../infra/components/product/api/routing.kt | 20 ++++ .../ktor/infra/components/product/defs.kt | 16 +++ .../persistency/MongoProductRepository.kt | 43 ++++++++ .../src/main/resources/application.yaml | 25 +++++ .../kotlin/ktor/e2e/setup/TestData.kt | 7 ++ .../ktor/e2e/setup/TestProjectConfig.kt | 61 ++++++++++- .../kotlin/ktor/e2e/tests/IndexTests.kt | 15 ++- .../tests/configuration/ConfigurationTests.kt | 17 +++ .../ktor/e2e/tests/product/CreateTests.kt | 103 ++++++++++++++++++ .../src/test-e2e/resources/logback-test.xml | 20 ++++ recipes/settings.gradle.kts | 1 + .../examples/domain/ddd/EventPublisher.java | 5 + 37 files changed, 879 insertions(+), 47 deletions(-) delete mode 100644 recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/domain/EventPublisher.java rename recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/domain/{ProductRepository.java => ProductReactiveRepository.java} (80%) rename recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/components/product/persistency/{ProductRepositoryImpl.java => CouchbaseProductRepository.java} (75%) create mode 100644 recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/RecipeAppConfig.kt create mode 100644 recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/external/CategoryHttpApi.kt create mode 100644 recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/external/CategoryHttpApiImpl.kt create mode 100644 recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/product/command/ProductCommandHandler.kt create mode 100644 recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/product/command/handling.kt create mode 100644 recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/domain/product/ProductRepository.kt create mode 100644 recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/http/http.kt create mode 100644 recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/kediatr/kediatr.kt create mode 100644 recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/mongo/mongo.kt create mode 100644 recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/serialization/JacksonConfiguration.kt create mode 100644 recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/util.kt create mode 100644 recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/components/external/category.kt create mode 100644 recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/components/product/api/routing.kt create mode 100644 recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/components/product/defs.kt create mode 100644 recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/components/product/persistency/MongoProductRepository.kt create mode 100644 recipes/kotlin-recipes/ktor-recipe/src/main/resources/application.yaml create mode 100644 recipes/kotlin-recipes/ktor-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/setup/TestData.kt create mode 100644 recipes/kotlin-recipes/ktor-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/tests/configuration/ConfigurationTests.kt create mode 100644 recipes/kotlin-recipes/ktor-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/tests/product/CreateTests.kt create mode 100644 recipes/kotlin-recipes/ktor-recipe/src/test-e2e/resources/logback-test.xml create mode 100644 recipes/shared/domain/src/main/java/com/trendyol/stove/examples/domain/ddd/EventPublisher.java diff --git a/recipes/build.gradle.kts b/recipes/build.gradle.kts index 84b28688..3077319f 100644 --- a/recipes/build.gradle.kts +++ b/recipes/build.gradle.kts @@ -33,11 +33,15 @@ subprojects { java { removeUnusedImports() googleJavaFormat() + targetExclude("build") + targetExcludeIfContentContains("generated") } kotlin { ktlint().setEditorConfigPath(rootProject.layout.projectDirectory.file(".editorconfig")) + targetExcludeIfContentContains("generated") } } + the().apply { module { isDownloadSources = true diff --git a/recipes/gradle/libs.versions.toml b/recipes/gradle/libs.versions.toml index e1ca9dd2..3bbfac85 100644 --- a/recipes/gradle/libs.versions.toml +++ b/recipes/gradle/libs.versions.toml @@ -12,6 +12,8 @@ spring-kafka = "3.1.4" # arrow arrow = "1.2.4" +arrow-jackson = "0.14.1" +arrowSuspendApp = "0.4.0" # Jackson jackson = "2.17.1" @@ -31,10 +33,14 @@ io-reactor-extensions = "1.2.2" # Logging slf4j = "2.0.13" +kotlinLogging = "6.0.9" # Ktor ktor = "2.3.11" koin = "3.5.6" +koin-annotations = "1.3.1" + +cohort = "2.5.0" # R2DBC r2dbc-spi = "1.0.0.RELEASE" @@ -59,6 +65,7 @@ lombok = "1.18.32" # Misc hoplite = "2.8.0.RC3" +kediatr = "3.0.0" # Testing stove = "0.9.9-SNAPSHOT" @@ -79,6 +86,12 @@ kotlinx-io-reactor-extensions = { module = "io.projectreactor.kotlin:reactor-kot # Arrow arrow-core = { module = "io.arrow-kt:arrow-core", version.ref = "arrow" } +arrow-jackson = { module = "io.arrow-kt:arrow-integrations-jackson-module", version.ref = "arrow-jackson" } +arrow-suspendApp = { module = "io.arrow-kt:suspendapp", version.ref = "arrowSuspendApp" } +arrow-suspendApp-ktor = { module = "io.arrow-kt:suspendapp-ktor", version.ref = "arrowSuspendApp" } +arrow-continuations = { module = "io.arrow-kt:arrow-continuations", version.ref = "arrow" } +arrow-fx-coroutines = { module = "io.arrow-kt:arrow-fx-coroutines", version.ref = "arrow" } +arrow-resilience = { module = "io.arrow-kt:arrow-resilience", version.ref = "arrow" } # Spring spring-boot = { module = "org.springframework.boot:spring-boot", version.ref = "spring-boot" } @@ -105,23 +118,40 @@ jackson-core = { module = "com.fasterxml.jackson.core:jackson-core", version.ref slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } -ktor-server-host-common = { module = "io.ktor:ktor-server-host-common", version.ref = "ktor" } -ktor-server = { module = "io.ktor:ktor-server", version.ref = "ktor" } -ktor-server-call-logging = { module = "io.ktor:ktor-server-call-logging", version.ref = "ktor" } -ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" } -ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-server-core-jvm = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor" } +ktor-server-config-yml = { module = "io.ktor:ktor-server-config-yaml", version.ref = "ktor" } +ktor-server-content-negotiation-jvm = { module = "io.ktor:ktor-server-content-negotiation-jvm", version.ref = "ktor" } +ktor-serialization-kotlinx-json-jvm = { module = "io.ktor:ktor-serialization-kotlinx-json-jvm", version.ref = "ktor" } +ktor-serialization-jackson-json = { module = "io.ktor:ktor-serialization-jackson", version.ref = "ktor" } +ktor-server-netty-jvm = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "ktor" } +ktor-server-statuspages = { module = "io.ktor:ktor-server-status-pages", version.ref = "ktor" } +ktor-server-callLogging = { module = "io.ktor:ktor-server-call-logging", version.ref = "ktor" } +ktor-server-autoHeadResponse = { module = "io.ktor:ktor-server-auto-head-response", version.ref = "ktor" } +ktor-server-cachingHeaders = { module = "io.ktor:ktor-server-caching-headers", version.ref = "ktor" } +ktor-server-callId = { module = "io.ktor:ktor-server-call-id-jvm", version.ref = "ktor" } +ktor-server-conditionalHeaders = { module = "io.ktor:ktor-server-conditional-headers", version.ref = "ktor" } +ktor-server-cors = { module = "io.ktor:ktor-server-cors-jvm", version.ref = "ktor" } +ktor-server-defaultHeaders = { module = "io.ktor:ktor-server-default-headers", version.ref = "ktor" } +ktor-swagger-ui = { module = "io.github.smiley4:ktor-swagger-ui", version = "2.9.0" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } -ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-plugins-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } -ktor-serialization-jackson-json = { module = "io.ktor:ktor-serialization-jackson", version.ref = "ktor" } +koin = { module = "io.insert-koin:koin-core", version.ref = "koin" } koin-ktor = { module = "io.insert-koin:koin-ktor", version.ref = "koin" } koin-logger-slf4j = { module = "io.insert-koin:koin-logger-slf4j", version.ref = "koin" } +koin-annotations = { module = "io.insert-koin:koin-annotations", version.ref = "koin-annotations" } +koin-ksp-compiler = { module = "io.insert-koin:koin-ksp-compiler", version.ref = "koin-annotations" } +kotlinFpUtil = { module = "it.czerwinski:kotlin-util", version = "1.9.1" } +cohort = { module = "com.sksamuel.cohort:cohort-ktor", version.ref = "cohort" } +cohort-logback = { module = "com.sksamuel.cohort:cohort-logback", version.ref = "cohort" } r2dbc-spi = { module = "io.r2dbc:r2dbc-spi", version.ref = "r2dbc-spi" } r2dbc-postgresql = { module = "io.r2dbc:r2dbc-postgresql", version.ref = "r2dbc-postgresql" } elastic = { module = "co.elastic.clients:elasticsearch-java", version.ref = "elastic" } mongodb-reactivestreams = { module = "org.mongodb:mongodb-driver-reactivestreams", version.ref = "mongodb" } +mongodb-bson-kotlin = { module = "org.mongodb:bson-kotlin", version.ref = "mongodb" } +mongodb-kotlin-coroutine = { module = "org.mongodb:mongodb-driver-kotlin-coroutine", version.ref = "mongodb" } +kotlin-logging-jvm = { module = "io.github.oshai:kotlin-logging-jvm", version.ref = "kotlinLogging" } r2dbc-mssql = { module = "io.r2dbc:r2dbc-mssql", version.ref = "r2dbc-mssql" } lettuce-core = { module = "io.lettuce:lettuce-core", version = "6.3.2.RELEASE" } @@ -131,6 +161,11 @@ detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", hoplite = { module = "com.sksamuel.hoplite:hoplite-core", version.ref = "hoplite" } hoplite-yaml = { module = "com.sksamuel.hoplite:hoplite-yaml", version.ref = "hoplite" } +# kediatR +kediatr-core = { module = "com.trendyol:kediatr-core", version.ref = "kediatr" } +kediatr-spring = { module = "com.trendyol:kediatr-spring-starter", version.ref = "kediatr" } +kediatr-koin = { module = "com.trendyol:kediatr-koin-starter", version.ref = "kediatr" } + # Tooling lombok = { module = "org.projectlombok:lombok", version.ref = "lombok" } @@ -154,6 +189,7 @@ stove-testing-redis = { module = "com.trendyol:stove-testing-e2e-redis", version stove-testing-wiremock = { module = "com.trendyol:stove-testing-e2e-wiremock", version.ref = "stove" } stove-testing-elasticsearch = { module = "com.trendyol:stove-testing-e2e-elasticsearch", version.ref = "stove" } stove-testing-rdbms-postgres = { module = "com.trendyol:stove-testing-e2e-rdbms-postgres", version.ref = "stove" } +stove-testing-mongodb = { module = "com.trendyol:stove-testing-e2e-mongodb", version.ref = "stove" } # Scala scala2-library = { module = "org.scala-lang:scala-library", version.ref = "scala2x" } @@ -166,5 +202,6 @@ kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } testLogger = { id = "com.adarshr.test-logger", version = "4.0.0" } +ksp = { id = "com.google.devtools.ksp", version = "1.9.23-1.0.20" } diff --git a/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/ExampleSpringBootApp.java b/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/ExampleSpringBootApp.java index 38f2917b..17281473 100644 --- a/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/ExampleSpringBootApp.java +++ b/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/ExampleSpringBootApp.java @@ -1,7 +1,6 @@ package com.trendyol.stove.examples.java.spring; import com.trendyol.stove.examples.java.spring.infra.boilerplate.couchbase.CouchbaseConfiguration; -import com.trendyol.stove.examples.java.spring.infra.boilerplate.kafka.KafkaConfiguration; import java.util.function.Consumer; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @@ -10,7 +9,7 @@ import org.springframework.kafka.annotation.EnableKafka; @SpringBootApplication -@EnableConfigurationProperties({CouchbaseConfiguration.class, KafkaConfiguration.class}) +@EnableConfigurationProperties({CouchbaseConfiguration.class}) @EnableKafka public class ExampleSpringBootApp { public static void main(String[] args) { diff --git a/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/application/product/command/ProductApplicationService.java b/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/application/product/command/ProductApplicationService.java index a4124396..01ea3e21 100644 --- a/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/application/product/command/ProductApplicationService.java +++ b/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/application/product/command/ProductApplicationService.java @@ -2,7 +2,7 @@ import com.trendyol.stove.examples.domain.product.Product; import com.trendyol.stove.examples.java.spring.application.external.category.CategoryHttpApi; -import com.trendyol.stove.examples.java.spring.domain.ProductRepository; +import com.trendyol.stove.examples.java.spring.domain.ProductReactiveRepository; import com.trendyol.stove.recipes.shared.application.BusinessException; import com.trendyol.stove.recipes.shared.application.category.CategoryApiResponse; import org.springframework.stereotype.Component; @@ -10,11 +10,11 @@ @Component public class ProductApplicationService { - private final ProductRepository productRepository; + private final ProductReactiveRepository productRepository; private final CategoryHttpApi categoryHttpApi; public ProductApplicationService( - ProductRepository productRepository, CategoryHttpApi categoryHttpApi) { + ProductReactiveRepository productRepository, CategoryHttpApi categoryHttpApi) { this.productRepository = productRepository; this.categoryHttpApi = categoryHttpApi; } diff --git a/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/domain/EventPublisher.java b/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/domain/EventPublisher.java deleted file mode 100644 index ac62ecbc..00000000 --- a/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/domain/EventPublisher.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.trendyol.stove.examples.java.spring.domain; - -import com.trendyol.stove.examples.domain.ddd.AggregateRoot; - -public interface EventPublisher { - void publishFor(AggregateRoot aggregateRoot); -} diff --git a/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/domain/ProductRepository.java b/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/domain/ProductReactiveRepository.java similarity index 80% rename from recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/domain/ProductRepository.java rename to recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/domain/ProductReactiveRepository.java index ad783aa6..9c14425a 100644 --- a/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/domain/ProductRepository.java +++ b/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/domain/ProductReactiveRepository.java @@ -3,6 +3,6 @@ import com.trendyol.stove.examples.domain.product.Product; import reactor.core.publisher.Mono; -public interface ProductRepository { +public interface ProductReactiveRepository { Mono save(Product product); } diff --git a/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/boilerplate/kafka/KafkaBeanConfiguration.java b/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/boilerplate/kafka/KafkaBeanConfiguration.java index ae185100..b12cc70e 100644 --- a/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/boilerplate/kafka/KafkaBeanConfiguration.java +++ b/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/boilerplate/kafka/KafkaBeanConfiguration.java @@ -9,6 +9,7 @@ import org.apache.kafka.common.serialization.StringDeserializer; import org.apache.kafka.common.serialization.StringSerializer; import org.slf4j.Logger; +import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; @@ -20,6 +21,17 @@ public class KafkaBeanConfiguration { private final Logger logger = org.slf4j.LoggerFactory.getLogger(KafkaBeanConfiguration.class); + @Bean + @ConfigurationProperties(prefix = "kafka") + public KafkaConfiguration kafkaConfiguration() { + return new KafkaConfiguration(); + } + + @Bean + public TopicResolver topicResolver(KafkaConfiguration kafkaConfiguration) { + return new TopicResolver(kafkaConfiguration); + } + @Bean public Properties consumerProperties(KafkaConfiguration kafkaConfiguration) { Properties properties = new Properties(); @@ -36,8 +48,9 @@ public Properties consumerProperties(KafkaConfiguration kafkaConfiguration) { ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, (int) Duration.ofSeconds(kafkaConfiguration.getSessionTimeoutSeconds()).toMillis()); properties.put( - ConsumerConfig.ALLOW_AUTO_CREATE_TOPICS_CONFIG, kafkaConfiguration.autoCreateTopics); - properties.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, kafkaConfiguration.autoOffsetReset); + ConsumerConfig.ALLOW_AUTO_CREATE_TOPICS_CONFIG, kafkaConfiguration.isAutoCreateTopics()); + properties.put( + ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, kafkaConfiguration.getAutoOffsetReset()); properties.put( ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); properties.put( diff --git a/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/boilerplate/kafka/KafkaConfiguration.java b/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/boilerplate/kafka/KafkaConfiguration.java index 48056247..e31f77b8 100644 --- a/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/boilerplate/kafka/KafkaConfiguration.java +++ b/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/boilerplate/kafka/KafkaConfiguration.java @@ -2,9 +2,7 @@ import java.util.Map; import lombok.Data; -import org.springframework.boot.context.properties.ConfigurationProperties; -@ConfigurationProperties(prefix = "kafka") public @Data class KafkaConfiguration { String bootstrapServers; String groupId; diff --git a/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/boilerplate/kafka/KafkaDomainEventPublisher.java b/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/boilerplate/kafka/KafkaDomainEventPublisher.java index 19108652..c1737c5f 100644 --- a/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/boilerplate/kafka/KafkaDomainEventPublisher.java +++ b/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/boilerplate/kafka/KafkaDomainEventPublisher.java @@ -3,7 +3,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.trendyol.stove.examples.domain.ddd.AggregateRoot; -import com.trendyol.stove.examples.java.spring.domain.EventPublisher; +import com.trendyol.stove.examples.domain.ddd.EventPublisher; import java.util.stream.Stream; import org.apache.kafka.clients.producer.ProducerRecord; import org.slf4j.Logger; @@ -39,9 +39,9 @@ private Stream> mapEventsToProducerRecords( event -> { var topic = topicResolver.resolve(aggregateRoot.getAggregateName()); try { - logger.info("Publishing event {} to topic {}", event, topic.name); + logger.info("Publishing event {} to topic {}", event, topic.getName()); return new ProducerRecord<>( - topic.name, + topic.getName(), aggregateRoot.getIdAsString(), objectMapper.writeValueAsString(event)); } catch (JsonProcessingException e) { diff --git a/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/boilerplate/kafka/TopicResolver.java b/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/boilerplate/kafka/TopicResolver.java index 578c60ff..76db17bc 100644 --- a/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/boilerplate/kafka/TopicResolver.java +++ b/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/boilerplate/kafka/TopicResolver.java @@ -1,8 +1,5 @@ package com.trendyol.stove.examples.java.spring.infra.boilerplate.kafka; -import org.springframework.stereotype.Component; - -@Component public class TopicResolver { private final KafkaConfiguration kafkaConfiguration; diff --git a/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/components/product/persistency/ProductRepositoryImpl.java b/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/components/product/persistency/CouchbaseProductRepository.java similarity index 75% rename from recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/components/product/persistency/ProductRepositoryImpl.java rename to recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/components/product/persistency/CouchbaseProductRepository.java index 7e203173..4c9c3df1 100644 --- a/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/components/product/persistency/ProductRepositoryImpl.java +++ b/recipes/java-recipes/spring-boot-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/components/product/persistency/CouchbaseProductRepository.java @@ -4,18 +4,18 @@ import com.couchbase.client.java.ReactiveBucket; import com.couchbase.client.java.ReactiveCollection; +import com.trendyol.stove.examples.domain.ddd.EventPublisher; import com.trendyol.stove.examples.domain.product.Product; -import com.trendyol.stove.examples.java.spring.domain.EventPublisher; -import com.trendyol.stove.examples.java.spring.domain.ProductRepository; +import com.trendyol.stove.examples.java.spring.domain.ProductReactiveRepository; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; @Component -public class ProductRepositoryImpl implements ProductRepository { +public class CouchbaseProductRepository implements ProductReactiveRepository { private final ReactiveBucket bucket; private final EventPublisher eventPublisher; - public ProductRepositoryImpl(ReactiveBucket bucket, EventPublisher eventPublisher) { + public CouchbaseProductRepository(ReactiveBucket bucket, EventPublisher eventPublisher) { this.bucket = bucket; this.eventPublisher = eventPublisher; } diff --git a/recipes/kotlin-recipes/ktor-recipe/build.gradle.kts b/recipes/kotlin-recipes/ktor-recipe/build.gradle.kts index a8ec30b9..66e25cd4 100644 --- a/recipes/kotlin-recipes/ktor-recipe/build.gradle.kts +++ b/recipes/kotlin-recipes/ktor-recipe/build.gradle.kts @@ -1,15 +1,49 @@ dependencies { - + implementation(projects.shared.domain) + implementation(projects.shared.application) + implementation(libs.ktor.server.core.jvm) + implementation(libs.ktor.server.netty.jvm) + implementation(libs.ktor.server.content.negotiation.jvm) + implementation(libs.ktor.server.statuspages) + implementation(libs.ktor.server.callLogging) + implementation(libs.ktor.server.callId) + implementation(libs.ktor.server.conditionalHeaders) + implementation(libs.ktor.server.cors) + implementation(libs.ktor.server.defaultHeaders) + implementation(libs.ktor.server.cachingHeaders) + implementation(libs.ktor.server.autoHeadResponse) + implementation(libs.ktor.server.config.yml) + implementation(libs.ktor.swagger.ui) + implementation(libs.ktor.serialization.jackson.json) + implementation(libs.koin) + implementation(libs.koin.ktor) + implementation(libs.slf4j.api) + implementation(libs.arrow.core) + implementation(libs.hoplite) + implementation(libs.hoplite.yaml) + implementation(libs.logback.classic) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.plugins.logging) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.kotlinFpUtil) + implementation(libs.kotlin.logging.jvm) + implementation(libs.kediatr.koin) + implementation(libs.mongodb.kotlin.coroutine) + implementation(libs.mongodb.bson.kotlin) } + dependencies { testImplementation(libs.kotest.runner.junit5) testImplementation(libs.kotest.framework.api.jvm) testImplementation(libs.kotest.property.jvm) testImplementation(libs.stove.testing) - testImplementation(libs.stove.testing.couchbase) + testImplementation(libs.stove.testing.mongodb) testImplementation(libs.stove.testing.http) testImplementation(libs.stove.testing.wiremock) testImplementation(libs.stove.testing.kafka) - testImplementation(libs.stove.spring.testing) + testImplementation(libs.stove.ktor.testing) testImplementation(libs.jackson.kotlin) } + + diff --git a/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/ExampleStoveKtorApp.kt b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/ExampleStoveKtorApp.kt index 47ea3896..2f617436 100644 --- a/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/ExampleStoveKtorApp.kt +++ b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/ExampleStoveKtorApp.kt @@ -1,10 +1,70 @@ package com.trendyol.stove.examples.kotlin.ktor -import com.trendyol.stove.examples.domain.product.Product +import com.trendyol.stove.examples.kotlin.ktor.application.RecipeAppConfig +import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.* +import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.http.registerHttpClient +import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kediatr.registerKediatR +import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.mongo.configureMongo +import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.serialization.* +import com.trendyol.stove.examples.kotlin.ktor.infra.components.product.api.productApi +import com.trendyol.stove.examples.kotlin.ktor.infra.components.product.registerProductComponents +import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.server.application.* +import io.ktor.server.plugins.autohead.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import org.koin.core.KoinApplication +import org.koin.dsl.module +import org.koin.ktor.plugin.Koin -class ExampleStoveKtorApp +val logger = KotlinLogging.logger("Stove Ktor Recipe") -fun main() { - val product = Product.create("Product 1", 100.0, 1) - println("Hello, Stove!, Product: $product") +object ExampleStoveKtorApp { + @JvmStatic + fun main(args: Array) { + run(args) + } + + fun run(args: Array, wait: Boolean = true, configure: org.koin.core.module.Module = module { }): Application { + val config = loadConfiguration(args) + logger.info { "Starting Ktor application with config: $config" } + return startKtorApplication(config, wait) { + appModule(config, configure) + } + } +} + +fun Application.appModule( + config: RecipeAppConfig, + overrides: org.koin.core.module.Module = module { } +) { + install(Koin) { + allowOverride(true) + modules(module { single { config } }) + registerAppDeps() + registerHttpClient() + modules(overrides) + } + configureRouting() + configureExceptionHandling() + configureContentNegotiation() +} + +fun KoinApplication.registerAppDeps() { + configureMongo() + configureJackson() + registerKediatR() + registerProductComponents() +} + +fun Application.configureRouting() { + install(AutoHeadResponse) + routing { + route("/") { + get { + call.respondText("Hello, World!") + } + } + productApi() + } } diff --git a/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/RecipeAppConfig.kt b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/RecipeAppConfig.kt new file mode 100644 index 00000000..68a262bf --- /dev/null +++ b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/RecipeAppConfig.kt @@ -0,0 +1,52 @@ +package com.trendyol.stove.examples.kotlin.ktor.application + +/** + * Represents the main configuration + */ +data class RecipeAppConfig( + val server: ServerConfig, + val kafka: KafkaConfiguration, + val mongo: MongoConfiguration +) + +/** + * Represents the configuration of the checker. + */ +data class ServerConfig( + /** + * Port of the server. + */ + val port: Int = 8080, + /** + * Host of the server. + */ + val host: String = "", + val name: String +) + +data class MongoConfiguration( + val uri: String, + val database: String +) + +data class KafkaConfiguration( + val bootstrapServers: String, + val groupId: String, + val requestTimeoutSeconds: Long = 30, + val heartbeatIntervalSeconds: Long = 3, + val sessionTimeoutSeconds: Long = 10, + val autoCreateTopics: Boolean = true, + val autoOffsetReset: String = "earliest", + val interceptorClasses: List, + val topics: Map +) { + fun flattenInterceptorClasses(): String { + return interceptorClasses.joinToString(",") + } +} + +data class Topic( + val name: String, + val retry: String, + val deadLetter: String +) diff --git a/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/external/CategoryHttpApi.kt b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/external/CategoryHttpApi.kt new file mode 100644 index 00000000..819844a5 --- /dev/null +++ b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/external/CategoryHttpApi.kt @@ -0,0 +1,7 @@ +package com.trendyol.stove.examples.kotlin.ktor.application.external + +import com.trendyol.stove.recipes.shared.application.category.CategoryApiResponse + +interface CategoryHttpApi { + suspend fun getCategory(id: Int): CategoryApiResponse +} diff --git a/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/external/CategoryHttpApiImpl.kt b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/external/CategoryHttpApiImpl.kt new file mode 100644 index 00000000..5af2e301 --- /dev/null +++ b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/external/CategoryHttpApiImpl.kt @@ -0,0 +1,20 @@ +package com.trendyol.stove.examples.kotlin.ktor.application.external + +import com.trendyol.stove.recipes.shared.application.category.* +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.http.* + +class CategoryHttpApiImpl( + private val httpClient: HttpClient, + private val categoryApiConfiguration: CategoryApiConfiguration +) : CategoryHttpApi { + override suspend fun getCategory(id: Int): CategoryApiResponse { + return httpClient + .get("${categoryApiConfiguration.url}/categories/$id") { + accept(ContentType.Application.Json) + } + .body() + } +} diff --git a/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/product/command/ProductCommandHandler.kt b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/product/command/ProductCommandHandler.kt new file mode 100644 index 00000000..2210d0fd --- /dev/null +++ b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/product/command/ProductCommandHandler.kt @@ -0,0 +1,21 @@ +package com.trendyol.stove.examples.kotlin.ktor.application.product.command + +import com.trendyol.kediatr.* +import com.trendyol.stove.examples.domain.product.Product +import com.trendyol.stove.examples.kotlin.ktor.domain.product.ProductRepository +import io.github.oshai.kotlinlogging.KotlinLogging + +data class CreateProductCommand( + val name: String, + val price: Double, + val categoryId: Int +) : Command + +class ProductCommandHandler(private val productRepository: ProductRepository) : CommandHandler { + private val logger = KotlinLogging.logger { } + + override suspend fun handle(command: CreateProductCommand) { + productRepository.save(Product.create(command.name, command.price, command.categoryId)) + logger.info { "Product saved: $command" } + } +} diff --git a/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/product/command/handling.kt b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/product/command/handling.kt new file mode 100644 index 00000000..ccf47df6 --- /dev/null +++ b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/product/command/handling.kt @@ -0,0 +1,12 @@ +package com.trendyol.stove.examples.kotlin.ktor.application.product.command + +import org.koin.core.KoinApplication +import org.koin.dsl.module + +fun KoinApplication.registerProductCommandHandling() { + modules( + module { + single { ProductCommandHandler(get()) } + } + ) +} diff --git a/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/domain/product/ProductRepository.kt b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/domain/product/ProductRepository.kt new file mode 100644 index 00000000..a28de9ce --- /dev/null +++ b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/domain/product/ProductRepository.kt @@ -0,0 +1,10 @@ +package com.trendyol.stove.examples.kotlin.ktor.domain.product + +import arrow.core.Option +import com.trendyol.stove.examples.domain.product.Product + +interface ProductRepository { + suspend fun save(product: Product) + + suspend fun findById(id: String): Option +} diff --git a/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/http/http.kt b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/http/http.kt new file mode 100644 index 00000000..783a4180 --- /dev/null +++ b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/http/http.kt @@ -0,0 +1,50 @@ +package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.http + +import com.fasterxml.jackson.databind.ObjectMapper +import io.github.oshai.kotlinlogging.* +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.logging.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.serialization.jackson.* +import org.koin.core.KoinApplication +import org.koin.dsl.module + +fun KoinApplication.registerHttpClient() { + modules(module { single { createHttpClient(get()) } }) +} + +private fun createHttpClient( + objectMapper: ObjectMapper +): HttpClient = HttpClient(CIO) { + install(Logging) { + logger = object : Logger { + private val logger: KLogger = KotlinLogging.logger("StoveHttpClient") + + override fun log(message: String) { + logger.info { message } + } + } + } + install(ContentNegotiation) { + register(ContentType.Application.Json, JacksonConverter(objectMapper)) + } + val logger = KotlinLogging.logger("JourneyHttpClient") + install(HttpRequestRetry) { + maxRetries = 1 + retryOnServerErrors() + retryOnException(retryOnTimeout = true) + exponentialDelay() + modifyRequest { request -> + logger.warn(cause) { "Retrying request: ${request.url}" } + request.headers.append("x-retry-count", retryCount.toString()) + } + } + + defaultRequest { + header(HttpHeaders.ContentType, ContentType.Application.Json) + } +} diff --git a/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/kediatr/kediatr.kt b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/kediatr/kediatr.kt new file mode 100644 index 00000000..c14d187a --- /dev/null +++ b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/kediatr/kediatr.kt @@ -0,0 +1,27 @@ +package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kediatr + +import com.trendyol.kediatr.PipelineBehavior +import com.trendyol.kediatr.koin.KediatRKoin +import org.koin.core.KoinApplication +import org.koin.dsl.module + +fun KoinApplication.registerKediatR() { + modules( + module { + single { KediatRKoin.getMediator() } + single { LoggingPipelineBehaviour() } + } + ) +} + +class LoggingPipelineBehaviour : PipelineBehavior { + override suspend fun handle( + request: TRequest, + next: suspend (TRequest) -> TResponse + ): TResponse { + println("Handling request: $request") + val response = next(request) + println("Handled request: $request") + return response + } +} diff --git a/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/mongo/mongo.kt b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/mongo/mongo.kt new file mode 100644 index 00000000..ef369bd4 --- /dev/null +++ b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/mongo/mongo.kt @@ -0,0 +1,31 @@ +package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.mongo + +import com.mongodb.* +import com.mongodb.kotlin.client.coroutine.* +import com.trendyol.stove.examples.kotlin.ktor.application.RecipeAppConfig +import org.bson.UuidRepresentation +import org.koin.core.KoinApplication +import org.koin.dsl.module + +fun KoinApplication.configureMongo() { + modules(createMongoModule()) +} + +private fun createMongoModule() = module { + single { createMongoClient(get()) } + single { createMongoDatabase(get(), get()) } +} + +private fun createMongoClient(recipeAppConfig: RecipeAppConfig): MongoClient { + return MongoClient.create( + MongoClientSettings.builder() + .uuidRepresentation(UuidRepresentation.STANDARD) + .applyConnectionString(ConnectionString(recipeAppConfig.mongo.uri)) + .readConcern(ReadConcern.MAJORITY) + .build() + ) +} + +private fun createMongoDatabase(mongoClient: MongoClient, recipeAppConfig: RecipeAppConfig): MongoDatabase { + return mongoClient.getDatabase(recipeAppConfig.mongo.database) +} diff --git a/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/serialization/JacksonConfiguration.kt b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/serialization/JacksonConfiguration.kt new file mode 100644 index 00000000..497d2e29 --- /dev/null +++ b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/serialization/JacksonConfiguration.kt @@ -0,0 +1,34 @@ +package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.serialization + +import com.fasterxml.jackson.databind.* +import com.fasterxml.jackson.databind.json.JsonMapper +import io.ktor.http.* +import io.ktor.serialization.jackson.* +import io.ktor.server.application.* +import io.ktor.server.plugins.contentnegotiation.* +import org.koin.core.KoinApplication +import org.koin.dsl.module +import org.koin.ktor.ext.inject + +object JacksonConfiguration { + val default: ObjectMapper = JsonMapper.builder() + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .disable(DeserializationFeature.FAIL_ON_IGNORED_PROPERTIES) + .disable(DeserializationFeature.FAIL_ON_INVALID_SUBTYPE) + .findAndAddModules() + .build() + .findAndRegisterModules() +} + +fun KoinApplication.configureJackson() { + modules(module { single { JacksonConfiguration.default } }) +} + +fun Application.configureContentNegotiation() { + val mapper: ObjectMapper by inject() + install(ContentNegotiation) { + register(ContentType.Application.Json, JacksonConverter(mapper)) + } +} diff --git a/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/util.kt b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/util.kt new file mode 100644 index 00000000..cf22d0b8 --- /dev/null +++ b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/util.kt @@ -0,0 +1,98 @@ +package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate + +import com.sksamuel.hoplite.* +import com.sksamuel.hoplite.env.Environment +import com.trendyol.stove.examples.kotlin.ktor.application.RecipeAppConfig +import io.github.oshai.kotlinlogging.* +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import io.ktor.server.plugins.statuspages.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import org.slf4j.LoggerFactory + +@OptIn(ExperimentalHoplite::class) +inline fun loadConfiguration(args: Array = arrayOf()): T = ConfigLoaderBuilder.default() + .addEnvironmentSource() + .addCommandLineSource(args) + .withExplicitSealedTypes() + .withEnvironment(AppEnv.toEnv()) + .apply { + when (AppEnv.current()) { + AppEnv.Local -> { + addResourceSource("/application.yaml", optional = true) + } + + AppEnv.Prod -> { + addResourceSource("/application-prod.yaml", optional = true) + addResourceSource("/application.yaml", optional = true) + } + + else -> { + addResourceSource("/application.yaml", optional = true) + } + } + } + .build() + .loadConfigOrThrow() + +enum class AppEnv(val env: String) { + Unspecified(""), + Local(Environment.local.name), + Prod(Environment.prod.name) + ; + + companion object { + fun current(): AppEnv = when (System.getenv("ENVIRONMENT")) { + Unspecified.env -> Unspecified + Local.env -> Local + Prod.env -> Prod + else -> Local + } + + fun toEnv(): Environment = when (current()) { + Local -> Environment.local + Prod -> Environment.prod + else -> Environment.local + } + } + + fun isLocal(): Boolean { + return this === Local + } + + fun isProd(): Boolean { + return this === Prod + } +} + +fun startKtorApplication(config: RecipeAppConfig, wait: Boolean = true, configure: Application.() -> Unit): Application { + val loggerName = configure.javaClass.name.split('$').first() + + val server = embeddedServer( + Netty, + environment = applicationEngineEnvironment { + log = LoggerFactory.getLogger(loggerName) + + module(configure) + + connector { + port = config.server.port + host = config.server.host + } + } + ) + + return server.start(wait = wait).application +} + +fun Application.configureExceptionHandling(logging: KLogger = KotlinLogging.logger {}) { + install(StatusPages) { + exception { call, reason -> + logging.error(reason) { "An unexpected error occurred ${call.request.uri}" } + call.respond(HttpStatusCode.InternalServerError, "An unexpected error occurred, please try again later") + } + } +} diff --git a/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/components/external/category.kt b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/components/external/category.kt new file mode 100644 index 00000000..386bfcea --- /dev/null +++ b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/components/external/category.kt @@ -0,0 +1,12 @@ +package com.trendyol.stove.examples.kotlin.ktor.infra.components.external + +import com.trendyol.stove.examples.kotlin.ktor.application.external.CategoryHttpApiImpl +import org.koin.core.KoinApplication + +fun KoinApplication.configureCategoryExternalApi() { + modules(createCategoryExternalApi()) +} + +private fun createCategoryExternalApi() = org.koin.dsl.module { + single { CategoryHttpApiImpl(get(), get()) } +} diff --git a/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/components/product/api/routing.kt b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/components/product/api/routing.kt new file mode 100644 index 00000000..ab661a82 --- /dev/null +++ b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/components/product/api/routing.kt @@ -0,0 +1,20 @@ +package com.trendyol.stove.examples.kotlin.ktor.infra.components.product.api + +import com.trendyol.kediatr.Mediator +import com.trendyol.stove.examples.kotlin.ktor.application.product.command.CreateProductCommand +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import org.koin.ktor.ext.get + +fun Routing.productApi() { + post("/products") { + val mediator = call.get() + val req = call.receive() + mediator.send(CreateProductCommand(req.name, req.price, req.categoryId)) + call.respond("Product created") + } +} + +data class ProductCreateRequest(var name: String, var price: Double, var categoryId: Int) diff --git a/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/components/product/defs.kt b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/components/product/defs.kt new file mode 100644 index 00000000..6c40b335 --- /dev/null +++ b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/components/product/defs.kt @@ -0,0 +1,16 @@ +package com.trendyol.stove.examples.kotlin.ktor.infra.components.product + +import com.trendyol.stove.examples.kotlin.ktor.application.product.command.registerProductCommandHandling +import com.trendyol.stove.examples.kotlin.ktor.domain.product.ProductRepository +import com.trendyol.stove.examples.kotlin.ktor.infra.components.product.persistency.MongoProductRepository +import org.koin.core.KoinApplication +import org.koin.dsl.* + +fun KoinApplication.registerProductComponents() { + modules( + module { + single { MongoProductRepository(get(), get()) }.bind() + } + ) + registerProductCommandHandling() +} diff --git a/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/components/product/persistency/MongoProductRepository.kt b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/components/product/persistency/MongoProductRepository.kt new file mode 100644 index 00000000..6d2c7e20 --- /dev/null +++ b/recipes/kotlin-recipes/ktor-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/components/product/persistency/MongoProductRepository.kt @@ -0,0 +1,43 @@ +package com.trendyol.stove.examples.kotlin.ktor.infra.components.product.persistency + +import arrow.core.* +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.convertValue +import com.mongodb.client.model.Filters +import com.mongodb.kotlin.client.coroutine.MongoDatabase +import com.trendyol.stove.examples.domain.product.Product +import com.trendyol.stove.examples.kotlin.ktor.domain.product.ProductRepository +import kotlinx.coroutines.flow.firstOrNull +import org.bson.Document +import org.bson.json.JsonWriterSettings +import org.bson.types.ObjectId + +class MongoProductRepository( + mongo: MongoDatabase, + private val objectMapper: ObjectMapper +) : ProductRepository { + private val collection = mongo.getCollection(PRODUCT_COLLECTION) + + override suspend fun save(product: Product) { + val doc = Document(objectMapper.convertValue>(product)) + doc[RESERVED_ID] = ObjectId.get() + collection.insertOne(doc) + } + + override suspend fun findById(id: String): Option { + return collection.find(Filters.eq("id", id)) + .firstOrNull()?.let { objectMapper.convertValue(it, Product::class.java) } + .toOption() + } + + companion object { + private const val RESERVED_ID = "_id" + const val PRODUCT_COLLECTION = "products" + } +} + +object MongoJsonWriterSettings { + val default: JsonWriterSettings = JsonWriterSettings.builder() + .objectIdConverter { value, writer -> writer.writeString(value.toHexString()) } + .build() +} diff --git a/recipes/kotlin-recipes/ktor-recipe/src/main/resources/application.yaml b/recipes/kotlin-recipes/ktor-recipe/src/main/resources/application.yaml new file mode 100644 index 00000000..e0af4c6f --- /dev/null +++ b/recipes/kotlin-recipes/ktor-recipe/src/main/resources/application.yaml @@ -0,0 +1,25 @@ +server: + port: 8081 + host: "localhost" + name: "test" +mongo: + database: stove-kotlin-ktor + uri: localhost:27017 +kafka: + bootstrap-servers: localhost:9092 + group-id: stove-kotlin-ktor + heartbeat-interval-seconds: 2 + request-timeout-seconds: 30 + session-timeout-seconds: 10 + auto-create-topics: true + auto-offset-reset: earliest + interceptor-classes: [ ] + topics: + product: + name: stove-kotlin-ktor.product + retry: stove-kotlin-ktor.retry + dead-letter: stove-kotlin-ktor.error +external-apis: + category: + url: http://localhost:9090 + timeout: 30 diff --git a/recipes/kotlin-recipes/ktor-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/setup/TestData.kt b/recipes/kotlin-recipes/ktor-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/setup/TestData.kt new file mode 100644 index 00000000..3fad974b --- /dev/null +++ b/recipes/kotlin-recipes/ktor-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/setup/TestData.kt @@ -0,0 +1,7 @@ +package com.trendyol.stove.examples.kotlin.ktor.e2e.setup + +object TestData { + object Random { + fun positiveInt() = kotlin.random.Random.nextInt(1, Int.MAX_VALUE) + } +} diff --git a/recipes/kotlin-recipes/ktor-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/setup/TestProjectConfig.kt b/recipes/kotlin-recipes/ktor-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/setup/TestProjectConfig.kt index 67264c6d..b7740f40 100644 --- a/recipes/kotlin-recipes/ktor-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/setup/TestProjectConfig.kt +++ b/recipes/kotlin-recipes/ktor-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/setup/TestProjectConfig.kt @@ -1,3 +1,62 @@ package com.trendyol.stove.examples.kotlin.ktor.e2e.setup -class TestProjectConfig +import com.trendyol.stove.examples.kotlin.ktor.ExampleStoveKtorApp +import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.serialization.JacksonConfiguration +import com.trendyol.stove.examples.kotlin.ktor.infra.components.product.persistency.MongoProductRepository.Companion.PRODUCT_COLLECTION +import com.trendyol.stove.testing.e2e.* +import com.trendyol.stove.testing.e2e.http.* +import com.trendyol.stove.testing.e2e.mongodb.* +import com.trendyol.stove.testing.e2e.system.TestSystem +import com.trendyol.stove.testing.e2e.wiremock.* +import io.kotest.core.config.AbstractProjectConfig +import org.koin.dsl.module + +private val database = "stove-kotlin-ktor" + +class TestProjectConfig : AbstractProjectConfig() { + override suspend fun beforeProject() { + TestSystem("http://localhost:8081").with { + httpClient { + HttpClientSystemOptions( + objectMapper = JacksonConfiguration.default + ) + } + bridge() + wiremock { + WireMockSystemOptions( + port = 9090 + ) + } + mongodb { + MongodbSystemOptions( + databaseOptions = DatabaseOptions(DatabaseOptions.DefaultDatabase(database, collection = PRODUCT_COLLECTION)), + container = MongoContainerOptions(), + objectMapper = JacksonConfiguration.default, + configureExposedConfiguration = { cfg -> + listOf( + "mongo.database=$database", + "mongo.uri=${cfg.connectionString}/?retryWrites=true&w=majority" + ) + } + ) + } + ktor( + runner = { parameters -> + ExampleStoveKtorApp.run( + parameters, + wait = false, + module { + } + ) + }, + withParameters = listOf( + "server.name=${Thread.currentThread().name}" + ) + ) + }.run() + } + + override suspend fun afterProject() { + TestSystem.stop() + } +} diff --git a/recipes/kotlin-recipes/ktor-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/tests/IndexTests.kt b/recipes/kotlin-recipes/ktor-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/tests/IndexTests.kt index 850264cc..3cb73962 100644 --- a/recipes/kotlin-recipes/ktor-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/tests/IndexTests.kt +++ b/recipes/kotlin-recipes/ktor-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/tests/IndexTests.kt @@ -1,12 +1,19 @@ package com.trendyol.stove.examples.kotlin.ktor.e2e.tests +import com.trendyol.stove.testing.e2e.http.http +import com.trendyol.stove.testing.e2e.system.TestSystem.Companion.validate import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe class IndexTests : FunSpec({ test("Index page should return 200") { - // Arrange - // Act - // Assert - println("Index page should return 200") + validate { + http { + getResponse("/") { actual -> + actual.status shouldBe 200 + actual.body() shouldBe "Hello, World!" + } + } + } } }) diff --git a/recipes/kotlin-recipes/ktor-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/tests/configuration/ConfigurationTests.kt b/recipes/kotlin-recipes/ktor-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/tests/configuration/ConfigurationTests.kt new file mode 100644 index 00000000..456d9b22 --- /dev/null +++ b/recipes/kotlin-recipes/ktor-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/tests/configuration/ConfigurationTests.kt @@ -0,0 +1,17 @@ +package com.trendyol.stove.examples.kotlin.ktor.e2e.tests.configuration + +import com.trendyol.stove.examples.kotlin.ktor.application.RecipeAppConfig +import com.trendyol.stove.testing.e2e.system.TestSystem.Companion.validate +import com.trendyol.stove.testing.e2e.system.using +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldNotBe + +class ConfigurationTests : FunSpec({ + test("configuration can be changed from app") { + validate { + using { + this.server.name shouldNotBe "test" + } + } + } +}) diff --git a/recipes/kotlin-recipes/ktor-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/tests/product/CreateTests.kt b/recipes/kotlin-recipes/ktor-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/tests/product/CreateTests.kt new file mode 100644 index 00000000..b198d309 --- /dev/null +++ b/recipes/kotlin-recipes/ktor-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/tests/product/CreateTests.kt @@ -0,0 +1,103 @@ +package com.trendyol.stove.examples.kotlin.ktor.e2e.tests.product + +import arrow.core.some +import com.mongodb.client.model.Filters +import com.trendyol.stove.examples.domain.product.Product +import com.trendyol.stove.examples.kotlin.ktor.domain.product.ProductRepository +import com.trendyol.stove.examples.kotlin.ktor.e2e.setup.TestData +import com.trendyol.stove.examples.kotlin.ktor.infra.components.product.api.ProductCreateRequest +import com.trendyol.stove.functional.get +import com.trendyol.stove.recipes.shared.application.category.CategoryApiResponse +import com.trendyol.stove.testing.e2e.http.http +import com.trendyol.stove.testing.e2e.mongodb.mongodb +import com.trendyol.stove.testing.e2e.system.TestSystem.Companion.validate +import com.trendyol.stove.testing.e2e.system.using +import com.trendyol.stove.testing.e2e.wiremock.wiremock +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import java.util.* + +class CreateTests : FunSpec({ + test("product can be created with valid category") { + validate { + val productName = TestData.Random.positiveInt().toString() + val productId = UUID.nameUUIDFromBytes(productName.toByteArray()) + + val categoryApiResponse = CategoryApiResponse( + TestData.Random.positiveInt(), + "category-name", + true + ) + + wiremock { + mockGet( + url = "/categories/${categoryApiResponse.id}", + statusCode = 200, + responseBody = categoryApiResponse.some() + ) + } + + http { + val req = ProductCreateRequest(productName, 100.0, categoryApiResponse.id) + postAndExpectBody("/products", body = req.some()) { actual -> + actual.status shouldBe 200 + } + } + + mongodb { + shouldQuery(Filters.eq("id", productId.toString()).toBsonDocument().toJson()) { actual -> + actual.size shouldBe 1 + actual[0].name shouldBe productName + actual[0].price shouldBe 100.0 + } + } + + using { + val product = findById(productId.toString()).get() + product.name shouldBe productName + product.price shouldBe 100.0 + product.categoryId shouldBe categoryApiResponse.id + } + +// kafka { +// shouldBePublished(10.seconds) { +// actual.price == 100.0 && actual.name == productName +// } +// +// shouldBeConsumed { +// actual.price == 100.0 && actual.name == productName +// } +// } + } + } + + xtest("when category is not active, product creation should fail") { + validate { + val productName = TestData.Random.positiveInt().toString() + val productId = UUID.nameUUIDFromBytes(productName.toByteArray()) + val categoryApiResponse = CategoryApiResponse( + TestData.Random.positiveInt(), + "category-name", + false + ) + + wiremock { + mockGet( + url = "/categories/${categoryApiResponse.id}", + statusCode = 200 + ) + } + + http { + val req = ProductCreateRequest(productName, 100.0, categoryApiResponse.id) + postAndExpectBody("/products", body = req.some()) { actual -> + actual.status shouldBe 409 + } + } + + mongodb { + shouldNotExist(productId.toString()) + } + } + } +}) diff --git a/recipes/kotlin-recipes/ktor-recipe/src/test-e2e/resources/logback-test.xml b/recipes/kotlin-recipes/ktor-recipe/src/test-e2e/resources/logback-test.xml new file mode 100644 index 00000000..a1e9ff6e --- /dev/null +++ b/recipes/kotlin-recipes/ktor-recipe/src/test-e2e/resources/logback-test.xml @@ -0,0 +1,20 @@ + + + + + + %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - + %yellow(%m) %n + + + + + + + + + + + + + diff --git a/recipes/settings.gradle.kts b/recipes/settings.gradle.kts index 9895e2c4..5c4355e2 100644 --- a/recipes/settings.gradle.kts +++ b/recipes/settings.gradle.kts @@ -13,6 +13,7 @@ include( "shared", "shared:domain", "shared:application", + "shared:infra", ) enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") diff --git a/recipes/shared/domain/src/main/java/com/trendyol/stove/examples/domain/ddd/EventPublisher.java b/recipes/shared/domain/src/main/java/com/trendyol/stove/examples/domain/ddd/EventPublisher.java new file mode 100644 index 00000000..eab82a43 --- /dev/null +++ b/recipes/shared/domain/src/main/java/com/trendyol/stove/examples/domain/ddd/EventPublisher.java @@ -0,0 +1,5 @@ +package com.trendyol.stove.examples.domain.ddd; + +public interface EventPublisher { + void publishFor(AggregateRoot aggregateRoot); +}