Skip to content

feat: API to add Jackson modules / customize immutable Jackson 3 mappers - #5324

Open
pjfanning wants to merge 2 commits into
swagger-api:3.0.0from
pjfanning:jackson3-mapper-customizers
Open

pjfanning wants to merge 2 commits into
swagger-api:3.0.0from
pjfanning:jackson3-mapper-customizers

Conversation

@pjfanning

Copy link
Copy Markdown
Contributor

Targets the 3.0.0 branch (Jackson 3).

Problem

Jackson 3 ObjectMappers are immutable. With Jackson 2, the documented way to make swagger-core aware of Kotlin/Scala/Jakarta XML Bind/Guava/… types was

Json.mapper().registerModule(new KotlinModule.Builder().build());

and it worked for two reasons: Json.mapper() was mutable, and AbstractModelConverter kept the same instance, so the default ModelResolver in ModelConverters saw the module too (that is what drives model introspection; see the _intr() comment about users loading modules later).

On 3.0.0 both are gone: Json/Yaml/Json31/Yaml31 hold a static final mapper with no way to replace it, and AbstractModelConverter now rebuild()s a private copy, so even a swapped Json.mapper() would not reach an already-created resolver. The only surviving hook is ObjectMapperProcessor, which is scoped to an OpenApiContext and appends an extra resolver rather than configuring the ~80 internal Json.mapper() call sites (ModelDeserializer, AnnotationsUtils, SwaggerSerializers, plugins, …).

This is the Jackson 3 version of what I proposed in #4052 / draft PR #4053 (Jackson 2, builder-based ObjectMapperFactory with a user-modifiable configuration and reset). Related: #3837 (findAndRegisterModules request), #4991 (Jackson 3 support), #5314 (the Jackson 3 migration this builds on).

Changes

ObjectMapperFactory customizer registry (new, swagger-core)

ObjectMapperFactory.addModule(new KotlinModule.Builder().build());      // every mapper swagger-core builds
ObjectMapperFactory.addCustomizer((builder, target) -> {                 // full MapperBuilder access
    if (target.isYaml()) { builder.enable(SerializationFeature.INDENT_OUTPUT); }
});
  • New MapperCustomizer ((MapperBuilder<?,?>, MapperTarget) -> void) and MapperTarget enum (JSON, YAML, JSON31, YAML31, JSON_CONVERTER).
  • addCustomizer / removeCustomizer / clearCustomizers / getCustomizers, addModule(s) shortcuts, and a generation() counter.
  • Customizers run as the last step of create(...) and createJsonConverter(), after swagger-core's modules/mixins/inclusion settings, so user config wins on conflicts — same precedence registerModule-after-construction had. buildStrictGenericObjectMapper() is intentionally not customized.

Json / Yaml / Json31 / Yaml31 become replaceable

mapper() is unchanged in signature but is now lazily built and rebuilt when the factory generation moves, so a customizer registered at any time takes effect. New per-mapper API for the "only this one" case:

Json.addModule(module);                                   // this mapper only
Json.configure(b -> b.changeDefaultPropertyInclusion(...)); // rebuild from current config
Json.mapper(myMapper);                                    // install as-is (e.g. a framework-managed mapper); not customized further
Json.reset();                                             // back to factory defaults

Json31.converterMapper() gets the same treatment (+ converterMapper(ObjectMapper)). The four classes share a package-private MapperHolder.

ModelConverters keeps its default ModelResolver in sync

This is the part that actually restores Jackson 2 behaviour for model introspection. ModelConverters remembers how it built its default resolver and, when ObjectMapperFactory.generation() has moved, replaces only that resolver in place on the next read/readAll/resolveAsResolvedSchema/getConverters call. User-added converters, skipped packages/classes and the singleton itself are untouched. The stale _intr() javadoc in AbstractModelConverter is updated to describe this.

ObjectMapperProcessor is unchanged and composes with this: IntegrationObjectMapperFactory.createJson() now includes registered customizers.

Migration (Jackson 2 → 3.0.0)

Jackson 2 3.0.0
Json.mapper().registerModule(m) + same on Yaml/Json31/Yaml31 ObjectMapperFactory.addModule(m)
Json.mapper().registerModule(m) (JSON only, on purpose) Json.addModule(m)
Json.mapper().setSerializationInclusion(...) / .configure(...) / .addMixIn(...) Json.configure(b -> ...)
new ModelResolver(myMapper) unchanged

Note for the migration guide: Jackson 3 databind bundles JSR-310, JDK8 Optional and parameter-names support, so JavaTimeModule — historically the #1 reason to call registerModule — is no longer needed.

Tests

  • MapperCustomizerTest (11): module reaches all five targets; customizer ordering (user overrides NON_NULL); registering after first use rebuilds and re-caches; remove/clear; strict generic mapper untouched; per-mapper addModule/configure keep swagger mixins; explicit mapper(ObjectMapper) survives factory changes until reset(); Json31.reset() also resets the converter mapper.
  • ModelConvertersCustomizerTest (4): a SNAKE_CASE naming strategy registered after ModelConverters.getInstance() changes resolved property names (3.0 and 3.1 instances); user-added converter keeps its position across the refresh; removing the customizer restores defaults.

All new public classes/methods carry @since 3.0.0. Everything added is new static/instance API — no signature changes on the 3.0.0 branch.

Not in this PR (possible follow-ups): ServiceLoader discovery of MapperCustomizer, and a jacksonModules option for the maven/gradle plugins.

🤖 Generated with Claude Code

pjfanning and others added 2 commits September 19, 2026 14:45
Jackson 3 ObjectMappers are immutable, so the Jackson 2 idiom
Json.mapper().registerModule(...) no longer works, and the default
ModelResolver copies its mapper so a replaced Json.mapper() would not
reach model introspection either.

- ObjectMapperFactory: MapperCustomizer registry (addCustomizer,
  addModule(s), remove/clear, generation counter) applied as the last
  step of every mapper it builds, keyed by MapperTarget.
- Json/Yaml/Json31/Yaml31: mapper() is now lazily built and rebuilt when
  customizers change; new mapper(ObjectMapper), configure(...),
  addModule(...) and reset() per class (shared MapperHolder).
- ModelConverters: rebuilds only its default ModelResolver in place when
  the factory generation moves, keeping user-added converters.
- Fix stale _intr() javadoc in AbstractModelConverter.
- Tests: MapperCustomizerTest, ModelConvertersCustomizerTest.

Jackson 3 follow-up to swagger-api#4052 / swagger-api#4053; related swagger-api#3837, swagger-api#4991, swagger-api#5314.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

2 participants