diff --git a/build.gradle b/build.gradle index 3699ed6..514d02a 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'org.springframework.boot' version '3.5.10' + id 'org.springframework.boot' version '4.0.2' id 'io.spring.dependency-management' version '1.1.7' id 'org.jetbrains.kotlin.jvm' version '2.2.21' id 'org.jetbrains.kotlin.plugin.spring' version '2.2.21' @@ -9,6 +9,27 @@ plugins { id 'application' } +ext { + set('springCloudVersion', '2025.1.1') + set('testcontainersVersion', '2.0.3') + set('springBootVersion', '4.0.2') + set('wiremockVersion', '3.13.2') + set('mockkVersion', '1.14.9') + set('querydslMongodbVersion', '5.1.0') + set('springmockkVersion', '4.0.2') + set('logbackVersion', '1.5.30') + set('springdocVersion', '3.0.1') + set('logbackAppenderVersion', '2.25.0-alpha') + set('postgresqlVersion', '42.7.10') + set('junitPlatformLauncherVersion', '6.0.3') + set('micrometerRegistryPrometheusVersion', '1.16.3') + set('springSecurityTestVersion', '7.0.3') + set('springResilience4jVersion', '5.0.1') + set('micrometerTracingBridgeOtelVersion', '1.6.3') + set('micrometerContextPropagationVersion', '1.2.1') + set('nettyResolverDnsNativeMacosVersion', '4.2.3.Final') +} + group = 'com.softeno' version = '0.0.1-SNAPSHOT' @@ -18,10 +39,6 @@ java { } } -ext { - set('springCloudVersion', '2025.0.1') - set('testcontainersVersion', '2.0.3') -} configurations { compileOnly { @@ -35,86 +52,83 @@ repositories { dependencies { // spring project dependencies - implementation 'org.springframework.boot:spring-boot-starter-data-mongodb-reactive' - implementation 'org.springframework.boot:spring-boot-starter-webflux' - implementation 'com.fasterxml.jackson.module:jackson-module-kotlin' + implementation "org.springframework.boot:spring-boot-starter-data-mongodb-reactive:${springBootVersion}" + implementation "org.springframework.boot:spring-boot-starter-webflux:${springBootVersion}" + implementation "org.springframework.boot:spring-boot-starter-webclient:${springBootVersion}" + implementation "org.springframework.boot:spring-boot-starter-jackson:${springBootVersion}" + developmentOnly "org.springframework.boot:spring-boot-devtools:${springBootVersion}" + annotationProcessor "org.springframework.boot:spring-boot-configuration-processor:${springBootVersion}" implementation 'io.projectreactor.kotlin:reactor-kotlin-extensions' implementation 'org.jetbrains.kotlin:kotlin-reflect' - implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-reactor' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-reactive' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-slf4j' - developmentOnly 'org.springframework.boot:spring-boot-devtools' - annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + implementation 'com.fasterxml.jackson.module:jackson-module-kotlin' // test utils - testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation "org.springframework.boot:spring-boot-starter-test:${springBootVersion}" + testImplementation "org.springframework.boot:spring-boot-starter-webflux-test:${springBootVersion}" + testImplementation "org.springframework.boot:spring-boot-webtestclient:${springBootVersion}" + testImplementation "org.springframework.boot:spring-boot-starter-jackson-test:${springBootVersion}" + testImplementation "io.mockk:mockk:${mockkVersion}" + testImplementation "com.ninja-squad:springmockk:${springmockkVersion}" + testRuntimeOnly "org.junit.platform:junit-platform-launcher:${junitPlatformLauncherVersion}" + testImplementation 'io.projectreactor:reactor-test' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test' testImplementation 'org.jetbrains.kotlin:kotlin-test-junit5' - testImplementation 'io.projectreactor:reactor-test' - testImplementation 'io.mockk:mockk:1.14.9' - testImplementation 'com.ninja-squad:springmockk:4.0.2' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' // security - implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' - implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' - implementation 'org.springframework.boot:spring-boot-starter-security' - testImplementation 'org.springframework.security:spring-security-test' + implementation "org.springframework.boot:spring-boot-starter-oauth2-client:${springBootVersion}" + implementation "org.springframework.boot:spring-boot-starter-oauth2-resource-server:${springBootVersion}" + implementation "org.springframework.boot:spring-boot-starter-security:${springBootVersion}" + testImplementation "org.springframework.security:spring-security-test:${springSecurityTestVersion}" // testcontainers - testImplementation 'org.springframework.boot:spring-boot-testcontainers' - testImplementation 'org.testcontainers:mongodb' - testImplementation 'org.testcontainers:junit-jupiter' + testImplementation "org.springframework.boot:spring-boot-testcontainers:${springBootVersion}" + testImplementation "org.testcontainers:testcontainers-kafka:${testcontainersVersion}" + testImplementation "org.testcontainers:testcontainers-mongodb:${testcontainersVersion}" + testImplementation "org.testcontainers:testcontainers-junit-jupiter:${testcontainersVersion}" // wiremock - testImplementation 'org.wiremock:wiremock-standalone:3.13.2' + testImplementation "org.wiremock:wiremock-standalone:${wiremockVersion}" // guerydsl - implementation ('com.querydsl:querydsl-mongodb:5.1.0') { + implementation ("com.querydsl:querydsl-mongodb:${querydslMongodbVersion}") { exclude group: 'org.mongodb', module: 'mongo-java-driver' } - kapt group: 'com.querydsl', name: 'querydsl-apt', version: '5.1.0' - - // rsocket - implementation 'org.springframework.boot:spring-boot-starter-rsocket' + kapt group: 'com.querydsl', name: 'querydsl-apt', version: "${querydslMongodbVersion}" // graphql - implementation 'org.springframework.boot:spring-boot-starter-graphql' - testImplementation 'org.springframework.graphql:spring-graphql-test' + implementation "org.springframework.boot:spring-boot-starter-graphql:${springBootVersion}" + testImplementation "org.springframework.boot:spring-boot-starter-graphql-test:${springBootVersion}" // kafka - implementation 'org.springframework.kafka:spring-kafka' - implementation 'io.projectreactor.kafka:reactor-kafka' - testImplementation 'org.springframework.kafka:spring-kafka-test' + implementation "org.springframework.boot:spring-boot-starter-kafka:${springBootVersion}" + testImplementation "org.springframework.kafka:spring-kafka-test:${springBootVersion}" // circuit breaker - implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-reactor-resilience4j' + implementation "org.springframework.cloud:spring-cloud-starter-circuitbreaker-reactor-resilience4j:${springResilience4jVersion}" // macOs ARM only if (osdetector.classifier == "osx-aarch_64") { - runtimeOnly("io.netty:netty-resolver-dns-native-macos:4.2.3.Final:${osdetector.classifier}") + runtimeOnly("io.netty:netty-resolver-dns-native-macos:${nettyResolverDnsNativeMacosVersion}:${osdetector.classifier}") } - // s3 - implementation 'io.minio:minio:8.6.0' - - // monitoring - implementation 'org.springframework.boot:spring-boot-starter-actuator' - runtimeOnly 'io.micrometer:micrometer-registry-prometheus' - - // elk - implementation 'net.logstash.logback:logstash-logback-encoder:8.1' - // springdoc - implementation 'org.springdoc:springdoc-openapi-starter-common:2.8.15' - implementation 'org.springdoc:springdoc-openapi-starter-webflux-ui:2.8.15' + implementation "org.springdoc:springdoc-openapi-starter-common:${springdocVersion}" + implementation "org.springdoc:springdoc-openapi-starter-webflux-ui:${springdocVersion}" - // zipkin - implementation 'io.micrometer:micrometer-tracing-bridge-otel' - implementation 'io.opentelemetry:opentelemetry-exporter-zipkin' - implementation 'io.zipkin.reporter2:zipkin-sender-urlconnection' + // monitoring + runtimeOnly "io.micrometer:micrometer-registry-prometheus:${micrometerRegistryPrometheusVersion}" + implementation "org.springframework.boot:spring-boot-starter-actuator:${springBootVersion}" + + // opentelemetry + implementation "org.springframework.boot:spring-boot-starter-opentelemetry:${springBootVersion}" + testImplementation "org.springframework.boot:spring-boot-starter-opentelemetry-test:${springBootVersion}" + implementation "io.opentelemetry.instrumentation:opentelemetry-logback-appender-1.0:${logbackAppenderVersion}" + implementation "io.micrometer:micrometer-tracing-bridge-otel:${micrometerTracingBridgeOtelVersion}" + implementation "io.micrometer:context-propagation:${micrometerContextPropagationVersion}" } dependencyManagement { diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2195f46 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,136 @@ +version: "3.7" +services: + kafka-broker: + image: bitnami/kafka:latest + container_name: kafka-broker + user: "${UID}" + hostname: kafka-broker + ports: + - "${IP:-0.0.0.0}:9094:9094" + - "${IP:-0.0.0.0}:9093:9093" + - "${IP:-0.0.0.0}:9092:9092" + environment: + KAFKA_CFG_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093,EXTERNAL://:9094 + KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://kafka-broker:9092,EXTERNAL://kafka-broker:9094 + KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT,PLAINTEXT:PLAINTEXT + ALLOW_PLAINTEXT_LISTENER: yes + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka-broker:9092,EXTERNAL://kafka-broker:9094 + KAFKA_BROKER_ID: 0 + KAFKA_CFG_NODE_ID: 0 + KAFKA_CFG_PROCESS_ROLES: controller,broker + KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 0@kafka-broker:9093 + volumes: + - kafka-broker-data:/bitnami + + init-kafka: + image: confluentinc/cp-kafka:latest + user: "${UID}" + depends_on: + - kafka-broker + entrypoint: [ '/bin/sh', '-c' ] + command: | + " + # blocks until kafka is reachable + kafka-topics --bootstrap-server kafka-broker:9092 --list + echo -e 'Creating kafka topics' + + kafka-topics --bootstrap-server kafka-broker:9092 --create --if-not-exists --topic sample_topic_2 --replication-factor 1 --partitions 1 + kafka-topics --bootstrap-server kafka-broker:9092 --create --if-not-exists --topic keycloak-events --replication-factor 1 --partitions 1 + kafka-topics --bootstrap-server kafka-broker:9092 --create --if-not-exists --topic keycloak-admin-events --replication-factor 1 --partitions 1 + + echo -e 'Successfully created the following topics:' + kafka-topics --bootstrap-server kafka-broker:9092 --list + " + + postgres-keycloak: + image: postgres:16.4 + container_name: postgres-keycloak + user: "${UID}" + hostname: postgres-keycloak + volumes: + - postgres-data-keycloak:/var/lib/postgresql/data + environment: + POSTGRES_DB: keycloak + POSTGRES_USER: keycloak + POSTGRES_PASSWORD: keycloak + ports: + - "${IP:-0.0.0.0}:5433:5432" + + keycloak: + image: xcodeassociated/keycloak-kafka:latest + user: "${UID}" + pull_policy: always + container_name: keycloak + hostname: keycloak + ports: + - "8090:8080" + depends_on: + - postgres-keycloak + - kafka-broker + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KAFKA_TOPIC: keycloak-events + KAFKA_CLIENT_ID: keycloak + KAFKA_BOOTSTRAP_SERVERS: kafka-broker:9092 + KAFKA_EVENTS: "REGISTER,LOGIN,LOGOUT" + KAFKA_ADMIN_TOPIC: keycloak-admin-events + KC_DB_URL_HOST: postgres-keycloak + KC_DB_URL_DATABASE: keycloak + KC_DB_URL_PORT: 5432 + KC_DB: postgres + KC_DB_USERNAME: keycloak + KC_DB_PASSWORD: keycloak + KC_DB_SCHEMA: public + KC_HOSTNAME_STRICT: false + KC_HTTP_ENABLED: true + command: + - start-dev + volumes: + - keycloak-data:/opt/keycloak/data/ + + mongo: + container_name: mongo_rs_primary + user: "${UID}" + hostname: mongo + image: mongo:6.0.4 + ports: + - "${IP:-0.0.0.0}:27017:27017" + entrypoint: [ "/usr/bin/mongod", "--config", "/etc/mongod.conf", "--bind_ip_all", "--replSet", "rs0" ] + environment: + - MONGO_INITDB_ROOT_USERNAME=user + - MONGO_INITDB_ROOT_PASSWORD=password + - MONGO_INITDB_DATABASE=example_db + volumes: + - mongo-rs-primary-data:/data/db + - "./mongo-rs/config/mongodb/mongod.conf:/etc/mongod.conf" + +volumes: + kafka-broker-data: + driver: local + driver_opts: + o: bind + type: none + device: ./kafka+keycloak/volumes/kafka-broker + + postgres-data-keycloak: + driver: local + driver_opts: + o: bind + type: none + device: ./kafka+keycloak/volumes/postgres + + keycloak-data: + driver: local + driver_opts: + o: bind + type: none + device: ./kafka+keycloak/volumes/keycloak + + mongo-rs-primary-data: + driver: local + driver_opts: + o: bind + type: none + device: ./mongo-rs/volumes/mongodb diff --git a/http/async.http b/http/async.http deleted file mode 100644 index f8e6d7c..0000000 --- a/http/async.http +++ /dev/null @@ -1,13 +0,0 @@ -### Async Controller HTTP - -### -GET {{host}}/async/result - -### -GET {{host}}/async/void - -### -GET {{host}}/async/fail - -### -GET {{host}}/async/void-fail \ No newline at end of file diff --git a/http/data/picture.jpg b/http/data/picture.jpg deleted file mode 100644 index fe1ab50..0000000 Binary files a/http/data/picture.jpg and /dev/null differ diff --git a/http/minio.http b/http/minio.http deleted file mode 100644 index a2aec03..0000000 --- a/http/minio.http +++ /dev/null @@ -1,20 +0,0 @@ -POST {{host}}/minio/upload HTTP/1.1 -Content-Type: multipart/form-data; boundary=boundary -Authorization: Bearer {{oauthToken}} - ---boundary -Content-Disposition: form-data; name="file"; filename="picture.jpg" - -< ./data/picture.jpg - -### - -GET {{host}}/minio/picture.jpg -Content-Type: application/octet-stream -Authorization: Bearer {{oauthToken}} - - -### - -DELETE {{host}}/minio/picture.jpg -Authorization: Bearer {{oauthToken}} diff --git a/http/rsocket.sh b/http/rsocket.sh deleted file mode 100644 index af3f9d0..0000000 --- a/http/rsocket.sh +++ /dev/null @@ -1,5 +0,0 @@ -#java -jar rsc/rsc-0.9.1.jar --data '{"symbol": "aaa"}' --route stock --stream ws://localhost:8081/rsocket --debug -#java -jar rsc/rsc-0.9.1.jar --data '{"symbol": "aaa"}' --route stock.error ws://localhost:8081/rsocket --debug -#java -jar rsc/rsc-0.9.1.jar --data '{"symbol": "aaa"}' --route stock.coroutine ws://localhost:8081/rsocket --debug -#java -jar rsc/rsc-0.9.1.jar --sm "client-id\"123" --smmt "application/json" --data '{"symbol": "aaa"}' --route stock.single ws://localhost:8081/rsocket --debug -java -jar rsc/rsc-0.9.1.jar --channel --route number.channel --data - ws://localhost:8081/rsocket --debug diff --git a/http/server-events.http b/http/server-events.http deleted file mode 100644 index 4661f52..0000000 --- a/http/server-events.http +++ /dev/null @@ -1,4 +0,0 @@ -### Get server event stream -GET {{host}}/coroutine/currency-rate/current -Accept: text/event-stream -Authorization: Bearer {{oauthToken}} diff --git a/init_volumes.sh b/init_volumes.sh new file mode 100755 index 0000000..e6dd703 --- /dev/null +++ b/init_volumes.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +set -euo pipefail + +DIRS=( + "kafka+keycloak/volumes/postgres" + "kafka+keycloak/volumes/keycloak" + "kafka+keycloak/volumes/kafka-broker" + "mongo-rs/volumes/mongodb" +) + +for dir in "${DIRS[@]}"; do + if [ -d "$dir" ]; then + echo "Directory exists: $dir" + else + echo "Directory does not exist, creating: $dir" + mkdir -p "$dir" + fi +done + +echo "done" \ No newline at end of file diff --git a/notes/keycloak-admin-api/user-create.json b/notes/keycloak-admin-api/user-create.json deleted file mode 100644 index abefa18..0000000 --- a/notes/keycloak-admin-api/user-create.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "username": "test-api-1", - "enabled": true, - "firstName": "api", - "lastName": "test", - "email": "test@api.com", - "requiredActions": ["UPDATE_PASSWORD"], - "credentials": [{ - "type": "password", - "value": "test" - }] -} \ No newline at end of file diff --git a/notes/keycloak-admin-api/user-search.json b/notes/keycloak-admin-api/user-search.json deleted file mode 100644 index 63974d5..0000000 --- a/notes/keycloak-admin-api/user-search.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "id": "862ed252-3637-4d47-a7ab-9c3504363fa0", - "createdTimestamp": 1675076704282, - "username": "admin", - "enabled": true, - "totp": false, - "emailVerified": true, - "firstName": "Admin", - "lastName": "Admin", - "email": "admin@admin.com", - "disableableCredentialTypes": [], - "requiredActions": [], - "notBefore": 0, - "access": { - "manageGroupMembership": true, - "view": true, - "mapRoles": true, - "impersonate": true, - "manage": true - } -} diff --git a/notes/keycloak-admin-api/user-update.json b/notes/keycloak-admin-api/user-update.json deleted file mode 100644 index a240e18..0000000 --- a/notes/keycloak-admin-api/user-update.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "id": "1aa63488-6cec-417a-9c11-6aa654b23458", - "createdTimestamp": 1675274855914, - "username": "test-api-1", - "enabled": true, - "totp": false, - "emailVerified": false, - "firstName": "api-2", - "lastName": "test-2", - "email": "test@api.com", - "disableableCredentialTypes": [], - "requiredActions": [], - "notBefore": 0, - "access": { - "manageGroupMembership": true, - "view": true, - "mapRoles": true, - "impersonate": true, - "manage": true - } -} \ No newline at end of file diff --git a/notes/keycloak-kafka-samples/login.json b/notes/keycloak-kafka-samples/login.json deleted file mode 100644 index 3939a63..0000000 --- a/notes/keycloak-kafka-samples/login.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "id": "d567e184-963e-4836-80d5-fedcb07870e5", - "time": 1674955961622, - "type": "LOGIN", - "realmId": "49b390fd-9e31-4931-a55f-34ff3e0776bb", - "clientId": "frontend", - "userId": "cd85a973-3f13-4219-827b-0c31044e241f", - "sessionId": "c71a9003-5165-4370-b98e-4d295297ecc0", - "ipAddress": "172.29.0.1", - "error": null, - "details": { - "auth_method": "openid-connect", - "auth_type": "code", - "redirect_uri": "http://localhost:3000/login", - "consent": "no_consent_required", - "code_id": "c71a9003-5165-4370-b98e-4d295297ecc0", - "username": "test" - } -} \ No newline at end of file diff --git a/notes/keycloak-kafka-samples/logout.json b/notes/keycloak-kafka-samples/logout.json deleted file mode 100644 index 97f051a..0000000 --- a/notes/keycloak-kafka-samples/logout.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "id": "2c908efd-858a-47e5-b899-e8e68559ab72", - "time": 1674955930173, - "type": "LOGOUT", - "realmId": "49b390fd-9e31-4931-a55f-34ff3e0776bb", - "clientId": null, - "userId": "8693efc7-736d-45c2-ab93-759ed2dd27c0", - "sessionId": "2cce54f3-28d5-4727-ac65-ab8f7c70a8c3", - "ipAddress": "172.29.0.1", - "error": null, - "details": { - "redirect_uri": "http://localhost:3000/" - } -} diff --git a/notes/keycloak-kafka-samples/register.json b/notes/keycloak-kafka-samples/register.json deleted file mode 100644 index 58d358b..0000000 --- a/notes/keycloak-kafka-samples/register.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "id": "4ae0d28e-a117-473a-b9ac-83b285874098", - "time": 1674955961567, - "type": "REGISTER", - "realmId": "49b390fd-9e31-4931-a55f-34ff3e0776bb", - "clientId": "frontend", - "userId": "cd85a973-3f13-4219-827b-0c31044e241f", - "sessionId": null, - "ipAddress": "172.29.0.1", - "error": null, - "details": { - "auth_method": "openid-connect", - "auth_type": "code", - "register_method": "form", - "last_name": "test", - "redirect_uri": "http://localhost:3000/login", - "first_name": "test", - "code_id": "c71a9003-5165-4370-b98e-4d295297ecc0", - "email": "test@test.com", - "username": "test" - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/softeno/template/SoftenoReactiveMongoApp.kt b/src/main/kotlin/com/softeno/template/SoftenoReactiveMongoApp.kt index bbfccb4..71dbea2 100644 --- a/src/main/kotlin/com/softeno/template/SoftenoReactiveMongoApp.kt +++ b/src/main/kotlin/com/softeno/template/SoftenoReactiveMongoApp.kt @@ -1,6 +1,5 @@ package com.softeno.template -import com.softeno.template.playground.CoroutinePlayground import org.slf4j.LoggerFactory import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.event.ApplicationReadyEvent @@ -34,15 +33,3 @@ class SpringApplicationReadyEventListener { } } -@Component -@Profile("playgroud") -class SpringApplicationReadyEventListenerPlayground { - private val logger = LoggerFactory.getLogger(this::class.java) - - @EventListener - fun onApplicationReady(event: ApplicationReadyEvent) { - logger.info(">> Application Ready") - // play around with kotlin coroutines - CoroutinePlayground().run() - } -} diff --git a/src/main/kotlin/com/softeno/template/app/common/PrincipalHandler.kt b/src/main/kotlin/com/softeno/template/app/common/PrincipalHandler.kt index 281290b..a8e37cf 100644 --- a/src/main/kotlin/com/softeno/template/app/common/PrincipalHandler.kt +++ b/src/main/kotlin/com/softeno/template/app/common/PrincipalHandler.kt @@ -11,7 +11,7 @@ interface PrincipalHandler { suspend fun showPrincipal(log: Log, monoPrincipal: Mono){ val principal = monoPrincipal.awaitSingleOrNull() log.debug("principal: $principal, name: ${principal?.name}") - val authentication = ReactiveSecurityContextHolder.getContext().map { it.authentication }.awaitSingleOrNull() + val authentication = ReactiveSecurityContextHolder.getContext().mapNotNull { it.authentication }.awaitSingleOrNull() if (authentication != null) { val token = (authentication as JwtAuthenticationToken).token val userId = token.claims["sub"] diff --git a/src/main/kotlin/com/softeno/template/app/config/http/WebExceptionHandler.kt b/src/main/kotlin/com/softeno/template/app/config/http/WebExceptionHandler.kt index 40079bf..90f8e0b 100644 --- a/src/main/kotlin/com/softeno/template/app/config/http/WebExceptionHandler.kt +++ b/src/main/kotlin/com/softeno/template/app/config/http/WebExceptionHandler.kt @@ -3,9 +3,9 @@ package com.softeno.template.app.config.http import com.softeno.template.sample.http.external.coroutine.ExternalServiceException import org.apache.commons.logging.LogFactory import org.springframework.boot.autoconfigure.web.WebProperties -import org.springframework.boot.autoconfigure.web.reactive.error.AbstractErrorWebExceptionHandler import org.springframework.boot.web.error.ErrorAttributeOptions -import org.springframework.boot.web.reactive.error.ErrorAttributes +import org.springframework.boot.webflux.autoconfigure.error.AbstractErrorWebExceptionHandler +import org.springframework.boot.webflux.error.ErrorAttributes import org.springframework.context.ApplicationContext import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -44,19 +44,19 @@ class GlobalErrorWebExceptionHandler( private val log = LogFactory.getLog(javaClass) - override fun getRoutingFunction(errorAttributes: ErrorAttributes?): RouterFunction { + override fun getRoutingFunction(errorAttributes: ErrorAttributes): RouterFunction { return RouterFunctions.route( RequestPredicates.all() ) { request: ServerRequest -> renderErrorResponse(request) } } - private fun getCustomErrorAttributes(request: ServerRequest, includeStackTrace: Boolean): Map { + private fun getCustomErrorAttributes(request: ServerRequest, includeStackTrace: Boolean): Map { val options: ErrorAttributeOptions = if (includeStackTrace) { ErrorAttributeOptions.of(ErrorAttributeOptions.Include.STACK_TRACE) } else { ErrorAttributeOptions.defaults() } - val errorAttributes: MutableMap = this.getErrorAttributes(request, options) + val errorAttributes = this.getErrorAttributes(request, options) val error = getError(request) log.error("[exception handler]: Handling exception: $error") @@ -70,7 +70,7 @@ class GlobalErrorWebExceptionHandler( // ... } - errorAttributes["message"] = error.message ?: "" // note: custom message + errorAttributes["message"] = error?.message ?: "" // note: custom message errorAttributes["status"] = httpStatus.value() errorAttributes.remove("trace") // note: trace (stacktrace) omitted, can be also configured by: `includeStackTrace = false` @@ -81,7 +81,7 @@ class GlobalErrorWebExceptionHandler( return errorAttributes } - private fun renderErrorResponse(request: ServerRequest): Mono { + private fun renderErrorResponse(request: ServerRequest): Mono { val errorPropertiesMap = getCustomErrorAttributes(request, includeStackTrace = true) log.warn( diff --git a/src/main/kotlin/com/softeno/template/app/config/opentelemetry/OpenTelemetryConfig.kt b/src/main/kotlin/com/softeno/template/app/config/opentelemetry/OpenTelemetryConfig.kt new file mode 100644 index 0000000..36564c7 --- /dev/null +++ b/src/main/kotlin/com/softeno/template/app/config/opentelemetry/OpenTelemetryConfig.kt @@ -0,0 +1,28 @@ +package com.softeno.template.app.config.opentelemetry + +import io.opentelemetry.api.OpenTelemetry +import io.opentelemetry.instrumentation.logback.appender.v1_0.OpenTelemetryAppender +import org.springframework.beans.factory.InitializingBean +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.task.support.ContextPropagatingTaskDecorator +import org.springframework.stereotype.Component + + +@Component +class InstallOpenTelemetryAppender( + private val openTelemetry: OpenTelemetry +) : InitializingBean { + + override fun afterPropertiesSet() { + OpenTelemetryAppender.install(openTelemetry) + } +} + +@Configuration(proxyBeanMethods = false) +class ContextPropagationConfiguration { + @Bean + fun contextPropagatingTaskDecorator(): ContextPropagatingTaskDecorator { + return ContextPropagatingTaskDecorator() + } +} diff --git a/src/main/kotlin/com/softeno/template/app/config/security/ReactiveMongoAuditorConfig.kt b/src/main/kotlin/com/softeno/template/app/config/security/ReactiveMongoAuditorConfig.kt index f428383..85e0904 100644 --- a/src/main/kotlin/com/softeno/template/app/config/security/ReactiveMongoAuditorConfig.kt +++ b/src/main/kotlin/com/softeno/template/app/config/security/ReactiveMongoAuditorConfig.kt @@ -1,6 +1,5 @@ package com.softeno.template.app.config.security -import org.apache.commons.logging.LogFactory import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.data.domain.ReactiveAuditorAware @@ -13,12 +12,11 @@ import reactor.core.publisher.Mono class AuditorAwareImpl : ReactiveAuditorAware { - private val log = LogFactory.getLog(javaClass) override fun getCurrentAuditor(): Mono { return ReactiveSecurityContextHolder.getContext() - .map(SecurityContext::getAuthentication) - .map(Authentication::getPrincipal) + .mapNotNull(SecurityContext::getAuthentication) + .mapNotNull(Authentication::getPrincipal) .switchIfEmpty(Mono.just("anonymous")) .flatMap { principal -> if (principal is Jwt) { diff --git a/src/main/kotlin/com/softeno/template/app/event/UserAction.kt b/src/main/kotlin/com/softeno/template/app/event/UserAction.kt deleted file mode 100644 index a8c62f6..0000000 --- a/src/main/kotlin/com/softeno/template/app/event/UserAction.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.softeno.template.app.event - -import com.softeno.template.app.kafka.KafkaMessage -import com.softeno.template.app.kafka.ReactiveKafkaSampleProducer -import kotlinx.coroutines.DelicateCoroutinesApi -import org.apache.commons.logging.LogFactory -import org.slf4j.MDC -import org.springframework.context.ApplicationEvent -import org.springframework.context.ApplicationListener -import org.springframework.stereotype.Component - -data class UserAction(val source: String, val traceId: String? = null, val spanId: String? = null) : ApplicationEvent(source) - -@Component -class SampleApplicationEventPublisher( - - private val reactiveKafkaProducer: ReactiveKafkaSampleProducer, -) : ApplicationListener { - private val log = LogFactory.getLog(javaClass) - - @OptIn(DelicateCoroutinesApi::class) - override fun onApplicationEvent(event: UserAction) { - // note: propagate traceId and spanId in MDC context - if (!event.spanId.isNullOrBlank() && !event.traceId.isNullOrBlank()) { - MDC.put("traceId", event.traceId) - MDC.put("spanId", event.spanId) - } - log.debug("[app event handler]: Received event: $event") - reactiveKafkaProducer.send(event.toKafkaMessage()) - } - -} - -fun UserAction.toKafkaMessage() = KafkaMessage(content = this.source, traceId = this.traceId, spanId = this.spanId) diff --git a/src/main/kotlin/com/softeno/template/app/kafka/KafkaKeycloakController.kt b/src/main/kotlin/com/softeno/template/app/kafka/KafkaKeycloakController.kt index 2462e96..6a53b31 100644 --- a/src/main/kotlin/com/softeno/template/app/kafka/KafkaKeycloakController.kt +++ b/src/main/kotlin/com/softeno/template/app/kafka/KafkaKeycloakController.kt @@ -3,41 +3,23 @@ package com.softeno.template.app.kafka import com.fasterxml.jackson.annotation.JsonCreator import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.ObjectMapper import org.apache.commons.logging.LogFactory import org.apache.kafka.clients.consumer.ConsumerRecord -import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.boot.CommandLineRunner -import org.springframework.kafka.core.reactive.ReactiveKafkaConsumerTemplate +import org.springframework.kafka.annotation.KafkaListener import org.springframework.stereotype.Controller -import reactor.core.publisher.Flux +import tools.jackson.databind.ObjectMapper @Controller -class ReactiveKafkaKeycloakController( - @param:Qualifier(value = "kafkaKeycloakConsumerTemplate") private val reactiveKafkaConsumerTemplate: ReactiveKafkaConsumerTemplate, +class KafkaKeycloakController( private val objectMapper: ObjectMapper -) : CommandLineRunner { +) { private val log = LogFactory.getLog(javaClass) - private fun consumeKafkaMessage(): Flux { - return reactiveKafkaConsumerTemplate - .receiveAutoAck() - .doOnNext { consumerRecord: ConsumerRecord -> - log.debug("[kafka] rx keycloak: ConsumerRecord: key=${consumerRecord.key()}, value=${consumerRecord.value()} from topic=${consumerRecord.topic()}, offset=${consumerRecord.offset()}") - } - .map { obj: ConsumerRecord -> obj.value() } - .doOnNext { message: JsonNode -> - val dto: KeycloakUserEvent = objectMapper.readValue(message.toString(), KeycloakUserEvent::class.java) - log.info("[kafka] rx keycloak: $dto") - } - .doOnError { throwable: Throwable -> - log.error("[kafka] keycloak: ${throwable.message}") - } - } - - override fun run(vararg args: String) { - log.info("[kafka]: keycloak consumer starts") - consumeKafkaMessage().subscribe() + @KafkaListener(id = "\${spring.kafka.consumer.group-id}-keycloak", topics = ["\${com.softeno.kafka.keycloak}"]) + suspend fun listen(record: ConsumerRecord) { + log.debug("[kafka] rx keycloak raw: ${record.key()}: ${record.value()}") + val dto: KeycloakUserEvent = objectMapper.readValue(record.value().toString(), KeycloakUserEvent::class.java) + log.info("[kafka] rx keycloak mapped: $dto") } } diff --git a/src/main/kotlin/com/softeno/template/app/kafka/KafkaSampleHandler.kt b/src/main/kotlin/com/softeno/template/app/kafka/KafkaSampleHandler.kt index e4843e6..2d05c55 100644 --- a/src/main/kotlin/com/softeno/template/app/kafka/KafkaSampleHandler.kt +++ b/src/main/kotlin/com/softeno/template/app/kafka/KafkaSampleHandler.kt @@ -3,99 +3,58 @@ package com.softeno.template.app.kafka import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.ObjectMapper -import com.softeno.template.app.kafka.config.KafkaApplicationProperties import com.softeno.template.app.user.notification.CoroutineUserUpdateEmitter import com.softeno.template.app.user.notification.ReactiveUserUpdateEmitter import com.softeno.template.sample.websocket.Message import com.softeno.template.sample.websocket.WebSocketNotificationSender -import io.micrometer.tracing.Span -import io.micrometer.tracing.Tracer -import kotlinx.coroutines.DelicateCoroutinesApi -import org.apache.commons.lang3.RandomUtils import org.apache.commons.logging.LogFactory import org.apache.kafka.clients.consumer.ConsumerRecord -import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.boot.CommandLineRunner -import org.springframework.kafka.core.reactive.ReactiveKafkaConsumerTemplate -import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.stereotype.Component import org.springframework.stereotype.Controller -import org.springframework.stereotype.Service -import reactor.core.publisher.Flux -import reactor.kafka.sender.SenderResult +import tools.jackson.databind.ObjectMapper @JsonIgnoreProperties(ignoreUnknown = true) -data class KafkaMessage(val content: String, val traceId: String? = null, val spanId: String? = null) +data class KafkaMessage(val content: String) fun KafkaMessage.toMessage() = Message(from = "SYSTEM", to = "ALL", content = this.content) +@ConfigurationProperties(prefix = "com.softeno.kafka") +data class KafkaApplicationProperties(val tx: String, val rx: String, val keycloak: String) + @Controller -class ReactiveKafkaSampleController( - @param:Qualifier(value = "kafkaSampleConsumerTemplate") private val reactiveKafkaConsumerTemplate: ReactiveKafkaConsumerTemplate, - private val objectMapper: ObjectMapper, - private val tracer: Tracer, +class KafkaSampleController( + private val props: KafkaApplicationProperties, private val ws: WebSocketNotificationSender, private val reactiveUserUpdateEmitter: ReactiveUserUpdateEmitter, private val userUpdateEmitter: CoroutineUserUpdateEmitter, -) : CommandLineRunner { + private val objectMapper: ObjectMapper +) { private val log = LogFactory.getLog(javaClass) - @OptIn(DelicateCoroutinesApi::class) - private fun consumeKafkaMessage(): Flux { - return reactiveKafkaConsumerTemplate - .receiveAutoAck() - .doOnNext { consumerRecord: ConsumerRecord -> - log.debug("[kafka] rx sample: ConsumerRecord: key=${consumerRecord.key()}, " + - "value=${consumerRecord.value()} from topic=${consumerRecord.topic()}, " + - "offset=${consumerRecord.offset()}, headers=${consumerRecord.headers()}") - } - .map { obj: ConsumerRecord -> obj.value() } - .doOnNext { message: JsonNode -> - val kafkaMessage: KafkaMessage = objectMapper.readValue(message.toString(), KafkaMessage::class.java) + @KafkaListener(id = "\${spring.kafka.consumer.group-id}", topics = ["\${com.softeno.kafka.rx}"]) + fun listen(record: ConsumerRecord) { + log.info("[kafka] rx (${props.rx}): ${record.key()}: ${record.value()}") - // todo: make better observation handling by reactive kafka, currently the zipkin does not show the traces properly - val contextWithCustomTraceId = tracer.traceContextBuilder() - .traceId(kafkaMessage.traceId ?: RandomUtils.secure().toString()) - .spanId(tracer.nextSpan().name("kafka-consumer").context().spanId()) - .parentId(kafkaMessage.spanId ?: (tracer.currentSpan()?.context()?.spanId() ?: Span.NOOP.context().spanId())) - .sampled(true) - .build() + val kafkaMessage: KafkaMessage = objectMapper.readValue(record.value().toString(), KafkaMessage::class.java) - tracer.currentTraceContext().newScope(contextWithCustomTraceId).use { - val span = tracer.nextSpan().name("kafka-consumer") - tracer.withSpan(span.start()).use { - log.info("[kafka] rx sample: $kafkaMessage") - ws.broadcast(kafkaMessage.toMessage()) - reactiveUserUpdateEmitter.broadcast(kafkaMessage.toMessage()) - userUpdateEmitter.broadcast(kafkaMessage.toMessage()) - } - span.end() - } - } - .doOnError { throwable: Throwable -> - log.error("[kafka] sample: ${throwable.message}") - } - } - - override fun run(vararg args: String) { - log.info("[kafka] sample: consumer starts") - consumeKafkaMessage().subscribe() + ws.broadcast(kafkaMessage.toMessage()) + reactiveUserUpdateEmitter.broadcast(kafkaMessage.toMessage()) + userUpdateEmitter.broadcast(kafkaMessage.toMessage()) } } -@Service -class ReactiveKafkaSampleProducer( - @param:Qualifier(value = "kafkaSampleProducerTemplate") private val reactiveKafkaProducerTemplate: ReactiveKafkaProducerTemplate, +@Component +class KafkaSampleProducer( + private val producer: KafkaTemplate, private val props: KafkaApplicationProperties ) { private val log = LogFactory.getLog(javaClass) fun send(message: KafkaMessage) { - log.info("[kafka] tx: topic: ${props.tx}, message: $message") - reactiveKafkaProducerTemplate.send(props.tx, message) - .doOnSuccess { senderResult: SenderResult -> - log.info("[kafka] tx ok, offset: ${senderResult.recordMetadata().offset()}") - } - .subscribe() + log.info("[kafka] tx (${props.tx}): $message") + producer.send(props.tx, message) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/softeno/template/app/kafka/config/KafkaConfig.kt b/src/main/kotlin/com/softeno/template/app/kafka/config/KafkaConfig.kt index fc9efee..8eb29a5 100644 --- a/src/main/kotlin/com/softeno/template/app/kafka/config/KafkaConfig.kt +++ b/src/main/kotlin/com/softeno/template/app/kafka/config/KafkaConfig.kt @@ -3,82 +3,34 @@ package com.softeno.template.app.kafka.config import com.fasterxml.jackson.databind.JsonNode import com.softeno.template.app.kafka.KafkaMessage import io.micrometer.observation.ObservationRegistry -import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.boot.autoconfigure.kafka.KafkaProperties -import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import org.springframework.kafka.core.reactive.ReactiveKafkaConsumerTemplate -import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate -import org.springframework.kafka.support.converter.ByteArrayJsonMessageConverter -import org.springframework.kafka.support.converter.JsonMessageConverter -import reactor.kafka.receiver.ReceiverOptions -import reactor.kafka.receiver.observation.KafkaReceiverObservation -import reactor.kafka.sender.SenderOptions -import reactor.kafka.sender.observation.KafkaSenderObservation -import java.util.* - - -@ConfigurationProperties(prefix = "com.softeno.kafka") -data class KafkaApplicationProperties(val tx: String, val rx: String, val keycloak: String) - -@Configuration -class JsonMessageConverterConfig { - @Bean - fun jsonMessageConverter(): JsonMessageConverter { - return ByteArrayJsonMessageConverter() - } -} +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory +import org.springframework.kafka.core.ConsumerFactory +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.core.ProducerFactory @Configuration -class ReactiveKafkaSampleConsumerConfig { - @Bean(value = ["kafkaSampleOptions"]) - fun kafkaReceiverOptions( - kafkaProperties: KafkaProperties, - props: KafkaApplicationProperties, - observationRegistry: ObservationRegistry - ): ReceiverOptions { - val basicReceiverOptions: ReceiverOptions = - ReceiverOptions.create(kafkaProperties.buildConsumerProperties(null)) - val basicReceiverOptionsWithObs = basicReceiverOptions - // todo: make better observation handling by reactive kafka, currently the zipkin does not show the traces properly - .withObservation(observationRegistry, KafkaReceiverObservation.DefaultKafkaReceiverObservationConvention() - ) - return basicReceiverOptionsWithObs.subscription(Collections.singletonList(props.rx)) - } +class KafkaConsumerConfig { - @Bean(value = ["kafkaSampleConsumerTemplate"]) - fun reactiveKafkaConsumerTemplate(@Qualifier(value = "kafkaSampleOptions") kafkaReceiverOptions: ReceiverOptions): ReactiveKafkaConsumerTemplate { - return ReactiveKafkaConsumerTemplate(kafkaReceiverOptions) - } -} - -@Configuration -class ReactiveKafkaSampleProducerConfig { - @Bean(value = ["kafkaSampleProducerTemplate"]) - fun reactiveKafkaProducerTemplate(properties: KafkaProperties, observationRegistry: ObservationRegistry): ReactiveKafkaProducerTemplate { - val props = properties.buildProducerProperties(null) - val options = SenderOptions.create(props) - // todo: make better observation handling by reactive kafka, currently the zipkin does not show the traces properly - .withObservation(observationRegistry, KafkaSenderObservation.DefaultKafkaSenderObservationConvention()) - return ReactiveKafkaProducerTemplate(options) + @Bean + fun kafkaListenerContainerFactory(consumerFactory: ConsumerFactory, registry: ObservationRegistry): ConcurrentKafkaListenerContainerFactory { + val factory = ConcurrentKafkaListenerContainerFactory() + factory.setConsumerFactory(consumerFactory) + factory.containerProperties.isObservationEnabled = true + factory.containerProperties.observationRegistry = registry + return factory } } @Configuration -class ReactiveKafkaKeycloakConsumerConfig { - @Bean(value = ["kafkaKeycloakOptions"]) - fun kafkaReceiverOptions( - kafkaProperties: KafkaProperties, - props: KafkaApplicationProperties - ): ReceiverOptions { - val basicReceiverOptions: ReceiverOptions = - ReceiverOptions.create(kafkaProperties.buildConsumerProperties(null)) - return basicReceiverOptions.subscription(Collections.singletonList(props.keycloak)) - } +class KafkaProducerConfig { - @Bean(value = ["kafkaKeycloakConsumerTemplate"]) - fun reactiveKafkaConsumerTemplate(@Qualifier(value = "kafkaKeycloakOptions") kafkaReceiverOptions: ReceiverOptions): ReactiveKafkaConsumerTemplate { - return ReactiveKafkaConsumerTemplate(kafkaReceiverOptions) + @Bean + fun kafkaTemplate(producerFactory: ProducerFactory, registry: ObservationRegistry): KafkaTemplate { + return KafkaTemplate(producerFactory).apply { + setObservationEnabled(true) + setObservationRegistry(registry) + } } } \ No newline at end of file diff --git a/src/main/kotlin/com/softeno/template/app/user/Service.kt b/src/main/kotlin/com/softeno/template/app/user/UserService.kt similarity index 93% rename from src/main/kotlin/com/softeno/template/app/user/Service.kt rename to src/main/kotlin/com/softeno/template/app/user/UserService.kt index 13c0055..d51811d 100644 --- a/src/main/kotlin/com/softeno/template/app/user/Service.kt +++ b/src/main/kotlin/com/softeno/template/app/user/UserService.kt @@ -3,7 +3,8 @@ package com.softeno.template.app.user import com.softeno.template.app.common.ErrorFactory import com.softeno.template.app.common.PrincipalHandler import com.softeno.template.app.common.getPageRequest -import com.softeno.template.app.event.UserAction +import com.softeno.template.app.kafka.KafkaMessage +import com.softeno.template.app.kafka.KafkaSampleProducer import com.softeno.template.app.permission.Permission import com.softeno.template.app.permission.PermissionService import com.softeno.template.app.permission.db.PermissionDocument @@ -21,7 +22,6 @@ import kotlinx.coroutines.slf4j.MDCContext import kotlinx.coroutines.withContext import org.apache.commons.logging.LogFactory import org.slf4j.MDC -import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service import reactor.core.publisher.Mono import java.security.Principal @@ -32,13 +32,11 @@ class UserService( private val userCoroutineRepository: UserCoroutineRepository, private val permissionService: PermissionService, private val userDocumentService: UserDocumentService, - private val applicationEventPublisher: ApplicationEventPublisher, + private val kafkaProducer: KafkaSampleProducer, private val tracer: Tracer ) : PrincipalHandler { private val log = LogFactory.getLog(javaClass) - // note: used by http rest controller to return users with mapped permissions -// @ContinueSpan suspend fun getAll( page: Int, size: Int, @@ -76,9 +74,8 @@ class UserService( val user = userCoroutineRepository.save(User(input, permissions).toDocument()).toDomain(permissions) log.info("User created: $user") - applicationEventPublisher.publishEvent( - UserAction("USER_CREATED: ${user.id}", traceId = MDC.get("traceId"), spanId = MDC.get("spanId")) - ) + val message = KafkaMessage(content = "USER_CREATED: ${user.id}") + kafkaProducer.send(message) return@withContext user } diff --git a/src/main/kotlin/com/softeno/template/app/user/api/reactive/ReactiveUsers.kt b/src/main/kotlin/com/softeno/template/app/user/api/reactive/ReactiveUsers.kt index 75ca2f6..f32e94e 100644 --- a/src/main/kotlin/com/softeno/template/app/user/api/reactive/ReactiveUsers.kt +++ b/src/main/kotlin/com/softeno/template/app/user/api/reactive/ReactiveUsers.kt @@ -1,7 +1,8 @@ package com.softeno.template.app.permission.api.reactive import com.softeno.template.app.common.getPageRequest -import com.softeno.template.app.event.UserAction +import com.softeno.template.app.kafka.KafkaMessage +import com.softeno.template.app.kafka.KafkaSampleProducer import com.softeno.template.app.permission.api.PermissionNotFoundException import com.softeno.template.app.permission.db.PermissionDocument import com.softeno.template.app.permission.db.PermissionsReactiveRepository @@ -14,7 +15,6 @@ import com.softeno.template.app.user.api.toDto import com.softeno.template.app.user.db.UserDocument import com.softeno.template.app.user.db.UserReactiveRepository import com.softeno.template.app.user.toDomain -import org.springframework.context.ApplicationEventPublisher import org.springframework.validation.annotation.Validated import org.springframework.web.bind.annotation.* import reactor.core.publisher.Flux @@ -28,7 +28,7 @@ import reactor.kotlin.core.publisher.toMono class ReactiveUserController( val userReactiveRepository: UserReactiveRepository, val permissionsReactiveRepository: PermissionsReactiveRepository, - val applicationEventPublisher: ApplicationEventPublisher + val kafkaProducer: KafkaSampleProducer, ) { @PostMapping("/users") fun createUser(@RequestBody input: UserModifyCommand): Mono { @@ -55,7 +55,10 @@ class ReactiveUserController( version = null ) }.flatMap { e -> userReactiveRepository.save(e) } - .doOnSuccess { applicationEventPublisher.publishEvent(UserAction("USER_CREATED_REACTIVE: ${it.id}")) } + .doOnSuccess { + val message = KafkaMessage(content = "USER_CREATED_REACTIVE: ${it?.id}") + kafkaProducer.send(message) + } .zipWith(permissions) .map { tuple -> tuple.t1.toDomain(tuple.t2.map { it.toDomain() }).toDto() } } diff --git a/src/main/kotlin/com/softeno/template/app/user/notification/Service.kt b/src/main/kotlin/com/softeno/template/app/user/notification/Service.kt index 523e2ed..6936708 100644 --- a/src/main/kotlin/com/softeno/template/app/user/notification/Service.kt +++ b/src/main/kotlin/com/softeno/template/app/user/notification/Service.kt @@ -1,6 +1,5 @@ package com.softeno.template.app.user.notification -import com.fasterxml.jackson.databind.ObjectMapper import com.softeno.template.sample.websocket.Message import com.softeno.template.sample.websocket.toJson import kotlinx.coroutines.channels.BufferOverflow @@ -14,6 +13,7 @@ import org.springframework.stereotype.Service import reactor.core.publisher.Flux import reactor.core.publisher.Mono import reactor.core.publisher.Sinks +import tools.jackson.databind.ObjectMapper import java.time.Duration import kotlin.coroutines.cancellation.CancellationException @@ -87,6 +87,10 @@ class ReactiveUserUpdateEmitter( log.warn("[reactive] Failed to broadcast message - buffer overflow") false } + Sinks.EmitResult.FAIL_ZERO_SUBSCRIBER -> { + log.warn("[reactive] Failed to broadcast message - no subscribers") + false + } else -> { log.error("[reactive] Failed to broadcast message: $result") false diff --git a/src/main/kotlin/com/softeno/template/playground/CoroutinePlayground.kt b/src/main/kotlin/com/softeno/template/playground/CoroutinePlayground.kt deleted file mode 100644 index 0f3cfbc..0000000 --- a/src/main/kotlin/com/softeno/template/playground/CoroutinePlayground.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.softeno.template.playground - -import kotlinx.coroutines.* -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import kotlin.coroutines.CoroutineContext - -class CoroutinePlayground { - private val logger = LoggerFactory.getLogger(this::class.java) - - @OptIn(DelicateCoroutinesApi::class) - fun run() { - logger.info(">> Coroutine playground started") - - // note: global `coroutineScope` exception handler - val exceptionHandler = CoroutineExceptionHandler { context: CoroutineContext, exception: Throwable -> - logger.error(">> Global CoroutineExceptionHandler: $exception") - } - - GlobalScope.launch(exceptionHandler) { - val messages = listOf(helloCoroutine(), helloCoroutineWithContext()) - logger.info(">> coroutine message: $messages") - - val tasks = listOf( - async { someComputation(logger) }, - async { someComputation(logger) } - ) - tasks.awaitAll() - - val deferredWithTimeout = async { someComputation(logger) } - runBlocking { - try { - withTimeout(500) { - logger.info(">> start awaiting with 500 secs timeout") - deferredWithTimeout.join() - } - } catch (exception: TimeoutCancellationException) { - logger.error(">> timeout") - deferredWithTimeout.cancel() // note: if the task will not be canceled it can still run - } - } - - val deferredWithError = supervisorScope { - async { someError() } - } - // note: if the exception should not be handled by global scope handler `supervisorScope` has to be used - try { - deferredWithError.await() - } catch (exception: Exception) { - deferredWithError.cancel() - logger.error(">> Handled local exception: $exception") - } - - launch { - logger.info(">> Coroutine playground ended") - }.join() - // note: launch - simple fire and forget scope - non-blocking unless `join` - } - - // ... - } - - suspend fun sampleFunction(input: String): String = - withContext(Dispatchers.Default) { - delay(2000) - return@withContext input - } -} - -suspend fun helloCoroutine(): String { - delay(500) - return "hello" -} - -suspend fun helloCoroutineWithContext(): String { - return withContext(Dispatchers.IO) { - delay(1000) - val message = "coroutine!" - return@withContext message - } -} - -suspend fun someComputation(logger: Logger) { - withContext(Dispatchers.Default) { - delay(2000) - logger.info(">> Some Computation done!") - } -} - -suspend fun someError() { - withContext(Dispatchers.Default) { - delay(1000) - throw RuntimeException("Some Error Occurred") - } -} diff --git a/src/main/kotlin/com/softeno/template/sample/http/dto/dto.kt b/src/main/kotlin/com/softeno/template/sample/http/dto/dto.kt deleted file mode 100644 index 6cbbc19..0000000 --- a/src/main/kotlin/com/softeno/template/sample/http/dto/dto.kt +++ /dev/null @@ -1,4 +0,0 @@ -package com.softeno.template.sample.http.dto - - -data class SampleResponseDto(val data: String) \ No newline at end of file diff --git a/src/main/kotlin/com/softeno/template/sample/http/external/config/CircuitBreakerConfig.kt b/src/main/kotlin/com/softeno/template/sample/http/external/config/CircuitBreakerConfig.kt index f797a30..a9e0908 100644 --- a/src/main/kotlin/com/softeno/template/sample/http/external/config/CircuitBreakerConfig.kt +++ b/src/main/kotlin/com/softeno/template/sample/http/external/config/CircuitBreakerConfig.kt @@ -1,26 +1,16 @@ package com.softeno.template.sample.http.external.config import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig -import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry import io.github.resilience4j.timelimiter.TimeLimiterConfig -import io.github.resilience4j.timelimiter.TimeLimiterRegistry import org.springframework.cloud.circuitbreaker.resilience4j.ReactiveResilience4JCircuitBreakerFactory import org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JConfigBuilder import org.springframework.cloud.client.circuitbreaker.Customizer -import org.springframework.cloud.client.circuitbreaker.ReactiveCircuitBreakerFactory import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import java.time.Duration - @Configuration class ReactiveCircuitBreakerConfig { - @Bean - fun reactiveResilience4JCircuitBreakerFactory(): ReactiveCircuitBreakerFactory<*, *> { - val circuitBreakerRegistry = CircuitBreakerRegistry.ofDefaults() - val timeLimiterRegistry = TimeLimiterRegistry.ofDefaults() - return ReactiveResilience4JCircuitBreakerFactory(circuitBreakerRegistry, timeLimiterRegistry, null,null) - } @Bean fun defaultCustomizer(): Customizer { diff --git a/src/main/kotlin/com/softeno/template/sample/http/external/coroutine/ExternalController.kt b/src/main/kotlin/com/softeno/template/sample/http/external/coroutine/ExternalController.kt index ef94807..53227de 100644 --- a/src/main/kotlin/com/softeno/template/sample/http/external/coroutine/ExternalController.kt +++ b/src/main/kotlin/com/softeno/template/sample/http/external/coroutine/ExternalController.kt @@ -1,7 +1,7 @@ package com.softeno.template.sample.http.external.coroutine -import com.softeno.template.sample.http.dto.SampleResponseDto import com.softeno.template.sample.http.external.config.ExternalClientConfig +import com.softeno.template.sample.http.internal.reactive.SampleResponseDto import kotlinx.coroutines.reactor.awaitSingle import org.apache.commons.logging.LogFactory import org.springframework.beans.factory.annotation.Qualifier diff --git a/src/main/kotlin/com/softeno/template/sample/http/internal/async/AsyncController.kt b/src/main/kotlin/com/softeno/template/sample/http/internal/async/AsyncController.kt deleted file mode 100644 index 4476ab6..0000000 --- a/src/main/kotlin/com/softeno/template/sample/http/internal/async/AsyncController.kt +++ /dev/null @@ -1,117 +0,0 @@ -package com.softeno.template.sample.http.internal.async - -import kotlinx.coroutines.reactor.awaitSingleOrNull -import org.apache.commons.logging.LogFactory -import org.springframework.scheduling.annotation.Async -import org.springframework.stereotype.Service -import org.springframework.validation.annotation.Validated -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController -import reactor.core.publisher.Mono -import java.util.concurrent.CompletableFuture - - -@RestController -@RequestMapping("/async") -@Validated -class AsyncController(private val asyncService: AsyncService) { - private val log = LogFactory.getLog(javaClass) - - @GetMapping("/result") - suspend fun asyncResultHandler(): String? { - log.info("[async]: triggering async method with result ...") - - val resultFuture: CompletableFuture = asyncService.asyncMethodWithReturnType("test", 5_000) - val result: String? = Mono.fromFuture(resultFuture).awaitSingleOrNull() - - log.info("[async]: finished with result: $result") - return result - } - - @GetMapping("/void") - fun asyncVoidHandler() { - log.info("[async]: triggering async method without result ...") - asyncService.asyncMethodVoid("test", 5_000) - } - - @GetMapping("/fail") - suspend fun asyncResultWithFailHandler(): String? { - log.info("[async]: triggering async method with result and fail ...") - - val resultFuture: CompletableFuture = asyncService.asyncMethodFail("fail", 2_000) - return Mono.fromFuture(resultFuture) - // note: in case of spring async the webflux exception handler will not be invoked since the thread pool does not belong to webflux context; - // exception has to be handled inside monad of Mono (proof: look at logger at thread name) - .doOnError { - log.error("[async]: error handled: ${it.message}") - // clean up here ... - } - .onErrorReturn("ERROR") - .awaitSingleOrNull() - } - - @GetMapping("/void-fail") - fun asyncVoidFailHandler() { - log.info("[async]: triggering async method without result and with fail ...") - asyncService.asyncMethodVoidFail("void-fail", 2_000) - } - -} - -@Service -class AsyncService { - private val log = LogFactory.getLog(javaClass) - - @Async(value = "asyncExecutor") - fun asyncMethodWithReturnType(input: String, delay: Long): CompletableFuture { - log.info("[async]: async method invoked") - try { - Thread.sleep(delay) - val result = "FUTURE_DONE: $input" - log.info("[async]: async method finished with result: $result") - return CompletableFuture.completedFuture(result) - } catch (e: InterruptedException) { - // ... - } - return CompletableFuture.completedFuture(null) - } - - @Async(value = "asyncExecutor") - fun asyncMethodVoid(input: String, delay: Long) { - log.info("[async]: async void method invoked") - try { - Thread.sleep(delay) - val result = "VOID_DONE: $input" - log.info("[async]: async void method finished with result: $result") - } catch (e: InterruptedException) { - // ... - } - } - - @Async(value = "asyncExecutor") - fun asyncMethodFail(input: String, delay: Long): CompletableFuture { - log.info("[async]: async void method invoked") - try { - Thread.sleep(delay) - log.error("[async]: Error occurred during async process...") - throw RuntimeException("ASYNC_METHOD_EXCEPTION") - } catch (e: InterruptedException) { - // ... - } - return CompletableFuture.completedFuture(null) - } - - @Async(value = "asyncExecutor") - fun asyncMethodVoidFail(input: String, delay: Long) { - log.info("[async]: async void method invoked") - try { - Thread.sleep(delay) - log.error("[async]: Error occurred during async void process...") - throw RuntimeException("ASYNC_METHOD_EXCEPTION") - } catch (e: InterruptedException) { - // ... - } - } -} - diff --git a/src/main/kotlin/com/softeno/template/sample/http/internal/async/config/AsyncConfig.kt b/src/main/kotlin/com/softeno/template/sample/http/internal/async/config/AsyncConfig.kt deleted file mode 100644 index 8592c39..0000000 --- a/src/main/kotlin/com/softeno/template/sample/http/internal/async/config/AsyncConfig.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.softeno.template.sample.http.internal.async.config - -import org.apache.commons.logging.LogFactory -import org.slf4j.MDC -import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.core.task.TaskDecorator -import org.springframework.scheduling.annotation.AsyncConfigurer -import org.springframework.scheduling.annotation.EnableAsync -import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor -import java.lang.reflect.Method -import java.util.concurrent.Executor - - -class MDCTaskDecorator : TaskDecorator { - override fun decorate(runnable: Runnable): Runnable { - val contextMap = MDC.getCopyOfContextMap() - return Runnable { - try { - MDC.setContextMap(contextMap) - runnable.run() - } finally { - MDC.clear() - } - } - } -} - - -@Configuration -@EnableAsync -class AsyncExecutorConfig { - @Bean(value = ["asyncExecutor"]) - fun asyncExecutor(): Executor { - val executor = ThreadPoolTaskExecutor() - executor.maxPoolSize = 24 - executor.corePoolSize = 8 - executor.setThreadNamePrefix("CUSTOM_SPRING_EXECUTOR-") - executor.setTaskDecorator(MDCTaskDecorator()) - executor.initialize() - return executor - } -} - -class AsyncExceptionHandler : AsyncUncaughtExceptionHandler { - private val log = LogFactory.getLog(javaClass) - - override fun handleUncaughtException(ex: Throwable, method: Method, vararg params: Any) { - log.error("[async]: Unexpected asynchronous exception at: ${method.declaringClass.name}.${method.name}, $ex") - // clean up here ... - } -} - -@Configuration -class AsyncConfig : AsyncConfigurer { - override fun getAsyncUncaughtExceptionHandler(): AsyncUncaughtExceptionHandler { - return AsyncExceptionHandler() - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/softeno/template/sample/http/internal/minio/MinioAdapter.kt b/src/main/kotlin/com/softeno/template/sample/http/internal/minio/MinioAdapter.kt deleted file mode 100644 index 3f2c379..0000000 --- a/src/main/kotlin/com/softeno/template/sample/http/internal/minio/MinioAdapter.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.softeno.template.sample.http.internal.minio - -import io.minio.* -import org.springframework.context.annotation.Profile -import org.springframework.core.io.InputStreamResource -import org.springframework.http.codec.multipart.FilePart -import org.springframework.stereotype.Component -import reactor.core.publisher.Mono -import reactor.core.scheduler.Schedulers -import java.io.File -import java.io.InputStream - -data class UploadResponse(val versionId: String, val objectId: String, val bucket: String) - -@Profile(value = ["!integration"]) -@Component -class MinioAdapter(private val minioClient: MinioClient, private val config: ExternalMinioConfig) { - fun uploadFile(file: Mono): Mono { - return file.publishOn(Schedulers.boundedElastic()).map { multipartFile -> - val temp = File.createTempFile(multipartFile.filename(), null) - .also { it.canRead() } - .also { it.canWrite() } - Pair(multipartFile, temp) - }.flatMap { - Mono.just(it.first).zipWith(Mono.just(it.second)).flatMap { tuple -> - tuple.t1.transferTo(tuple.t2).thenReturn { - val uploadObjectArgs: UploadObjectArgs = UploadObjectArgs.builder() - .bucket(config.bucket) - .`object`(tuple.t1.filename()) - .filename(tuple.t2.absolutePath) - .build() - val response: ObjectWriteResponse = minioClient.uploadObject(uploadObjectArgs) - tuple.t2.delete() - UploadResponse( - versionId = response.versionId() ?: "", - objectId = response.`object`(), - bucket = response.bucket() - ) - }.map { it() } - } - } - } - - fun download(name: String): Mono { - return Mono.fromCallable { - val response: InputStream = - minioClient.getObject(GetObjectArgs.builder().bucket(config.bucket).`object`(name).build()) - InputStreamResource(response) - }.subscribeOn(Schedulers.boundedElastic()) - } - - fun remove(name: String) { - minioClient.removeObject(RemoveObjectArgs.builder().bucket(config.bucket).`object`(name).build()) - } - -} \ No newline at end of file diff --git a/src/main/kotlin/com/softeno/template/sample/http/internal/minio/MinioController.kt b/src/main/kotlin/com/softeno/template/sample/http/internal/minio/MinioController.kt deleted file mode 100644 index 60e09c9..0000000 --- a/src/main/kotlin/com/softeno/template/sample/http/internal/minio/MinioController.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.softeno.template.sample.http.internal.minio - -import org.springframework.context.annotation.Profile -import org.springframework.core.io.InputStreamResource -import org.springframework.http.HttpHeaders -import org.springframework.http.MediaType -import org.springframework.http.ResponseEntity -import org.springframework.http.codec.multipart.FilePart -import org.springframework.util.MimeTypeUtils -import org.springframework.validation.annotation.Validated -import org.springframework.web.bind.annotation.* -import reactor.core.publisher.Mono - - -@Profile(value = ["!integration"]) -@RestController -@RequestMapping("/minio") -@Validated -class FileController(private val adapter: MinioAdapter) { - - @RequestMapping( - path = ["/upload"], - method = [RequestMethod.POST], - produces = [MimeTypeUtils.APPLICATION_JSON_VALUE], - consumes = [MediaType.MULTIPART_FORM_DATA_VALUE] - ) - fun upload( - @RequestPart(value = "file", required = true) files: Mono - ): Mono = adapter.uploadFile(files) - - @GetMapping("/{file}") - fun download( - @PathVariable(value = "file") file: String - ): ResponseEntity> = - ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=$file") - .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_OCTET_STREAM_VALUE).body(adapter.download(file)) - - - @DeleteMapping("/{file}") - fun remove(@PathVariable(value = "file") file: String) = adapter.remove(file) - -} \ No newline at end of file diff --git a/src/main/kotlin/com/softeno/template/sample/http/internal/minio/S3Config.kt b/src/main/kotlin/com/softeno/template/sample/http/internal/minio/S3Config.kt deleted file mode 100644 index 783c26b..0000000 --- a/src/main/kotlin/com/softeno/template/sample/http/internal/minio/S3Config.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.softeno.template.sample.http.internal.minio - -import io.minio.MinioClient -import okhttp3.OkHttpClient -import org.springframework.boot.context.properties.ConfigurationProperties -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.context.annotation.Profile -import java.util.concurrent.TimeUnit - - -@ConfigurationProperties(prefix = "io.min") -@Profile(value = ["!integration"]) -data class ExternalMinioConfig - (val name: String, val secret: String, val url: String, val bucket: String, val folder: String) - -@Profile(value = ["!integration"]) -@Configuration -class MinioConfig { - @Bean - fun generateMinioClient(config: ExternalMinioConfig): MinioClient { - return try { - val httpClient: OkHttpClient = OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.MINUTES) - .writeTimeout(10, TimeUnit.MINUTES) - .readTimeout(30, TimeUnit.MINUTES) - .build() - - MinioClient.builder() - .endpoint(config.url) - .httpClient(httpClient) - .credentials(config.name, config.secret) - .build() - } catch (e: Exception) { - throw RuntimeException(e.message) - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/softeno/template/sample/http/internal/reactive/SampleController.kt b/src/main/kotlin/com/softeno/template/sample/http/internal/reactive/SampleController.kt index 9204c92..c6f5be3 100644 --- a/src/main/kotlin/com/softeno/template/sample/http/internal/reactive/SampleController.kt +++ b/src/main/kotlin/com/softeno/template/sample/http/internal/reactive/SampleController.kt @@ -1,6 +1,5 @@ package com.softeno.template.sample.http.internal.reactive -import com.softeno.template.sample.http.dto.SampleResponseDto import org.apache.commons.logging.LogFactory import org.springframework.stereotype.Service import org.springframework.validation.annotation.Validated @@ -53,5 +52,6 @@ class SampleService { log.info("[sample-service]: GET id: $id") return Mono.empty() } +} -} \ No newline at end of file +data class SampleResponseDto(val data: String) \ No newline at end of file diff --git a/src/main/kotlin/com/softeno/template/sample/http/internal/router/Router.kt b/src/main/kotlin/com/softeno/template/sample/http/internal/router/Router.kt index 07be9b4..2a6c187 100644 --- a/src/main/kotlin/com/softeno/template/sample/http/internal/router/Router.kt +++ b/src/main/kotlin/com/softeno/template/sample/http/internal/router/Router.kt @@ -1,6 +1,6 @@ package com.softeno.template.sample.http.internal.router -import com.softeno.template.sample.http.dto.SampleResponseDto +import com.softeno.template.sample.http.internal.reactive.SampleResponseDto import com.softeno.template.sample.http.internal.reactive.SampleService import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration diff --git a/src/main/kotlin/com/softeno/template/sample/http/internal/serverevents/ServerEvents.kt b/src/main/kotlin/com/softeno/template/sample/http/internal/serverevents/ServerEvents.kt deleted file mode 100644 index 689e87b..0000000 --- a/src/main/kotlin/com/softeno/template/sample/http/internal/serverevents/ServerEvents.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.softeno.template.sample.http.internal.serverevents - -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import org.springframework.stereotype.Service -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController - -@RestController -@RequestMapping("/coroutine/currency-rate") -class CurrencyRateController(val currencyRateService: CurrencyRateService) { - - @GetMapping("/current", produces = ["text/event-stream"]) - suspend fun currentRates() = currencyRateService.currentRates() -} - -enum class CURRENCY { - USD, EUR, GBP, PLN -} - -data class CurrencyRate(val currency: CURRENCY, val value: Double) - -@Service -class CurrencyRateService { - - suspend fun currentRates(): Flow> = flow { - while (true) { - val rates: List = CURRENCY.entries.map { - CurrencyRate(it, Math.random()) - } - emit(rates) - - val delay = (100..2000).random() - delay(delay.toLong()) - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/softeno/template/sample/rsocket/Rsocket.kt b/src/main/kotlin/com/softeno/template/sample/rsocket/Rsocket.kt deleted file mode 100644 index 425703f..0000000 --- a/src/main/kotlin/com/softeno/template/sample/rsocket/Rsocket.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.softeno.template.sample.rsocket - -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.reactive.asFlow -import org.apache.commons.logging.LogFactory -import org.springframework.messaging.handler.annotation.MessageExceptionHandler -import org.springframework.messaging.handler.annotation.MessageMapping -import org.springframework.messaging.handler.annotation.Payload -import org.springframework.messaging.rsocket.RSocketRequester -import org.springframework.messaging.rsocket.annotation.ConnectMapping -import org.springframework.stereotype.Controller -import org.springframework.stereotype.Service -import org.springframework.util.MimeType -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono -import java.time.Duration.ofSeconds -import java.time.LocalDateTime -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.ThreadLocalRandom - - -@Controller -class StockPricesRSocketController(private val stockService: StockService) { - private val log = LogFactory.getLog(javaClass) - private var REQUESTER_MAP: HashMap = HashMap() - - @MessageMapping("stock") - fun prices(symbol: String): Flux = stockService.streamOfPrices(symbol) - - @MessageMapping("number.channel") - fun biDirectionalStream(numberFlux: Flux): Flux? { - return numberFlux - .map { n: Long -> n * n } - .onErrorReturn(-1L) - } - - @MessageMapping("stock.single") - fun singlePrice(symbol: String): Mono = stockService.getSingle(symbol) - - @MessageMapping("stock.coroutine") - suspend fun singlePriceCoroutine(symbol: String): StockPrice? = - stockService.getSingle(symbol).asFlow().firstOrNull() - - @MessageMapping("stock.error") - fun stockError(symbol: String) { - throw RuntimeException("exception for symbol: $symbol") - } - - @MessageExceptionHandler - fun handle(ex: Exception): Mono { - log.error("MessageExceptionHandler: $ex") - return Mono.error(ex) - } - - @ConnectMapping("client-id") - fun register(rSocketRequester: RSocketRequester, @Payload clientId: String): Mono { - log.info("hello: $clientId") - rSocketRequester.rsocket()!! - .onClose() - .subscribe( - null, null - ) { - log.info("good bye: $clientId") - REQUESTER_MAP.remove(clientId, rSocketRequester) - } - - REQUESTER_MAP.put(clientId, rSocketRequester) - - return rSocketRequester.metadata("hello", MimeType.valueOf("message/x.rsocket.routing.v0")).send() - } - -} - -@Service -class StockService { - private val pricesForStock = ConcurrentHashMap>() - private val log = LogFactory.getLog(javaClass) - - fun getSingle(symbol: String): Mono = - Mono.just(StockPrice(symbol, randomStockPrice(), LocalDateTime.now())) - .doOnSubscribe { log.info("New subscription for SINGLE symbol $symbol.") } - .share() - - fun streamOfPrices(symbol: String): Flux { - return pricesForStock.computeIfAbsent(symbol) { - Flux - .interval(ofSeconds(1)) - .map { StockPrice(symbol, randomStockPrice(), LocalDateTime.now()) } - .doOnSubscribe { log.info("New subscription for symbol $symbol.") } - .share() - } - } - - private fun randomStockPrice() = ThreadLocalRandom.current().nextDouble(100.0) -} - -data class StockPrice(val symbol: String, val price: Double, val time: LocalDateTime) \ No newline at end of file diff --git a/src/main/kotlin/com/softeno/template/sample/scheduled/ScheduledService.kt b/src/main/kotlin/com/softeno/template/sample/scheduled/ScheduledService.kt deleted file mode 100644 index aac3106..0000000 --- a/src/main/kotlin/com/softeno/template/sample/scheduled/ScheduledService.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.softeno.template.sample.scheduled - -import com.softeno.template.sample.http.internal.async.AsyncService -import org.apache.commons.logging.LogFactory -import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty -import org.springframework.context.annotation.Profile -import org.springframework.scheduling.annotation.Scheduled -import org.springframework.stereotype.Service -import java.util.concurrent.Executor -import java.util.concurrent.TimeUnit - -@Profile(value = ["!integration"]) -@ConditionalOnProperty( - name = ["com.softeno.scheduled-tasks"], - havingValue = "true", - matchIfMissing = false -) -@Service -class ScheduledService( - @param:Qualifier(value = "scheduledExecutor") private val executor: Executor, - private val syncService: AsyncService -) { - private val log = LogFactory.getLog(javaClass) - - @Scheduled(fixedDelay = 12, timeUnit = TimeUnit.HOURS) - fun periodicTaskDelay() { - // fixedDelay: specifically controls the next execution time when the last execution finishes. - log.info("[scheduled]: periodic task delay start") - // note: inplace Runnable - executor.execute { syncService.asyncMethodVoid("fixedDelay", 90_000) } - } - - @Scheduled(fixedRate = 12, timeUnit = TimeUnit.HOURS) - fun periodicTaskRate() { - // fixedRate: makes Spring run the task on periodic intervals even if the last invocation may still be running. - log.info("[scheduled]: periodic task rate start") - executor.execute { syncService.asyncMethodVoid("fixedRate", 90_000) } - - } - - @Scheduled(cron = "0 * */12 * * *") - fun periodicTaskCron() { - log.info("[scheduled]: periodic task cron start") - executor.execute { syncService.asyncMethodVoid("cron", 30_000) } - } - - @Scheduled(cron = "0 * */12 * * *") - fun periodicTaskWithFailCron() { - log.info("[scheduled]: periodic task cron with fail start") - executor.execute { syncService.asyncMethodVoidFail("cron-fail", 30_000) } - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/softeno/template/sample/scheduled/config/SchedulerConfig.kt b/src/main/kotlin/com/softeno/template/sample/scheduled/config/SchedulerConfig.kt deleted file mode 100644 index e6583d2..0000000 --- a/src/main/kotlin/com/softeno/template/sample/scheduled/config/SchedulerConfig.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.softeno.template.sample.scheduled.config - -import com.softeno.template.sample.http.internal.async.config.MDCTaskDecorator -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.scheduling.annotation.EnableScheduling -import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor -import java.util.concurrent.Executor - - -@Configuration -@EnableScheduling -class SchedulerExecutorConfig { - @Bean(value = ["scheduledExecutor"]) - fun taskExecutor(): Executor { - val executor = ThreadPoolTaskExecutor() - executor.maxPoolSize = 24 - executor.corePoolSize = 8 - executor.setThreadNamePrefix("CUSTOM_SPRING_SCHEDULER-") - executor.setTaskDecorator(MDCTaskDecorator()) - executor.initialize() - return executor - } -} diff --git a/src/main/kotlin/com/softeno/template/sample/websocket/CoroutineWebSocket.kt b/src/main/kotlin/com/softeno/template/sample/websocket/CoroutineWebSocket.kt index eceb9e0..dcbc2ab 100644 --- a/src/main/kotlin/com/softeno/template/sample/websocket/CoroutineWebSocket.kt +++ b/src/main/kotlin/com/softeno/template/sample/websocket/CoroutineWebSocket.kt @@ -1,6 +1,5 @@ package com.softeno.template.sample.websocket -import com.fasterxml.jackson.databind.ObjectMapper import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow @@ -24,6 +23,7 @@ import org.springframework.web.reactive.socket.WebSocketHandler import org.springframework.web.reactive.socket.WebSocketSession import org.springframework.web.reactive.socket.server.support.WebSocketHandlerAdapter import reactor.core.publisher.Mono +import tools.jackson.databind.ObjectMapper import java.time.Instant import java.util.concurrent.ConcurrentHashMap import kotlin.coroutines.CoroutineContext diff --git a/src/main/kotlin/com/softeno/template/sample/websocket/ReactiveWebSocket.kt b/src/main/kotlin/com/softeno/template/sample/websocket/ReactiveWebSocket.kt index 044c160..418f553 100644 --- a/src/main/kotlin/com/softeno/template/sample/websocket/ReactiveWebSocket.kt +++ b/src/main/kotlin/com/softeno/template/sample/websocket/ReactiveWebSocket.kt @@ -1,6 +1,5 @@ package com.softeno.template.sample.websocket -import com.fasterxml.jackson.databind.ObjectMapper import org.apache.commons.logging.LogFactory import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.context.properties.ConfigurationProperties @@ -19,6 +18,7 @@ import reactor.core.Disposable import reactor.core.publisher.Flux import reactor.core.publisher.Sinks import reactor.core.publisher.Sinks.Many +import tools.jackson.databind.ObjectMapper import java.time.Duration import java.time.Instant import java.util.concurrent.ConcurrentHashMap @@ -61,7 +61,7 @@ class WebSocketConfig( val handshake = Message(from = "SYSTEM", to = session.id, content = "HANDSHAKE") reactiveMessageService.send(handshake, session) - val authentication = ReactiveSecurityContextHolder.getContext().map { it.authentication } + val authentication = ReactiveSecurityContextHolder.getContext().mapNotNull { it.authentication } val userIdMessage: Flux = authentication.flux().map { val token = (it as JwtAuthenticationToken).token val userId = token.claims["sub"] @@ -103,7 +103,7 @@ class WebSocketConfig( } } catch (e: Exception) { log.error("ws: [chat] failed to parse message: ${wsMessage.payloadAsText}", e) - // optionally send error message back to client + // optionally send an error message back to a client } }.doOnError { error -> log.error("ws: [chat] error in session: ${session.id}", error) diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties new file mode 100644 index 0000000..f5ffa89 --- /dev/null +++ b/src/main/resources/application-local.properties @@ -0,0 +1,5 @@ +spring.main.allow-bean-definition-overriding=true + +management.tracing.export.otlp.enabled=false +management.logging.export.otlp.enabled=false +management.otlp.metrics.export.enabled=false \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 523f88f..bcf61c6 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -4,17 +4,13 @@ spring.main.allow-bean-definition-overriding=false server.port=8080 spring.application.name=SoftenoReactiveMongoApp -spring.data.mongodb.host=${MONGO_HOST} -spring.data.mongodb.port=${MONGO_PORT} -spring.data.mongodb.username=${MONGO_USER} -spring.data.mongodb.password=${MONGO_PASSWORD} -spring.data.mongodb.database=${MONGO_DB} +spring.mongodb.host=${MONGO_HOST} +spring.mongodb.port=${MONGO_PORT} +spring.mongodb.username=${MONGO_USER} +spring.mongodb.password=${MONGO_PASSWORD} +spring.mongodb.database=${MONGO_DB} spring.data.mongodb.auto-index-creation=true -spring.rsocket.server.mapping-path=/rsocket -spring.rsocket.server.transport=websocket -spring.rsocket.server.port=8081 - spring.kafka.bootstrap-servers=${KAFKA_URL} spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer @@ -26,7 +22,6 @@ spring.kafka.properties.spring.json.trusted.packages=* spring.kafka.consumer.properties.spring.json.use.type.headers=false spring.kafka.consumer.properties.spring.json.value.default.type=com.fasterxml.jackson.databind.JsonNode -com.softeno.scheduled-tasks=false com.softeno.kafka.tx=${KAFKA_TOPIC_TX} com.softeno.kafka.rx=${KAFKA_TOPIC_RX} com.softeno.kafka.keycloak=${KAFKA_KEYCLOAK} @@ -59,42 +54,38 @@ spring.security.oauth2.client.provider.keycloak.token-uri=${OAUTH_TOKEN_URI} spring.security.oauth2.client.provider.keycloak.user-info-uri=${OAUTH_USER_INFO_URI} spring.security.oauth2.client.provider.keycloak.jwk-set-uri=${OAUTH_JWK_SET_URI} +# metrics, prometheus & actuator management.endpoints.web.base-path=/actuator management.endpoints.web.exposure.include=* management.endpoint.health.show-details=always -management.endpoint.metrics.enabled=true -management.endpoint.prometheus.enabled=true springdoc.api-docs.enabled=true springdoc.api-docs.path=/v3/api-docs springdoc.swagger-ui.path=/swagger-ui.html -io.min.name=${IO_MIN_NAME} -io.min.secret=${IO_MIN_PASSWORD} -io.min.url=${IO_MIN_URL} -io.min.bucket=${IO_MIN_BUCKET} -io.min.folder=${IO_MIN_FOLDER} - ### grphql -spring.graphql.path=/graphql +spring.graphql.http.path=/graphql spring.graphql.graphiql.enabled=true spring.graphql.graphiql.path=/graphiql spring.graphql.cors.allowed-origins=* spring.graphql.cors.allowed-headers=* spring.graphql.cors.allowed-methods=* -## observation & zipkin -management.tracing.enabled=true -management.zipkin.tracing.endpoint=${ZIPKIN_URL} +# metrics, prometheus & actuator +management.otlp.metrics.export.url=http://localhost:4318/v1/metrics +management.opentelemetry.tracing.export.otlp.endpoint=http://localhost:4318/v1/traces +management.opentelemetry.logging.export.otlp.endpoint=http://localhost:4318/v1/logs management.tracing.sampling.probability=1.0 -management.tracing.propagation.consume=b3 -management.tracing.propagation.produce=b3 -management.tracing.propagation.type=b3 -spring.kafka.template.observation-enabled=true -spring.kafka.listener.observation-enabled=true -management.tracing.baggage.correlation.enabled=true +spring.reactor.context-propagation=auto + +management.tracing.propagation.consume=w3c +management.tracing.propagation.produce=w3c +management.tracing.propagation.type=w3c + management.tracing.baggage.enabled=true -management.tracing.baggage.correlation.fields=spanId,traceId +management.tracing.baggage.correlation.enabled=true -management.otlp.metrics.export.enabled=true +# fix for tracing logs +management.observations.enable.spring.security=false +logging.level.io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor=ERROR \ No newline at end of file diff --git a/src/main/resources/config/env.template.properties b/src/main/resources/config/env.template.properties index 6093509..b7d0db9 100644 --- a/src/main/resources/config/env.template.properties +++ b/src/main/resources/config/env.template.properties @@ -22,11 +22,3 @@ OAUTH_GRANT_TYPE= EXTERNAL_URL= EXTERNAL_NAME= EXTERNAL_GRAPH_QL= - -ZIPKIN_URL= - -IO_MIN_NAME= -IO_MIN_PASSWORD= -IO_MIN_URL= -IO_MIN_BUCKET= -IO_MIN_FOLDER= diff --git a/src/main/resources/graphql/schema.graphqls b/src/main/resources/graphql/schema.graphqls index fd1129a..f4fc54e 100644 --- a/src/main/resources/graphql/schema.graphqls +++ b/src/main/resources/graphql/schema.graphqls @@ -1,5 +1,4 @@ type Query { - getAllPermissions(page: Int, size: Int, sort: String, direction: String): [Permission] getPermission(id: String): Permission diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 3c8f9d7..4ee9a2b 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -1,6 +1,7 @@ + @@ -12,77 +13,67 @@ - + + ${LOGS}/spring-boot-logger.log + + %d %p %C [%t] %m%n + + + + + ${LOGS}/archived/spring-boot-logger-%d{yyyy-MM-dd}.%i.log + + + 10MB + + + + + + + + - + + + + + + + + + - - - - - - ${LOGSTASH_HOST}:${LOGSTASH_PORT} - - - - - - { - "application": "${projectName}", - "timestamp": "%d{yyyy-MM-dd'T'HH:mm:ss.SSSZZ}", - "logger": "%logger", - "trace": "%X{traceId:-}", - "span": "%X{spanId:-}", - "thread": "%thread", - "level": "%level", - "message": "%message" - } - - - - - 2048 - 32768 - 256 - true - - - - - - - ${LOGS}/spring-boot-logger.log - - %d %p %C [%t] %m%n - - - - - ${LOGS}/archived/spring-boot-logger-%d{yyyy-MM-dd}.%i.log - - - 10MB - - - + - + - - + - + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/kotlin/com/softeno/template/sample/http/internal/reactive/IntegrationTest.kt b/src/test/kotlin/com/softeno/template/sample/http/internal/reactive/IntegrationTest.kt index 9c1e180..c13e918 100644 --- a/src/test/kotlin/com/softeno/template/sample/http/internal/reactive/IntegrationTest.kt +++ b/src/test/kotlin/com/softeno/template/sample/http/internal/reactive/IntegrationTest.kt @@ -8,7 +8,6 @@ import com.softeno.template.SoftenoReactiveMongoApp import com.softeno.template.app.permission.db.PermissionsReactiveRepository import com.softeno.template.app.user.db.UserReactiveRepository import com.softeno.template.fixture.PermissionFixture -import com.softeno.template.sample.http.dto.SampleResponseDto import io.mockk.every import kotlinx.coroutines.reactive.awaitFirstOrNull import kotlinx.coroutines.reactor.awaitSingle @@ -23,22 +22,24 @@ import org.junit.jupiter.api.TestInstance import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.context.properties.EnableConfigurationProperties -import org.springframework.boot.test.autoconfigure.core.AutoConfigureCache -import org.springframework.boot.test.autoconfigure.graphql.AutoConfigureGraphQl -import org.springframework.boot.test.autoconfigure.graphql.tester.AutoConfigureGraphQlTester +import org.springframework.boot.graphql.test.autoconfigure.AutoConfigureGraphQl +import org.springframework.boot.graphql.test.autoconfigure.tester.AutoConfigureGraphQlTester import org.springframework.boot.test.autoconfigure.json.AutoConfigureJson -import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient import org.springframework.boot.test.context.SpringBootTest -import org.springframework.boot.test.util.TestPropertyValues -import org.springframework.context.ApplicationContextInitializer -import org.springframework.context.ConfigurableApplicationContext +import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient import org.springframework.core.Ordered import org.springframework.core.annotation.Order +import org.springframework.data.mongodb.repository.config.EnableMongoRepositories import org.springframework.graphql.test.tester.GraphQlTester -import org.springframework.test.context.ContextConfiguration +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource import org.springframework.test.web.reactive.server.WebTestClient import org.springframework.web.reactive.function.client.WebClient -import org.testcontainers.containers.MongoDBContainer +import org.springframework.web.reactive.function.client.bodyToMono +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.kafka.KafkaContainer +import org.testcontainers.mongodb.MongoDBContainer import org.testcontainers.utility.DockerImageName import reactor.core.publisher.Flux import reactor.core.publisher.Mono @@ -49,34 +50,40 @@ import reactor.core.publisher.Mono properties = ["spring.profiles.active=integration"], webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT ) -@ContextConfiguration(initializers = [BaseIntegrationTest.Companion.Initializer::class]) @EnableConfigurationProperties +@EnableMongoRepositories @ConfigurationPropertiesScan("com.softeno") @AutoConfigureWebTestClient(timeout = "6000") @TestInstance(TestInstance.Lifecycle.PER_CLASS) -@AutoConfigureCache @AutoConfigureJson @AutoConfigureGraphQl @AutoConfigureGraphQlTester +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_CLASS) abstract class BaseIntegrationTest { companion object { - - private const val DATABASE_NAME = "example1" - - @JvmField - val dbContainer: MongoDBContainer = MongoDBContainer(DockerImageName.parse("mongo:6.0.4")) - - class Initializer : ApplicationContextInitializer { - override fun initialize(applicationContext: ConfigurableApplicationContext) { - dbContainer.start() - - TestPropertyValues.of( - "spring.data.mongodb.uri=${Companion.dbContainer.connectionString}/${DATABASE_NAME}", - "mongo.database=$DATABASE_NAME" - ).applyTo(applicationContext.environment) + @Container + var kafka: KafkaContainer = KafkaContainer(DockerImageName.parse("apache/kafka-native:3.8.0")) + .withEnv("KAFKA_AUTO_CREATE_TOPICS_ENABLE", "true") + .withEnv("ALLOW_PLAINTEXT_LISTENER", "true") + .withEnv("KAFKA_CREATE_TOPICS", "sample_topic_2" + ":1:1") + + @Container + var mongoDBContainer = MongoDBContainer(DockerImageName.parse("mongo:6.0.4")) + .withEnv("MONGO_INITDB_DATABASE", "example1") + + @JvmStatic + @DynamicPropertySource + fun registerDynamicProperties(registry: DynamicPropertyRegistry) { + kafka.start() + registry.add("spring.kafka.bootstrap-servers") { + kafka.bootstrapServers } + mongoDBContainer.start() + registry.add("spring.mongodb.uri") { + mongoDBContainer.replicaSetUrl + } } } @@ -105,11 +112,10 @@ abstract class BaseIntegrationTest { class ContextLoadsTest : BaseIntegrationTest() { - val dbContainer: MongoDBContainer = BaseIntegrationTest.dbContainer - @Test fun contextLoads() { - assertTrue(dbContainer.isRunning) + assertTrue(mongoDBContainer.isRunning) + assertTrue(kafka.isRunning) } } @@ -184,7 +190,7 @@ class ExternalControllerTest : BaseIntegrationTest(), ExternalApiAbility { // expect val response = webclient.get().uri("http://localhost:4500/sample/100") .retrieve() - .bodyToMono(SampleResponseDto::class.java) + .bodyToMono() .awaitSingle() assertEquals(expected, response) @@ -246,7 +252,7 @@ class GraphqlPermissionControllerTestDocument : BaseIntegrationTest(), Permissio } """.trimIndent() - // note: returned result differs from graphigl because we use: .path("getAllPermissions") + // note: a returned result differs from graphigl because we use: .path("getAllPermissions") val expected = """ [{"name":"${aPermission.name}","description":"${aPermission.description}"}] """.trimIndent() diff --git a/src/test/kotlin/com/softeno/template/sample/http/internal/reactive/PlaygroundTest.kt b/src/test/kotlin/com/softeno/template/sample/http/internal/reactive/PlaygroundTest.kt deleted file mode 100644 index f64a35a..0000000 --- a/src/test/kotlin/com/softeno/template/sample/http/internal/reactive/PlaygroundTest.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.softeno.template.sample.http.internal.reactive - -import com.softeno.template.playground.CoroutinePlayground -import com.softeno.template.playground.helloCoroutine -import com.softeno.template.playground.someError -import io.mockk.coEvery -import io.mockk.mockk -import io.mockk.mockkStatic -import io.mockk.unmockkStatic -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.CsvSource -import org.junit.jupiter.params.provider.ValueSource - -class PlaygroundTest { - - @Test - fun testHelloCoroutine() = runTest { - val result = helloCoroutine() - assertEquals("hello", result) - } - - @Test - fun testSomeError() = runTest { - val exception = assertThrows { someError() } - assertEquals(exception.message, "Some Error Occurred") - } - -} - -class PlaygroundMockTest { - - @Test - fun `test hello coroutine Mock`() = runTest { - // setup - mockkStatic("com.softeno.template.playground.CoroutinePlaygroundKt") - - // given - val mockedValue = "mockk" - coEvery { helloCoroutine() }.answers { mockedValue } - - // when - val result = helloCoroutine() - - // then - assertEquals(result, mockedValue) - - // clean up - unmockkStatic("com.softeno.template.playground.CoroutinePlaygroundKt") - } - - @ParameterizedTest(name = "{index} => input=''{0}''") - @ValueSource(strings = ["Hello", "World"]) - fun `test coroutine playground run mocked`(input: String) = runTest { - // given - val mockedValue = "mockk" - val coroutinePlayground = mockk() - coEvery { coroutinePlayground.sampleFunction(any()) }.answers { mockedValue } - - // when - val result = coroutinePlayground.sampleFunction(input) - - // then - assertEquals(result, mockedValue) - } - - - @ParameterizedTest - @CsvSource(value = ["test:test", "tEst:test", "Java:java"], delimiter = ':') - fun `test coroutine playground run mocked with multiple values`(a: String, b: String) = runTest { - // given - val mockedValue = "$a, $b" - val coroutinePlayground = mockk() - coEvery { coroutinePlayground.sampleFunction(any()) }.answers { mockedValue } - - // when - val result = coroutinePlayground.sampleFunction(a + b) - - // then - assertEquals(result, mockedValue) - } - -} \ No newline at end of file diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 2f65b56..e86d711 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -4,6 +4,8 @@ com.softeno.kafka.tx=sample_topic_2 com.softeno.kafka.rx=sample_topic_2 com.softeno.kafka.keycloak=keycloak-events +spring.data.mongodb.auto-index-creation=true + com.softeno.external.url=http://localhost:4500/sample com.softeno.external.name=node-service com.softeno.external.graphql-url=http://localhost:4500/graphql @@ -20,15 +22,11 @@ resilience4j.circuitbreaker.instances.customer-service.slidingWindowSize=10 resilience4j.circuitbreaker.instances.customer-service.waitDurationInOpenState=50s resilience4j.circuitbreaker.instances.customer-service.permittedNumberOfCallsInHalfOpenState=3 -management.endpoint.metrics.enabled=false -management.endpoint.prometheus.enabled=false - springdoc.api-docs.enabled=false spring.graphql.graphiql.enabled=false -spring.graphql.path=/graphql +spring.graphql.http.path=/graphql -spring.kafka.bootstrap-servers=localhost:9092 spring.kafka.consumer.group-id=sample-group-jvm spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer @@ -37,10 +35,3 @@ spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.Str spring.kafka.consumer.value-deserializer=org.springframework.kafka.support.serializer.JsonDeserializer spring.kafka.properties.spring.json.trusted.packages=* spring.kafka.consumer.properties.spring.json.use.type.headers=false - -management.tracing.enabled=false -spring.kafka.template.observation-enabled=false -spring.kafka.listener.observation-enabled=false -management.tracing.baggage.correlation.enabled=false -management.tracing.baggage.enabled=false -management.otlp.metrics.export.enabled=false \ No newline at end of file