diff --git a/AGENTS.md b/AGENTS.md index 586ac49..26c1610 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,9 @@ Guidance for AI coding assistants working in this repository. `DataSource-Extensions` is a multi-module Gradle (Kotlin DSL) library that wraps Caplin's `com.caplin.platform.integration.java:datasource` SDK with modern reactive APIs and a Spring Boot starter. Kotlin Coroutines `Flow` is the canonical internal representation; Java `Flow.Publisher` and Reactive Streams `Publisher` variants are thin adapters over it. -JDK 17. Spring Boot pinned to 3.5.x and Kotlin to 2.2.x (see `gradle/libs.versions.toml`). The `common-library` convention plugin applies `io.spring.dependency-management` and overrides Spring's BOM `kotlin.version` to our catalog value — without this, transitive Jackson updates raise `kotlin-stdlib` past what the compiler can read. +JDK 17. Two parallel release lines (see the compatibility table in `README.md`): **`main` targets Spring Boot 4.0.x** (Jackson 3 is the default JSON binding; Jackson 2 is a `compileOnly` opt-in), and **`springboot-3.5.x` is the Spring Boot 3.5.x maintenance branch** (Jackson 2 default). Kotlin pinned to 2.2.21 (see `gradle/libs.versions.toml`). The `common-library` convention plugin applies `io.spring.dependency-management` and overrides Spring's BOM `kotlin.version` to our catalog value — without this, transitive Jackson updates raise `kotlin-stdlib` past what the compiler can read. + +`datasourcex-util` ships both Jackson serialization layers under `serialization/{jackson2,jackson3}` (mirrored serializers + a `JsonHandler` each, sharing zjsonpatch for RFC 6902 diff/patch). On `main`, Jackson 3 (`tools.jackson.*`) is the runtime default and Jackson 2 (`com.fasterxml.jackson.*`) is `compileOnly`; on `springboot-3.5.x` it's the reverse. The Spring starter's `JsonHandler` bean auto-selects Jackson 3 when present and falls back to Jackson 2 (`DataSourceAutoConfiguration`). ## Common commands diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..03ccb7d --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,60 @@ +# Migration guide + +## 2.x (Spring Boot 3.5) → 3.x (Spring Boot 4.0) + +Version `3.x` targets **Spring Boot 4.0** and makes **Jackson 3** (`tools.jackson.*`) the default +JSON binding, matching Spring Boot 4's own default. To stay on Spring Boot 3.5, remain on the +[`2.x` line](./README.md#compatibility) (branch `springboot-3.5.x`). + +### Prerequisites + +- Spring Boot **4.0.x** +- Kotlin **2.2.21+** +- JDK **17+** (unchanged) + +### 1. Bump the dependency + +```kotlin +dependencies { + implementation("com.caplin.integration.datasourcex:spring-boot-starter-datasource:3.+") + // or, for the reactive / util modules: + implementation("com.caplin.integration.datasourcex:datasourcex-kotlin:3.+") +} +``` + +### 2. Jackson 3 is now the default + +Spring Boot 4 auto-configures a Jackson 3 `tools.jackson.databind.ObjectMapper`, and the starter +wires a Jackson-3-backed `JsonHandler` onto the DataSource by default. For most applications no +change is required — plain POJOs serialize as before. + +If you registered **custom Jackson 2 modules, serializers, or `ObjectMapper` customizers**, port them +to Jackson 3. See the +[Jackson 3 release notes](https://github.com/FasterXML/jackson/wiki/Jackson-Release-3.0). + +### 3. Keeping Jackson 2 (optional) + +To keep using Jackson 2 for DataSource JSON, add Spring Boot's (deprecated) Jackson 2 module and +define your own `JsonHandler` bean. The starter's handler is `@ConditionalOnMissingBean`, so yours +takes precedence: + +```kotlin +// build.gradle.kts +implementation("org.springframework.boot:spring-boot-jackson2") +``` + +```kotlin +import com.caplin.datasource.messaging.json.JsonHandler +import com.caplin.integration.datasourcex.util.SimpleDataSourceFactory +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.context.annotation.Bean + +@Bean +fun dataSourceJsonHandler(objectMapper: ObjectMapper): JsonHandler<*> = + SimpleDataSourceFactory.createJackson2JsonHandler(objectMapper) +``` + +Outside Spring, `SimpleDataSourceFactory.createDataSource(...)` now defaults to the Jackson 3 +handler; pass `SimpleDataSourceFactory.defaultJackson2JsonHandler` (or your own) explicitly to keep +Jackson 2. On the `3.x` line the Jackson 2 artifacts are `compileOnly` in `datasourcex-util`, so add +them to your own classpath if you use the Jackson 2 helpers directly. diff --git a/README.md b/README.md index 8fb1c1a..c04dacb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,19 @@ # Extensions for DataSource -Requires Kotlin 2.2 or later. +Requires Kotlin 2.2 or later and JDK 17 or later. + +## Compatibility + +This library is maintained in two parallel lines — choose the version that matches your Spring Boot +major version: + +| Library version | Branch | Spring Boot | Default JSON binding | Kotlin | +|------------------|---------------------|-------------|---------------------------------------------------------------------------------------------------------------|---------| +| `3.x` | `main` | 4.0.x | Jackson 3 (Jackson 2 available via [`spring-boot-jackson2`](https://docs.spring.io/spring-boot/reference/features/json.html)) | 2.2.21+ | +| `2.x` | `springboot-3.5.x` | 3.5.x | Jackson 2 (Jackson 3 available by adding the `tools.jackson` dependencies) | 2.2+ | + +Upgrading from `2.x` (Spring Boot 3.5) to `3.x` (Spring Boot 4.0)? See the +[migration guide](./MIGRATION.md). ## Reactive @@ -32,7 +45,7 @@ Then refer to the documentation: ## Spring This module provides a starter for integrating Caplin DataSource with your -[Spring Boot 3.5](https://spring.io/projects/spring-boot) application, and integration with +[Spring Boot 4.0](https://spring.io/projects/spring-boot) application, and integration with [Spring Messaging](https://docs.spring.io/spring-boot/docs/current/reference/html/messaging.html) for publishing data from annotated functions. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 70bdfb3..c2d886e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,7 @@ [versions] -springBoot = "3.5.14" -kotlin = "2.2.0" +springBoot = "4.0.6" +kotlin = "2.2.21" kotlinCollectionsImmutable = "0.4.0" -jackson3 = "3.1.3" zjsonpatch = "0.6.2" jmh = "1.37" jmh-plugin = "0.7.3" @@ -31,8 +30,6 @@ spring-dependency-management-plugin = "1.1.7" [libraries] kotlin-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinCollectionsImmutable" } -jackson3-databind = { module = "tools.jackson.core:jackson-databind", version.ref = "jackson3" } -jackson3-module-kotlin = { module = "tools.jackson.module:jackson-module-kotlin", version.ref = "jackson3" } zjsonpatch = { module = "io.github.vishwakarma:zjsonpatch", version.ref = "zjsonpatch" } kotlinpoet = { module = "com.squareup:kotlinpoet", version.ref = "kotlinpoet" } fory-core = { module = "org.apache.fory:fory-core", version.ref = "fory" } diff --git a/spring/build.gradle.kts b/spring/build.gradle.kts index b19291f..1608b4e 100644 --- a/spring/build.gradle.kts +++ b/spring/build.gradle.kts @@ -14,7 +14,10 @@ dependencies { implementation("io.projectreactor.kotlin:reactor-kotlin-extensions") implementation("org.springframework.boot:spring-boot-starter") implementation("org.springframework.boot:spring-boot-starter-json") - implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("tools.jackson.module:jackson-module-kotlin") + // Jackson 2 is only needed to compile the jackson2 fallback in DataSourceAutoConfiguration; it is + // present at runtime only if the consumer adds spring-boot-jackson2. + compileOnly("com.fasterxml.jackson.core:jackson-databind") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") implementation("org.slf4j:slf4j-api") diff --git a/spring/src/main/kotlin/com/caplin/integration/datasourcex/spring/annotations/DataMessageMapping.kt b/spring/src/main/kotlin/com/caplin/integration/datasourcex/spring/annotations/DataMessageMapping.kt index 05532d1..34f0b0d 100644 --- a/spring/src/main/kotlin/com/caplin/integration/datasourcex/spring/annotations/DataMessageMapping.kt +++ b/spring/src/main/kotlin/com/caplin/integration/datasourcex/spring/annotations/DataMessageMapping.kt @@ -20,8 +20,8 @@ annotation class DataMessageMapping( * Methods annotated with this message should return one or more objects to be serialized to * JSON. * - * The JSON handler installed in [DataSource] will be used. By default, this is the - * [com.fasterxml.jackson.databind.ObjectMapper] provided by Spring Boot. + * The JSON handler installed in [DataSource] will be used. By default, this is backed by the + * [tools.jackson.databind.ObjectMapper] provided by Spring Boot. */ JSON, diff --git a/spring/src/main/kotlin/com/caplin/integration/datasourcex/spring/internal/DataSourceAutoConfiguration.kt b/spring/src/main/kotlin/com/caplin/integration/datasourcex/spring/internal/DataSourceAutoConfiguration.kt index 89b4e4e..d6f0b65 100644 --- a/spring/src/main/kotlin/com/caplin/integration/datasourcex/spring/internal/DataSourceAutoConfiguration.kt +++ b/spring/src/main/kotlin/com/caplin/integration/datasourcex/spring/internal/DataSourceAutoConfiguration.kt @@ -9,16 +9,19 @@ import com.caplin.integration.datasourcex.util.SimpleDataSourceConfig.Discovery import com.caplin.integration.datasourcex.util.SimpleDataSourceConfig.Peer import com.caplin.integration.datasourcex.util.SimpleDataSourceFactory.createDataSource import com.caplin.integration.datasourcex.util.SimpleDataSourceFactory.createJackson2JsonHandler +import com.caplin.integration.datasourcex.util.SimpleDataSourceFactory.createJackson3JsonHandler import com.caplin.integration.datasourcex.util.getLogger -import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.ObjectMapper as Jackson2ObjectMapper import java.nio.file.Paths import java.util.UUID import java.util.logging.Logger import org.springframework.beans.factory.annotation.Value import org.springframework.boot.autoconfigure.AutoConfiguration +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean +import tools.jackson.databind.ObjectMapper as Jackson3ObjectMapper @AutoConfiguration @EnableConfigurationProperties(DataSourceConfigurationProperties::class) @@ -31,9 +34,20 @@ internal class DataSourceAutoConfiguration { internal const val DEFAULT_DATASOURCE_NAME = "caplin-adapter" } + // Prefer Jackson 3 (the Spring Boot 4 default). Falls back to Jackson 2 only when Jackson 3 is + // absent and a Jackson 2 ObjectMapper is present (e.g. the consumer added spring-boot-jackson2). + // Both are @ConditionalOnMissingBean(JsonHandler) so a consumer-defined JsonHandler wins + // outright. @Bean - @ConditionalOnMissingBean - fun jsonHandler(objectMapper: ObjectMapper): JsonHandler<*> = + @ConditionalOnClass(Jackson3ObjectMapper::class) + @ConditionalOnMissingBean(JsonHandler::class) + fun jackson3JsonHandler(objectMapper: Jackson3ObjectMapper): JsonHandler<*> = + createJackson3JsonHandler(objectMapper) + + @Bean + @ConditionalOnClass(Jackson2ObjectMapper::class) + @ConditionalOnMissingBean(JsonHandler::class) + fun jackson2JsonHandler(objectMapper: Jackson2ObjectMapper): JsonHandler<*> = createJackson2JsonHandler(objectMapper) @Bean diff --git a/spring/src/test/kotlin/com/caplin/integration/datasourcex/spring/internal/DataSourceAutoConfigurationTest.kt b/spring/src/test/kotlin/com/caplin/integration/datasourcex/spring/internal/DataSourceAutoConfigurationTest.kt new file mode 100644 index 0000000..87256f5 --- /dev/null +++ b/spring/src/test/kotlin/com/caplin/integration/datasourcex/spring/internal/DataSourceAutoConfigurationTest.kt @@ -0,0 +1,46 @@ +package com.caplin.integration.datasourcex.spring.internal + +import com.caplin.datasource.DataSource +import com.caplin.datasource.messaging.json.JsonHandler +import com.caplin.integration.datasourcex.util.serialization.jackson3.Jackson3JsonHandler +import io.kotest.core.spec.style.FunSpec +import io.kotest.extensions.spring.SpringExtension +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.mockk +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.ImportAutoConfiguration +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.test.context.TestPropertySource +import tools.jackson.databind.ObjectMapper as Jackson3ObjectMapper +import tools.jackson.databind.json.JsonMapper + +/** + * Verifies the autoconfiguration selects the Jackson 3 [JsonHandler] when a Jackson 3 + * [ObjectMapper] is present — the Spring Boot 4 default. [ImportAutoConfiguration] loads the + * autoconfiguration with proper ordering so its `@ConditionalOnMissingBean` conditions see the + * test's beans first, letting us mock the DataSource rather than create a real one. + */ +@SpringBootTest(classes = [DataSourceAutoConfigurationTest.TestConfig::class]) +@ImportAutoConfiguration(DataSourceAutoConfiguration::class) +@TestPropertySource(properties = ["caplin.datasource.managed.discovery.hostname=localhost"]) +class DataSourceAutoConfigurationTest : FunSpec() { + + @Autowired private lateinit var jsonHandler: JsonHandler<*> + + init { + extension(SpringExtension()) + + test("wires the Jackson 3 JsonHandler by default") { + jsonHandler.shouldBeInstanceOf() + } + } + + @Configuration(proxyBeanMethods = false) + class TestConfig { + @Bean fun jackson3ObjectMapper(): Jackson3ObjectMapper = JsonMapper.builder().build() + + @Bean fun dataSource(): DataSource = mockk(relaxed = true) + } +} diff --git a/spring/src/test/kotlin/com/caplin/integration/datasourcex/spring/internal/DataSourceEndToEndTest.kt b/spring/src/test/kotlin/com/caplin/integration/datasourcex/spring/internal/DataSourceEndToEndTest.kt index 3d52e67..63105dc 100644 --- a/spring/src/test/kotlin/com/caplin/integration/datasourcex/spring/internal/DataSourceEndToEndTest.kt +++ b/spring/src/test/kotlin/com/caplin/integration/datasourcex/spring/internal/DataSourceEndToEndTest.kt @@ -9,8 +9,6 @@ import com.caplin.integration.datasourcex.spring.annotations.DataMessageMapping. import com.caplin.integration.datasourcex.spring.annotations.DataService import com.caplin.integration.datasourcex.spring.annotations.IngressDestinationVariable import com.caplin.integration.datasourcex.spring.annotations.IngressToken.USER_ID -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import io.kotest.assertions.nondeterministic.eventually import io.kotest.core.spec.style.FunSpec import io.kotest.extensions.spring.SpringExtension @@ -29,6 +27,8 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.messaging.handler.annotation.Payload import org.springframework.test.context.TestPropertySource +import tools.jackson.databind.ObjectMapper +import tools.jackson.module.kotlin.jacksonMapperBuilder /** * End-to-end test of the Spring Boot starter: a real application context wires the @@ -176,7 +176,7 @@ class DataSourceEndToEndTest : FunSpec() { @Bean fun dataSource(): DataSource = fake.dataSource - @Bean fun objectMapper(): ObjectMapper = jacksonObjectMapper() + @Bean fun objectMapper(): ObjectMapper = jacksonMapperBuilder().build() } private companion object { diff --git a/util/build.gradle.kts b/util/build.gradle.kts index 0eaf024..073cb35 100644 --- a/util/build.gradle.kts +++ b/util/build.gradle.kts @@ -14,16 +14,19 @@ dependencies { api("org.slf4j:slf4j-api") api("org.jetbrains.kotlinx:kotlinx-coroutines-core") api("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm") - api("com.fasterxml.jackson.core:jackson-core") - implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") - implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + // Jackson 3 is the default JSON binding on this (Spring Boot 4) line; versions come from the + // Spring Boot BOM. + api("tools.jackson.core:jackson-databind") + implementation("tools.jackson.module:jackson-module-kotlin") implementation(libs.zjsonpatch) - // Jackson 3 support is opt-in: consumers that want the Jackson 3 serializers / JsonHandler must - // bring Jackson 3 onto their own classpath. Kept compileOnly here so it stays off the default - // (Jackson 2) runtime path. - compileOnly(libs.jackson3.databind) - compileOnly(libs.jackson3.module.kotlin) + // Jackson 2 support is opt-in: consumers that want the Jackson 2 serializers / JsonHandler must + // bring Jackson 2 onto their own classpath (e.g. via spring-boot-jackson2). Kept compileOnly so + // it + // stays off the default (Jackson 3) runtime path. + compileOnly("com.fasterxml.jackson.core:jackson-databind") + compileOnly("com.fasterxml.jackson.module:jackson-module-kotlin") + compileOnly("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") implementation("org.jetbrains.kotlin:kotlin-reflect") @@ -40,13 +43,17 @@ dependencies { testImplementation(libs.kotest.runner) testImplementation(libs.fory.core) testImplementation(libs.fory.kotlin) - testImplementation(libs.jackson3.databind) - testImplementation(libs.jackson3.module.kotlin) + // Jackson 2 is compileOnly in main; the Jackson 2 serialization/handler tests need it at runtime. + testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin") + testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") jmh(libs.jmh.core) jmh(libs.jmh.generator) - jmh(libs.jackson3.databind) - jmh(libs.jackson3.module.kotlin) + // JacksonSerializationBenchmark exercises both Jackson lines; Jackson 2 (compileOnly in main) + // must + // be added explicitly for the jmh runtime. + jmh("com.fasterxml.jackson.module:jackson-module-kotlin") + jmh("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") } jmh { duplicateClassesStrategy.set(DuplicatesStrategy.EXCLUDE) } diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/SimpleDataSourceFactory.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/SimpleDataSourceFactory.kt index 3a15174..59aaf1c 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/SimpleDataSourceFactory.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/SimpleDataSourceFactory.kt @@ -75,14 +75,14 @@ object SimpleDataSourceFactory { * * @param simpleConfig The simple configuration for the data source. * @param jsonHandler The [JsonHandler] to use for serializing and deserializing JSON payloads. - * This defaults to the Jackson 2 [defaultJackson2JsonHandler] backed by - * [defaultJackson2ObjectMapper]. + * This defaults to the Jackson 3 [defaultJackson3JsonHandler] backed by + * [defaultJackson3ObjectMapper]. * @return The created data source. */ @JvmStatic fun createDataSource( simpleConfig: SimpleDataSourceConfig, - jsonHandler: JsonHandler<*> = defaultJackson2JsonHandler, + jsonHandler: JsonHandler<*> = defaultJackson3JsonHandler, ): DataSource { val logPath = simpleConfig.logDirectory