diff --git a/module/spring-boot-http-codec/build.gradle b/module/spring-boot-http-codec/build.gradle index 7eb8b70cec72..a07af76acff4 100644 --- a/module/spring-boot-http-codec/build.gradle +++ b/module/spring-boot-http-codec/build.gradle @@ -30,7 +30,9 @@ dependencies { optional(project(":core:spring-boot-autoconfigure")) optional(project(":core:spring-boot-test")) + optional(project(":module:spring-boot-gson")) optional(project(":module:spring-boot-jackson")) + optional(project(":module:spring-boot-kotlin-serialization")) optional("org.springframework:spring-webflux") testImplementation(project(":core:spring-boot-test")) diff --git a/module/spring-boot-http-codec/src/main/java/org/springframework/boot/http/codec/autoconfigure/CodecsAutoConfiguration.java b/module/spring-boot-http-codec/src/main/java/org/springframework/boot/http/codec/autoconfigure/CodecsAutoConfiguration.java index f721ee63e967..413bb69abce9 100644 --- a/module/spring-boot-http-codec/src/main/java/org/springframework/boot/http/codec/autoconfigure/CodecsAutoConfiguration.java +++ b/module/spring-boot-http-codec/src/main/java/org/springframework/boot/http/codec/autoconfigure/CodecsAutoConfiguration.java @@ -16,14 +16,17 @@ package org.springframework.boot.http.codec.autoconfigure; +import com.google.gson.Gson; +import kotlinx.serialization.Serializable; +import kotlinx.serialization.json.Json; import org.jspecify.annotations.Nullable; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.json.JsonMapper; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.PropertyMapper; import org.springframework.boot.http.codec.CodecCustomizer; @@ -32,8 +35,13 @@ import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.codec.CodecConfigurer; +import org.springframework.http.codec.CodecConfigurer.CustomCodecs; +import org.springframework.http.codec.json.GsonDecoder; +import org.springframework.http.codec.json.GsonEncoder; import org.springframework.http.codec.json.JacksonJsonDecoder; import org.springframework.http.codec.json.JacksonJsonEncoder; +import org.springframework.http.codec.json.KotlinSerializationJsonDecoder; +import org.springframework.http.codec.json.KotlinSerializationJsonEncoder; import org.springframework.util.unit.DataSize; import org.springframework.web.reactive.function.client.WebClient; @@ -43,19 +51,25 @@ * {@link org.springframework.core.codec.Decoder Decoders}. * * @author Brian Clozel + * @author Vasily Pelikh * @since 2.0.0 */ -@AutoConfiguration(afterName = "org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration") +@AutoConfiguration(afterName = { "org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration", + "org.springframework.boot.gson.autoconfigure.GsonAutoConfiguration", + "org.springframework.boot.kotlin.serialization.autoconfigure.KotlinSerializationAutoConfiguration" }) @ConditionalOnClass({ CodecConfigurer.class, WebClient.class }) public final class CodecsAutoConfiguration { + private static final String PREFERRED_MAPPER_PROPERTY = "spring.http.codecs.preferred-json-mapper"; + @Configuration(proxyBeanMethods = false) - @ConditionalOnClass(ObjectMapper.class) + @ConditionalOnClass(JsonMapper.class) + @ConditionalOnBean(JsonMapper.class) + @ConditionalOnProperty(name = PREFERRED_MAPPER_PROPERTY, havingValue = "jackson", matchIfMissing = true) static class JacksonJsonCodecConfiguration { @Bean @Order(0) - @ConditionalOnBean(JsonMapper.class) CodecCustomizer jacksonCodecCustomizer(JsonMapper jsonMapper) { return (configurer) -> { CodecConfigurer.DefaultCodecs defaults = configurer.defaultCodecs(); @@ -66,6 +80,41 @@ CodecCustomizer jacksonCodecCustomizer(JsonMapper jsonMapper) { } + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(Gson.class) + @ConditionalOnBean(Gson.class) + @ConditionalOnProperty(name = PREFERRED_MAPPER_PROPERTY, havingValue = "gson") + static class GsonJsonCodecConfiguration { + + @Bean + CodecCustomizer gsonCodecCustomizer(Gson gson) { + return (configurer) -> { + CustomCodecs customCodecs = configurer.customCodecs(); + customCodecs.registerWithDefaultConfig(new GsonDecoder(gson)); + customCodecs.registerWithDefaultConfig(new GsonEncoder(gson)); + }; + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass({ Serializable.class, Json.class }) + @ConditionalOnBean(Json.class) + @ConditionalOnProperty(name = PREFERRED_MAPPER_PROPERTY, havingValue = "kotlin-serialization") + static class KotlinSerializationJsonCodecConfiguration { + + @Bean + @Order(-10) // configured ahead of JSON mappers + CodecCustomizer kotlinSerializationCodecCustomizer(Json json) { + return (configurer) -> { + CustomCodecs customCodecs = configurer.customCodecs(); + customCodecs.registerWithDefaultConfig(new KotlinSerializationJsonDecoder(json)); + customCodecs.registerWithDefaultConfig(new KotlinSerializationJsonEncoder(json)); + }; + } + + } + @Configuration(proxyBeanMethods = false) @EnableConfigurationProperties(HttpCodecsProperties.class) static class DefaultCodecsConfiguration { diff --git a/module/spring-boot-http-codec/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/module/spring-boot-http-codec/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 671fd2520c26..c5fbe57615c4 100644 --- a/module/spring-boot-http-codec/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/module/spring-boot-http-codec/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -1,4 +1,31 @@ { - "groups": [], - "properties": [] + "properties": [ + { + "name": "spring.http.codecs.preferred-json-mapper", + "type": "java.lang.String", + "defaultValue": "jackson", + "description": "Preferred JSON mapper to use for JSON encoder and decoder. By default, auto-detected according to the environment. Supported values are 'jackson', 'gson' and 'kotlin-serialization'." + } + ], + "hints": [ + { + "name": "spring.http.codecs.preferred-json-mapper", + "values": [ + { + "value": "jackson" + }, + { + "value": "gson" + }, + { + "value": "kotlin-serialization" + } + ], + "providers": [ + { + "name": "any" + } + ] + } + ] } diff --git a/module/spring-boot-http-codec/src/test/java/org/springframework/boot/http/codec/autoconfigure/CodecsAutoConfigurationTests.java b/module/spring-boot-http-codec/src/test/java/org/springframework/boot/http/codec/autoconfigure/CodecsAutoConfigurationTests.java index 39e7d050b67a..dacd2587f333 100644 --- a/module/spring-boot-http-codec/src/test/java/org/springframework/boot/http/codec/autoconfigure/CodecsAutoConfigurationTests.java +++ b/module/spring-boot-http-codec/src/test/java/org/springframework/boot/http/codec/autoconfigure/CodecsAutoConfigurationTests.java @@ -16,17 +16,12 @@ package org.springframework.boot.http.codec.autoconfigure; -import java.util.List; - import org.junit.jupiter.api.Test; -import tools.jackson.databind.json.JsonMapper; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.http.codec.CodecCustomizer; import org.springframework.boot.test.context.assertj.AssertableApplicationContext; import org.springframework.boot.test.context.runner.ApplicationContextRunner; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; import org.springframework.http.codec.CodecConfigurer; import org.springframework.http.codec.CodecConfigurer.DefaultCodecs; @@ -71,27 +66,6 @@ void defaultCodecCustomizerBeanShouldHaveOrderZero() { .run((context) -> assertThat(context.getBean("defaultCodecCustomizer", Ordered.class).getOrder()).isZero()); } - @Test - void jacksonCodecCustomizerBacksOffWhenThereIsNoObjectMapper() { - this.contextRunner.run((context) -> assertThat(context).doesNotHaveBean("jacksonCodecCustomizer")); - } - - @Test - void jacksonCodecCustomizerIsAutoConfiguredWhenJsonMapperIsPresent() { - this.contextRunner.withUserConfiguration(JsonMapperConfiguration.class) - .run((context) -> assertThat(context).hasBean("jacksonCodecCustomizer")); - } - - @Test - void userProvidedCustomizerCanOverrideJacksonCodecCustomizer() { - this.contextRunner.withUserConfiguration(JsonMapperConfiguration.class, CodecCustomizerConfiguration.class) - .run((context) -> { - List codecCustomizers = context.getBean(CodecCustomizers.class).codecCustomizers; - assertThat(codecCustomizers).hasSize(3); - assertThat(codecCustomizers.get(2)).isInstanceOf(TestCodecCustomizer.class); - }); - } - @Test void maxInMemorySizeEnforcedInDefaultCodecs() { this.contextRunner.withPropertyValues("spring.http.codecs.max-in-memory-size=1MB") @@ -106,47 +80,4 @@ private DefaultCodecs defaultCodecs(AssertableApplicationContext context) { return configurer.defaultCodecs(); } - @Configuration(proxyBeanMethods = false) - static class JsonMapperConfiguration { - - @Bean - JsonMapper jsonMapper() { - return new JsonMapper(); - } - - } - - @Configuration(proxyBeanMethods = false) - static class CodecCustomizerConfiguration { - - @Bean - CodecCustomizer codecCustomizer() { - return new TestCodecCustomizer(); - } - - @Bean - CodecCustomizers codecCustomizers(List customizers) { - return new CodecCustomizers(customizers); - } - - } - - private static final class TestCodecCustomizer implements CodecCustomizer { - - @Override - public void customize(CodecConfigurer configurer) { - } - - } - - private static final class CodecCustomizers { - - private final List codecCustomizers; - - private CodecCustomizers(List codecCustomizers) { - this.codecCustomizers = codecCustomizers; - } - - } - } diff --git a/module/spring-boot-http-codec/src/test/java/org/springframework/boot/http/codec/autoconfigure/CodecsAutoConfigurationWithPreferredJsonMapperTests.java b/module/spring-boot-http-codec/src/test/java/org/springframework/boot/http/codec/autoconfigure/CodecsAutoConfigurationWithPreferredJsonMapperTests.java new file mode 100644 index 000000000000..b68f3f8cb35c --- /dev/null +++ b/module/spring-boot-http-codec/src/test/java/org/springframework/boot/http/codec/autoconfigure/CodecsAutoConfigurationWithPreferredJsonMapperTests.java @@ -0,0 +1,213 @@ +/* + * Copyright 2012-present 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.http.codec.autoconfigure; + +import java.util.List; + +import com.google.gson.Gson; +import kotlinx.serialization.json.Json; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.json.JsonMapper; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.http.codec.CodecCustomizer; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.codec.CodecConfigurer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link CodecsAutoConfiguration} with + * 'spring.http.codecs.preferred-json-mapper' property. + * + * @author Vasily Pelikh + */ +class CodecsAutoConfigurationWithPreferredJsonMapperTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(CodecsAutoConfiguration.class)); + + @Test + void noJsonCodecCustomizerAutoConfiguredWhenThereIsNoJsonMappers() { + this.contextRunner.run((context) -> assertThat(context).doesNotHaveBean("jacksonCodecCustomizer") + .doesNotHaveBean("gsonCodecCustomizer") + .doesNotHaveBean("kotlinSerializationCodecCustomizer")); + } + + @Test + void jacksonCodecCustomizerIsAutoConfiguredWhenJsonMapperIsPresentAndPreferredJsonMapperIsNotSet() { + this.contextRunner.withUserConfiguration(JsonMapperConfiguration.class) + .run((context) -> assertThat(context).hasBean("jacksonCodecCustomizer") + .doesNotHaveBean("gsonCodecCustomizer") + .doesNotHaveBean("kotlinSerializationCodecCustomizer")); + } + + @Test + void jacksonCodecCustomizerIsAutoConfiguredWhenJsonMapperIsPresentAndPreferredJsonMapperCorresponds() { + this.contextRunner.withUserConfiguration(JsonMapperConfiguration.class) + .withPropertyValues("spring.http.codecs.preferred-json-mapper=jackson") + .run((context) -> assertThat(context).hasBean("jacksonCodecCustomizer") + .doesNotHaveBean("gsonCodecCustomizer") + .doesNotHaveBean("kotlinSerializationCodecCustomizer")); + } + + @Test + void gsonCodecCustomizerIsAutoConfiguredWhenGsonIsPresentAndPreferredJsonMapperCorresponds() { + this.contextRunner.withUserConfiguration(GsonConfiguration.class) + .withPropertyValues("spring.http.codecs.preferred-json-mapper=gson") + .run((context) -> assertThat(context).hasBean("gsonCodecCustomizer") + .doesNotHaveBean("jacksonCodecCustomizer") + .doesNotHaveBean("kotlinSerializationCodecCustomizer")); + } + + @Test + void kotlinSerializationCodecCustomizerIsAutoConfiguredWhenJsonIsPresentAndPreferredJsonMapperCorresponds() { + this.contextRunner.withUserConfiguration(KotlinSerializationConfiguration.class) + .withPropertyValues("spring.http.codecs.preferred-json-mapper=kotlin-serialization") + .run((context) -> assertThat(context).hasBean("kotlinSerializationCodecCustomizer") + .doesNotHaveBean("jacksonCodecCustomizer") + .doesNotHaveBean("gsonCodecCustomizer")); + } + + @Test + void jacksonCodecCustomizerIsAutoConfiguredWhenEverythingIsPresentOnClasspathAndPreferredJsonMapperIsNotSet() { + this.contextRunner + .withUserConfiguration(JsonMapperConfiguration.class, GsonConfiguration.class, + KotlinSerializationConfiguration.class) + .run((context) -> assertThat(context).hasBean("jacksonCodecCustomizer") + .doesNotHaveBean("gsonCodecCustomizer") + .doesNotHaveBean("kotlinSerializationCodecCustomizer")); + } + + @Test + void jacksonCodecCustomizerIsAutoConfiguredWhenEverythingIsPresentOnClasspathAndPreferredJsonMapperCorresponds() { + this.contextRunner + .withUserConfiguration(JsonMapperConfiguration.class, GsonConfiguration.class, + KotlinSerializationConfiguration.class) + .withPropertyValues("spring.http.codecs.preferred-json-mapper=jackson") + .run((context) -> assertThat(context).hasBean("jacksonCodecCustomizer") + .doesNotHaveBean("gsonCodecCustomizer") + .doesNotHaveBean("kotlinSerializationCodecCustomizer")); + } + + @Test + void gsonCodecCustomizerIsAutoConfiguredWhenEverythingIsPresentOnClasspathAndPreferredJsonMapperCorresponds() { + this.contextRunner + .withUserConfiguration(JsonMapperConfiguration.class, GsonConfiguration.class, + KotlinSerializationConfiguration.class) + .withPropertyValues("spring.http.codecs.preferred-json-mapper=gson") + .run((context) -> assertThat(context).hasBean("gsonCodecCustomizer") + .doesNotHaveBean("jacksonCodecCustomizer") + .doesNotHaveBean("kotlinSerializationCodecCustomizer")); + } + + @Test + void kotlinSerializationCodecCustomizerIsAutoConfiguredWhenEverythingIsPresentOnClasspathAndPreferredJsonMapperCorresponds() { + this.contextRunner + .withUserConfiguration(JsonMapperConfiguration.class, GsonConfiguration.class, + KotlinSerializationConfiguration.class) + .withPropertyValues("spring.http.codecs.preferred-json-mapper=kotlin-serialization") + .run((context) -> assertThat(context).hasBean("kotlinSerializationCodecCustomizer") + .doesNotHaveBean("jacksonCodecCustomizer") + .doesNotHaveBean("gsonCodecCustomizer")); + } + + @Test + void jacksonCodecCustomizerIsAutoConfiguredWhenEverythingIsPresentOnClasspathAndPreferredJsonMapperNotSet() { + this.contextRunner + .withUserConfiguration(JsonMapperConfiguration.class, GsonConfiguration.class, + KotlinSerializationConfiguration.class) + .run((context) -> assertThat(context).hasBean("jacksonCodecCustomizer") + .doesNotHaveBean("gsonCodecCustomizer") + .doesNotHaveBean("kotlinSerializationCodecCustomizer")); + } + + @Test + void userProvidedCustomizerCanOverrideJacksonCodecCustomizer() { + this.contextRunner.withUserConfiguration(JsonMapperConfiguration.class, CodecCustomizerConfiguration.class) + .run((context) -> { + List codecCustomizers = context.getBean(CodecCustomizers.class).codecCustomizers; + assertThat(codecCustomizers).hasSize(3).element(2).isInstanceOf(TestCodecCustomizer.class); + }); + } + + @Configuration(proxyBeanMethods = false) + static class JsonMapperConfiguration { + + @Bean + JsonMapper jsonMapper() { + return new JsonMapper(); + } + + } + + @Configuration(proxyBeanMethods = false) + static class GsonConfiguration { + + @Bean + Gson gson() { + return new Gson(); + } + + } + + @Configuration(proxyBeanMethods = false) + static class KotlinSerializationConfiguration { + + @Bean + Json json() { + return Json.Default; + } + + } + + @Configuration(proxyBeanMethods = false) + static class CodecCustomizerConfiguration { + + @Bean + CodecCustomizer codecCustomizer() { + return new TestCodecCustomizer(); + } + + @Bean + CodecCustomizers codecCustomizers(List customizers) { + return new CodecCustomizers(customizers); + } + + } + + private static final class TestCodecCustomizer implements CodecCustomizer { + + @Override + public void customize(CodecConfigurer configurer) { + } + + } + + private static final class CodecCustomizers { + + private final List codecCustomizers; + + private CodecCustomizers(List codecCustomizers) { + this.codecCustomizers = codecCustomizers; + } + + } + +}