diff --git a/module/spring-boot-http-codec/build.gradle b/module/spring-boot-http-codec/build.gradle index 2fae3abe8ea..4f628d85338 100644 --- a/module/spring-boot-http-codec/build.gradle +++ b/module/spring-boot-http-codec/build.gradle @@ -32,6 +32,7 @@ dependencies { optional(project(":core:spring-boot-test")) optional(project(":module:spring-boot-jackson")) optional(project(":module:spring-boot-jackson2")) + optional(project(":module:spring-boot-kotlinx-serialization-json")) 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 550e3e79bdd..a0e71cac86a 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 @@ -17,6 +17,7 @@ package org.springframework.boot.http.codec.autoconfigure; import com.fasterxml.jackson.databind.ObjectMapper; +import kotlinx.serialization.json.Json; import org.jspecify.annotations.Nullable; import tools.jackson.databind.json.JsonMapper; @@ -35,9 +36,13 @@ import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; +import org.springframework.core.io.ResourceLoader; import org.springframework.http.codec.CodecConfigurer; 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.ClassUtils; import org.springframework.util.unit.DataSize; import org.springframework.web.reactive.function.client.WebClient; @@ -93,6 +98,29 @@ public final class CodecsAutoConfiguration { } + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(Json.class) + static class KotlinxSerializationJsonCodecConfiguration { + + @Bean + @ConditionalOnBean(Json.class) + CodecCustomizer kotlinxJsonCodecCustomizer(Json json, ResourceLoader resourceLoader) { + ClassLoader classLoader = resourceLoader.getClassLoader(); + boolean hasAnyJsonSupport = ClassUtils.isPresent("tools.jackson.databind.json.JsonMapper", classLoader) + || ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", classLoader) + || ClassUtils.isPresent("com.google.gson.Gson", classLoader); + + return (configurer) -> { + CodecConfigurer.DefaultCodecs defaults = configurer.defaultCodecs(); + defaults.kotlinSerializationJsonEncoder(hasAnyJsonSupport ? new KotlinSerializationJsonEncoder(json) + : new KotlinSerializationJsonEncoder(json, (type) -> true)); + defaults.kotlinSerializationJsonDecoder(hasAnyJsonSupport ? new KotlinSerializationJsonDecoder(json) + : new KotlinSerializationJsonDecoder(json, (type) -> true)); + }; + } + + } + @Configuration(proxyBeanMethods = false) @EnableConfigurationProperties(HttpCodecsProperties.class) static class DefaultCodecsConfiguration { 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 978b3ec400a..4e58f4404ad 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 @@ -17,8 +17,10 @@ package org.springframework.boot.http.codec.autoconfigure; import java.util.List; +import java.util.Map; import com.fasterxml.jackson.databind.ObjectMapper; +import kotlinx.serialization.json.Json; import org.junit.jupiter.api.Test; import tools.jackson.databind.json.JsonMapper; @@ -30,8 +32,13 @@ 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.core.ResolvableType; +import org.springframework.http.MediaType; import org.springframework.http.codec.CodecConfigurer; import org.springframework.http.codec.CodecConfigurer.DefaultCodecs; +import org.springframework.http.codec.EncoderHttpMessageWriter; +import org.springframework.http.codec.ServerCodecConfigurer; +import org.springframework.http.codec.json.KotlinSerializationJsonEncoder; import org.springframework.http.codec.support.DefaultClientCodecConfigurer; import static org.assertj.core.api.Assertions.assertThat; @@ -132,6 +139,40 @@ class CodecsAutoConfigurationTests { 1048576)); } + @Test + void kotlinSerializationUsesLimitedPredicateWhenOtherJsonConverterIsAvailable() { + this.contextRunner.withUserConfiguration(KotlinxJsonConfiguration.class).run((context) -> { + KotlinSerializationJsonEncoder encoder = findEncoder(context, KotlinSerializationJsonEncoder.class); + assertThat(encoder.canEncode(ResolvableType.forClass(Map.class), MediaType.APPLICATION_JSON)).isFalse(); + }); + } + + @Test + void kotlinSerializationUsesUnrestrictedPredicateWhenNoOtherJsonConverterIsAvailable() { + FilteredClassLoader classLoader = new FilteredClassLoader(JsonMapper.class.getPackage().getName(), + ObjectMapper.class.getPackage().getName()); + this.contextRunner.withClassLoader(classLoader) + .withUserConfiguration(KotlinxJsonConfiguration.class) + .run((context) -> { + KotlinSerializationJsonEncoder encoder = findEncoder(context, KotlinSerializationJsonEncoder.class); + assertThat(encoder.canEncode(ResolvableType.forClass(Map.class), MediaType.APPLICATION_JSON)).isTrue(); + }); + } + + @SuppressWarnings("unchecked") + private T findEncoder(AssertableApplicationContext context, Class encoderClass) { + ServerCodecConfigurer configurer = ServerCodecConfigurer.create(); + context.getBeansOfType(CodecCustomizer.class).values().forEach((codec) -> codec.customize(configurer)); + return (T) configurer.getWriters() + .stream() + .filter((writer) -> writer instanceof EncoderHttpMessageWriter) + .map((writer) -> (EncoderHttpMessageWriter) writer) + .map(EncoderHttpMessageWriter::getEncoder) + .filter((encoder) -> encoderClass.isAssignableFrom(encoder.getClass())) + .findFirst() + .orElseThrow(); + } + private DefaultCodecs defaultCodecs(AssertableApplicationContext context) { CodecCustomizer customizer = context.getBean(CodecCustomizer.class); CodecConfigurer configurer = new DefaultClientCodecConfigurer(); @@ -159,6 +200,16 @@ class CodecsAutoConfigurationTests { } + @Configuration(proxyBeanMethods = false) + static class KotlinxJsonConfiguration { + + @Bean + Json kotlinxJson() { + return Json.Default; + } + + } + @Configuration(proxyBeanMethods = false) static class CodecCustomizerConfiguration {