Stop an ObjectMapper from causing JsonMapper auto-config to back off

Previously, the auto-configured JsonMapper would back off when any
type of ObjectMapper was defined. Updating this was missed as part
of the move to Jackson 3 where both Boot and Framework now intend to
use the more specific JsonMapper type.

This commit updates the condition such that a JsonMapper will still
be auto-configured when any other type of ObjectMapper is definied.
It will now only back off when a JsonMapper bean is defined.

Closes gh-47379
This commit is contained in:
Andy Wilkinson 2025-10-02 09:21:52 +01:00
parent b28d390c69
commit ee2730ca8d
2 changed files with 75 additions and 35 deletions

View File

@ -68,10 +68,10 @@ import org.springframework.util.ReflectionUtils;
/**
* Auto configuration for Jackson. The following auto-configuration will get applied:
* <ul>
* <li>an {@link JsonMapper} in case none is already configured.</li>
* <li>a {@link tools.jackson.databind.json.JsonMapper.Builder} in case none is already
* configured.</li>
* <li>auto-registration for all {@link JacksonModule} beans with all {@link ObjectMapper}
* <li>a {@link JsonMapper} in case none is already configured.</li>
* <li>auto-registration for all {@link JacksonModule} beans with all {@link JsonMapper}
* beans (including the defaulted ones).</li>
* </ul>
*
@ -111,7 +111,7 @@ public final class JacksonAutoConfiguration {
@Bean
@Primary
@ConditionalOnMissingBean(ObjectMapper.class)
@ConditionalOnMissingBean(JsonMapper.class)
JsonMapper jacksonJsonMapper(JsonMapper.Builder builder) {
return builder.build();
}

View File

@ -61,6 +61,7 @@ import org.springframework.boot.jackson.JsonMixinModule;
import org.springframework.boot.jackson.JsonMixinModuleEntries;
import org.springframework.boot.jackson.ObjectValueSerializer;
import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration.JacksonAutoConfigurationRuntimeHints;
import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration.JacksonJsonMapperBuilderCustomizerConfiguration.StandardJsonMapperBuilderCustomizer;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -87,16 +88,53 @@ import static org.mockito.Mockito.mock;
* @author Ralf Ueberfuhr
* @author Eddú Meléndez
*/
@SuppressWarnings("removal")
class JacksonAutoConfigurationTests {
protected final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class));
@Test
void definesJsonMapper() {
this.contextRunner.run((context) -> assertThat(context).hasSingleBean(JsonMapper.class));
}
@Test
void definesJsonMapperBuilder() {
this.contextRunner.run((context) -> assertThat(context).hasSingleBean(JsonMapper.Builder.class));
}
@Test
void jsonMapperBacksOffWhenCustomJsonMapperIsDefined() {
this.contextRunner.withBean("customJsonMapper", JsonMapper.class).run((context) -> {
assertThat(context).hasSingleBean(JsonMapper.class);
assertThat(context).hasBean("customJsonMapper");
});
}
@Test
void jsonMapperDoesNotBackOffWhenObjectMapperIsDefined() {
this.contextRunner.withBean(ObjectMapper.class).run((context) -> {
assertThat(context).hasSingleBean(JsonMapper.class);
assertThat(context.getBeansOfType(ObjectMapper.class)).hasSize(2);
});
}
@Test
void jsonMapperBuilderDoesNotBackOffWhenJsonMapperIsDefined() {
this.contextRunner.withBean(JsonMapper.class)
.run((context) -> assertThat(context).hasSingleBean(JsonMapper.Builder.class));
}
@Test
void standardJsonMapperBuilderCustomizerDoesNotBackOffWhenCustomizerIsDefined() {
this.contextRunner.withBean(JsonMapperBuilderCustomizer.class, () -> mock(JsonMapperBuilderCustomizer.class))
.run((context) -> assertThat(context).hasSingleBean(StandardJsonMapperBuilderCustomizer.class));
}
@Test
void doubleModuleRegistration() {
this.contextRunner.withUserConfiguration(DoubleModulesConfig.class).run((context) -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
JsonMapper mapper = context.getBean(JsonMapper.class);
assertThat(mapper.writeValueAsString(new Foo())).isEqualTo("{\"foo\":\"bar\"}");
});
}
@ -114,7 +152,7 @@ class JacksonAutoConfigurationTests {
@Test
void noCustomDateFormat() {
this.contextRunner.run((context) -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
JsonMapper mapper = context.getBean(JsonMapper.class);
assertThat(mapper.serializationConfig().getDateFormat()).isInstanceOf(StdDateFormat.class);
assertThat(mapper.deserializationConfig().getDateFormat()).isInstanceOf(StdDateFormat.class);
});
@ -123,7 +161,7 @@ class JacksonAutoConfigurationTests {
@Test
void customDateFormat() {
this.contextRunner.withPropertyValues("spring.jackson.date-format:yyyyMMddHHmmss").run((context) -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
JsonMapper mapper = context.getBean(JsonMapper.class);
DateFormat serializationDateFormat = mapper.serializationConfig().getDateFormat();
assertThat(serializationDateFormat).isInstanceOf(SimpleDateFormat.class);
assertThat(((SimpleDateFormat) serializationDateFormat).toPattern()).isEqualTo("yyyyMMddHHmmss");
@ -137,7 +175,7 @@ class JacksonAutoConfigurationTests {
void customDateFormatClass() {
this.contextRunner.withPropertyValues("spring.jackson.date-format:" + MyDateFormat.class.getName())
.run((context) -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
JsonMapper mapper = context.getBean(JsonMapper.class);
assertThat(mapper.serializationConfig().getDateFormat()).isInstanceOf(MyDateFormat.class);
assertThat(mapper.deserializationConfig().getDateFormat()).isInstanceOf(MyDateFormat.class);
});
@ -146,7 +184,7 @@ class JacksonAutoConfigurationTests {
@Test
void noCustomPropertyNamingStrategy() {
this.contextRunner.run((context) -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
JsonMapper mapper = context.getBean(JsonMapper.class);
assertThat(mapper.serializationConfig().getPropertyNamingStrategy()).isNull();
});
}
@ -154,7 +192,7 @@ class JacksonAutoConfigurationTests {
@Test
void customPropertyNamingStrategyField() {
this.contextRunner.withPropertyValues("spring.jackson.property-naming-strategy:SNAKE_CASE").run((context) -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
JsonMapper mapper = context.getBean(JsonMapper.class);
assertThat(mapper.serializationConfig().getPropertyNamingStrategy()).isInstanceOf(SnakeCaseStrategy.class);
});
}
@ -164,7 +202,7 @@ class JacksonAutoConfigurationTests {
this.contextRunner.withPropertyValues(
"spring.jackson.property-naming-strategy:tools.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy")
.run((context) -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
JsonMapper mapper = context.getBean(JsonMapper.class);
assertThat(mapper.serializationConfig().getPropertyNamingStrategy())
.isInstanceOf(SnakeCaseStrategy.class);
});
@ -173,7 +211,7 @@ class JacksonAutoConfigurationTests {
@Test
void enableSerializationFeature() {
this.contextRunner.withPropertyValues("spring.jackson.serialization.indent_output:true").run((context) -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
JsonMapper mapper = context.getBean(JsonMapper.class);
assertThat(SerializationFeature.INDENT_OUTPUT.enabledByDefault()).isFalse();
assertThat(
mapper.serializationConfig().hasSerializationFeatures(SerializationFeature.INDENT_OUTPUT.getMask()))
@ -184,7 +222,7 @@ class JacksonAutoConfigurationTests {
@Test
void disableSerializationFeature() {
this.contextRunner.withPropertyValues("spring.jackson.serialization.wrap_exceptions:false").run((context) -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
JsonMapper mapper = context.getBean(JsonMapper.class);
assertThat(SerializationFeature.WRAP_EXCEPTIONS.enabledByDefault()).isTrue();
assertThat(mapper.isEnabled(SerializationFeature.WRAP_EXCEPTIONS)).isFalse();
});
@ -194,7 +232,7 @@ class JacksonAutoConfigurationTests {
void enableDeserializationFeature() {
this.contextRunner.withPropertyValues("spring.jackson.deserialization.use_big_decimal_for_floats:true")
.run((context) -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
JsonMapper mapper = context.getBean(JsonMapper.class);
assertThat(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS.enabledByDefault()).isFalse();
assertThat(mapper.deserializationConfig()
.hasDeserializationFeatures(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS.getMask())).isTrue();
@ -205,7 +243,7 @@ class JacksonAutoConfigurationTests {
void disableDeserializationFeature() {
this.contextRunner.withPropertyValues("spring.jackson.deserialization.fail-on-null-for-primitives:false")
.run((context) -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
JsonMapper mapper = context.getBean(JsonMapper.class);
assertThat(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES.enabledByDefault()).isTrue();
assertThat(mapper.deserializationConfig()
.hasDeserializationFeatures(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES.getMask()))
@ -217,7 +255,7 @@ class JacksonAutoConfigurationTests {
void enableMapperFeature() {
this.contextRunner.withPropertyValues("spring.jackson.mapper.require_setters_for_getters:true")
.run((context) -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
JsonMapper mapper = context.getBean(JsonMapper.class);
assertThat(MapperFeature.REQUIRE_SETTERS_FOR_GETTERS.enabledByDefault()).isFalse();
assertThat(mapper.serializationConfig().isEnabled(MapperFeature.REQUIRE_SETTERS_FOR_GETTERS)).isTrue();
@ -229,7 +267,7 @@ class JacksonAutoConfigurationTests {
@Test
void disableMapperFeature() {
this.contextRunner.withPropertyValues("spring.jackson.mapper.use_annotations:false").run((context) -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
JsonMapper mapper = context.getBean(JsonMapper.class);
assertThat(MapperFeature.USE_ANNOTATIONS.enabledByDefault()).isTrue();
assertThat(mapper.deserializationConfig().isEnabled(MapperFeature.USE_ANNOTATIONS)).isFalse();
assertThat(mapper.serializationConfig().isEnabled(MapperFeature.USE_ANNOTATIONS)).isFalse();
@ -291,7 +329,7 @@ class JacksonAutoConfigurationTests {
void enableEnumFeature() {
this.contextRunner.withPropertyValues("spring.jackson.datatype.enum.write-enums-to-lowercase=true")
.run((context) -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
JsonMapper mapper = context.getBean(JsonMapper.class);
assertThat(EnumFeature.WRITE_ENUMS_TO_LOWERCASE.enabledByDefault()).isFalse();
assertThat(mapper.serializationConfig().isEnabled(EnumFeature.WRITE_ENUMS_TO_LOWERCASE)).isTrue();
});
@ -301,7 +339,7 @@ class JacksonAutoConfigurationTests {
void disableJsonNodeFeature() {
this.contextRunner.withPropertyValues("spring.jackson.datatype.json-node.write-null-properties:false")
.run((context) -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
JsonMapper mapper = context.getBean(JsonMapper.class);
assertThat(JsonNodeFeature.WRITE_NULL_PROPERTIES.enabledByDefault()).isTrue();
assertThat(mapper.deserializationConfig().isEnabled(JsonNodeFeature.WRITE_NULL_PROPERTIES)).isFalse();
});
@ -358,7 +396,7 @@ class JacksonAutoConfigurationTests {
@Test
void enableDefaultLeniency() {
this.contextRunner.withPropertyValues("spring.jackson.default-leniency:true").run((context) -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
JsonMapper mapper = context.getBean(JsonMapper.class);
Person person = mapper.readValue("{\"birthDate\": \"2010-12-30\"}", Person.class);
assertThat(person.getBirthDate()).isNotNull();
});
@ -367,7 +405,7 @@ class JacksonAutoConfigurationTests {
@Test
void disableDefaultLeniency() {
this.contextRunner.withPropertyValues("spring.jackson.default-leniency:false").run((context) -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
JsonMapper mapper = context.getBean(JsonMapper.class);
assertThatExceptionOfType(InvalidFormatException.class)
.isThrownBy(() -> mapper.readValue("{\"birthDate\": \"2010-12-30\"}", Person.class))
.withMessageContaining("expected format")
@ -378,7 +416,7 @@ class JacksonAutoConfigurationTests {
@Test
void constructorDetectorWithNoStrategyUseDefault() {
this.contextRunner.run((context) -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
JsonMapper mapper = context.getBean(JsonMapper.class);
ConstructorDetector cd = mapper.deserializationConfig().getConstructorDetector();
assertThat(cd.singleArgMode()).isEqualTo(SingleArgConstructor.HEURISTIC);
assertThat(cd.requireCtorAnnotation()).isFalse();
@ -389,7 +427,7 @@ class JacksonAutoConfigurationTests {
@Test
void constructorDetectorWithDefaultStrategy() {
this.contextRunner.withPropertyValues("spring.jackson.constructor-detector=default").run((context) -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
JsonMapper mapper = context.getBean(JsonMapper.class);
ConstructorDetector cd = mapper.deserializationConfig().getConstructorDetector();
assertThat(cd.singleArgMode()).isEqualTo(SingleArgConstructor.HEURISTIC);
assertThat(cd.requireCtorAnnotation()).isFalse();
@ -401,7 +439,7 @@ class JacksonAutoConfigurationTests {
void constructorDetectorWithUsePropertiesBasedStrategy() {
this.contextRunner.withPropertyValues("spring.jackson.constructor-detector=use-properties-based")
.run((context) -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
JsonMapper mapper = context.getBean(JsonMapper.class);
ConstructorDetector cd = mapper.deserializationConfig().getConstructorDetector();
assertThat(cd.singleArgMode()).isEqualTo(SingleArgConstructor.PROPERTIES);
assertThat(cd.requireCtorAnnotation()).isFalse();
@ -412,7 +450,7 @@ class JacksonAutoConfigurationTests {
@Test
void constructorDetectorWithUseDelegatingStrategy() {
this.contextRunner.withPropertyValues("spring.jackson.constructor-detector=use-delegating").run((context) -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
JsonMapper mapper = context.getBean(JsonMapper.class);
ConstructorDetector cd = mapper.deserializationConfig().getConstructorDetector();
assertThat(cd.singleArgMode()).isEqualTo(SingleArgConstructor.DELEGATING);
assertThat(cd.requireCtorAnnotation()).isFalse();
@ -423,7 +461,7 @@ class JacksonAutoConfigurationTests {
@Test
void constructorDetectorWithExplicitOnlyStrategy() {
this.contextRunner.withPropertyValues("spring.jackson.constructor-detector=explicit-only").run((context) -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
JsonMapper mapper = context.getBean(JsonMapper.class);
ConstructorDetector cd = mapper.deserializationConfig().getConstructorDetector();
assertThat(cd.singleArgMode()).isEqualTo(SingleArgConstructor.REQUIRE_MODE);
assertThat(cd.requireCtorAnnotation()).isFalse();
@ -433,17 +471,19 @@ class JacksonAutoConfigurationTests {
@Test
void additionalJacksonBuilderCustomization() {
this.contextRunner.withUserConfiguration(JsonMapperBuilderCustomConfig.class).run((context) -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
assertThat(mapper.deserializationConfig().getDateFormat()).isInstanceOf(MyDateFormat.class);
assertThat(mapper.serializationConfig().getDateFormat()).isInstanceOf(MyDateFormat.class);
});
this.contextRunner.withBean(JsonMapperBuilderCustomizer.class, () -> null)
.withUserConfiguration(JsonMapperBuilderCustomConfig.class)
.run((context) -> {
JsonMapper mapper = context.getBean(JsonMapper.class);
assertThat(mapper.deserializationConfig().getDateFormat()).isInstanceOf(MyDateFormat.class);
assertThat(mapper.serializationConfig().getDateFormat()).isInstanceOf(MyDateFormat.class);
});
}
@Test
void writeDurationAsTimestampsDefault() {
this.contextRunner.run((context) -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
JsonMapper mapper = context.getBean(JsonMapper.class);
Duration duration = Duration.ofHours(2);
assertThat(mapper.writeValueAsString(duration)).isEqualTo("\"PT2H\"");
});
@ -454,7 +494,7 @@ class JacksonAutoConfigurationTests {
this.contextRunner
.withPropertyValues("spring.jackson.visibility.getter:none", "spring.jackson.visibility.field:any")
.run((context) -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
JsonMapper mapper = context.getBean(JsonMapper.class);
String json = mapper.writeValueAsString(new VisibilityBean());
assertThat(json).contains("property1");
assertThat(json).contains("property2");
@ -490,7 +530,7 @@ class JacksonAutoConfigurationTests {
@Test
void shouldRegisterProblemDetailsMixin() {
this.contextRunner.run((context) -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
JsonMapper mapper = context.getBean(JsonMapper.class);
ProblemDetail problemDetail = ProblemDetail.forStatus(404);
problemDetail.setProperty("spring", "boot");
String json = mapper.writeValueAsString(problemDetail);
@ -548,7 +588,7 @@ class JacksonAutoConfigurationTests {
@Bean
@Primary
ObjectMapper objectMapper() {
JsonMapper jsonMapper() {
return JsonMapper.builder().addModule(jacksonModule()).build();
}