Skip to content

Add Jackson 3 support#945

Open
jiholee17 wants to merge 2 commits into
masterfrom
feature/add-jackson-3-support
Open

Add Jackson 3 support#945
jiholee17 wants to merge 2 commits into
masterfrom
feature/add-jackson-3-support

Conversation

@jiholee17

@jiholee17 jiholee17 commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

Closes #899

Changes

This PR re-introduces support for Jackson 3 (now configured by default in Spring Boot 4 projects) for projects that use the Kotlin 2 generator (generateKotlinNullableClasses = true) since these are the ones that use the builder, by detecting which versions of Jackson annotations are available on the compile classpath and adding annotations for the available version(s). If neither version is found, codegen defaults to Jackson 2 annotations for backwards compatibility. If both versions are found, codegen adds both annotations and defers to runtime selection behavior.

Affected annotations are:

  • @JsonDeserialize in com.fasterxml.jackson.databind.annotation (Jackson 2) and tools.jackson.databind.annotation (Jackson 3)
  • @JsonPOJOBuilder in com.fasterxml.jackson.databind.annotation (Jackson 2) and tools.jackson.databind.annotation (Jackson 3)

Examples

Example with both Jackson versions:

import com.fasterxml.jackson.databind.`annotation`.JsonDeserialize as FasterxmlJacksonDatabindAnnotationJsonDeserialize
import tools.jackson.databind.`annotation`.JsonDeserialize as ToolsJacksonDatabindAnnotationJsonDeserialize
...
@JsonTypeInfo(use = JsonTypeInfo.Id.NONE)
@FasterxmlJacksonDatabindAnnotationJsonDeserialize(builder = Country.Builder::class)
@ToolsJacksonDatabindAnnotationJsonDeserialize(builder = Country.Builder::class)
public class Country(
...

Example with single Jackson version:

import com.fasterxml.jackson.databind.`annotation`.JsonDeserialize
...
@JsonTypeInfo(use = JsonTypeInfo.Id.NONE)
@JsonDeserialize(builder = Query.Builder::class)
public class Query(
...

How Jackson version detection works

The plugin wires a lazy provider onto the task's @Input jacksonVersions: SetProperty<JacksonVersion>:

configurations.named("compileClasspath")                                                                                                                                
    .map { it.incoming.resolutionResult.allComponents }   // resolved dependency graph
    .map { JacksonVersionDetector.detect(it) }            // → Set<JacksonVersion>

detect scans the graph's components for the jackson-databind module and keys on group — com.fasterxml.jackson.core → Jackson 2, tools.jackson.core → Jackson 3 (group is the only discriminator, since both lineages share the artifact name jackson-databind). At task execution, jacksonVersions.get() is read into CodeGenConfig, which selects the annotation package; an empty set defaults to Jackson 2.

Safe with the Jakarta EE migration plugin

Detection reads resolutionResult.allComponents — the dependency graph metadata — and never calls getFiles() / resolvedArtifacts. The Jakarta plugin works as an artifact transform that only fires when files are requested, so a metadata-only read never triggers it (and never forces sibling modules to build). That eager-resolution path is what broke the earlier resolvedArtifacts-based attempt.

Safe with the Gradle configuration cache

The task never holds a Configuration or Project (neither is cc-serializable). The chain stays a lazy provider, resolved only at execution or cache-store time — after all dependencies are declared — and only the resulting Set<JacksonVersion> (trivially serializable) is stored. A functional test runs generateJava twice with --configuration-cache --configuration-cache-problems=fail and asserts store-then-reuse.

* Which Jackson major versions are present among the resolved [components]
* (pass `resolutionResult.allComponents`). Reads graph metadata only — no artifact download.
*/
fun detect(components: Set<ResolvedComponentResult>): Set<JacksonVersion> =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: maybe add a couple of tests to cover various project setups (specifically thinking of muti-module project setups with various combinations of how and where plugin is declared vs. applied)

testImplementation 'org.jetbrains.kotlin:kotlin-compiler'

integTestImplementation 'com.fasterxml.jackson.module:jackson-module-kotlin'
integTestImplementation 'tools.jackson.core:jackson-databind:latest.release'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are there any integration tests that explicitly reference Jackson3? idk if having two sets (Jackson 2 and the same for Jackson 3 would be an overkill)

)

@Input
val jacksonVersions: SetProperty<JacksonVersion> =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wdyt of providing a customer-facing property that Gradle plugin consumers can override to specify the Jackson version to use if we detect it incorrectly (so they are not blocked)?

The con is that it's yet another prop that we need to maintain

val jacksonVersions: SetProperty<JacksonVersion> =
objectFactory.setProperty(JacksonVersion::class.java).convention(
project.configurations
.named(JavaPlugin.COMPILE_CLASSPATH_CONFIGURATION_NAME)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall we consider runtime dependencies as well?

Thinking of a (weird) setup where Jackson is only present on the runtime classpath via transitive dependencies

objectFactory.setProperty(JacksonVersion::class.java).convention(
project.configurations
.named(JavaPlugin.COMPILE_CLASSPATH_CONFIGURATION_NAME)
.map { it.incoming.resolutionResult.allComponents }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line uses an eager getter that will trigger eager resolution at configuration time.
Gradle provides ResolutionResult.getRootComponent(): Provider<ResolvedComponentResult>:
https://docs.gradle.org/current/javadoc/org/gradle/api/artifacts/result/ResolutionResult.html
(since Gradle 7.4+ so it should be totally safe to use) as a config-cache-safe way to feed a resolution result into a task. Also better aligns with our code modernization efforts to play well with Gradle caching.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you switch to the ResolutionResult.getRootComponent() you'll also need to rewrite the detect() method to start walking from the root of the graph and keep track of visited nodes

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Jackson 3 support

2 participants