From eeb967d2eb48433729aaf5653b90196be500cecb Mon Sep 17 00:00:00 2001 From: xcodeassociated Date: Tue, 17 Feb 2026 19:25:03 +0100 Subject: [PATCH 01/17] wip: migrate to spring boot 4 --- build.gradle | 50 ++++++++++--------- .../template/app/common/PrincipalHandler.kt | 2 +- .../config/security/ReactiveAuditorConfig.kt | 4 +- .../http/config/CircuitBreakerConfig.kt | 2 +- 4 files changed, 30 insertions(+), 28 deletions(-) diff --git a/build.gradle b/build.gradle index ca00112..1eb077f 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ buildscript { } 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' @@ -14,6 +14,13 @@ plugins { id 'groovy' } +ext { + set('springCloudVersion', '2025.1.1') + set('testcontainersVersion', '2.0.3') + set('springBootVersion', '4.0.2') +} + + group = 'com.softeno' version = '0.0.1-SNAPSHOT' @@ -33,14 +40,9 @@ repositories { mavenCentral() } -ext { - set('springCloudVersion', '2025.0.1') - set('testcontainersVersion', '2.0.3') -} - dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc' - implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation "org.springframework.boot:spring-boot-starter-data-r2dbc:${springBootVersion}" + implementation "org.springframework.boot:spring-boot-starter-webflux:${springBootVersion}" implementation 'org.springframework:spring-jdbc' runtimeOnly 'org.postgresql:r2dbc-postgresql' runtimeOnly 'org.postgresql:postgresql' @@ -52,10 +54,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' developmentOnly 'org.springframework.boot:spring-boot-devtools' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-slf4j' - annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + annotationProcessor "org.springframework.boot:spring-boot-configuration-processor:${springBootVersion}" // test utils - testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation "org.springframework.boot:spring-boot-starter-test:${springBootVersion}" testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test' testImplementation 'org.jetbrains.kotlin:kotlin-test-junit5' testImplementation 'io.projectreactor:reactor-test' @@ -68,16 +70,16 @@ dependencies { // monitoring runtimeOnly 'io.micrometer:micrometer-registry-prometheus' - implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation "org.springframework.boot:spring-boot-starter-actuator:${springBootVersion}" // elk implementation 'net.logstash.logback:logstash-logback-encoder:8.1' // testcontainers - testImplementation 'org.springframework.boot:spring-boot-testcontainers' - testImplementation 'org.testcontainers:junit-jupiter' - testImplementation 'org.testcontainers:postgresql' - testImplementation 'org.testcontainers:r2dbc' + testImplementation "org.springframework.boot:spring-boot-testcontainers:${springBootVersion}" + testImplementation "org.testcontainers:junit-jupiter:1.21.4" + testImplementation "org.testcontainers:postgresql:1.21.4" + testImplementation "org.testcontainers:r2dbc:1.21.4" // liquibase implementation ('org.liquibase:liquibase-core:4.33.0') { @@ -91,22 +93,22 @@ dependencies { liquibaseRuntime 'ch.qos.logback:logback-core:1.5.30' liquibaseRuntime 'ch.qos.logback:logback-classic:1.5.30' liquibaseRuntime 'info.picocli:picocli:4.7.7' - liquibaseRuntime 'org.springframework.boot:spring-boot-starter-data-r2dbc:3.5.10' + liquibaseRuntime "org.springframework.boot:spring-boot-starter-data-r2dbc:${springBootVersion}" liquibaseRuntime sourceSets.main.output // spring 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:7.0.3" // kafka - implementation 'org.springframework.kafka:spring-kafka' - implementation 'io.projectreactor.kafka:reactor-kafka' - testImplementation 'org.springframework.kafka:spring-kafka-test' + implementation "org.springframework.kafka:spring-kafka:${springBootVersion}" + implementation "io.projectreactor.kafka:reactor-kafka:1.3.25" + 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" // macOs ARM only if (osdetector.classifier == "osx-aarch_64") { 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 e18ed37..f166d20 100644 --- a/src/main/kotlin/com/softeno/template/app/common/PrincipalHandler.kt +++ b/src/main/kotlin/com/softeno/template/app/common/PrincipalHandler.kt @@ -12,7 +12,7 @@ interface PrincipalHandler { suspend fun showPrincipal(log: Log, monoPrincipal: Mono){ val principal = monoPrincipal.awaitSingleOrNull() log.info("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/security/ReactiveAuditorConfig.kt b/src/main/kotlin/com/softeno/template/app/config/security/ReactiveAuditorConfig.kt index 2d69b1b..da8b0f2 100644 --- a/src/main/kotlin/com/softeno/template/app/config/security/ReactiveAuditorConfig.kt +++ b/src/main/kotlin/com/softeno/template/app/config/security/ReactiveAuditorConfig.kt @@ -17,8 +17,8 @@ class AuditorAwareImpl : ReactiveAuditorAware { 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/sample/http/config/CircuitBreakerConfig.kt b/src/main/kotlin/com/softeno/template/sample/http/config/CircuitBreakerConfig.kt index 681e3f3..318678f 100644 --- a/src/main/kotlin/com/softeno/template/sample/http/config/CircuitBreakerConfig.kt +++ b/src/main/kotlin/com/softeno/template/sample/http/config/CircuitBreakerConfig.kt @@ -19,7 +19,7 @@ class ReactiveCircuitBreakerConfig { fun reactiveResilience4JCircuitBreakerFactory(): ReactiveCircuitBreakerFactory<*, *> { val circuitBreakerRegistry = CircuitBreakerRegistry.ofDefaults() val timeLimiterRegistry = TimeLimiterRegistry.ofDefaults() - return ReactiveResilience4JCircuitBreakerFactory(circuitBreakerRegistry, timeLimiterRegistry, null) + return ReactiveResilience4JCircuitBreakerFactory(circuitBreakerRegistry, timeLimiterRegistry, null, null) } @Bean From 995d793b9950b9d7fe527bf4239f29b5ecd76480 Mon Sep 17 00:00:00 2001 From: xcodeassociated Date: Tue, 17 Feb 2026 20:13:27 +0100 Subject: [PATCH 02/17] wip: add AGENTS.md for opencode --- AGENTS.md | 209 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..64a0068 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,209 @@ +# AGENTS.md – Guidance for Automated Coding Agents + +--- +## Table of Contents + +### Workflow Guidelines +- **Clarify Requirements First**: Before starting any change, ensure the requirement is fully understood. Ask the user follow‑up questions if anything is ambiguous. Multiple clarification questions are allowed. +- **Obtain Acceptance of Action Plan**: Always draft a **detailed, verbose implementation plan** (steps, design decisions, test strategy) and present it to the user. Do **not** begin any code changes until the user explicitly approves the plan. +- **Test‑Driven Development (TDD) Cycle**: + 1. Write comprehensive unit/integration tests following project conventions. + 2. Implement the minimum production code needed to satisfy the tests. + 3. Refactor while keeping all tests green. + 4. Repeat – add more tests, expand implementation, ensure clean, modular code. +- **Never Cut Corners**: Do not add dummy implementations merely to make tests pass. Follow SOLID, Open‑Closed, KISS, and hexagonal architecture principles. +- **Modular Design**: Keep new code modular, well‑encapsulated, and adhere to the existing layered architecture. +- **Documentation & Review**: Update relevant documentation, comments, and ensure code quality matches a senior developer’s standards. + + +1. [Project Overview](#project-overview) +2. [Build, Lint & Test Commands](#build-lint--test-commands) +3. [Running a Single Test](#running-a-single-test) +4. [Code Style & Conventions](#code-style--conventions) + - Imports + - Formatting & Layout + - Types & Nullability + - Naming Conventions + - Error Handling + - Reactive & Coroutine Practices + - Architecture & Design Patterns +5. **Testing Guidelines** +6. **Performance & Back‑pressure** +7. **Commit & PR Best Practices** +8. **Additional Resources** +--- +## Project Overview +- **Preference**: Prefer Kotlin coroutines over Project Reactor for asynchronous handling. Agents should convert reactive streams to coroutines where feasible, while maintaining back‑pressure support. +- **Language**: Kotlin 1.9 (JVM target) +- **Framework**: Spring Boot 4.x (reactive stack) +- **Database**: R2DBC (PostgreSQL) with Flyway migrations +- **Messaging**: Kafka (reactive client) +- **Testing**: JUnit 5, Kotest, MockK, Spring Test, WebTestClient, Testcontainers +- **Build Tool**: Gradle 8 (Kotlin DSL) +- **Containerisation**: Docker & Docker‑Compose (Postgres) + +The codebase follows a **hexagonal / clean‑architecture** flavour – core domain logic lives in `src/main/kotlin/com/softeno/template/app/...` and adapters (web, db, kafka) are separate packages. + +--- +## Build, Lint & Test Commands +| Goal | Command | Description | +|------|---------|-------------| +| **Compile** | `./gradlew clean build` | Full clean compile, runs tests, creates JAR. | +| **Run Application** | `./gradlew bootRun` | Starts the Spring Boot app (uses `application.properties`). | +| **Package JAR** | `./gradlew bootJar` | Produces an executable JAR in `build/libs`. | +| **Run Checks Only** | `./gradlew check` | Executes linting, static analysis, and tests without building the JAR. | +| **Static Analysis** | `./gradlew detektMain detektTest` | Runs Detekt (Kotlin linter) on source and test code. | +| **Formatting** | `./gradlew spotlessApply` | Applies Spotless formatting (Kotlin + Gradle). | +| **Run All Tests** | `./gradlew test` | Executes unit + integration tests. | +| **Run Integration Tests Only** | `./gradlew integrationTest` *(if defined)* | Use custom source set; otherwise filter with tags. | +| **Run Tests with Coverage** | `./gradlew jacocoTestReport` | Generates a JaCoCo coverage report under `build/reports/jacoco`. | +| **Run a Single Test** | See section below. | + +--- +## Running a Single Test +Kotlin test classes are discovered by the `test` task. To run a single test method or class, use the Gradle `--tests` filter: + +```bash +# Run a single test class +./gradlew test --tests "com.softeno.template.app.permission.service.PermissionServiceTest" + +# Run a single test method (full qualified name) +./gradlew test --tests "com.softeno.template.app.permission.service.PermissionServiceTest.shouldPersistAndRetrievePermission" +``` + +*Tip for agents*: When a failing test is identified, re‑run it with the same filter to verify the fix quickly. + +--- +## Code Style & Conventions +The following rules are **non‑negotiable** for any automated agent. Violations will cause the CI lint step to fail. + +### 1. Imports +- **Alphabetical order**, grouped by: `kotlin.*`, `java.*`, `org.*`, `com.*`. +- One blank line between groups. +- Use **single‑type imports** (`import java.util.UUID`) – avoid wildcard `*` imports. +- Remove unused imports – Detekt’s `UnusedImports` rule enforces this. + +```kotlin +import com.softeno.template.app.permission.api.PermissionController +import com.softeno.template.app.permission.service.PermissionService +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +``` + +### 2. Formatting & Layout +- **Indentation**: 4 spaces, no tabs. +- **Line length**: max 120 characters. +- **Braces**: K&R style – opening brace on same line. +- **Trailing commas** in multi‑line collections/parameter lists (Kotlin 1.9 supports it). +- **Blank lines**: separate logical sections (e.g., imports, class header, properties, functions). +- **Spotless** is the single source of truth – run `./gradlew spotlessApply` before committing. + +### 3. Types & Nullability +- Prefer **non‑nullable** types. Use `?` only when the value can legitimately be absent. +- When a nullable value is required, employ **`requireNotNull`** or **`?.let {}`** to avoid NPEs. +- Leverage Kotlin’s **type‑aliases** for complex reactive types, e.g.: `typealias Flux = reactor.core.publisher.Flux` for readability. +- Use **value classes** (`@JvmInline`) for simple wrappers (e.g., `PermissionId`). + +### 4. Naming Conventions +| Element | Convention | +|---------|-------------| +| Packages | lower‑case, dot‑separated (`com.softeno.template.app.permission`) | +| Classes / Interfaces | PascalCase (`PermissionService`, `PermissionRepository`) | +| Enums | PascalCase, singular (`PermissionStatus`) | +| Functions | camelCase, verb‑first (`createPermission`, `findById`) | +| Properties | camelCase, descriptive (`permissionId`, `createdAt`) | +| Constants | Upper‑snake (`MAX_RETRY_ATTEMPTS`) | +| Test classes | `Test` (`PermissionServiceTest`) | +| Test methods | descriptive, `should` (`shouldPersistAndRetrievePermission`) | + +### 5. Error Handling +- **Never swallow exceptions**; re‑throw or map to a domain‑specific exception. +- Use **Spring’s `ResponseStatusException`** for HTTP errors, with appropriate status codes. +- For reactive pipelines, prefer **`onErrorMap`** / **`onErrorResume`** rather than `try/catch`. +- Create a **`DomainException`** hierarchy for business‑logic errors. +- Log errors at **WARN** or **ERROR** level with **structured logging** (`log.error("Failed to …", e)`). + +### 6. Reactive & Coroutine Practices +- Prefer **Project Reactor** (`Mono`, `Flux`) for public APIs; use **Kotlin coroutines** (`suspend`, `Flow`) for internal services when it simplifies code. +- **Back‑pressure**: never `block()` inside a reactive chain. Use `publishOn`/`subscribeOn` wisely. +- Convert between Reactor and Coroutines with `reactor.kotlin.coroutines.awaitSingle`, `asFlux()`, etc. +- Keep **side‑effects** at the end of the chain; avoid modifying shared state inside `map`. +- **Timeouts**: apply `timeout(Duration)` on external calls (WebClient, R2DBC) to protect the system. + +### 7. Architecture & Design Patterns +- Follow **Hexagonal (Ports & Adapters)**: core domain (`service`, `mapper`, `model`) has no Spring annotations; adapters (`controller`, `repository`, `kafka`) implement interfaces defined in the domain. +- Use **Dependency Injection** (constructor injection only). No field injection. +- Apply **SOLID**: + - **Single Responsibility** – each class does one thing. + - **Open/Closed** – extend behavior via interfaces, avoid modifying existing classes. + - **Liskov Substitution** – keep contracts consistent. + - **Interface Segregation** – small, focused interfaces. + - **Dependency Inversion** – depend on abstractions. +- **KISS** – avoid over‑engineering; introduce a pattern only when the problem demands it. +- Common OO patterns used: + - **Factory** (for creating domain objects from DTOs). + - **Adapter** (e.g., Kafka listener to service). + - **Decorator** (adding logging or metrics to repositories). +- **Do NOT** introduce a full‑blown DDD layer unless a clear bounded context emerges. + +--- +## Testing Guidelines +1. **Unit Tests** + - Focus on **pure business logic** – repository & controller layers are covered by integration tests. + - Use **MockK** for mocks/stubs; verify interactions only when they affect behavior. + - Avoid testing getters/setters or trivial data classes. + - Use **`@Nested`** classes (JUnit) for grouping scenarios. + - Keep test method names expressive (`shouldReturnPermissionWhenExists`). +2. **Integration Tests** + - Spin up **Testcontainers** for PostgreSQL & Kafka where needed. + - Use **`@SpringBootTest`** with `webEnvironment = RANDOM_PORT` and `WebTestClient` for end‑to‑end HTTP flow. + - Test **error paths**, **validation**, **security** (401/403), and **back‑pressure** behaviour. + - Do **not** mock Spring beans; rely on the actual context to verify wiring. +3. **Performance / Reactive Tests** + - Use **`StepVerifier`** with `virtualTime()` for time‑based operators. + - Verify **`request(n)`** behaviour to ensure back‑pressure compliance. +4. **Coverage** + - Target **~80%** line coverage; focus on critical paths. + - Exclude generated code and third‑party libraries from coverage metrics. +5. **Test Data** + - Use **Builder pattern** or Kotlin `apply {}` blocks for fixture creation. + - Keep fixtures **immutable**; share via companion objects when appropriate. +6. **CI Recommendations** + - `./gradlew test` must succeed in CI. + - `./gradlew detektMain detektTest spotlessCheck` must pass. + - Failing tests abort the PR. + +--- +## Performance & Back‑pressure +- **Reactive DB Calls**: always use **R2DBC** non‑blocking APIs; never call `blocking` JDBC. +- **Kafka**: configure **`max.poll.records`** and **`reactor.kafka.receiver.KafkaReceiver`** to respect demand. +- **WebClient**: enable **`connectionPool`** and **`readTimeout`**; reuse the same bean. +- **Circuit Breaker**: use Resilience4j with sensible defaults (`failureRateThreshold=50`, `waitDurationInOpenState=30s`). +- **Metric Collection**: expose **Micrometer** counters for request latency and throughput. +- **Avoid heavy object allocation** in hot paths – reuse immutable data classes. +- **Profiling**: run `./gradlew bootRun --args='--spring.profiles.active=dev'` and attach a profiler (YourKit, VisualVM) for bottleneck analysis. + +--- +## Commit & PR Best Practices +1. **Atomic Commits** – one logical change per commit. +2. **Commit Message** – `{type}(scope): short description` + - `type` = `feat`, `fix`, `refactor`, `test`, `chore`. + - Example: `feat(permission): add bulk‑create endpoint`. +3. **PR Title** – concise, matches first line of commit. +4. **PR Description** – include **What**, **Why**, **How** and **Testing steps**. +5. **No Secrets** – never commit `.env`, API keys, passwords. +6. **Never commit or push without explicit user approval** – agents must obtain clear user confirmation before executing any `git commit` or `git push` commands. +7. **Rebase** – keep a linear history; avoid merge commits. +8. **CI Checks** – ensure `./gradlew check` passes before merging. + +--- +## Additional Resources +- **Kotlin Coding Conventions** – https://kotlinlang.org/docs/coding-conventions.html +- **Spring Reactive Documentation** – https://docs.spring.io/spring-framework/reference/web/reactive.html +- **Detekt Ruleset** – https://detekt.dev/docs/rules/ +- **Hexagonal Architecture** – https://alistair.cockburn.us/hexagonal-architecture/ +- **Resilience4j** – https://resilience4j.readme.io/ +- **Testcontainers** – https://www.testcontainers.org/ +- **Spotless Kotlin Plugin** – https://github.com/diffplug/spotless/tree/main/plugin-gradle#kotlin +--- +*Generated by the AI assistant to guide automated agents working on the `spring-reactive-r2dbc-template` repository. Follow these rules strictly to keep the codebase clean, performant, and maintainable.* From 3cf6843522e8501618ce744bc030e85a3ae05ffb Mon Sep 17 00:00:00 2001 From: xcodeassociated Date: Wed, 18 Feb 2026 17:39:28 +0100 Subject: [PATCH 03/17] wip: migrate spring boot to version 4 with spring kafka --- build.gradle | 29 +++++-- .../com/softeno/template/SoftenoMvcJpaApp.kt | 7 +- .../app/kafka/KafkaKeycloakController.kt | 35 ++------- .../template/app/kafka/KafkaSampleHandler.kt | 78 +++---------------- .../template/app/kafka/config/KafkaConfig.kt | 68 ++++++++-------- .../template/app/permission/Permission.kt | 7 ++ .../permission/api/PermissionController.kt | 12 ++- .../app/permission/db/PermissionRepository.kt | 34 +++++--- .../permission/service/PermissionService.kt | 6 +- .../http/config/CircuitBreakerConfig.kt | 9 --- .../sample/http/config/WebClientConfig.kt | 3 +- src/main/resources/application.properties | 3 +- .../softeno/template/app/IntegrationTest.kt | 55 ++++++++++++- 13 files changed, 174 insertions(+), 172 deletions(-) diff --git a/build.gradle b/build.gradle index 1eb077f..c62d1f5 100644 --- a/build.gradle +++ b/build.gradle @@ -43,6 +43,7 @@ repositories { dependencies { implementation "org.springframework.boot:spring-boot-starter-data-r2dbc:${springBootVersion}" implementation "org.springframework.boot:spring-boot-starter-webflux:${springBootVersion}" + implementation "org.springframework.boot:spring-boot-r2dbc:${springBootVersion}" implementation 'org.springframework:spring-jdbc' runtimeOnly 'org.postgresql:r2dbc-postgresql' runtimeOnly 'org.postgresql:postgresql' @@ -58,6 +59,11 @@ dependencies { // test utils 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-starter-r2dbc-test:${springBootVersion}" + testImplementation "org.springframework.boot:spring-boot-starter-data-r2dbc-test:${springBootVersion}" + testImplementation "org.springframework.boot:spring-boot-starter-liquibase-test:${springBootVersion}" + testImplementation "org.springframework.boot:spring-boot-webtestclient:${springBootVersion}" testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test' testImplementation 'org.jetbrains.kotlin:kotlin-test-junit5' testImplementation 'io.projectreactor:reactor-test' @@ -77,11 +83,12 @@ dependencies { // testcontainers testImplementation "org.springframework.boot:spring-boot-testcontainers:${springBootVersion}" - testImplementation "org.testcontainers:junit-jupiter:1.21.4" - testImplementation "org.testcontainers:postgresql:1.21.4" - testImplementation "org.testcontainers:r2dbc:1.21.4" + testImplementation "org.testcontainers:testcontainers-junit-jupiter:${testcontainersVersion}" + testImplementation "org.testcontainers:testcontainers-postgresql:${testcontainersVersion}" + testImplementation "org.testcontainers:testcontainers-r2dbc:${testcontainersVersion}" // liquibase + implementation "org.springframework.boot:spring-boot-starter-liquibase:${springBootVersion}" implementation ('org.liquibase:liquibase-core:4.33.0') { exclude group: 'javax.xml.bind', module: 'jaxb-receiver' exclude group: 'org.liquibase.ext', module: 'liquibase-hibernate5' @@ -103,6 +110,7 @@ dependencies { testImplementation "org.springframework.security:spring-security-test:7.0.3" // kafka + implementation "org.springframework.boot:spring-boot-starter-kafka:${springBootVersion}" implementation "org.springframework.kafka:spring-kafka:${springBootVersion}" implementation "io.projectreactor.kafka:reactor-kafka:1.3.25" testImplementation "org.springframework.kafka:spring-kafka-test:${springBootVersion}" @@ -116,13 +124,18 @@ dependencies { } // 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:3.0.1' + implementation 'org.springdoc:springdoc-openapi-starter-webflux-ui:3.0.1' // zipkin - implementation 'io.micrometer:micrometer-tracing-bridge-otel' - implementation 'io.opentelemetry:opentelemetry-exporter-zipkin' - implementation 'io.zipkin.reporter2:zipkin-sender-urlconnection' +// implementation "org.springframework.boot:spring-boot-starter-zipkin:${springBootVersion}" +// testImplementation "org.springframework.boot:spring-boot-starter-zipkin-test:${springBootVersion}" +// implementation "org.springframework.boot:spring-boot-starter-opentelemetry:${springBootVersion}" +// testImplementation "org.springframework.boot:spring-boot-starter-opentelemetry-test:${springBootVersion}" +// +// implementation 'io.micrometer:micrometer-tracing-bridge-otel' +// implementation 'io.opentelemetry:opentelemetry-exporter-zipkin' +// implementation 'io.zipkin.reporter2:zipkin-sender-urlconnection' } diff --git a/src/main/kotlin/com/softeno/template/SoftenoMvcJpaApp.kt b/src/main/kotlin/com/softeno/template/SoftenoMvcJpaApp.kt index 30d2fd6..7e6cd06 100644 --- a/src/main/kotlin/com/softeno/template/SoftenoMvcJpaApp.kt +++ b/src/main/kotlin/com/softeno/template/SoftenoMvcJpaApp.kt @@ -4,7 +4,10 @@ import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.runApplication +import org.springframework.context.annotation.ComponentScan +import org.springframework.data.r2dbc.config.EnableR2dbcAuditing import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories +import org.springframework.kafka.annotation.EnableKafka import org.springframework.transaction.annotation.EnableTransactionManagement import reactor.core.publisher.Hooks @@ -12,6 +15,9 @@ import reactor.core.publisher.Hooks @EnableTransactionManagement @EnableConfigurationProperties @EnableR2dbcRepositories +@EnableR2dbcAuditing +@EnableKafka +@ComponentScan("com.softeno") @ConfigurationPropertiesScan("com.softeno") class SoftenoMvcJpaApp @@ -19,4 +25,3 @@ fun main(args: Array) { Hooks.enableAutomaticContextPropagation() runApplication(*args) } - 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 20b9ddd..d645fcd 100644 --- a/src/main/kotlin/com/softeno/template/app/kafka/KafkaKeycloakController.kt +++ b/src/main/kotlin/com/softeno/template/app/kafka/KafkaKeycloakController.kt @@ -1,41 +1,18 @@ package com.softeno.template.app.kafka import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.ObjectMapper -import com.softeno.template.app.kafka.dto.KeycloakUserEvent 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 + @Controller -class ReactiveKafkaKeycloakController( - @Qualifier(value = "kafkaKeycloakConsumerTemplate") private val reactiveKafkaConsumerTemplate: ReactiveKafkaConsumerTemplate, - private val objectMapper: ObjectMapper -) : CommandLineRunner { +class KafkaKeycloakController { 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 = "kafka-keycloak", topics = ["keycloak-events"]) + fun listen1(record: ConsumerRecord) { + log.info("[kafka] rx keycloak: ${record.key()}: ${record.value()}") } } \ No newline at end of file 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 7dd7eea..e597883 100644 --- a/src/main/kotlin/com/softeno/template/app/kafka/KafkaSampleHandler.kt +++ b/src/main/kotlin/com/softeno/template/app/kafka/KafkaSampleHandler.kt @@ -1,90 +1,34 @@ package com.softeno.template.app.kafka - 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.kafka.dto.KafkaMessage -import io.micrometer.observation.ObservationRegistry -import io.micrometer.tracing.Span -import io.micrometer.tracing.Tracer -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.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.receiver.ReceiverOptions -import reactor.kafka.sender.SenderResult - @Controller -class ReactiveKafkaSampleController( - @Qualifier(value = "kafkaSampleConsumerTemplate") private val reactiveKafkaConsumerTemplate: ReactiveKafkaConsumerTemplate, - private val objectMapper: ObjectMapper, - private val tracer: Tracer, - private val observationRegistry: ObservationRegistry, - @Qualifier(value = "kafkaSampleOptions") private val kafkaReceiverOptions: ReceiverOptions -) : CommandLineRunner { +class KafkaSampleController { private val log = LogFactory.getLog(javaClass) - 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) - - // 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.nextLong().toString()) - .spanId(tracer.nextSpan().name("kafka-consumer").context().spanId()) - .parentId(kafkaMessage.spanId ?: (tracer.currentSpan()?.context()?.spanId() ?: Span.NOOP.context().spanId())) - .sampled(true) - .build() - - tracer.currentTraceContext().newScope(contextWithCustomTraceId).use { - val span = tracer.nextSpan().name("kafka-consumer") - tracer.withSpan(span.start()).use { - log.info("[kafka] rx sample: $kafkaMessage") - // additional processing ... - } - 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() + @KafkaListener(id = "sample_group_1", topics = ["\${com.softeno.kafka.rx}"]) + fun listen1(record: ConsumerRecord) { + log.info("[kafka] rx keycloak: ${record.key()}: ${record.value()}") } } -@Service -class ReactiveKafkaSampleProducer( - @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() + 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 7229c16..101780f 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,13 +3,14 @@ package com.softeno.template.app.kafka.config import com.fasterxml.jackson.databind.JsonNode import com.softeno.template.app.kafka.dto.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.boot.kafka.autoconfigure.KafkaProperties 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.core.DefaultKafkaConsumerFactory +import org.springframework.kafka.core.DefaultKafkaProducerFactory +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.core.ProducerFactory import org.springframework.kafka.support.converter.ByteArrayJsonMessageConverter import org.springframework.kafka.support.converter.JsonMessageConverter import reactor.kafka.receiver.ReceiverOptions @@ -31,15 +32,15 @@ class JsonMessageConverterConfig { } @Configuration -class ReactiveKafkaSampleConsumerConfig { - @Bean(value = ["kafkaSampleOptions"]) +class KafkaConsumerConfig { + @Bean fun kafkaReceiverOptions( kafkaProperties: KafkaProperties, props: KafkaApplicationProperties, observationRegistry: ObservationRegistry ): ReceiverOptions { val basicReceiverOptions: ReceiverOptions = - ReceiverOptions.create(kafkaProperties.buildConsumerProperties(null)) + ReceiverOptions.create(kafkaProperties.buildConsumerProperties()) val basicReceiverOptionsWithObs = basicReceiverOptions // todo: make better observation handling by reactive kafka, currently the zipkin does not show the traces properly .withObservation(observationRegistry, KafkaReceiverObservation.DefaultKafkaReceiverObservationConvention() @@ -47,38 +48,43 @@ class ReactiveKafkaSampleConsumerConfig { return basicReceiverOptionsWithObs.subscription(Collections.singletonList(props.rx)) } - @Bean(value = ["kafkaSampleConsumerTemplate"]) - fun reactiveKafkaConsumerTemplate(@Qualifier(value = "kafkaSampleOptions") kafkaReceiverOptions: ReceiverOptions): ReactiveKafkaConsumerTemplate { - return ReactiveKafkaConsumerTemplate(kafkaReceiverOptions) + @Bean + fun kafkaConsumerTemplate(kafkaReceiverOptions: ReceiverOptions): DefaultKafkaConsumerFactory { + return DefaultKafkaConsumerFactory(kafkaReceiverOptions.consumerProperties()) } } @Configuration -class ReactiveKafkaSampleProducerConfig { - @Bean(value = ["kafkaSampleProducerTemplate"]) - fun reactiveKafkaProducerTemplate(properties: KafkaProperties, observationRegistry: ObservationRegistry): ReactiveKafkaProducerTemplate { - val props = properties.buildProducerProperties(null) +class KafkaProducerConfig { + @Bean + fun kafkaProducerTemplate(properties: KafkaProperties, observationRegistry: ObservationRegistry): ProducerFactory { + val props = properties.buildProducerProperties() 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) + return DefaultKafkaProducerFactory(options.producerProperties()) } -} -@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)) + @Bean + fun kafkaTemplate(producerFactory: ProducerFactory): KafkaTemplate { + return KafkaTemplate(producerFactory) } +} - @Bean(value = ["kafkaKeycloakConsumerTemplate"]) - fun reactiveKafkaConsumerTemplate(@Qualifier(value = "kafkaKeycloakOptions") kafkaReceiverOptions: ReceiverOptions): ReactiveKafkaConsumerTemplate { - return ReactiveKafkaConsumerTemplate(kafkaReceiverOptions) - } -} \ No newline at end of file +//@Configuration +//class ReactiveKafkaKeycloakConsumerConfig { +// @Bean(value = ["kafkaKeycloakOptions"]) +// fun kafkaReceiverOptions( +// kafkaProperties: KafkaProperties, +// props: KafkaApplicationProperties +// ): ReceiverOptions { +// val basicReceiverOptions: ReceiverOptions = +// ReceiverOptions.create(kafkaProperties.buildConsumerProperties()) +// return basicReceiverOptions.subscription(Collections.singletonList(props.keycloak)) +// } +// +// @Bean(value = ["kafkaKeycloakConsumerTemplate"]) +// fun kafkaConsumerTemplate(@Qualifier(value = "kafkaKeycloakOptions") kafkaReceiverOptions: ReceiverOptions): DefaultKafkaConsumerFactory { +// return DefaultKafkaConsumerFactory(kafkaReceiverOptions.consumerProperties()) +// } +//} \ No newline at end of file diff --git a/src/main/kotlin/com/softeno/template/app/permission/Permission.kt b/src/main/kotlin/com/softeno/template/app/permission/Permission.kt index 6e80450..07c66a3 100644 --- a/src/main/kotlin/com/softeno/template/app/permission/Permission.kt +++ b/src/main/kotlin/com/softeno/template/app/permission/Permission.kt @@ -1,12 +1,17 @@ package com.softeno.template.app.permission +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull import org.springframework.data.annotation.* import org.springframework.data.relational.core.mapping.Column import org.springframework.data.relational.core.mapping.Table import java.util.* open class BaseEntity( + @Transient open val uuid: UUID, + + @Transient open var id: Long? = null, ) @@ -39,9 +44,11 @@ data class Permission( @Column(value = "version") val version: Long = 0, + @NotBlank @Column(value = "name") val name: String, + @NotNull @Column(value = "description") val description: String ) : BaseEntity(uuid) { diff --git a/src/main/kotlin/com/softeno/template/app/permission/api/PermissionController.kt b/src/main/kotlin/com/softeno/template/app/permission/api/PermissionController.kt index 914d19b..eb47270 100644 --- a/src/main/kotlin/com/softeno/template/app/permission/api/PermissionController.kt +++ b/src/main/kotlin/com/softeno/template/app/permission/api/PermissionController.kt @@ -3,11 +3,9 @@ package com.softeno.template.app.permission.api import com.softeno.template.app.common.PrincipalHandler import com.softeno.template.app.permission.mapper.PermissionDto import com.softeno.template.app.permission.service.PermissionService -import io.micrometer.tracing.Tracer import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.reactor.awaitSingle import org.apache.commons.logging.LogFactory -import org.slf4j.MDC import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Sort import org.springframework.http.ResponseEntity @@ -20,7 +18,7 @@ import java.security.Principal @Validated class PermissionController( private val permissionService: PermissionService, - private val tracer: Tracer +// private val tracer: Tracer ) : PrincipalHandler { private val log = LogFactory.getLog(javaClass) @@ -46,10 +44,10 @@ class PermissionController( showPrincipal(log, monoPrincipal) // debug only - val traceId = tracer.currentSpan()?.context()?.traceId() - val mdc = MDC.get("traceId") - val mdcSpan = MDC.get("spanId") - log.debug("Show traceId=$traceId, mdcTraceId=$mdc and mdcSpanId=$mdcSpan") +// val traceId = tracer.currentSpan()?.context()?.traceId() +// val mdc = MDC.get("traceId") +// val mdcSpan = MDC.get("spanId") +// log.debug("Show traceId=$traceId, mdcTraceId=$mdc and mdcSpanId=$mdcSpan") log.debug("Show request params: " + "page=$page, size=$size, sort=$sort, direction=$direction, " + diff --git a/src/main/kotlin/com/softeno/template/app/permission/db/PermissionRepository.kt b/src/main/kotlin/com/softeno/template/app/permission/db/PermissionRepository.kt index 3bc18cf..0878476 100644 --- a/src/main/kotlin/com/softeno/template/app/permission/db/PermissionRepository.kt +++ b/src/main/kotlin/com/softeno/template/app/permission/db/PermissionRepository.kt @@ -12,7 +12,6 @@ import org.springframework.data.relational.core.mapping.Table import org.springframework.data.repository.kotlin.CoroutineCrudRepository import org.springframework.data.repository.query.Param import org.springframework.r2dbc.core.DatabaseClient -import org.springframework.r2dbc.core.bind import org.springframework.stereotype.Component import org.springframework.stereotype.Repository import reactor.core.publisher.Mono @@ -126,7 +125,13 @@ class BatchPermissionRepositoryImpl(private val databaseClient: entities.forEachIndexed { idx, entity -> activeProps.forEach { (prop, _) -> val value = (prop as KProperty1).get(entity) - spec = spec.bind("${prop.name}$idx", value) + val paramName = "${prop.name}$idx" + + spec = if (value != null) { + spec.bind(paramName, value) + } else { + spec.bindNull(paramName, Any::class.java) + } } } @@ -140,28 +145,31 @@ class BatchPermissionRepositoryImpl(private val databaseClient: throw OperationNotPermittedException("Cannot update entities with null id") val sample = entities.first() + val tableName = sample::class.findAnnotation()?.value - ?: throw OperationNotPermittedException("Missing @Table annotation on ${sample::class.simpleName}") + ?: throw OperationNotPermittedException( + "Missing @Table annotation on ${sample::class.simpleName}" + ) val properties = sample::class.memberProperties .filter { it.javaField?.isAnnotationPresent(Column::class.java) == true } .map { prop -> prop to prop.javaField!!.getAnnotation(Column::class.java).value } .filter { (_, colName) -> colName != "id" && colName != "uuid" } - val setClauses = properties.mapNotNull { (prop, colName) -> + if (properties.isEmpty()) return emptyMap() + + val setClauses = properties.map { (prop, colName) -> val cases = entities.mapIndexed { idx, entity -> val value = (prop as KProperty1).get(entity) - if (value != null) { - "WHEN :uuid$idx THEN :${prop.name}$idx" - } else null - }.filterNotNull() - if (cases.isEmpty()) null - else "$colName = CASE uuid ${cases.joinToString(" ")} ELSE $colName END" + if (value != null) + "WHEN :uuid$idx THEN :${prop.name}$idx" + else + "WHEN :uuid$idx THEN NULL" + } + "$colName = CASE uuid ${cases.joinToString(" ")} ELSE $colName END" } - if (setClauses.isEmpty()) return emptyMap() - val sql = """ UPDATE $tableName SET ${setClauses.joinToString(", ")} @@ -171,10 +179,12 @@ class BatchPermissionRepositoryImpl(private val databaseClient: var spec = databaseClient.sql(sql) + // Bind only what actually exists in SQL entities.forEachIndexed { idx, entity -> spec = spec.bind("uuid$idx", entity.uuid) properties.forEach { (prop, _) -> val value = (prop as KProperty1).get(entity) + if (value != null) { spec = spec.bind("${prop.name}$idx", value) } diff --git a/src/main/kotlin/com/softeno/template/app/permission/service/PermissionService.kt b/src/main/kotlin/com/softeno/template/app/permission/service/PermissionService.kt index 8905927..b388ef1 100644 --- a/src/main/kotlin/com/softeno/template/app/permission/service/PermissionService.kt +++ b/src/main/kotlin/com/softeno/template/app/permission/service/PermissionService.kt @@ -1,6 +1,6 @@ package com.softeno.template.app.permission.service -import com.softeno.template.app.kafka.ReactiveKafkaSampleProducer +import com.softeno.template.app.kafka.KafkaSampleProducer import com.softeno.template.app.kafka.dto.KafkaMessage import com.softeno.template.app.permission.Permission import com.softeno.template.app.permission.api.PermissionController @@ -28,7 +28,7 @@ import java.time.Instant @Service class PermissionService( private val permissionRepository: PermissionRepository, - private val kafkaPublisher: ReactiveKafkaSampleProducer, + private val kafkaProducer: KafkaSampleProducer, private val template: R2dbcEntityTemplate ) { private val log = LogFactory.getLog(javaClass) @@ -53,7 +53,7 @@ class PermissionService( suspend fun createPermission(permissionDto: PermissionDto): PermissionDto = withContext(MDCContext()) { val created = permissionRepository.save(Permission(name = permissionDto.name, description = permissionDto.description)).toDto() - kafkaPublisher.send(KafkaMessage(content = "CREATED_PREMISSION: ${created.id}", traceId = MDC.get("traceId"), spanId = MDC.get("spanId"))) + kafkaProducer.send(KafkaMessage(content = "CREATED_PREMISSION: ${created.id}", traceId = MDC.get("traceId"), spanId = MDC.get("spanId"))) return@withContext created } diff --git a/src/main/kotlin/com/softeno/template/sample/http/config/CircuitBreakerConfig.kt b/src/main/kotlin/com/softeno/template/sample/http/config/CircuitBreakerConfig.kt index 318678f..d0696d9 100644 --- a/src/main/kotlin/com/softeno/template/sample/http/config/CircuitBreakerConfig.kt +++ b/src/main/kotlin/com/softeno/template/sample/http/config/CircuitBreakerConfig.kt @@ -1,13 +1,10 @@ package com.softeno.template.sample.http.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 @@ -15,12 +12,6 @@ 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/config/WebClientConfig.kt b/src/main/kotlin/com/softeno/template/sample/http/config/WebClientConfig.kt index 2caaed3..b5f5f6b 100644 --- a/src/main/kotlin/com/softeno/template/sample/http/config/WebClientConfig.kt +++ b/src/main/kotlin/com/softeno/template/sample/http/config/WebClientConfig.kt @@ -39,12 +39,11 @@ class WebClientConfig { fun buildWebClient( config: ExternalClientConfig, authorizedClientManager: ReactiveOAuth2AuthorizedClientManager, - builder: WebClient.Builder ): WebClient { val oauth = ServerOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager) oauth.setDefaultClientRegistrationId("keycloak") - return builder + return WebClient.builder() .filter(oauth) .baseUrl(config.url) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 89d07e9..4c7941f 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -68,7 +68,7 @@ resilience4j.circuitbreaker.metrics.enabled=true resilience4j.retry.metrics.enabled=true resilience4j.ratelimiter.metrics.enabled=true -## observation & zipkin +### observation & zipkin management.tracing.enabled=true management.zipkin.tracing.endpoint=http://localhost:9411/api/v2/spans management.tracing.sampling.probability=1.0 @@ -78,6 +78,7 @@ management.tracing.propagation.type=b3 spring.kafka.template.observation-enabled=true spring.kafka.listener.observation-enabled=true + management.tracing.baggage.correlation.enabled=true management.tracing.baggage.enabled=true management.tracing.baggage.correlation.fields=spanId,traceId diff --git a/src/test/kotlin/com/softeno/template/app/IntegrationTest.kt b/src/test/kotlin/com/softeno/template/app/IntegrationTest.kt index d6e1be9..94a1492 100644 --- a/src/test/kotlin/com/softeno/template/app/IntegrationTest.kt +++ b/src/test/kotlin/com/softeno/template/app/IntegrationTest.kt @@ -28,8 +28,8 @@ import org.junit.jupiter.api.assertThrows 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.web.reactive.AutoConfigureWebTestClient import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient import org.springframework.core.Ordered import org.springframework.core.annotation.Order import org.springframework.data.domain.Pageable @@ -41,8 +41,8 @@ import org.springframework.test.web.reactive.server.WebTestClient import org.springframework.test.web.reactive.server.expectBody import org.springframework.web.reactive.function.BodyInserters import org.springframework.web.reactive.function.client.WebClient -import org.testcontainers.containers.PostgreSQLContainer import org.testcontainers.junit.jupiter.Container +import org.testcontainers.postgresql.PostgreSQLContainer @SpringBootTest( classes = [SoftenoMvcJpaApp::class], @@ -240,6 +240,57 @@ class BatchPermissionRepositoryImplTest : BaseIntegrationTest(), PermissionFixtu assertEquals(changedSaved[1].modifiedDate, changedAnotherPermission.modifiedDate) assertEquals(changedSaved[1].modifiedBy, changedAnotherPermission.modifiedBy) } + + @Test + fun `should allow batch update by setting null values to the props that were non-null before`() = runTest { + // given + val aPermission = aPermission(name = "1", description = "before change 1") + .copy(createdBy = "user 1", createdDate = 123456L, modifiedBy = "user 2", modifiedDate = 123456L, version = 0) + + // when + val returned = permissionRepository.insertAllReturningIds(listOf(aPermission)) + val aPermissionWithId = aPermission.copy(id = returned[aPermission]) + + // then + assertEquals(returned.size, 1) + + val saved = permissionRepository.findAll().toList() + assertEquals(saved.size, 1) + + assertEquals(saved[0].uuid, aPermission.uuid) + assertEquals(saved[0].name, aPermission.name) + assertEquals(saved[0].description, aPermission.description) + assertEquals(saved[0].uuid, aPermission.uuid) + assertEquals(saved[0].version, aPermission.version) + assertEquals(saved[0].createdDate, aPermission.createdDate) + assertEquals(saved[0].createdBy, aPermission.createdBy) + assertEquals(saved[0].modifiedBy, aPermission.modifiedBy) + assertEquals(saved[0].modifiedDate, aPermission.modifiedDate) + + + // when change + val changedPermission = aPermissionWithId.copy(description = "after change 1", modifiedDate = 1111111L, modifiedBy = null, createdBy = null, version = saved[0].version + 1) + + val changedReturned = permissionRepository.updateAllReturningIds(listOf(changedPermission)) + + // then + assertEquals(changedPermission.uuid, saved[0].uuid) + + assertEquals(changedReturned.size, 1) + + val changedSaved = permissionRepository.findAll().toList() + assertEquals(changedSaved.size, 1) + + assertEquals(changedSaved[0].id, changedPermission.id) + assertEquals(changedSaved[0].name, changedPermission.name) + assertEquals(changedSaved[0].description, changedPermission.description) + assertEquals(changedSaved[0].uuid, changedPermission.uuid) + assertEquals(changedSaved[0].version, changedPermission.version) + assertEquals(changedSaved[0].createdDate, changedPermission.createdDate) + assertEquals(changedSaved[0].createdBy, changedPermission.createdBy) + assertEquals(changedSaved[0].modifiedDate, changedPermission.modifiedDate) + assertEquals(changedSaved[0].modifiedBy, changedPermission.modifiedBy) + } } class PermissionTest : BaseIntegrationTest(), PermissionFixture { From bfa384e31374e5b29d2c1d62f043acde2ff0e1c3 Mon Sep 17 00:00:00 2001 From: xcodeassociated Date: Wed, 18 Feb 2026 19:40:38 +0100 Subject: [PATCH 04/17] wip: optimize kafka and add kafka testcontainer --- build.gradle | 1 + .../app/kafka/KafkaKeycloakController.kt | 2 +- .../{KafkaSampleHandler.kt => KafkaSample.kt} | 15 +++-- .../template/app/kafka/config/KafkaConfig.kt | 65 ++++--------------- src/main/resources/application.properties | 34 +++++----- .../softeno/template/app/IntegrationTest.kt | 15 +++++ src/test/resources/application.properties | 2 +- 7 files changed, 56 insertions(+), 78 deletions(-) rename src/main/kotlin/com/softeno/template/app/kafka/{KafkaSampleHandler.kt => KafkaSample.kt} (61%) diff --git a/build.gradle b/build.gradle index c62d1f5..a83eedd 100644 --- a/build.gradle +++ b/build.gradle @@ -86,6 +86,7 @@ dependencies { testImplementation "org.testcontainers:testcontainers-junit-jupiter:${testcontainersVersion}" testImplementation "org.testcontainers:testcontainers-postgresql:${testcontainersVersion}" testImplementation "org.testcontainers:testcontainers-r2dbc:${testcontainersVersion}" + testImplementation "org.testcontainers:testcontainers-kafka:${testcontainersVersion}" // liquibase implementation "org.springframework.boot:spring-boot-starter-liquibase:${springBootVersion}" 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 d645fcd..dba7908 100644 --- a/src/main/kotlin/com/softeno/template/app/kafka/KafkaKeycloakController.kt +++ b/src/main/kotlin/com/softeno/template/app/kafka/KafkaKeycloakController.kt @@ -11,7 +11,7 @@ import org.springframework.stereotype.Controller class KafkaKeycloakController { private val log = LogFactory.getLog(javaClass) - @KafkaListener(id = "kafka-keycloak", topics = ["keycloak-events"]) + @KafkaListener(id = "\${spring.kafka.consumer.group-id}-keycloak", topics = ["\${com.softeno.kafka.keycloak}"]) fun listen1(record: ConsumerRecord) { log.info("[kafka] rx keycloak: ${record.key()}: ${record.value()}") } diff --git a/src/main/kotlin/com/softeno/template/app/kafka/KafkaSampleHandler.kt b/src/main/kotlin/com/softeno/template/app/kafka/KafkaSample.kt similarity index 61% rename from src/main/kotlin/com/softeno/template/app/kafka/KafkaSampleHandler.kt rename to src/main/kotlin/com/softeno/template/app/kafka/KafkaSample.kt index e597883..26deb6a 100644 --- a/src/main/kotlin/com/softeno/template/app/kafka/KafkaSampleHandler.kt +++ b/src/main/kotlin/com/softeno/template/app/kafka/KafkaSample.kt @@ -1,22 +1,27 @@ package com.softeno.template.app.kafka import com.fasterxml.jackson.databind.JsonNode -import com.softeno.template.app.kafka.config.KafkaApplicationProperties import com.softeno.template.app.kafka.dto.KafkaMessage import org.apache.commons.logging.LogFactory import org.apache.kafka.clients.consumer.ConsumerRecord +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 +@ConfigurationProperties(prefix = "com.softeno.kafka") +data class KafkaApplicationProperties(val tx: String, val rx: String, val keycloak: String) + @Controller -class KafkaSampleController { +class KafkaSampleController( + private val props: KafkaApplicationProperties +) { private val log = LogFactory.getLog(javaClass) - @KafkaListener(id = "sample_group_1", topics = ["\${com.softeno.kafka.rx}"]) + @KafkaListener(id = "\${spring.kafka.consumer.group-id}", topics = ["\${com.softeno.kafka.rx}"]) fun listen1(record: ConsumerRecord) { - log.info("[kafka] rx keycloak: ${record.key()}: ${record.value()}") + log.info("[kafka] rx (${props.rx}): ${record.key()}: ${record.value()}") } } @@ -28,7 +33,7 @@ class KafkaSampleProducer( private val log = LogFactory.getLog(javaClass) fun send(message: KafkaMessage) { - log.info("[kafka] tx: topic: ${props.tx}, message: $message") + 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 101780f..9711871 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 @@ -2,54 +2,31 @@ package com.softeno.template.app.kafka.config import com.fasterxml.jackson.databind.JsonNode import com.softeno.template.app.kafka.dto.KafkaMessage -import io.micrometer.observation.ObservationRegistry -import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.boot.kafka.autoconfigure.KafkaProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import org.springframework.kafka.core.DefaultKafkaConsumerFactory -import org.springframework.kafka.core.DefaultKafkaProducerFactory -import org.springframework.kafka.core.KafkaTemplate -import org.springframework.kafka.core.ProducerFactory -import org.springframework.kafka.support.converter.ByteArrayJsonMessageConverter -import org.springframework.kafka.support.converter.JsonMessageConverter +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory +import org.springframework.kafka.core.* 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 { +class KafkaConsumerConfig { @Bean - fun jsonMessageConverter(): JsonMessageConverter { - return ByteArrayJsonMessageConverter() + fun kafkaReceiverOptions(kafkaProperties: KafkaProperties): ReceiverOptions { + return ReceiverOptions.create(kafkaProperties.buildConsumerProperties()) } -} -@Configuration -class KafkaConsumerConfig { @Bean - fun kafkaReceiverOptions( - kafkaProperties: KafkaProperties, - props: KafkaApplicationProperties, - observationRegistry: ObservationRegistry - ): ReceiverOptions { - val basicReceiverOptions: ReceiverOptions = - ReceiverOptions.create(kafkaProperties.buildConsumerProperties()) - 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)) + fun kafkaListenerContainerFactory(consumerFactory: ConsumerFactory): ConcurrentKafkaListenerContainerFactory { + val factory = ConcurrentKafkaListenerContainerFactory() + factory.setConsumerFactory(consumerFactory) + return factory } @Bean - fun kafkaConsumerTemplate(kafkaReceiverOptions: ReceiverOptions): DefaultKafkaConsumerFactory { + fun kafkaConsumerTemplate(kafkaReceiverOptions: ReceiverOptions): ConsumerFactory { return DefaultKafkaConsumerFactory(kafkaReceiverOptions.consumerProperties()) } } @@ -57,11 +34,9 @@ class KafkaConsumerConfig { @Configuration class KafkaProducerConfig { @Bean - fun kafkaProducerTemplate(properties: KafkaProperties, observationRegistry: ObservationRegistry): ProducerFactory { + fun kafkaProducerTemplate(properties: KafkaProperties): ProducerFactory { val props = properties.buildProducerProperties() 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 DefaultKafkaProducerFactory(options.producerProperties()) } @@ -70,21 +45,3 @@ class KafkaProducerConfig { return KafkaTemplate(producerFactory) } } - -//@Configuration -//class ReactiveKafkaKeycloakConsumerConfig { -// @Bean(value = ["kafkaKeycloakOptions"]) -// fun kafkaReceiverOptions( -// kafkaProperties: KafkaProperties, -// props: KafkaApplicationProperties -// ): ReceiverOptions { -// val basicReceiverOptions: ReceiverOptions = -// ReceiverOptions.create(kafkaProperties.buildConsumerProperties()) -// return basicReceiverOptions.subscription(Collections.singletonList(props.keycloak)) -// } -// -// @Bean(value = ["kafkaKeycloakConsumerTemplate"]) -// fun kafkaConsumerTemplate(@Qualifier(value = "kafkaKeycloakOptions") kafkaReceiverOptions: ReceiverOptions): DefaultKafkaConsumerFactory { -// return DefaultKafkaConsumerFactory(kafkaReceiverOptions.consumerProperties()) -// } -//} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 4c7941f..e7d2f8b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -41,9 +41,9 @@ spring.security.oauth2.client.provider.keycloak.jwk-set-uri=http://localhost:809 # kafka spring.kafka.bootstrap-servers=localhost:9094 +spring.kafka.consumer.group-id=other-group-jvm spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer -spring.kafka.consumer.group-id=other-group-jvm spring.kafka.consumer.auto-offset-reset=earliest spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer spring.kafka.consumer.value-deserializer=org.springframework.kafka.support.serializer.JsonDeserializer @@ -68,19 +68,19 @@ resilience4j.circuitbreaker.metrics.enabled=true resilience4j.retry.metrics.enabled=true resilience4j.ratelimiter.metrics.enabled=true -### observation & zipkin -management.tracing.enabled=true -management.zipkin.tracing.endpoint=http://localhost:9411/api/v2/spans -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 -management.tracing.baggage.enabled=true -management.tracing.baggage.correlation.fields=spanId,traceId - -management.otlp.metrics.export.enabled=true +#### observation & zipkin +#management.tracing.enabled=true +#management.zipkin.tracing.endpoint=http://localhost:9411/api/v2/spans +#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 +#management.tracing.baggage.enabled=true +#management.tracing.baggage.correlation.fields=spanId,traceId +# +#management.otlp.metrics.export.enabled=true diff --git a/src/test/kotlin/com/softeno/template/app/IntegrationTest.kt b/src/test/kotlin/com/softeno/template/app/IntegrationTest.kt index 94a1492..a624c3f 100644 --- a/src/test/kotlin/com/softeno/template/app/IntegrationTest.kt +++ b/src/test/kotlin/com/softeno/template/app/IntegrationTest.kt @@ -42,7 +42,9 @@ import org.springframework.test.web.reactive.server.expectBody import org.springframework.web.reactive.function.BodyInserters import org.springframework.web.reactive.function.client.WebClient import org.testcontainers.junit.jupiter.Container +import org.testcontainers.kafka.KafkaContainer import org.testcontainers.postgresql.PostgreSQLContainer +import org.testcontainers.utility.DockerImageName @SpringBootTest( classes = [SoftenoMvcJpaApp::class], @@ -63,6 +65,13 @@ abstract class BaseIntegrationTest { lateinit var webTestClient: WebTestClient companion object { + @Container + var kafka: KafkaContainer = KafkaContainer("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 postgreSQLContainer = PostgreSQLContainer("postgres:16-alpine") .withDatabaseName("application") @@ -73,6 +82,11 @@ abstract class BaseIntegrationTest { @JvmStatic @DynamicPropertySource fun registerDynamicProperties(registry: DynamicPropertyRegistry) { + kafka.start() + registry.add("spring.kafka.bootstrap-servers") { + kafka.bootstrapServers + } + postgreSQLContainer.start() registry.add("spring.liquibase.url") { @@ -89,6 +103,7 @@ abstract class BaseIntegrationTest { registry.add("spring.r2dbc.username") { postgreSQLContainer.username } registry.add("spring.r2dbc.password") { postgreSQLContainer.password } } + } @BeforeEach diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 19ad617..ea70841 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -10,7 +10,6 @@ com.softeno.kafka.rx=sample_topic_2 com.softeno.kafka.keycloak=keycloak-events ### kafka -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 @@ -19,6 +18,7 @@ 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 +spring.kafka.consumer.properties.spring.json.value.default.type=com.fasterxml.jackson.databind.JsonNode ##### r2dbc spring.r2dbc.pool.enabled=true From 876b202595134f94495aebc678e90deac3fc5e29 Mon Sep 17 00:00:00 2001 From: xcodeassociated Date: Fri, 27 Feb 2026 19:46:19 +0100 Subject: [PATCH 05/17] add spring opentelemetry --- build.gradle | 17 +---- .../InstallOpenTelemetryAppender.kt | 17 +++++ src/main/resources/application.properties | 23 ++---- src/main/resources/logback-spring.xml | 73 ++++++------------- src/test/resources/application.properties | 5 +- 5 files changed, 50 insertions(+), 85 deletions(-) create mode 100644 src/main/kotlin/com/softeno/template/app/config/opentelementry/InstallOpenTelemetryAppender.kt diff --git a/build.gradle b/build.gradle index a83eedd..98added 100644 --- a/build.gradle +++ b/build.gradle @@ -78,9 +78,6 @@ dependencies { runtimeOnly 'io.micrometer:micrometer-registry-prometheus' implementation "org.springframework.boot:spring-boot-starter-actuator:${springBootVersion}" - // elk - implementation 'net.logstash.logback:logstash-logback-encoder:8.1' - // testcontainers testImplementation "org.springframework.boot:spring-boot-testcontainers:${springBootVersion}" testImplementation "org.testcontainers:testcontainers-junit-jupiter:${testcontainersVersion}" @@ -128,16 +125,10 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-common:3.0.1' implementation 'org.springdoc:springdoc-openapi-starter-webflux-ui:3.0.1' - // zipkin -// implementation "org.springframework.boot:spring-boot-starter-zipkin:${springBootVersion}" -// testImplementation "org.springframework.boot:spring-boot-starter-zipkin-test:${springBootVersion}" -// implementation "org.springframework.boot:spring-boot-starter-opentelemetry:${springBootVersion}" -// testImplementation "org.springframework.boot:spring-boot-starter-opentelemetry-test:${springBootVersion}" -// -// implementation 'io.micrometer:micrometer-tracing-bridge-otel' -// implementation 'io.opentelemetry:opentelemetry-exporter-zipkin' -// implementation 'io.zipkin.reporter2:zipkin-sender-urlconnection' - + // 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:2.25.0-alpha" } liquibase { diff --git a/src/main/kotlin/com/softeno/template/app/config/opentelementry/InstallOpenTelemetryAppender.kt b/src/main/kotlin/com/softeno/template/app/config/opentelementry/InstallOpenTelemetryAppender.kt new file mode 100644 index 0000000..a8f610f --- /dev/null +++ b/src/main/kotlin/com/softeno/template/app/config/opentelementry/InstallOpenTelemetryAppender.kt @@ -0,0 +1,17 @@ +package com.softeno.template.app.config.opentelementry + +import io.opentelemetry.api.OpenTelemetry +import io.opentelemetry.instrumentation.logback.appender.v1_0.OpenTelemetryAppender +import org.springframework.beans.factory.InitializingBean +import org.springframework.stereotype.Component + + +@Component +class InstallOpenTelemetryAppender( + private val openTelemetry: OpenTelemetry +) : InitializingBean { + + override fun afterPropertiesSet() { + OpenTelemetryAppender.install(openTelemetry) + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index e7d2f8b..32a9e4c 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -55,8 +55,6 @@ spring.kafka.consumer.properties.spring.json.value.default.type=com.fasterxml.ja 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 # swagger springdoc.api-docs.enabled=true @@ -68,19 +66,8 @@ resilience4j.circuitbreaker.metrics.enabled=true resilience4j.retry.metrics.enabled=true resilience4j.ratelimiter.metrics.enabled=true -#### observation & zipkin -#management.tracing.enabled=true -#management.zipkin.tracing.endpoint=http://localhost:9411/api/v2/spans -#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 -#management.tracing.baggage.enabled=true -#management.tracing.baggage.correlation.fields=spanId,traceId -# -#management.otlp.metrics.export.enabled=true +# metrics, prometheus & actuator +management.otlp.metrics.export.url=http://localhost:4318/v1/metrics +management.tracing.sampling.probability=1.0 +management.opentelemetry.tracing.export.otlp.endpoint=http://localhost:4318/v1/traces +management.opentelemetry.logging.export.otlp.endpoint=http://localhost:4318/v1/logs diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 2655d5b..9103db8 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -12,73 +12,46 @@ + + ${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 - - - - + - + diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index ea70841..af153d7 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -32,9 +32,6 @@ logging.level.org.springframework.data=DEBUG logging.level.org.springframework.r2dbc=TRACE logging.level.liquibase=DEBUG -# metrics, prometheus & actuator -management.endpoint.metrics.enabled=false -management.endpoint.prometheus.enabled=false - # swagger springdoc.api-docs.enabled=false + From fba6c50eea9cf5417ea0201ca2a693233794005c Mon Sep 17 00:00:00 2001 From: xcodeassociated Date: Sat, 28 Feb 2026 09:22:38 +0100 Subject: [PATCH 06/17] propagate the tracing for kafka and change the tracing type to w3c --- build.gradle | 3 +++ http/user-app.http | 2 +- ...etryAppender.kt => OpenTelemetryConfig.kt} | 13 ++++++++++++- .../template/app/kafka/config/KafkaConfig.kt | 12 +++++++++--- .../permission/api/PermissionController.kt | 12 +++++++----- .../sample/http/api/ExternalController.kt | 14 +++++++++----- .../sample/http/config/WebClientConfig.kt | 6 +++++- src/main/resources/application.properties | 19 +++++++++++++++++-- .../softeno/template/app/IntegrationTest.kt | 1 - 9 files changed, 63 insertions(+), 19 deletions(-) rename src/main/kotlin/com/softeno/template/app/config/opentelementry/{InstallOpenTelemetryAppender.kt => OpenTelemetryConfig.kt} (54%) diff --git a/build.gradle b/build.gradle index 98added..eae7cbc 100644 --- a/build.gradle +++ b/build.gradle @@ -43,6 +43,7 @@ repositories { dependencies { implementation "org.springframework.boot:spring-boot-starter-data-r2dbc:${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-r2dbc:${springBootVersion}" implementation 'org.springframework:spring-jdbc' runtimeOnly 'org.postgresql:r2dbc-postgresql' @@ -129,6 +130,8 @@ dependencies { 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:2.25.0-alpha" + implementation "io.micrometer:micrometer-tracing-bridge-otel" + implementation "io.micrometer:context-propagation" } liquibase { diff --git a/http/user-app.http b/http/user-app.http index 8221aef..b5d0a83 100644 --- a/http/user-app.http +++ b/http/user-app.http @@ -5,7 +5,7 @@ GET {{host}}/permissions?page=0&size=10&sort=id&direction=DESC Authorization: Bearer {{oauthToken}} ### -GET {{host}}/permissions/count?search=test&createdFrom=1759429679915&createdBy=78652bda-8e80-432b-ba1b-47b4f7ee98e6 +GET {{host}}/permissions/count?search=test&createdFrom=1772265820594&createdBy=05dbf0b6-057b-44b4-aeaa-d83d662c5833 Authorization: Bearer {{oauthToken}} ### diff --git a/src/main/kotlin/com/softeno/template/app/config/opentelementry/InstallOpenTelemetryAppender.kt b/src/main/kotlin/com/softeno/template/app/config/opentelementry/OpenTelemetryConfig.kt similarity index 54% rename from src/main/kotlin/com/softeno/template/app/config/opentelementry/InstallOpenTelemetryAppender.kt rename to src/main/kotlin/com/softeno/template/app/config/opentelementry/OpenTelemetryConfig.kt index a8f610f..5b5f257 100644 --- a/src/main/kotlin/com/softeno/template/app/config/opentelementry/InstallOpenTelemetryAppender.kt +++ b/src/main/kotlin/com/softeno/template/app/config/opentelementry/OpenTelemetryConfig.kt @@ -3,6 +3,9 @@ package com.softeno.template.app.config.opentelementry 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 @@ -14,4 +17,12 @@ class InstallOpenTelemetryAppender( override fun afterPropertiesSet() { OpenTelemetryAppender.install(openTelemetry) } -} \ No newline at end of file +} + +@Configuration(proxyBeanMethods = false) +class ContextPropagationConfiguration { + @Bean + fun contextPropagatingTaskDecorator(): ContextPropagatingTaskDecorator { + return ContextPropagatingTaskDecorator() + } +} 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 9711871..a813eae 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 @@ -2,6 +2,7 @@ package com.softeno.template.app.kafka.config import com.fasterxml.jackson.databind.JsonNode import com.softeno.template.app.kafka.dto.KafkaMessage +import io.micrometer.observation.ObservationRegistry import org.springframework.boot.kafka.autoconfigure.KafkaProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -19,9 +20,11 @@ class KafkaConsumerConfig { } @Bean - fun kafkaListenerContainerFactory(consumerFactory: ConsumerFactory): ConcurrentKafkaListenerContainerFactory { + fun kafkaListenerContainerFactory(consumerFactory: ConsumerFactory, registry: ObservationRegistry): ConcurrentKafkaListenerContainerFactory { val factory = ConcurrentKafkaListenerContainerFactory() factory.setConsumerFactory(consumerFactory) + factory.containerProperties.isObservationEnabled = true + factory.containerProperties.observationRegistry = registry return factory } @@ -41,7 +44,10 @@ class KafkaProducerConfig { } @Bean - fun kafkaTemplate(producerFactory: ProducerFactory): KafkaTemplate { - return KafkaTemplate(producerFactory) + fun kafkaTemplate(producerFactory: ProducerFactory, registry: ObservationRegistry): KafkaTemplate { + return KafkaTemplate(producerFactory).apply { + setObservationEnabled(true) + setObservationRegistry(registry) + } } } diff --git a/src/main/kotlin/com/softeno/template/app/permission/api/PermissionController.kt b/src/main/kotlin/com/softeno/template/app/permission/api/PermissionController.kt index eb47270..914d19b 100644 --- a/src/main/kotlin/com/softeno/template/app/permission/api/PermissionController.kt +++ b/src/main/kotlin/com/softeno/template/app/permission/api/PermissionController.kt @@ -3,9 +3,11 @@ package com.softeno.template.app.permission.api import com.softeno.template.app.common.PrincipalHandler import com.softeno.template.app.permission.mapper.PermissionDto import com.softeno.template.app.permission.service.PermissionService +import io.micrometer.tracing.Tracer import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.reactor.awaitSingle import org.apache.commons.logging.LogFactory +import org.slf4j.MDC import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Sort import org.springframework.http.ResponseEntity @@ -18,7 +20,7 @@ import java.security.Principal @Validated class PermissionController( private val permissionService: PermissionService, -// private val tracer: Tracer + private val tracer: Tracer ) : PrincipalHandler { private val log = LogFactory.getLog(javaClass) @@ -44,10 +46,10 @@ class PermissionController( showPrincipal(log, monoPrincipal) // debug only -// val traceId = tracer.currentSpan()?.context()?.traceId() -// val mdc = MDC.get("traceId") -// val mdcSpan = MDC.get("spanId") -// log.debug("Show traceId=$traceId, mdcTraceId=$mdc and mdcSpanId=$mdcSpan") + val traceId = tracer.currentSpan()?.context()?.traceId() + val mdc = MDC.get("traceId") + val mdcSpan = MDC.get("spanId") + log.debug("Show traceId=$traceId, mdcTraceId=$mdc and mdcSpanId=$mdcSpan") log.debug("Show request params: " + "page=$page, size=$size, sort=$sort, direction=$direction, " + diff --git a/src/main/kotlin/com/softeno/template/sample/http/api/ExternalController.kt b/src/main/kotlin/com/softeno/template/sample/http/api/ExternalController.kt index 4f94207..c66ba5c 100644 --- a/src/main/kotlin/com/softeno/template/sample/http/api/ExternalController.kt +++ b/src/main/kotlin/com/softeno/template/sample/http/api/ExternalController.kt @@ -4,6 +4,7 @@ import com.softeno.template.sample.http.config.ExternalClientConfig import kotlinx.coroutines.reactor.awaitSingle import org.apache.commons.logging.LogFactory import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.cloud.client.circuitbreaker.ReactiveCircuitBreaker import org.springframework.cloud.client.circuitbreaker.ReactiveCircuitBreakerFactory import org.springframework.validation.annotation.Validated import org.springframework.web.bind.annotation.GetMapping @@ -11,6 +12,7 @@ import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.bodyToMono import reactor.core.publisher.Mono import java.time.Duration @@ -19,24 +21,26 @@ import java.time.Duration @Validated class ExternalController( @Qualifier(value = "external") private val webClient: WebClient, - private val reactiveCircuitBreakerFactory: ReactiveCircuitBreakerFactory<*, *>, - private val config: ExternalClientConfig + reactiveCircuitBreakerFactory: ReactiveCircuitBreakerFactory<*, *>, + config: ExternalClientConfig, + private val rcb: ReactiveCircuitBreaker = reactiveCircuitBreakerFactory.create(config.name) ) { private val log = LogFactory.getLog(javaClass) @GetMapping("/{id}") suspend fun getHandler(@PathVariable id: String): SampleResponseDto { + log.info("[external]: GET id: $id") val response: SampleResponseDto = webClient.get() .uri("/${id}") .retrieve() - .bodyToMono(SampleResponseDto::class.java) + .bodyToMono() .timeout(Duration.ofMillis(1_000)) .transform { - val rcb = reactiveCircuitBreakerFactory.create(config.name) - // note: custom exception might be thrown since exception handler is defined + // note: a custom exception might be thrown since the exception handler is defined rcb.run(it) { Mono.just(SampleResponseDto(data = "FALLBACK")) } } + .contextCapture() .awaitSingle() log.info("[external]: received: $response") diff --git a/src/main/kotlin/com/softeno/template/sample/http/config/WebClientConfig.kt b/src/main/kotlin/com/softeno/template/sample/http/config/WebClientConfig.kt index b5f5f6b..032eb75 100644 --- a/src/main/kotlin/com/softeno/template/sample/http/config/WebClientConfig.kt +++ b/src/main/kotlin/com/softeno/template/sample/http/config/WebClientConfig.kt @@ -1,5 +1,6 @@ package com.softeno.template.sample.http.config +import io.micrometer.observation.ObservationRegistry import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -39,14 +40,17 @@ class WebClientConfig { fun buildWebClient( config: ExternalClientConfig, authorizedClientManager: ReactiveOAuth2AuthorizedClientManager, + webClient: WebClient.Builder, + registry: ObservationRegistry ): WebClient { val oauth = ServerOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager) oauth.setDefaultClientRegistrationId("keycloak") - return WebClient.builder() + return webClient .filter(oauth) .baseUrl(config.url) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .observationRegistry(registry) .build() } } \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 32a9e4c..ee9e0f4 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,11 +1,12 @@ spring.main.allow-bean-definition-overriding=true server.port=8082 spring.application.name=SoftenoJpaPostgresApp +spring.main.web-application-type=reactive ### custom: external services #com.softeno.external.url=http://localhost:4500/sample #com.softeno.external.url=http://localhost:8080/sample -com.softeno.external.url=http://localhost:8080/sample-secured +com.softeno.external.url=http://localhost:8082/sample-secured com.softeno.external.name=node-service com.softeno.kafka.tx=sample_topic_2 @@ -68,6 +69,20 @@ resilience4j.ratelimiter.metrics.enabled=true # metrics, prometheus & actuator management.otlp.metrics.export.url=http://localhost:4318/v1/metrics -management.tracing.sampling.probability=1.0 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 + +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.enabled=true + +# fix for tracing logs +management.observations.enable.spring.security=false +logging.level.io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor=ERROR + diff --git a/src/test/kotlin/com/softeno/template/app/IntegrationTest.kt b/src/test/kotlin/com/softeno/template/app/IntegrationTest.kt index a624c3f..7822dd4 100644 --- a/src/test/kotlin/com/softeno/template/app/IntegrationTest.kt +++ b/src/test/kotlin/com/softeno/template/app/IntegrationTest.kt @@ -44,7 +44,6 @@ import org.springframework.web.reactive.function.client.WebClient import org.testcontainers.junit.jupiter.Container import org.testcontainers.kafka.KafkaContainer import org.testcontainers.postgresql.PostgreSQLContainer -import org.testcontainers.utility.DockerImageName @SpringBootTest( classes = [SoftenoMvcJpaApp::class], From 303e6d4f1f3c7c32841391fc6f8a702935d47212 Mon Sep 17 00:00:00 2001 From: xcodeassociated Date: Sat, 28 Feb 2026 09:27:36 +0100 Subject: [PATCH 07/17] remove span and trace from kafka message --- src/main/kotlin/com/softeno/template/app/kafka/dto/dto.kt | 2 +- .../template/app/permission/service/PermissionService.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/softeno/template/app/kafka/dto/dto.kt b/src/main/kotlin/com/softeno/template/app/kafka/dto/dto.kt index b0df3e9..d6128e1 100644 --- a/src/main/kotlin/com/softeno/template/app/kafka/dto/dto.kt +++ b/src/main/kotlin/com/softeno/template/app/kafka/dto/dto.kt @@ -4,7 +4,7 @@ import com.fasterxml.jackson.annotation.JsonCreator import com.fasterxml.jackson.annotation.JsonIgnoreProperties @JsonIgnoreProperties(ignoreUnknown = true) -data class KafkaMessage(val content: String, val traceId: String? = null, val spanId: String? = null) +data class KafkaMessage(val content: String) enum class KeycloakEventType { LOGIN, diff --git a/src/main/kotlin/com/softeno/template/app/permission/service/PermissionService.kt b/src/main/kotlin/com/softeno/template/app/permission/service/PermissionService.kt index b388ef1..94191ee 100644 --- a/src/main/kotlin/com/softeno/template/app/permission/service/PermissionService.kt +++ b/src/main/kotlin/com/softeno/template/app/permission/service/PermissionService.kt @@ -53,7 +53,7 @@ class PermissionService( suspend fun createPermission(permissionDto: PermissionDto): PermissionDto = withContext(MDCContext()) { val created = permissionRepository.save(Permission(name = permissionDto.name, description = permissionDto.description)).toDto() - kafkaProducer.send(KafkaMessage(content = "CREATED_PREMISSION: ${created.id}", traceId = MDC.get("traceId"), spanId = MDC.get("spanId"))) + kafkaProducer.send(KafkaMessage(content = "CREATED_PREMISSION: ${created.id}")) return@withContext created } From 496c9be21bb058c13d41e88e768131d336599210 Mon Sep 17 00:00:00 2001 From: xcodeassociated Date: Sun, 1 Mar 2026 16:29:44 +0100 Subject: [PATCH 08/17] minor clean-up --- build.gradle | 5 ++--- .../template/app/config/security/ReactiveAuditorConfig.kt | 2 -- .../com/softeno/template/sample/http/api/SampleRouter.kt | 4 +++- src/main/kotlin/com/softeno/template/sample/http/api/dto.kt | 3 --- src/test/kotlin/com/softeno/template/app/IntegrationTest.kt | 2 +- 5 files changed, 6 insertions(+), 10 deletions(-) delete mode 100644 src/main/kotlin/com/softeno/template/sample/http/api/dto.kt diff --git a/build.gradle b/build.gradle index eae7cbc..8620950 100644 --- a/build.gradle +++ b/build.gradle @@ -20,7 +20,6 @@ ext { set('springBootVersion', '4.0.2') } - group = 'com.softeno' version = '0.0.1-SNAPSHOT' @@ -53,8 +52,8 @@ dependencies { implementation 'com.fasterxml.jackson.module:jackson-module-kotlin' implementation 'org.jetbrains.kotlin:kotlin-reflect' implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8' - implementation 'org.springframework.boot:spring-boot-starter-validation' - developmentOnly 'org.springframework.boot:spring-boot-devtools' + implementation "org.springframework.boot:spring-boot-starter-validation:${springBootVersion}" + developmentOnly "org.springframework.boot:spring-boot-devtools:${springBootVersion}" implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-slf4j' annotationProcessor "org.springframework.boot:spring-boot-configuration-processor:${springBootVersion}" diff --git a/src/main/kotlin/com/softeno/template/app/config/security/ReactiveAuditorConfig.kt b/src/main/kotlin/com/softeno/template/app/config/security/ReactiveAuditorConfig.kt index da8b0f2..ea9d592 100644 --- a/src/main/kotlin/com/softeno/template/app/config/security/ReactiveAuditorConfig.kt +++ b/src/main/kotlin/com/softeno/template/app/config/security/ReactiveAuditorConfig.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,7 +12,6 @@ import reactor.core.publisher.Mono class AuditorAwareImpl : ReactiveAuditorAware { - private val log = LogFactory.getLog(javaClass) override fun getCurrentAuditor(): Mono { return ReactiveSecurityContextHolder.getContext() diff --git a/src/main/kotlin/com/softeno/template/sample/http/api/SampleRouter.kt b/src/main/kotlin/com/softeno/template/sample/http/api/SampleRouter.kt index 2d02c23..62111e8 100644 --- a/src/main/kotlin/com/softeno/template/sample/http/api/SampleRouter.kt +++ b/src/main/kotlin/com/softeno/template/sample/http/api/SampleRouter.kt @@ -57,4 +57,6 @@ class SampleService { 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/api/dto.kt b/src/main/kotlin/com/softeno/template/sample/http/api/dto.kt deleted file mode 100644 index 822aaf6..0000000 --- a/src/main/kotlin/com/softeno/template/sample/http/api/dto.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.softeno.template.sample.http.api - -data class SampleResponseDto(val data: String) diff --git a/src/test/kotlin/com/softeno/template/app/IntegrationTest.kt b/src/test/kotlin/com/softeno/template/app/IntegrationTest.kt index 7822dd4..3dd5c51 100644 --- a/src/test/kotlin/com/softeno/template/app/IntegrationTest.kt +++ b/src/test/kotlin/com/softeno/template/app/IntegrationTest.kt @@ -70,7 +70,6 @@ abstract class BaseIntegrationTest { .withEnv("ALLOW_PLAINTEXT_LISTENER", "true") .withEnv("KAFKA_CREATE_TOPICS", "sample_topic_2" + ":1:1") - @Container var postgreSQLContainer = PostgreSQLContainer("postgres:16-alpine") .withDatabaseName("application") @@ -124,6 +123,7 @@ class ContextLoadsTest : BaseIntegrationTest() { @Test fun testConnection() { assertTrue(postgreSQLContainer.isRunning) + assertTrue(kafka.isRunning) } } From bcde1f472485b9f5f00bd95dde55fb4bc3384345 Mon Sep 17 00:00:00 2001 From: xcodeassociated Date: Sun, 1 Mar 2026 20:43:52 +0100 Subject: [PATCH 09/17] add deps versions to gradle.build --- build.gradle | 75 +++++++++++++++++++++++++++++++++------------------- 1 file changed, 48 insertions(+), 27 deletions(-) diff --git a/build.gradle b/build.gradle index 8620950..21bdd2a 100644 --- a/build.gradle +++ b/build.gradle @@ -18,6 +18,25 @@ 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('springmockkVersion', '4.0.2') + set('liquibaseVersion', '4.33.0') + set('postgresqlVersion', '42.7.10') + set('logbackVersion', '1.5.30') + set('picocliVersion', '4.7.7') + set('springdocVersion', '3.0.1') + set('logbackAppenderVersion', '2.25.0-alpha') + set('reactorKafkaVersion', '1.3.25') + set('r2dbcPostgresqlVersion', '1.1.1.RELEASE') + 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' @@ -43,39 +62,41 @@ dependencies { implementation "org.springframework.boot:spring-boot-starter-data-r2dbc:${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}" implementation "org.springframework.boot:spring-boot-r2dbc:${springBootVersion}" - implementation 'org.springframework:spring-jdbc' - runtimeOnly 'org.postgresql:r2dbc-postgresql' - runtimeOnly 'org.postgresql:postgresql' + runtimeOnly "org.postgresql:r2dbc-postgresql:${r2dbcPostgresqlVersion}" + runtimeOnly "org.postgresql:postgresql:${postgresqlVersion}" + implementation "org.springframework.boot:spring-boot-starter-validation:${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.kotlinx:kotlinx-coroutines-reactor' implementation 'com.fasterxml.jackson.module:jackson-module-kotlin' implementation 'org.jetbrains.kotlin:kotlin-reflect' implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8' - implementation "org.springframework.boot:spring-boot-starter-validation:${springBootVersion}" - developmentOnly "org.springframework.boot:spring-boot-devtools:${springBootVersion}" implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-slf4j' - annotationProcessor "org.springframework.boot:spring-boot-configuration-processor:${springBootVersion}" // test utils 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-starter-jackson-test:${springBootVersion}" testImplementation "org.springframework.boot:spring-boot-starter-r2dbc-test:${springBootVersion}" testImplementation "org.springframework.boot:spring-boot-starter-data-r2dbc-test:${springBootVersion}" testImplementation "org.springframework.boot:spring-boot-starter-liquibase-test:${springBootVersion}" testImplementation "org.springframework.boot:spring-boot-webtestclient:${springBootVersion}" + testRuntimeOnly "org.junit.platform:junit-platform-launcher:${junitPlatformLauncherVersion}" + testImplementation "io.mockk:mockk:${mockkVersion}" + testImplementation "com.ninja-squad:springmockk:${springmockkVersion}" 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' + // wiremock - testImplementation 'org.wiremock:wiremock-standalone:3.13.2' + testImplementation "org.wiremock:wiremock-standalone:${wiremockVersion}" // monitoring - runtimeOnly 'io.micrometer:micrometer-registry-prometheus' + runtimeOnly "io.micrometer:micrometer-registry-prometheus:${micrometerRegistryPrometheusVersion}" implementation "org.springframework.boot:spring-boot-starter-actuator:${springBootVersion}" // testcontainers @@ -87,50 +108,50 @@ dependencies { // liquibase implementation "org.springframework.boot:spring-boot-starter-liquibase:${springBootVersion}" - implementation ('org.liquibase:liquibase-core:4.33.0') { + implementation ("org.liquibase:liquibase-core:${liquibaseVersion}") { exclude group: 'javax.xml.bind', module: 'jaxb-receiver' exclude group: 'org.liquibase.ext', module: 'liquibase-hibernate5' } + liquibaseRuntime "org.liquibase.ext:liquibase-postgresql:${liquibaseVersion}" + liquibaseRuntime "org.postgresql:postgresql:${postgresqlVersion}" + liquibaseRuntime "ch.qos.logback:logback-core:${logbackVersion}" + liquibaseRuntime "ch.qos.logback:logback-classic:${logbackVersion}" + liquibaseRuntime "info.picocli:picocli:${picocliVersion}" + liquibaseRuntime "org.springframework.boot:spring-boot-starter-data-r2dbc:${springBootVersion}" liquibaseRuntime 'org.jetbrains.kotlin:kotlin-stdlib-jdk8' liquibaseRuntime 'org.jetbrains.kotlin:kotlin-reflect' - liquibaseRuntime 'org.liquibase.ext:liquibase-postgresql:4.33.0' - liquibaseRuntime 'org.postgresql:postgresql:42.7.10' - liquibaseRuntime 'ch.qos.logback:logback-core:1.5.30' - liquibaseRuntime 'ch.qos.logback:logback-classic:1.5.30' - liquibaseRuntime 'info.picocli:picocli:4.7.7' - liquibaseRuntime "org.springframework.boot:spring-boot-starter-data-r2dbc:${springBootVersion}" liquibaseRuntime sourceSets.main.output // spring security 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:7.0.3" + testImplementation "org.springframework.security:spring-security-test:${springSecurityTestVersion}" // kafka implementation "org.springframework.boot:spring-boot-starter-kafka:${springBootVersion}" implementation "org.springframework.kafka:spring-kafka:${springBootVersion}" - implementation "io.projectreactor.kafka:reactor-kafka:1.3.25" + implementation "io.projectreactor.kafka:reactor-kafka:${reactorKafkaVersion}" 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}") } // springdoc - implementation 'org.springdoc:springdoc-openapi-starter-common:3.0.1' - implementation 'org.springdoc:springdoc-openapi-starter-webflux-ui:3.0.1' + implementation "org.springdoc:springdoc-openapi-starter-common:${springdocVersion}" + implementation "org.springdoc:springdoc-openapi-starter-webflux-ui:${springdocVersion}" // 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:2.25.0-alpha" - implementation "io.micrometer:micrometer-tracing-bridge-otel" - implementation "io.micrometer:context-propagation" + implementation "io.opentelemetry.instrumentation:opentelemetry-logback-appender-1.0:${logbackAppenderVersion}" + implementation "io.micrometer:micrometer-tracing-bridge-otel:${micrometerTracingBridgeOtelVersion}" + implementation "io.micrometer:context-propagation:${micrometerContextPropagationVersion}" } liquibase { From b3e8306fcb74ab96a2a18772ac09312a85cac41e Mon Sep 17 00:00:00 2001 From: xcodeassociated Date: Mon, 2 Mar 2026 10:37:15 +0100 Subject: [PATCH 10/17] remove reactor-kafka leftovers --- build.gradle | 2 - .../app/kafka/KafkaKeycloakController.kt | 42 +++++++++++++++++-- .../softeno/template/app/kafka/KafkaSample.kt | 9 ++-- .../template/app/kafka/config/KafkaConfig.kt | 20 +-------- .../com/softeno/template/app/kafka/dto/dto.kt | 34 --------------- .../permission/service/PermissionService.kt | 3 +- 6 files changed, 46 insertions(+), 64 deletions(-) delete mode 100644 src/main/kotlin/com/softeno/template/app/kafka/dto/dto.kt diff --git a/build.gradle b/build.gradle index 21bdd2a..157f8d2 100644 --- a/build.gradle +++ b/build.gradle @@ -130,8 +130,6 @@ dependencies { // kafka implementation "org.springframework.boot:spring-boot-starter-kafka:${springBootVersion}" - implementation "org.springframework.kafka:spring-kafka:${springBootVersion}" - implementation "io.projectreactor.kafka:reactor-kafka:${reactorKafkaVersion}" testImplementation "org.springframework.kafka:spring-kafka-test:${springBootVersion}" // circuit breaker 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 dba7908..217f953 100644 --- a/src/main/kotlin/com/softeno/template/app/kafka/KafkaKeycloakController.kt +++ b/src/main/kotlin/com/softeno/template/app/kafka/KafkaKeycloakController.kt @@ -1,18 +1,52 @@ 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 org.apache.commons.logging.LogFactory import org.apache.kafka.clients.consumer.ConsumerRecord import org.springframework.kafka.annotation.KafkaListener import org.springframework.stereotype.Controller +import tools.jackson.databind.ObjectMapper @Controller -class KafkaKeycloakController { +class KafkaKeycloakController( + private val objectMapper: ObjectMapper +) { private val log = LogFactory.getLog(javaClass) @KafkaListener(id = "\${spring.kafka.consumer.group-id}-keycloak", topics = ["\${com.softeno.kafka.keycloak}"]) - fun listen1(record: ConsumerRecord) { - log.info("[kafka] rx keycloak: ${record.key()}: ${record.value()}") + 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") } -} \ No newline at end of file +} + +enum class KeycloakEventType { + LOGIN, + LOGOUT, + REGISTER; + + companion object { + @JsonCreator + fun byString(input: String): KeycloakEventType? { + return KeycloakEventType.entries.firstOrNull { it.name.equals(input, true) } + } + } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class KeycloakUserEvent( + val id: String, + val time: Long, + val type: KeycloakEventType, + val realmId: String, + val clientId: String?, + val userId: String, + val sessionId: String?, + val ipAddress: String, + val error: String?, + val details: Map +) diff --git a/src/main/kotlin/com/softeno/template/app/kafka/KafkaSample.kt b/src/main/kotlin/com/softeno/template/app/kafka/KafkaSample.kt index 26deb6a..5a4b626 100644 --- a/src/main/kotlin/com/softeno/template/app/kafka/KafkaSample.kt +++ b/src/main/kotlin/com/softeno/template/app/kafka/KafkaSample.kt @@ -1,7 +1,7 @@ package com.softeno.template.app.kafka +import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.databind.JsonNode -import com.softeno.template.app.kafka.dto.KafkaMessage import org.apache.commons.logging.LogFactory import org.apache.kafka.clients.consumer.ConsumerRecord import org.springframework.boot.context.properties.ConfigurationProperties @@ -20,7 +20,7 @@ class KafkaSampleController( private val log = LogFactory.getLog(javaClass) @KafkaListener(id = "\${spring.kafka.consumer.group-id}", topics = ["\${com.softeno.kafka.rx}"]) - fun listen1(record: ConsumerRecord) { + fun listen(record: ConsumerRecord) { log.info("[kafka] rx (${props.rx}): ${record.key()}: ${record.value()}") } } @@ -36,4 +36,7 @@ class KafkaSampleProducer( log.info("[kafka] tx (${props.tx}): $message") producer.send(props.tx, message) } -} \ No newline at end of file +} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class KafkaMessage(val content: String) \ 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 a813eae..f274ce1 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 @@ -1,23 +1,16 @@ package com.softeno.template.app.kafka.config import com.fasterxml.jackson.databind.JsonNode -import com.softeno.template.app.kafka.dto.KafkaMessage +import com.softeno.template.app.kafka.KafkaMessage import io.micrometer.observation.ObservationRegistry -import org.springframework.boot.kafka.autoconfigure.KafkaProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory import org.springframework.kafka.core.* -import reactor.kafka.receiver.ReceiverOptions -import reactor.kafka.sender.SenderOptions @Configuration class KafkaConsumerConfig { - @Bean - fun kafkaReceiverOptions(kafkaProperties: KafkaProperties): ReceiverOptions { - return ReceiverOptions.create(kafkaProperties.buildConsumerProperties()) - } @Bean fun kafkaListenerContainerFactory(consumerFactory: ConsumerFactory, registry: ObservationRegistry): ConcurrentKafkaListenerContainerFactory { @@ -27,21 +20,10 @@ class KafkaConsumerConfig { factory.containerProperties.observationRegistry = registry return factory } - - @Bean - fun kafkaConsumerTemplate(kafkaReceiverOptions: ReceiverOptions): ConsumerFactory { - return DefaultKafkaConsumerFactory(kafkaReceiverOptions.consumerProperties()) - } } @Configuration class KafkaProducerConfig { - @Bean - fun kafkaProducerTemplate(properties: KafkaProperties): ProducerFactory { - val props = properties.buildProducerProperties() - val options = SenderOptions.create(props) - return DefaultKafkaProducerFactory(options.producerProperties()) - } @Bean fun kafkaTemplate(producerFactory: ProducerFactory, registry: ObservationRegistry): KafkaTemplate { diff --git a/src/main/kotlin/com/softeno/template/app/kafka/dto/dto.kt b/src/main/kotlin/com/softeno/template/app/kafka/dto/dto.kt deleted file mode 100644 index d6128e1..0000000 --- a/src/main/kotlin/com/softeno/template/app/kafka/dto/dto.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.softeno.template.app.kafka.dto - -import com.fasterxml.jackson.annotation.JsonCreator -import com.fasterxml.jackson.annotation.JsonIgnoreProperties - -@JsonIgnoreProperties(ignoreUnknown = true) -data class KafkaMessage(val content: String) - -enum class KeycloakEventType { - LOGIN, - LOGOUT, - REGISTER; - - companion object { - @JsonCreator - fun byString(input: String): KeycloakEventType? { - return KeycloakEventType.entries.firstOrNull { it.name.equals(input, true) } - } - } -} - -@JsonIgnoreProperties(ignoreUnknown = true) -data class KeycloakUserEvent( - val id: String, - val time: Long, - val type: KeycloakEventType, - val realmId: String, - val clientId: String?, - val userId: String, - val sessionId: String?, - val ipAddress: String, - val error: String?, - val details: Map -) diff --git a/src/main/kotlin/com/softeno/template/app/permission/service/PermissionService.kt b/src/main/kotlin/com/softeno/template/app/permission/service/PermissionService.kt index 94191ee..9316c8e 100644 --- a/src/main/kotlin/com/softeno/template/app/permission/service/PermissionService.kt +++ b/src/main/kotlin/com/softeno/template/app/permission/service/PermissionService.kt @@ -1,7 +1,7 @@ package com.softeno.template.app.permission.service +import com.softeno.template.app.kafka.KafkaMessage import com.softeno.template.app.kafka.KafkaSampleProducer -import com.softeno.template.app.kafka.dto.KafkaMessage import com.softeno.template.app.permission.Permission import com.softeno.template.app.permission.api.PermissionController import com.softeno.template.app.permission.db.PermissionRepository @@ -14,7 +14,6 @@ import kotlinx.coroutines.reactor.awaitSingleOrNull import kotlinx.coroutines.slf4j.MDCContext import kotlinx.coroutines.withContext import org.apache.commons.logging.LogFactory -import org.slf4j.MDC import org.springframework.data.domain.Pageable import org.springframework.data.r2dbc.core.R2dbcEntityTemplate import org.springframework.data.relational.core.query.Criteria.where From f486eecbfb8ed1f965b9ffe5df944aaacbcfdfd2 Mon Sep 17 00:00:00 2001 From: xcodeassociated Date: Mon, 2 Mar 2026 10:56:43 +0100 Subject: [PATCH 11/17] add local spring boot profile (no grafana) --- build.gradle | 2 -- src/main/resources/application-local.properties | 5 +++++ src/main/resources/application.properties | 3 +++ src/main/resources/logback-spring.xml | 9 +++++++++ 4 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 src/main/resources/application-local.properties diff --git a/build.gradle b/build.gradle index 157f8d2..e24f14f 100644 --- a/build.gradle +++ b/build.gradle @@ -73,7 +73,6 @@ dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-reactor' implementation 'com.fasterxml.jackson.module:jackson-module-kotlin' implementation 'org.jetbrains.kotlin:kotlin-reflect' - implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-slf4j' // test utils @@ -91,7 +90,6 @@ dependencies { testImplementation 'org.jetbrains.kotlin:kotlin-test-junit5' testImplementation 'io.projectreactor:reactor-test' - // wiremock testImplementation "org.wiremock:wiremock-standalone:${wiremockVersion}" 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 ee9e0f4..072bb93 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -68,7 +68,10 @@ resilience4j.retry.metrics.enabled=true resilience4j.ratelimiter.metrics.enabled=true # metrics, prometheus & actuator +management.otlp.metrics.export.enabled=true management.otlp.metrics.export.url=http://localhost:4318/v1/metrics + +management.tracing.export.enabled=true 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 diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 9103db8..00ceb07 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -31,6 +31,15 @@ + + + + + + + + + From 0fdca3b973e4f0e1b295c01417478b2f63bc7baf Mon Sep 17 00:00:00 2001 From: xcodeassociated Date: Mon, 2 Mar 2026 11:05:32 +0100 Subject: [PATCH 12/17] add docker-compose for the project --- docker-compose.yml | 134 +++++++++++++++++++++++++++++++++++++++++++++ init_volumes.sh | 21 +++++++ 2 files changed, 155 insertions(+) create mode 100644 docker-compose.yml create mode 100755 init_volumes.sh diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e26935d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,134 @@ +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/ + + postgres: + image: postgres:16.4 + container_name: postgres + user: "${UID}" + hostname: postgres + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + POSTGRES_DB: application + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin + ports: + - "${IP:-0.0.0.0}:5432:5432" + +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 + + postgres-data: + driver: local + driver_opts: + o: bind + type: none + device: ./postgres/volumes/postgres \ No newline at end of file diff --git a/init_volumes.sh b/init_volumes.sh new file mode 100755 index 0000000..3a29b6d --- /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" + "postgres/volumes/postgres" +) + +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 From 970e6a38b1203cd5fa98e72ee3ed57ded8be1fb7 Mon Sep 17 00:00:00 2001 From: xcodeassociated Date: Tue, 3 Mar 2026 08:41:57 +0100 Subject: [PATCH 13/17] minor kafka change --- .../com/softeno/template/app/kafka/KafkaKeycloakController.kt | 2 +- src/main/kotlin/com/softeno/template/app/kafka/KafkaSample.kt | 2 +- .../template/app/permission/service/PermissionService.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 217f953..c6912dc 100644 --- a/src/main/kotlin/com/softeno/template/app/kafka/KafkaKeycloakController.kt +++ b/src/main/kotlin/com/softeno/template/app/kafka/KafkaKeycloakController.kt @@ -17,7 +17,7 @@ class KafkaKeycloakController( private val log = LogFactory.getLog(javaClass) @KafkaListener(id = "\${spring.kafka.consumer.group-id}-keycloak", topics = ["\${com.softeno.kafka.keycloak}"]) - fun listen(record: ConsumerRecord) { + 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/KafkaSample.kt b/src/main/kotlin/com/softeno/template/app/kafka/KafkaSample.kt index 5a4b626..dcb7ab3 100644 --- a/src/main/kotlin/com/softeno/template/app/kafka/KafkaSample.kt +++ b/src/main/kotlin/com/softeno/template/app/kafka/KafkaSample.kt @@ -20,7 +20,7 @@ class KafkaSampleController( private val log = LogFactory.getLog(javaClass) @KafkaListener(id = "\${spring.kafka.consumer.group-id}", topics = ["\${com.softeno.kafka.rx}"]) - fun listen(record: ConsumerRecord) { + suspend fun listen(record: ConsumerRecord) { log.info("[kafka] rx (${props.rx}): ${record.key()}: ${record.value()}") } } diff --git a/src/main/kotlin/com/softeno/template/app/permission/service/PermissionService.kt b/src/main/kotlin/com/softeno/template/app/permission/service/PermissionService.kt index 9316c8e..20c740d 100644 --- a/src/main/kotlin/com/softeno/template/app/permission/service/PermissionService.kt +++ b/src/main/kotlin/com/softeno/template/app/permission/service/PermissionService.kt @@ -52,7 +52,7 @@ class PermissionService( suspend fun createPermission(permissionDto: PermissionDto): PermissionDto = withContext(MDCContext()) { val created = permissionRepository.save(Permission(name = permissionDto.name, description = permissionDto.description)).toDto() - kafkaProducer.send(KafkaMessage(content = "CREATED_PREMISSION: ${created.id}")) + kafkaProducer.send(KafkaMessage(content = "CREATED_PERMISSION: ${created.id}")) return@withContext created } From 100fc76e2ade6447128e18383c96a58de4458f8f Mon Sep 17 00:00:00 2001 From: xcodeassociated Date: Tue, 3 Mar 2026 08:44:10 +0100 Subject: [PATCH 14/17] update logback config --- src/main/resources/logback-spring.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 00ceb07..71fbb71 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -1,6 +1,7 @@ + @@ -51,7 +52,7 @@ - + From 01936ef165ab7c6b4ff1cbfb57ae5c9b52cc1191 Mon Sep 17 00:00:00 2001 From: xcodeassociated Date: Tue, 3 Mar 2026 14:14:29 +0100 Subject: [PATCH 15/17] fix typo in opentelemetry module --- AGENTS.md | 4 ++-- .../{opentelementry => opentelemetry}/OpenTelemetryConfig.kt | 2 +- src/test/kotlin/com/softeno/template/app/IntegrationTest.kt | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) rename src/main/kotlin/com/softeno/template/app/config/{opentelementry => opentelemetry}/OpenTelemetryConfig.kt (94%) diff --git a/AGENTS.md b/AGENTS.md index 64a0068..c2a10c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,10 +34,10 @@ --- ## Project Overview - **Preference**: Prefer Kotlin coroutines over Project Reactor for asynchronous handling. Agents should convert reactive streams to coroutines where feasible, while maintaining back‑pressure support. -- **Language**: Kotlin 1.9 (JVM target) +- **Language**: Kotlin 2.2.21 (JVM target) - **Framework**: Spring Boot 4.x (reactive stack) - **Database**: R2DBC (PostgreSQL) with Flyway migrations -- **Messaging**: Kafka (reactive client) +- **Messaging**: Kafka - **Testing**: JUnit 5, Kotest, MockK, Spring Test, WebTestClient, Testcontainers - **Build Tool**: Gradle 8 (Kotlin DSL) - **Containerisation**: Docker & Docker‑Compose (Postgres) diff --git a/src/main/kotlin/com/softeno/template/app/config/opentelementry/OpenTelemetryConfig.kt b/src/main/kotlin/com/softeno/template/app/config/opentelemetry/OpenTelemetryConfig.kt similarity index 94% rename from src/main/kotlin/com/softeno/template/app/config/opentelementry/OpenTelemetryConfig.kt rename to src/main/kotlin/com/softeno/template/app/config/opentelemetry/OpenTelemetryConfig.kt index 5b5f257..36564c7 100644 --- a/src/main/kotlin/com/softeno/template/app/config/opentelementry/OpenTelemetryConfig.kt +++ b/src/main/kotlin/com/softeno/template/app/config/opentelemetry/OpenTelemetryConfig.kt @@ -1,4 +1,4 @@ -package com.softeno.template.app.config.opentelementry +package com.softeno.template.app.config.opentelemetry import io.opentelemetry.api.OpenTelemetry import io.opentelemetry.instrumentation.logback.appender.v1_0.OpenTelemetryAppender diff --git a/src/test/kotlin/com/softeno/template/app/IntegrationTest.kt b/src/test/kotlin/com/softeno/template/app/IntegrationTest.kt index 3dd5c51..717cc80 100644 --- a/src/test/kotlin/com/softeno/template/app/IntegrationTest.kt +++ b/src/test/kotlin/com/softeno/template/app/IntegrationTest.kt @@ -86,7 +86,6 @@ abstract class BaseIntegrationTest { } postgreSQLContainer.start() - registry.add("spring.liquibase.url") { "jdbc:postgresql://${postgreSQLContainer.host}:${postgreSQLContainer.firstMappedPort}/${postgreSQLContainer.databaseName}" } From 965321f2296483abe050f3bd2ceec9308dbc7a5a Mon Sep 17 00:00:00 2001 From: xcodeassociated Date: Tue, 3 Mar 2026 14:17:39 +0100 Subject: [PATCH 16/17] minor testconatainers change --- src/test/kotlin/com/softeno/template/app/IntegrationTest.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/test/kotlin/com/softeno/template/app/IntegrationTest.kt b/src/test/kotlin/com/softeno/template/app/IntegrationTest.kt index 717cc80..152101e 100644 --- a/src/test/kotlin/com/softeno/template/app/IntegrationTest.kt +++ b/src/test/kotlin/com/softeno/template/app/IntegrationTest.kt @@ -44,6 +44,7 @@ import org.springframework.web.reactive.function.client.WebClient import org.testcontainers.junit.jupiter.Container import org.testcontainers.kafka.KafkaContainer import org.testcontainers.postgresql.PostgreSQLContainer +import org.testcontainers.utility.DockerImageName @SpringBootTest( classes = [SoftenoMvcJpaApp::class], @@ -65,13 +66,13 @@ abstract class BaseIntegrationTest { companion object { @Container - var kafka: KafkaContainer = KafkaContainer("apache/kafka-native:3.8.0") + 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 postgreSQLContainer = PostgreSQLContainer("postgres:16-alpine") + var postgreSQLContainer = PostgreSQLContainer(DockerImageName.parse("postgres:16-alpine")) .withDatabaseName("application") .withUsername("admin") .withPassword("admin") From bb379e918bcaa83140a746d21d3468b5a514a5d3 Mon Sep 17 00:00:00 2001 From: xcodeassociated Date: Tue, 3 Mar 2026 16:12:57 +0100 Subject: [PATCH 17/17] update agents.md --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c2a10c0..c60e6e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,10 +36,10 @@ - **Preference**: Prefer Kotlin coroutines over Project Reactor for asynchronous handling. Agents should convert reactive streams to coroutines where feasible, while maintaining back‑pressure support. - **Language**: Kotlin 2.2.21 (JVM target) - **Framework**: Spring Boot 4.x (reactive stack) -- **Database**: R2DBC (PostgreSQL) with Flyway migrations +- **Database**: R2DBC (PostgreSQL) with Liquibase migrations - **Messaging**: Kafka - **Testing**: JUnit 5, Kotest, MockK, Spring Test, WebTestClient, Testcontainers -- **Build Tool**: Gradle 8 (Kotlin DSL) +- **Build Tool**: Gradle 8 (Groovy DSL) - **Containerisation**: Docker & Docker‑Compose (Postgres) The codebase follows a **hexagonal / clean‑architecture** flavour – core domain logic lives in `src/main/kotlin/com/softeno/template/app/...` and adapters (web, db, kafka) are separate packages.