diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/ConditionalOnPreferredJsonMapper.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/ConditionalOnPreferredJsonMapper.java new file mode 100644 index 00000000000..a1ab26d5010 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/ConditionalOnPreferredJsonMapper.java @@ -0,0 +1,54 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.http; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.context.annotation.Conditional; + +/** + * {@link Conditional @Conditional} that matches based on the preferred JSON mapper. A + * preference is expressed using the {@code spring.http.converters.preferred-json-mapper} + * configuration property, falling back to the + * {@code spring.mvc.converters.preferred-json-mapper} configuration property. When no + * preference is expressed Jackson is preferred by default. + * + * @author Andy Wilkinson + */ +@Conditional(OnPreferredJsonMapperCondition.class) +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Documented +@interface ConditionalOnPreferredJsonMapper { + + JsonMapper value(); + + enum JsonMapper { + + GSON, + + JACKSON, + + JSONB, + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/GsonHttpMessageConvertersConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/GsonHttpMessageConvertersConfiguration.java index 1eacbebd98f..9aa1311c124 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/GsonHttpMessageConvertersConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/GsonHttpMessageConvertersConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,8 +22,8 @@ import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.NoneNestedConditions; +import org.springframework.boot.autoconfigure.http.ConditionalOnPreferredJsonMapper.JsonMapper; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; @@ -61,8 +61,7 @@ class GsonHttpMessageConvertersConfiguration { super(ConfigurationPhase.REGISTER_BEAN); } - @ConditionalOnProperty(name = HttpMessageConvertersAutoConfiguration.PREFERRED_MAPPER_PROPERTY, - havingValue = "gson") + @ConditionalOnPreferredJsonMapper(JsonMapper.GSON) static class GsonPreferred { } @@ -85,8 +84,7 @@ class GsonHttpMessageConvertersConfiguration { } - @ConditionalOnProperty(name = HttpMessageConvertersAutoConfiguration.PREFERRED_MAPPER_PROPERTY, - havingValue = "jsonb") + @ConditionalOnPreferredJsonMapper(JsonMapper.JSONB) static class JsonbPreferred { } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/HttpMessageConvertersAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/HttpMessageConvertersAutoConfiguration.java index fe50c8eab9c..0aaa539eb8b 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/HttpMessageConvertersAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/HttpMessageConvertersAutoConfiguration.java @@ -66,8 +66,6 @@ import org.springframework.http.converter.StringHttpMessageConverter; @ImportRuntimeHints(HttpMessageConvertersAutoConfigurationRuntimeHints.class) public class HttpMessageConvertersAutoConfiguration { - static final String PREFERRED_MAPPER_PROPERTY = "spring.mvc.converters.preferred-json-mapper"; - @Bean @ConditionalOnMissingBean public HttpMessageConverters messageConverters(ObjectProvider> converters) { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/JacksonHttpMessageConvertersConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/JacksonHttpMessageConvertersConfiguration.java index 687cb29dfc5..b3bac31dcb1 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/JacksonHttpMessageConvertersConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/JacksonHttpMessageConvertersConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,7 +22,7 @@ import com.fasterxml.jackson.dataformat.xml.XmlMapper; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.http.ConditionalOnPreferredJsonMapper.JsonMapper; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; @@ -40,8 +40,7 @@ class JacksonHttpMessageConvertersConfiguration { @Configuration(proxyBeanMethods = false) @ConditionalOnClass(ObjectMapper.class) @ConditionalOnBean(ObjectMapper.class) - @ConditionalOnProperty(name = HttpMessageConvertersAutoConfiguration.PREFERRED_MAPPER_PROPERTY, - havingValue = "jackson", matchIfMissing = true) + @ConditionalOnPreferredJsonMapper(JsonMapper.JACKSON) static class MappingJackson2HttpMessageConverterConfiguration { @Bean diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/JsonbHttpMessageConvertersConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/JsonbHttpMessageConvertersConfiguration.java index 14291e97eb2..a4d71684e73 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/JsonbHttpMessageConvertersConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/JsonbHttpMessageConvertersConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,7 +22,7 @@ import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.http.ConditionalOnPreferredJsonMapper.JsonMapper; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; @@ -60,8 +60,7 @@ class JsonbHttpMessageConvertersConfiguration { super(ConfigurationPhase.REGISTER_BEAN); } - @ConditionalOnProperty(name = HttpMessageConvertersAutoConfiguration.PREFERRED_MAPPER_PROPERTY, - havingValue = "jsonb") + @ConditionalOnPreferredJsonMapper(JsonMapper.JSONB) static class JsonbPreferred { } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/OnPreferredJsonMapperCondition.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/OnPreferredJsonMapperCondition.java new file mode 100644 index 00000000000..3018e30def5 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/OnPreferredJsonMapperCondition.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.http; + +import java.util.Locale; + +import org.springframework.boot.autoconfigure.condition.ConditionMessage; +import org.springframework.boot.autoconfigure.condition.ConditionOutcome; +import org.springframework.boot.autoconfigure.condition.SpringBootCondition; +import org.springframework.boot.autoconfigure.http.ConditionalOnPreferredJsonMapper.JsonMapper; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.env.Environment; +import org.springframework.core.type.AnnotatedTypeMetadata; + +/** + * {@link SpringBootCondition} for + * {@link ConditionalOnPreferredJsonMapper @ConditionalOnPreferredJsonMapper}. + * + * @author Andy Wilkinson + */ +class OnPreferredJsonMapperCondition extends SpringBootCondition { + + private static final String PREFERRED_MAPPER_PROPERTY = "spring.http.converters.preferred-json-mapper"; + + @Deprecated(since = "3.5.0", forRemoval = true) + private static final String DEPRECATED_PREFERRED_MAPPER_PROPERTY = "spring.mvc.converters.preferred-json-mapper"; + + @Override + public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) { + JsonMapper conditionMapper = metadata.getAnnotations() + .get(ConditionalOnPreferredJsonMapper.class) + .getEnum("value", JsonMapper.class); + ConditionOutcome outcome = getMatchOutcome(context.getEnvironment(), PREFERRED_MAPPER_PROPERTY, + conditionMapper); + if (outcome != null) { + return outcome; + } + outcome = getMatchOutcome(context.getEnvironment(), DEPRECATED_PREFERRED_MAPPER_PROPERTY, conditionMapper); + if (outcome != null) { + return outcome; + } + ConditionMessage message = ConditionMessage + .forCondition(ConditionalOnPreferredJsonMapper.class, conditionMapper.name()) + .because("no property was configured and Jackson is the default"); + return (conditionMapper == JsonMapper.JACKSON) ? ConditionOutcome.match(message) + : ConditionOutcome.noMatch(message); + } + + private ConditionOutcome getMatchOutcome(Environment environment, String key, JsonMapper conditionMapper) { + String property = environment.getProperty(key); + if (property == null) { + return null; + } + JsonMapper configuredMapper = JsonMapper.valueOf(property.toUpperCase(Locale.ROOT)); + ConditionMessage message = ConditionMessage + .forCondition(ConditionalOnPreferredJsonMapper.class, configuredMapper.name()) + .because("property '%s' had the value '%s'".formatted(key, property)); + return (configuredMapper == conditionMapper) ? ConditionOutcome.match(message) + : ConditionOutcome.noMatch(message); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 387fe2b9644..d7f4de96efc 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -1615,11 +1615,8 @@ { "name": "spring.http.converters.preferred-json-mapper", "type": "java.lang.String", - "description": "Preferred JSON mapper to use for HTTP message conversion. By default, auto-detected according to the environment.", - "deprecation": { - "replacement": "spring.mvc.converters.preferred-json-mapper", - "level": "error" - } + "defaultValue": "jackson", + "description": "Preferred JSON mapper to use for HTTP message conversion. By default, auto-detected according to the environment. Supported values are 'jackson', 'gson', and 'jsonb'. When other json mapping libraries (such as kotlinx.serialization) are present, use a custom HttpMessageConverters bean to control the preferred mapper." }, { "name": "spring.http.encoding.charset", @@ -2107,7 +2104,11 @@ "name": "spring.mvc.converters.preferred-json-mapper", "type": "java.lang.String", "defaultValue": "jackson", - "description": "Preferred JSON mapper to use for HTTP message conversion. By default, auto-detected according to the environment. Supported values are 'jackson', 'gson', and 'jsonb'. When other json mapping libraries (such as kotlinx.serialization) are present, use a custom HttpMessageConverters bean to control the preferred mapper." + "description": "Preferred JSON mapper to use for HTTP message conversion. By default, auto-detected according to the environment. Supported values are 'jackson', 'gson', and 'jsonb'. When other json mapping libraries (such as kotlinx.serialization) are present, use a custom HttpMessageConverters bean to control the preferred mapper.", + "deprecation": { + "replacement": "spring.http.converters.preferred-json-mapper", + "level": "error" + } }, { "name": "spring.mvc.date-format", diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/HttpMessageConvertersAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/HttpMessageConvertersAutoConfigurationTests.java index 2486dfe4913..590616184c4 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/HttpMessageConvertersAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/HttpMessageConvertersAutoConfigurationTests.java @@ -32,7 +32,9 @@ import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConf import org.springframework.boot.autoconfigure.http.JacksonHttpMessageConvertersConfiguration.MappingJackson2HttpMessageConverterConfiguration; import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; import org.springframework.boot.autoconfigure.jsonb.JsonbAutoConfiguration; +import org.springframework.boot.autoconfigure.logging.ConditionEvaluationReportLoggingListener; import org.springframework.boot.autoconfigure.web.ServerProperties; +import org.springframework.boot.logging.LogLevel; import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.assertj.AssertableApplicationContext; import org.springframework.boot.test.context.runner.ApplicationContextRunner; @@ -130,12 +132,41 @@ class HttpMessageConvertersAutoConfigurationTests { @Test void gsonCanBePreferred() { - allOptionsRunner().withPropertyValues("spring.mvc.converters.preferred-json-mapper:gson").run((context) -> { - assertConverterBeanExists(context, GsonHttpMessageConverter.class, "gsonHttpMessageConverter"); - assertConverterBeanRegisteredWithHttpMessageConverters(context, GsonHttpMessageConverter.class); - assertThat(context).doesNotHaveBean(JsonbHttpMessageConverter.class); - assertThat(context).doesNotHaveBean(MappingJackson2HttpMessageConverter.class); - }); + allOptionsRunner().withInitializer(ConditionEvaluationReportLoggingListener.forLogLevel(LogLevel.INFO)) + .withPropertyValues("spring.http.converters.preferred-json-mapper:gson") + .run((context) -> { + assertConverterBeanExists(context, GsonHttpMessageConverter.class, "gsonHttpMessageConverter"); + assertConverterBeanRegisteredWithHttpMessageConverters(context, GsonHttpMessageConverter.class); + assertThat(context).doesNotHaveBean(JsonbHttpMessageConverter.class); + assertThat(context).doesNotHaveBean(MappingJackson2HttpMessageConverter.class); + }); + } + + @Test + @Deprecated(since = "3.5.0", forRemoval = true) + void gsonCanBePreferredWithDeprecatedProperty() { + allOptionsRunner().withInitializer(ConditionEvaluationReportLoggingListener.forLogLevel(LogLevel.INFO)) + .withPropertyValues("spring.mvc.converters.preferred-json-mapper:gson") + .run((context) -> { + assertConverterBeanExists(context, GsonHttpMessageConverter.class, "gsonHttpMessageConverter"); + assertConverterBeanRegisteredWithHttpMessageConverters(context, GsonHttpMessageConverter.class); + assertThat(context).doesNotHaveBean(JsonbHttpMessageConverter.class); + assertThat(context).doesNotHaveBean(MappingJackson2HttpMessageConverter.class); + }); + } + + @Test + @Deprecated(since = "3.5.0", forRemoval = true) + void gsonCanBePreferredWithNonDeprecatedPropertyTakingPrecedence() { + allOptionsRunner().withInitializer(ConditionEvaluationReportLoggingListener.forLogLevel(LogLevel.INFO)) + .withPropertyValues("spring.http.converters.preferred-json-mapper:gson", + "spring.mvc.converters.preferred-json-mapper:jackson") + .run((context) -> { + assertConverterBeanExists(context, GsonHttpMessageConverter.class, "gsonHttpMessageConverter"); + assertConverterBeanRegisteredWithHttpMessageConverters(context, GsonHttpMessageConverter.class); + assertThat(context).doesNotHaveBean(JsonbHttpMessageConverter.class); + assertThat(context).doesNotHaveBean(MappingJackson2HttpMessageConverter.class); + }); } @Test @@ -169,6 +200,31 @@ class HttpMessageConvertersAutoConfigurationTests { }); } + @Test + @Deprecated(since = "3.5.0", forRemoval = true) + void jsonbCanBePreferredWithDeprecatedProperty() { + allOptionsRunner().withPropertyValues("spring.http.converters.preferred-json-mapper:jsonb").run((context) -> { + assertConverterBeanExists(context, JsonbHttpMessageConverter.class, "jsonbHttpMessageConverter"); + assertConverterBeanRegisteredWithHttpMessageConverters(context, JsonbHttpMessageConverter.class); + assertThat(context).doesNotHaveBean(GsonHttpMessageConverter.class); + assertThat(context).doesNotHaveBean(MappingJackson2HttpMessageConverter.class); + }); + } + + @Test + @Deprecated(since = "3.5.0", forRemoval = true) + void jsonbCanBePreferredWithNonDeprecatedPropertyTakingPrecedence() { + allOptionsRunner() + .withPropertyValues("spring.http.converters.preferred-json-mapper:jsonb", + "spring.mvc.converters.preferred-json-mapper:gson") + .run((context) -> { + assertConverterBeanExists(context, JsonbHttpMessageConverter.class, "jsonbHttpMessageConverter"); + assertConverterBeanRegisteredWithHttpMessageConverters(context, JsonbHttpMessageConverter.class); + assertThat(context).doesNotHaveBean(GsonHttpMessageConverter.class); + assertThat(context).doesNotHaveBean(MappingJackson2HttpMessageConverter.class); + }); + } + @Test void stringDefaultConverter() { this.contextRunner.run(assertConverter(StringHttpMessageConverter.class, "stringHttpMessageConverter"));