From 5928326a39001a5f420a179a4b1c29c65f5ecc50 Mon Sep 17 00:00:00 2001 From: Dmitry Sulman Date: Fri, 25 Jul 2025 00:32:54 +0300 Subject: [PATCH] Introduce Kotlin Serialization auto-configuration See #44241 Signed-off-by: Dmitry Sulman --- .../DocumentConfigurationProperties.java | 1 + .../reference/pages/features/json.adoc | 10 + .../build.gradle | 3 + .../spring-boot-http-converter/build.gradle | 1 + ...sonHttpMessageConvertersConfiguration.java | 6 + .../autoconfigure/HttpMessageConverters.java | 6 +- ...ttpMessageConvertersAutoConfiguration.java | 6 +- ...onbHttpMessageConvertersConfiguration.java | 30 +- ...ionHttpMessageConvertersConfiguration.java | 52 +++ ...itional-spring-configuration-metadata.json | 5 +- ...ssageConvertersAutoConfigurationTests.java | 50 ++- .../HttpMessageConvertersTests.java | 20 ++ .../build.gradle | 48 +++ .../KotlinSerializationAutoConfiguration.java | 113 ++++++ ...linSerializationJsonBuilderCustomizer.java | 38 +++ .../KotlinSerializationProperties.java | 257 ++++++++++++++ .../autoconfigure/package-info.java | 20 ++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + ...tlinSerializationAutoConfigurationTests.kt | 321 ++++++++++++++++++ ...toconfigure.json.AutoConfigureJson.imports | 1 + .../spring-boot-dependencies/build.gradle | 2 + settings.gradle | 5 + .../build.gradle | 27 ++ 23 files changed, 1014 insertions(+), 9 deletions(-) create mode 100644 module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/KotlinSerializationHttpMessageConvertersConfiguration.java create mode 100644 module/spring-boot-kotlin-serialization/build.gradle create mode 100644 module/spring-boot-kotlin-serialization/src/main/java/org/springframework/boot/kotlin/serialization/autoconfigure/KotlinSerializationAutoConfiguration.java create mode 100644 module/spring-boot-kotlin-serialization/src/main/java/org/springframework/boot/kotlin/serialization/autoconfigure/KotlinSerializationJsonBuilderCustomizer.java create mode 100644 module/spring-boot-kotlin-serialization/src/main/java/org/springframework/boot/kotlin/serialization/autoconfigure/KotlinSerializationProperties.java create mode 100644 module/spring-boot-kotlin-serialization/src/main/java/org/springframework/boot/kotlin/serialization/autoconfigure/package-info.java create mode 100644 module/spring-boot-kotlin-serialization/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 module/spring-boot-kotlin-serialization/src/test/kotlin/org/springframework/boot/kotlin/serialization/autoconfigure/KotlinSerializationAutoConfigurationTests.kt create mode 100644 starter/spring-boot-starter-kotlin-serialization/build.gradle diff --git a/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java b/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java index bb0487c32b23..ca70851af251 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java @@ -118,6 +118,7 @@ private void mailPrefixes(Config config) { private void jsonPrefixes(Config config) { config.accept("spring.jackson"); config.accept("spring.gson"); + config.accept("spring.kotlin-serialization"); } private void dataPrefixes(Config config) { diff --git a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/features/json.adoc b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/features/json.adoc index 3fd663dd8869..3508bdf76027 100644 --- a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/features/json.adoc +++ b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/features/json.adoc @@ -6,6 +6,7 @@ Spring Boot provides integration with three JSON mapping libraries: - Gson - Jackson - JSON-B +- Kotlin Serialization Jackson is the preferred and default library. @@ -68,3 +69,12 @@ To take more control, one or more javadoc:org.springframework.boot.autoconfigure Auto-configuration for JSON-B is provided. When the JSON-B API and an implementation are on the classpath a javadoc:jakarta.json.bind.Jsonb[] bean will be automatically configured. The preferred JSON-B implementation is Eclipse Yasson for which dependency management is provided. + + + +[[features.json.kotlin-serialization]] +== Kotlin Serialization + +Auto-configuration for Kotlin Serialization is provided. +When `kotlinx-serialization-json` is on the classpath a https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json/[Json] bean is automatically configured. +Several `+spring.kotlin-serialization.*+` configuration properties are provided for customizing the configuration. diff --git a/module/spring-boot-autoconfigure-classic-modules/build.gradle b/module/spring-boot-autoconfigure-classic-modules/build.gradle index 981c8f1bf136..b8d131b785cf 100644 --- a/module/spring-boot-autoconfigure-classic-modules/build.gradle +++ b/module/spring-boot-autoconfigure-classic-modules/build.gradle @@ -157,6 +157,9 @@ dependencies { api(project(":module:spring-boot-kafka")) { transitive = false } + api(project(":module:spring-boot-kotlin-serialization")) { + transitive = false + } api(project(":module:spring-boot-ldap")) { transitive = false } diff --git a/module/spring-boot-http-converter/build.gradle b/module/spring-boot-http-converter/build.gradle index 31d50cd302fa..6560416df08a 100644 --- a/module/spring-boot-http-converter/build.gradle +++ b/module/spring-boot-http-converter/build.gradle @@ -34,6 +34,7 @@ dependencies { optional(project(":module:spring-boot-gson")) optional(project(":module:spring-boot-jackson")) optional(project(":module:spring-boot-jsonb")) + optional(project(":module:spring-boot-kotlin-serialization")) optional("com.fasterxml.jackson.dataformat:jackson-dataformat-xml") optional("com.google.code.gson:gson") optional("jakarta.json.bind:jakarta.json.bind-api") diff --git a/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/GsonHttpMessageConvertersConfiguration.java b/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/GsonHttpMessageConvertersConfiguration.java index 79e3c646ca51..dfaf7090283b 100644 --- a/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/GsonHttpMessageConvertersConfiguration.java +++ b/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/GsonHttpMessageConvertersConfiguration.java @@ -91,6 +91,12 @@ static class JsonbPreferred { } + @ConditionalOnProperty(name = HttpMessageConvertersAutoConfiguration.PREFERRED_MAPPER_PROPERTY, + havingValue = "kotlin-serialization") + static class KotlinxSerialization { + + } + } } diff --git a/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/HttpMessageConverters.java b/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/HttpMessageConverters.java index 33888686c2e0..a457a0b40605 100644 --- a/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/HttpMessageConverters.java +++ b/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/HttpMessageConverters.java @@ -71,9 +71,11 @@ public class HttpMessageConverters implements Iterable> MultiValueMap, Class> equivalentConverters = new LinkedMultiValueMap<>(); putIfExists(equivalentConverters, "org.springframework.http.converter.json.JacksonJsonHttpMessageConverter", "org.springframework.http.converter.json.MappingJackson2HttpMessageConverter", - "org.springframework.http.converter.json.GsonHttpMessageConverter"); + "org.springframework.http.converter.json.GsonHttpMessageConverter", + "org.springframework.http.converter.json.KotlinSerializationJsonHttpMessageConverter"); putIfExists(equivalentConverters, "org.springframework.http.converter.json.MappingJackson2HttpMessageConverter", - "org.springframework.http.converter.json.GsonHttpMessageConverter"); + "org.springframework.http.converter.json.GsonHttpMessageConverter", + "org.springframework.http.converter.json.KotlinSerializationJsonHttpMessageConverter"); EQUIVALENT_CONVERTERS = CollectionUtils.unmodifiableMultiValueMap(equivalentConverters); } diff --git a/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/HttpMessageConvertersAutoConfiguration.java b/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/HttpMessageConvertersAutoConfiguration.java index 3c17b217c9b5..2b519977bec9 100644 --- a/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/HttpMessageConvertersAutoConfiguration.java +++ b/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/HttpMessageConvertersAutoConfiguration.java @@ -45,15 +45,17 @@ * @author Sebastien Deleuze * @author Stephane Nicoll * @author Eddú Meléndez + * @author Dmitry Sulman * @since 4.0.0 */ @AutoConfiguration(afterName = { "org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration", "org.springframework.boot.jsonb.autoconfigure.JsonbAutoConfiguration", - "org.springframework.boot.gson.autoconfigure.GsonAutoConfiguration" }) + "org.springframework.boot.gson.autoconfigure.GsonAutoConfiguration", + "org.springframework.boot.kotlin.serialization.autoconfigure.KotlinSerializationAutoConfiguration" }) @ConditionalOnClass(HttpMessageConverter.class) @Conditional(NotReactiveWebApplicationCondition.class) @Import({ JacksonHttpMessageConvertersConfiguration.class, GsonHttpMessageConvertersConfiguration.class, - JsonbHttpMessageConvertersConfiguration.class }) + JsonbHttpMessageConvertersConfiguration.class, KotlinSerializationHttpMessageConvertersConfiguration.class }) public final class HttpMessageConvertersAutoConfiguration { static final String PREFERRED_MAPPER_PROPERTY = "spring.http.converters.preferred-json-mapper"; diff --git a/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/JsonbHttpMessageConvertersConfiguration.java b/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/JsonbHttpMessageConvertersConfiguration.java index 440cfa555a84..0b8fc5f209ae 100644 --- a/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/JsonbHttpMessageConvertersConfiguration.java +++ b/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/JsonbHttpMessageConvertersConfiguration.java @@ -23,6 +23,7 @@ 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.context.annotation.Bean; import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; @@ -65,10 +66,33 @@ static class JsonbPreferred { } + @Conditional(JacksonAndGsonAndKotlinSerializationMissingCondition.class) + static class JacksonAndGsonAndKotlinSerializationMissing { + + } + + } + + private static class JacksonAndGsonAndKotlinSerializationMissingCondition extends NoneNestedConditions { + + JacksonAndGsonAndKotlinSerializationMissingCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + @SuppressWarnings("removal") - @ConditionalOnMissingBean({ org.springframework.http.converter.json.MappingJackson2HttpMessageConverter.class, - GsonHttpMessageConverter.class }) - static class JacksonAndGsonMissing { + @ConditionalOnBean(org.springframework.http.converter.json.MappingJackson2HttpMessageConverter.class) + static class JacksonAvailable { + + } + + @ConditionalOnBean(GsonHttpMessageConverter.class) + static class GsonAvailable { + + } + + @ConditionalOnProperty(name = HttpMessageConvertersAutoConfiguration.PREFERRED_MAPPER_PROPERTY, + havingValue = "kotlin-serialization") + static class KotlinxPreferred { } diff --git a/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/KotlinSerializationHttpMessageConvertersConfiguration.java b/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/KotlinSerializationHttpMessageConvertersConfiguration.java new file mode 100644 index 000000000000..89c996e242f4 --- /dev/null +++ b/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/KotlinSerializationHttpMessageConvertersConfiguration.java @@ -0,0 +1,52 @@ +/* + * 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.converter.autoconfigure; + +import kotlinx.serialization.json.Json; + +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.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.json.KotlinSerializationJsonHttpMessageConverter; + +/** + * Configuration for HTTP message converters that use Kotlin Serialization. + * + * @author Dmitry Sulman + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(Json.class) +class KotlinSerializationHttpMessageConvertersConfiguration { + + @Configuration(proxyBeanMethods = false) + @ConditionalOnBean(Json.class) + @ConditionalOnProperty(name = HttpMessageConvertersAutoConfiguration.PREFERRED_MAPPER_PROPERTY, + havingValue = "kotlin-serialization") + static class KotlinSerializationHttpMessageConverterConfiguration { + + @Bean + @ConditionalOnMissingBean + KotlinSerializationJsonHttpMessageConverter kotlinSerializationJsonHttpMessageConverter(Json json) { + return new KotlinSerializationJsonHttpMessageConverter(json); + } + + } + +} diff --git a/module/spring-boot-http-converter/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/module/spring-boot-http-converter/src/main/resources/META-INF/additional-spring-configuration-metadata.json index d2a39422bf94..6bf4ae4ff3b0 100644 --- a/module/spring-boot-http-converter/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/module/spring-boot-http-converter/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -4,7 +4,7 @@ "name": "spring.http.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', 'jsonb' and 'kotlin-serialization'. When other json mapping libraries are present, use a custom HttpMessageConverters bean to control the preferred mapper." } ], "hints": [ @@ -19,6 +19,9 @@ }, { "value": "jsonb" + }, + { + "value": "kotlin-serialization" } ], "providers": [ diff --git a/module/spring-boot-http-converter/src/test/java/org/springframework/boot/http/converter/autoconfigure/HttpMessageConvertersAutoConfigurationTests.java b/module/spring-boot-http-converter/src/test/java/org/springframework/boot/http/converter/autoconfigure/HttpMessageConvertersAutoConfigurationTests.java index 93c7974fd8e9..cedd86ac8037 100644 --- a/module/spring-boot-http-converter/src/test/java/org/springframework/boot/http/converter/autoconfigure/HttpMessageConvertersAutoConfigurationTests.java +++ b/module/spring-boot-http-converter/src/test/java/org/springframework/boot/http/converter/autoconfigure/HttpMessageConvertersAutoConfigurationTests.java @@ -22,6 +22,7 @@ import com.google.gson.Gson; import jakarta.json.bind.Jsonb; import jakarta.json.bind.JsonbBuilder; +import kotlinx.serialization.json.Json; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.config.BeanDefinition; @@ -44,6 +45,7 @@ import org.springframework.http.converter.json.GsonHttpMessageConverter; import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; import org.springframework.http.converter.json.JsonbHttpMessageConverter; +import org.springframework.http.converter.json.KotlinSerializationJsonHttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter; @@ -60,6 +62,7 @@ * @author Eddú Meléndez * @author Moritz Halbritter * @author Sebastien Deleuze + * @author Dmitry Sulman */ @SuppressWarnings("removal") class HttpMessageConvertersAutoConfigurationTests { @@ -128,6 +131,7 @@ void gsonCanBePreferred() { assertConverterBeanExists(context, GsonHttpMessageConverter.class, "gsonHttpMessageConverter"); assertConverterBeanRegisteredWithHttpMessageConverters(context, GsonHttpMessageConverter.class); assertThat(context).doesNotHaveBean(JsonbHttpMessageConverter.class); + assertThat(context).doesNotHaveBean(KotlinSerializationJsonHttpMessageConverter.class); assertThat(context).doesNotHaveBean(MappingJackson2HttpMessageConverter.class); }); } @@ -159,10 +163,41 @@ void jsonbCanBePreferred() { assertConverterBeanExists(context, JsonbHttpMessageConverter.class, "jsonbHttpMessageConverter"); assertConverterBeanRegisteredWithHttpMessageConverters(context, JsonbHttpMessageConverter.class); assertThat(context).doesNotHaveBean(GsonHttpMessageConverter.class); + assertThat(context).doesNotHaveBean(KotlinSerializationJsonHttpMessageConverter.class); assertThat(context).doesNotHaveBean(MappingJackson2HttpMessageConverter.class); }); } + @Test + void kotlinSerializationNotAvailable() { + this.contextRunner.run((context) -> { + assertThat(context).doesNotHaveBean(Json.class); + assertThat(context).doesNotHaveBean(KotlinSerializationJsonHttpMessageConverter.class); + }); + } + + @Test + void kotlinSerializationCustomConverter() { + this.contextRunner.withUserConfiguration(KotlinSerializationConverterConfig.class) + .withBean(Json.class, () -> Json.Default) + .run(assertConverter(KotlinSerializationJsonHttpMessageConverter.class, + "customKotlinSerializationJsonHttpMessageConverter")); + } + + @Test + void kotlinSerializationCanBePreferred() { + allOptionsRunner().withPropertyValues("spring.http.converters.preferred-json-mapper:kotlin-serialization") + .run((context) -> { + assertConverterBeanExists(context, KotlinSerializationJsonHttpMessageConverter.class, + "kotlinSerializationJsonHttpMessageConverter"); + assertConverterBeanRegisteredWithHttpMessageConverters(context, + KotlinSerializationJsonHttpMessageConverter.class); + assertThat(context).doesNotHaveBean(GsonHttpMessageConverter.class); + assertThat(context).doesNotHaveBean(JsonbHttpMessageConverter.class); + assertThat(context).doesNotHaveBean(MappingJackson2HttpMessageConverter.class); + }); + } + @Test void stringDefaultConverter() { this.contextRunner.run(assertConverter(StringHttpMessageConverter.class, "stringHttpMessageConverter")); @@ -206,6 +241,7 @@ void jacksonIsPreferredByDefault() { assertConverterBeanRegisteredWithHttpMessageConverters(context, MappingJackson2HttpMessageConverter.class); assertThat(context).doesNotHaveBean(GsonHttpMessageConverter.class); assertThat(context).doesNotHaveBean(JsonbHttpMessageConverter.class); + assertThat(context).doesNotHaveBean(KotlinSerializationJsonHttpMessageConverter.class); }); } @@ -216,6 +252,7 @@ void gsonIsPreferredIfJacksonIsNotAvailable() { assertConverterBeanExists(context, GsonHttpMessageConverter.class, "gsonHttpMessageConverter"); assertConverterBeanRegisteredWithHttpMessageConverters(context, GsonHttpMessageConverter.class); assertThat(context).doesNotHaveBean(JsonbHttpMessageConverter.class); + assertThat(context).doesNotHaveBean(KotlinSerializationJsonHttpMessageConverter.class); }); } @@ -267,7 +304,8 @@ void whenEncodingCharsetIsConfiguredThenStringMessageConverterUsesSpecificCharse private ApplicationContextRunner allOptionsRunner() { return this.contextRunner.withBean(Gson.class) .withBean(ObjectMapper.class) - .withBean(Jsonb.class, JsonbBuilder::create); + .withBean(Jsonb.class, JsonbBuilder::create) + .withBean(Json.class, () -> Json.Default); } private ContextConsumer assertConverter( @@ -351,6 +389,16 @@ JsonbHttpMessageConverter customJsonbMessageConverter(Jsonb jsonb) { } + @Configuration(proxyBeanMethods = false) + static class KotlinSerializationConverterConfig { + + @Bean + KotlinSerializationJsonHttpMessageConverter customKotlinSerializationJsonHttpMessageConverter(Json json) { + return new KotlinSerializationJsonHttpMessageConverter(json); + } + + } + @Configuration(proxyBeanMethods = false) static class StringConverterConfig { diff --git a/module/spring-boot-http-converter/src/test/java/org/springframework/boot/http/converter/autoconfigure/HttpMessageConvertersTests.java b/module/spring-boot-http-converter/src/test/java/org/springframework/boot/http/converter/autoconfigure/HttpMessageConvertersTests.java index baf81f76a541..93b5f03e3552 100644 --- a/module/spring-boot-http-converter/src/test/java/org/springframework/boot/http/converter/autoconfigure/HttpMessageConvertersTests.java +++ b/module/spring-boot-http-converter/src/test/java/org/springframework/boot/http/converter/autoconfigure/HttpMessageConvertersTests.java @@ -30,6 +30,7 @@ import org.springframework.http.converter.StringHttpMessageConverter; import org.springframework.http.converter.cbor.MappingJackson2CborHttpMessageConverter; import org.springframework.http.converter.json.GsonHttpMessageConverter; +import org.springframework.http.converter.json.KotlinSerializationJsonHttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter; import org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter; @@ -89,6 +90,25 @@ void addBeforeExistingEquivalentConverter() { MappingJackson2HttpMessageConverter.class); } + @Test + void addBeforeExistingAnotherEquivalentConverter() { + KotlinSerializationJsonHttpMessageConverter converter1 = new KotlinSerializationJsonHttpMessageConverter(); + HttpMessageConverters converters = new HttpMessageConverters(converter1); + Stream> converterClasses = converters.getConverters().stream().map(HttpMessageConverter::getClass); + assertThat(converterClasses).containsSequence(KotlinSerializationJsonHttpMessageConverter.class, + MappingJackson2HttpMessageConverter.class); + } + + @Test + void addBeforeExistingMultipleEquivalentConverters() { + GsonHttpMessageConverter converter1 = new GsonHttpMessageConverter(); + KotlinSerializationJsonHttpMessageConverter converter2 = new KotlinSerializationJsonHttpMessageConverter(); + HttpMessageConverters converters = new HttpMessageConverters(converter1, converter2); + Stream> converterClasses = converters.getConverters().stream().map(HttpMessageConverter::getClass); + assertThat(converterClasses).containsSequence(GsonHttpMessageConverter.class, + KotlinSerializationJsonHttpMessageConverter.class, MappingJackson2HttpMessageConverter.class); + } + @Test void addNewConverters() { HttpMessageConverter converter1 = mock(HttpMessageConverter.class); diff --git a/module/spring-boot-kotlin-serialization/build.gradle b/module/spring-boot-kotlin-serialization/build.gradle new file mode 100644 index 000000000000..15dcac28f80a --- /dev/null +++ b/module/spring-boot-kotlin-serialization/build.gradle @@ -0,0 +1,48 @@ +import org.springframework.boot.build.autoconfigure.CheckAutoConfigurationClasses + +/* + * 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. + */ + +plugins { + id "java-library" + id "org.jetbrains.kotlin.jvm" + id "org.jetbrains.kotlin.plugin.serialization" + id "org.springframework.boot.auto-configuration" + id "org.springframework.boot.configuration-properties" + id "org.springframework.boot.deployed" + id "org.springframework.boot.optional-dependencies" +} + +description = "Spring Boot Kotlin Serialization" + +dependencies { + api(project(":core:spring-boot")) + api("org.jetbrains.kotlinx:kotlinx-serialization-json") + + optional(project(":core:spring-boot-autoconfigure")) + + testImplementation(project(":core:spring-boot-test")) + testImplementation(project(":test-support:spring-boot-test-support")) + + testRuntimeOnly("ch.qos.logback:logback-classic") + testRuntimeOnly("org.jetbrains.kotlin:kotlin-reflect") +} + +tasks.named("checkAutoConfigurationClasses", CheckAutoConfigurationClasses.class) { + doFirst { + classpath = classpath.filter { !it.path.contains('/build/classes/kotlin/main') } + } +} diff --git a/module/spring-boot-kotlin-serialization/src/main/java/org/springframework/boot/kotlin/serialization/autoconfigure/KotlinSerializationAutoConfiguration.java b/module/spring-boot-kotlin-serialization/src/main/java/org/springframework/boot/kotlin/serialization/autoconfigure/KotlinSerializationAutoConfiguration.java new file mode 100644 index 000000000000..b7a02da0221d --- /dev/null +++ b/module/spring-boot-kotlin-serialization/src/main/java/org/springframework/boot/kotlin/serialization/autoconfigure/KotlinSerializationAutoConfiguration.java @@ -0,0 +1,113 @@ +/* + * 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.kotlin.serialization.autoconfigure; + +import java.util.List; +import java.util.function.Consumer; + +import kotlin.Unit; +import kotlin.jvm.functions.Function1; +import kotlinx.serialization.json.Json; +import kotlinx.serialization.json.JsonBuilder; +import kotlinx.serialization.json.JsonKt; +import kotlinx.serialization.json.JsonNamingStrategy; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.core.Ordered; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for Kotlin Serialization. + * + * @author Dmitry Sulman + * @since 4.0.0 + */ +@AutoConfiguration +@ConditionalOnClass(Json.class) +@EnableConfigurationProperties(KotlinSerializationProperties.class) +public final class KotlinSerializationAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + Json kotlinSerializationJson(List customizers) { + Function1 builderAction = (jsonBuilder) -> { + customizers.forEach((c) -> c.customize(jsonBuilder)); + return Unit.INSTANCE; + }; + return JsonKt.Json(Json.Default, builderAction); + } + + @Bean + StandardKotlinSerializationJsonBuilderCustomizer standardKotlinSerializationJsonBuilderCustomizer( + KotlinSerializationProperties kotlinSerializationProperties) { + return new StandardKotlinSerializationJsonBuilderCustomizer(kotlinSerializationProperties); + } + + static final class StandardKotlinSerializationJsonBuilderCustomizer + implements KotlinSerializationJsonBuilderCustomizer, Ordered { + + private final KotlinSerializationProperties properties; + + StandardKotlinSerializationJsonBuilderCustomizer(KotlinSerializationProperties properties) { + this.properties = properties; + } + + @Override + public int getOrder() { + return 0; + } + + @Override + public void customize(JsonBuilder jsonBuilder) { + KotlinSerializationProperties properties = this.properties; + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getNamingStrategy).to(setNamingStrategy(jsonBuilder)); + map.from(properties::getPrettyPrint).to(jsonBuilder::setPrettyPrint); + map.from(properties::getLenient).to(jsonBuilder::setLenient); + map.from(properties::getIgnoreUnknownKeys).to(jsonBuilder::setIgnoreUnknownKeys); + map.from(properties::getEncodeDefaults).to(jsonBuilder::setEncodeDefaults); + map.from(properties::getExplicitNulls).to(jsonBuilder::setExplicitNulls); + map.from(properties::getCoerceInputValues).to(jsonBuilder::setCoerceInputValues); + map.from(properties::getAllowStructuredMapKeys).to(jsonBuilder::setAllowStructuredMapKeys); + map.from(properties::getAllowSpecialFloatingPointValues) + .to(jsonBuilder::setAllowSpecialFloatingPointValues); + map.from(properties::getClassDiscriminator).to(jsonBuilder::setClassDiscriminator); + map.from(properties::getClassDiscriminatorMode).to(jsonBuilder::setClassDiscriminatorMode); + map.from(properties::getDecodeEnumsCaseInsensitive).to(jsonBuilder::setDecodeEnumsCaseInsensitive); + map.from(properties::getUseAlternativeNames).to(jsonBuilder::setUseAlternativeNames); + map.from(properties::getAllowTrailingComma).to(jsonBuilder::setAllowTrailingComma); + map.from(properties::getAllowComments).to(jsonBuilder::setAllowComments); + } + + private Consumer setNamingStrategy(JsonBuilder builder) { + return (strategy) -> { + JsonNamingStrategy namingStrategy = switch (strategy) { + case SNAKE_CASE -> JsonNamingStrategy.Builtins.getSnakeCase(); + case KEBAB_CASE -> JsonNamingStrategy.Builtins.getKebabCase(); + }; + builder.setNamingStrategy(namingStrategy); + }; + } + + } + +} diff --git a/module/spring-boot-kotlin-serialization/src/main/java/org/springframework/boot/kotlin/serialization/autoconfigure/KotlinSerializationJsonBuilderCustomizer.java b/module/spring-boot-kotlin-serialization/src/main/java/org/springframework/boot/kotlin/serialization/autoconfigure/KotlinSerializationJsonBuilderCustomizer.java new file mode 100644 index 000000000000..0872113bf130 --- /dev/null +++ b/module/spring-boot-kotlin-serialization/src/main/java/org/springframework/boot/kotlin/serialization/autoconfigure/KotlinSerializationJsonBuilderCustomizer.java @@ -0,0 +1,38 @@ +/* + * 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.kotlin.serialization.autoconfigure; + +import kotlinx.serialization.json.Json; +import kotlinx.serialization.json.JsonBuilder; + +/** + * Callback interface that can be implemented by beans wishing to further customize the + * {@link Json} through {@link JsonBuilder} retaining its default configuration. + * + * @author Dmitry Sulman + * @since 4.0.0 + */ +@FunctionalInterface +public interface KotlinSerializationJsonBuilderCustomizer { + + /** + * Customize the Kotlin Serialization {@link Json} through {@link JsonBuilder}. + * @param jsonBuilder the {@link JsonBuilder} to customize + */ + void customize(JsonBuilder jsonBuilder); + +} diff --git a/module/spring-boot-kotlin-serialization/src/main/java/org/springframework/boot/kotlin/serialization/autoconfigure/KotlinSerializationProperties.java b/module/spring-boot-kotlin-serialization/src/main/java/org/springframework/boot/kotlin/serialization/autoconfigure/KotlinSerializationProperties.java new file mode 100644 index 000000000000..a00ce5d50ec7 --- /dev/null +++ b/module/spring-boot-kotlin-serialization/src/main/java/org/springframework/boot/kotlin/serialization/autoconfigure/KotlinSerializationProperties.java @@ -0,0 +1,257 @@ +/* + * 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.kotlin.serialization.autoconfigure; + +import kotlinx.serialization.json.ClassDiscriminatorMode; +import kotlinx.serialization.json.Json; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties to configure Kotlin Serialization {@link Json}. + * + * @author Dmitry Sulman + * @since 4.0.0 + */ +@ConfigurationProperties("spring.kotlin-serialization") +public class KotlinSerializationProperties { + + /** + * Specifies JsonNamingStrategy that should be used for all properties in classes for + * serialization and deserialization. + */ + private JsonNamingStrategy namingStrategy; + + /** + * Whether resulting JSON should be pretty-printed: formatted and optimized for human + * readability. + */ + private Boolean prettyPrint; + + /** + * Enable lenient mode that removes JSON specification restriction (RFC-4627) and + * makes parser more liberal to the malformed input. + */ + private Boolean lenient; + + /** + * Whether encounters of unknown properties in the input JSON should be ignored + * instead of throwing SerializationException. + */ + private Boolean ignoreUnknownKeys; + + /** + * Whether default values of Kotlin properties should be encoded. + */ + private Boolean encodeDefaults; + + /** + * Whether null values should be encoded for nullable properties and must be present + * in JSON object during decoding. + */ + private Boolean explicitNulls; + + /** + * Enable coercing incorrect JSON values. + */ + private Boolean coerceInputValues; + + /** + * Enable structured objects to be serialized as map keys by changing serialized form + * of the map from JSON object (key-value pairs) to flat array like [k1, v1, k2, v2]. + */ + private Boolean allowStructuredMapKeys; + + /** + * Whether to remove JSON specification restriction on special floating-point values + * such as 'NaN' and 'Infinity' and enable their serialization and deserialization as + * float literals without quotes. + */ + private Boolean allowSpecialFloatingPointValues; + + /** + * Name of the class descriptor property for polymorphic serialization. + */ + private String classDiscriminator; + + /** + * Defines which classes and objects should have class discriminator added to the + * output. + */ + private ClassDiscriminatorMode classDiscriminatorMode; + + /** + * Enable decoding enum values in a case-insensitive manner. + */ + private Boolean decodeEnumsCaseInsensitive; + + /** + * Whether Json instance makes use of JsonNames annotation. + */ + private Boolean useAlternativeNames; + + /** + * Whether to allow parser to accept trailing (ending) commas in JSON objects and + * arrays. + */ + private Boolean allowTrailingComma; + + /** + * Whether to allow parser to accept C/Java-style comments in JSON input. + */ + private Boolean allowComments; + + public JsonNamingStrategy getNamingStrategy() { + return this.namingStrategy; + } + + public void setNamingStrategy(JsonNamingStrategy namingStrategy) { + this.namingStrategy = namingStrategy; + } + + public Boolean getPrettyPrint() { + return this.prettyPrint; + } + + public void setPrettyPrint(Boolean prettyPrint) { + this.prettyPrint = prettyPrint; + } + + public Boolean getLenient() { + return this.lenient; + } + + public void setLenient(Boolean lenient) { + this.lenient = lenient; + } + + public Boolean getIgnoreUnknownKeys() { + return this.ignoreUnknownKeys; + } + + public void setIgnoreUnknownKeys(Boolean ignoreUnknownKeys) { + this.ignoreUnknownKeys = ignoreUnknownKeys; + } + + public Boolean getEncodeDefaults() { + return this.encodeDefaults; + } + + public void setEncodeDefaults(Boolean encodeDefaults) { + this.encodeDefaults = encodeDefaults; + } + + public Boolean getExplicitNulls() { + return this.explicitNulls; + } + + public void setExplicitNulls(Boolean explicitNulls) { + this.explicitNulls = explicitNulls; + } + + public Boolean getCoerceInputValues() { + return this.coerceInputValues; + } + + public void setCoerceInputValues(Boolean coerceInputValues) { + this.coerceInputValues = coerceInputValues; + } + + public Boolean getAllowStructuredMapKeys() { + return this.allowStructuredMapKeys; + } + + public void setAllowStructuredMapKeys(Boolean allowStructuredMapKeys) { + this.allowStructuredMapKeys = allowStructuredMapKeys; + } + + public Boolean getAllowSpecialFloatingPointValues() { + return this.allowSpecialFloatingPointValues; + } + + public void setAllowSpecialFloatingPointValues(Boolean allowSpecialFloatingPointValues) { + this.allowSpecialFloatingPointValues = allowSpecialFloatingPointValues; + } + + public String getClassDiscriminator() { + return this.classDiscriminator; + } + + public void setClassDiscriminator(String classDiscriminator) { + this.classDiscriminator = classDiscriminator; + } + + public ClassDiscriminatorMode getClassDiscriminatorMode() { + return this.classDiscriminatorMode; + } + + public void setClassDiscriminatorMode(ClassDiscriminatorMode classDiscriminatorMode) { + this.classDiscriminatorMode = classDiscriminatorMode; + } + + public Boolean getDecodeEnumsCaseInsensitive() { + return this.decodeEnumsCaseInsensitive; + } + + public void setDecodeEnumsCaseInsensitive(Boolean decodeEnumsCaseInsensitive) { + this.decodeEnumsCaseInsensitive = decodeEnumsCaseInsensitive; + } + + public Boolean getUseAlternativeNames() { + return this.useAlternativeNames; + } + + public void setUseAlternativeNames(Boolean useAlternativeNames) { + this.useAlternativeNames = useAlternativeNames; + } + + public Boolean getAllowTrailingComma() { + return this.allowTrailingComma; + } + + public void setAllowTrailingComma(Boolean allowTrailingComma) { + this.allowTrailingComma = allowTrailingComma; + } + + public Boolean getAllowComments() { + return this.allowComments; + } + + public void setAllowComments(Boolean allowComments) { + this.allowComments = allowComments; + } + + /** + * Enum representing strategies for JSON property naming. The values correspond to + * {@link kotlinx.serialization.json.JsonNamingStrategy} implementations that cannot + * be directly referenced. + */ + public enum JsonNamingStrategy { + + /** + * Snake case strategy. + */ + SNAKE_CASE, + + /** + * Kebab case strategy. + */ + KEBAB_CASE + + } + +} diff --git a/module/spring-boot-kotlin-serialization/src/main/java/org/springframework/boot/kotlin/serialization/autoconfigure/package-info.java b/module/spring-boot-kotlin-serialization/src/main/java/org/springframework/boot/kotlin/serialization/autoconfigure/package-info.java new file mode 100644 index 000000000000..9421538de613 --- /dev/null +++ b/module/spring-boot-kotlin-serialization/src/main/java/org/springframework/boot/kotlin/serialization/autoconfigure/package-info.java @@ -0,0 +1,20 @@ +/* + * 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. + */ + +/** + * Auto-configuration for Kotlin Serialization. + */ +package org.springframework.boot.kotlin.serialization.autoconfigure; diff --git a/module/spring-boot-kotlin-serialization/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/module/spring-boot-kotlin-serialization/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000000..fe45f6b2c493 --- /dev/null +++ b/module/spring-boot-kotlin-serialization/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.springframework.boot.kotlin.serialization.autoconfigure.KotlinSerializationAutoConfiguration \ No newline at end of file diff --git a/module/spring-boot-kotlin-serialization/src/test/kotlin/org/springframework/boot/kotlin/serialization/autoconfigure/KotlinSerializationAutoConfigurationTests.kt b/module/spring-boot-kotlin-serialization/src/test/kotlin/org/springframework/boot/kotlin/serialization/autoconfigure/KotlinSerializationAutoConfigurationTests.kt new file mode 100644 index 000000000000..7f8fa6e9ec39 --- /dev/null +++ b/module/spring-boot-kotlin-serialization/src/test/kotlin/org/springframework/boot/kotlin/serialization/autoconfigure/KotlinSerializationAutoConfigurationTests.kt @@ -0,0 +1,321 @@ +/* + * 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.kotlin.serialization.autoconfigure + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNames +import kotlinx.serialization.json.JsonNamingStrategy +import org.assertj.core.api.Assertions.assertThatExceptionOfType +import org.assertj.core.api.AssertionsForInterfaceTypes.assertThat +import org.junit.jupiter.api.Test +import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.test.context.FilteredClassLoader +import org.springframework.boot.test.context.runner.ApplicationContextRunner +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +/** + * Tests for [KotlinSerializationAutoConfiguration]. + * + * @author Dmitry Sulman + */ +class KotlinSerializationAutoConfigurationTests { + private val contextRunner = ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(KotlinSerializationAutoConfiguration::class.java)) + + @Test + fun shouldSupplyBean() { + this.contextRunner.run { context -> + assertThat(context).hasSingleBean(Json::class.java) + } + } + + @Test + fun shouldNotSupplyBean() { + this.contextRunner + .withClassLoader(FilteredClassLoader(Json::class.java)) + .run { context -> + assertThat(context).doesNotHaveBean(Json::class.java) + } + } + + @Test + fun serializeToString() { + this.contextRunner.run { context -> + val json = context.getBean(Json::class.java) + assertThat(json.encodeToString(DataObject("hello"))) + .isEqualTo("""{"stringField":"hello"}""") + } + } + + @Test + fun deserializeFromString() { + this.contextRunner.run { context -> + val json = context.getBean(Json::class.java) + assertThat(json.decodeFromString("""{"stringField":"hello"}""")) + .isEqualTo(DataObject("hello")) + } + } + + @Test + fun customJsonBean() { + this.contextRunner + .withUserConfiguration(CustomKotlinSerializationConfig::class.java) + .run { context -> + assertThat(context).hasSingleBean(Json::class.java) + val json = context.getBean(Json::class.java) + assertThat(json.encodeToString(DataObject("hello"))) + .isEqualTo("""{"string_field":"hello"}""") + } + } + + @Test + fun serializeSnakeCase() { + this.contextRunner + .withPropertyValues("spring.kotlin-serialization.naming-strategy=snake_case") + .run { context -> + val json = context.getBean(Json::class.java) + assertThat(json.encodeToString(DataObject("hello"))) + .isEqualTo("""{"string_field":"hello"}""") + } + } + + @Test + fun serializeKebabCase() { + this.contextRunner + .withPropertyValues("spring.kotlin-serialization.naming-strategy=kebab_case") + .run { context -> + val json = context.getBean(Json::class.java) + assertThat(json.encodeToString(DataObject("hello"))) + .isEqualTo("""{"string-field":"hello"}""") + } + } + + @Test + fun serializePrettyPrint() { + this.contextRunner + .withPropertyValues("spring.kotlin-serialization.pretty-print=true") + .run { context -> + val json = context.getBean(Json::class.java) + assertThat(json.encodeToString(DataObject("hello"))) + .isEqualTo( + """ + { + "stringField": "hello" + } + """.trimIndent() + ) + } + } + + @Test + @Suppress("JsonStandardCompliance") + fun deserializeLenient() { + this.contextRunner + .withPropertyValues("spring.kotlin-serialization.lenient=true") + .run { context -> + val json = context.getBean(Json::class.java) + assertThat(json.decodeFromString("""{"stringField":hello}""")) + .isEqualTo(DataObject("hello")) + } + } + + @Test + fun deserializeIgnoreUnknownKeys() { + this.contextRunner + .withPropertyValues("spring.kotlin-serialization.ignore-unknown-keys=true") + .run { context -> + val json = context.getBean(Json::class.java) + assertThat(json.decodeFromString("""{"stringField":"hello", "anotherField":"value"}""")) + .isEqualTo(DataObject("hello")) + } + } + + @Test + fun serializeDefaults() { + this.contextRunner + .withPropertyValues("spring.kotlin-serialization.encode-defaults=true") + .run { context -> + val json = context.getBean(Json::class.java) + assertThat(json.encodeToString(DataObjectWithDefault("hello"))) + .isEqualTo("""{"stringField":"hello","defaultField":"default"}""") + } + } + + @Test + fun serializeExplicitNullsFalse() { + this.contextRunner + .withPropertyValues("spring.kotlin-serialization.explicit-nulls=false") + .run { context -> + val json = context.getBean(Json::class.java) + assertThat(json.encodeToString(DataObjectWithDefault(null, "hello"))) + .isEqualTo("""{"defaultField":"hello"}""") + } + } + + @Test + fun deserializeCoerceInputValues() { + this.contextRunner + .withPropertyValues("spring.kotlin-serialization.coerce-input-values=true") + .run { context -> + val json = context.getBean(Json::class.java) + assertThat(json.decodeFromString("""{"stringField":"hello", "defaultField":null}""")) + .isEqualTo(DataObjectWithDefault("hello", "default")) + } + } + + @Test + fun serializeStructuredMapKeys() { + this.contextRunner + .withPropertyValues("spring.kotlin-serialization.allow-structured-map-keys=true") + .run { context -> + val json = context.getBean(Json::class.java) + val map = mapOf( + DataObject("key1") to "value1", + DataObject("key2") to "value2", + ) + assertThat(json.encodeToString(map)) + .isEqualTo("""[{"stringField":"key1"},"value1",{"stringField":"key2"},"value2"]""") + } + } + + @Test + fun serializeSpecialFloatingPointValues() { + this.contextRunner + .withPropertyValues("spring.kotlin-serialization.allow-special-floating-point-values=true") + .run { context -> + val json = context.getBean(Json::class.java) + assertThat(json.encodeToString(DataObjectDouble(Double.NaN))) + .isEqualTo("""{"value":NaN}""") + } + } + + @Test + fun serializeClassDiscriminator() { + this.contextRunner + .withPropertyValues("spring.kotlin-serialization.class-discriminator=class") + .run { context -> + val json = context.getBean(Json::class.java) + val value: BaseClass = ChildClass("value") + assertThat(json.encodeToString(value)) + .isEqualTo("""{"class":"child","stringField":"value"}""") + } + } + + @Test + fun serializeClassDiscriminatorNone() { + this.contextRunner + .withPropertyValues("spring.kotlin-serialization.class-discriminator-mode=none") + .run { context -> + val json = context.getBean(Json::class.java) + val value: BaseClass = ChildClass("value") + assertThat(json.encodeToString(value)) + .isEqualTo("""{"stringField":"value"}""") + } + } + + @Test + fun deserializeEnumsCaseInsensitive() { + this.contextRunner + .withPropertyValues("spring.kotlin-serialization.decode-enums-case-insensitive=true") + .run { context -> + val json = context.getBean(Json::class.java) + assertThat(json.decodeFromString("""{"values":["value_A", "alternative"]}""")) + .isEqualTo(DataObjectEnumValues(listOf(EnumValue.VALUE_A, EnumValue.VALUE_B))) + } + } + + @Test + fun deserializeAlternativeNames() { + this.contextRunner + .withPropertyValues("spring.kotlin-serialization.use-alternative-names=false") + .run { context -> + val json = context.getBean(Json::class.java) + assertThatExceptionOfType(SerializationException::class.java).isThrownBy { + json.decodeFromString("""{"alternative":"hello"}""") + }.withMessageContaining("Encountered an unknown key") + } + } + + @Test + @Suppress("JsonStandardCompliance") + fun deserializeTrailingComma() { + this.contextRunner + .withPropertyValues("spring.kotlin-serialization.allow-trailing-comma=true") + .run { context -> + val json = context.getBean(Json::class.java) + assertThat(json.decodeFromString("""{"stringField":"hello",}""")) + .isEqualTo(DataObject("hello")) + } + } + + @Test + @Suppress("JsonStandardCompliance") + fun deserializeComments() { + this.contextRunner + .withPropertyValues("spring.kotlin-serialization.allow-comments=true") + .run { context -> + val json = context.getBean(Json::class.java) + assertThat(json.decodeFromString("""{"stringField":"hello" /*comment*/}""")) + .isEqualTo(DataObject("hello")) + } + } + + @Serializable + @OptIn(ExperimentalSerializationApi::class) + private data class DataObject(@JsonNames("alternative") private val stringField: String) + + @Serializable + private data class DataObjectWithDefault( + private val stringField: String?, + private val defaultField: String = "default", + ) + + @Serializable + private data class DataObjectDouble(private val value: Double) + + @OptIn(ExperimentalSerializationApi::class) + enum class EnumValue { VALUE_A, @JsonNames("Alternative") VALUE_B } + + @Serializable + private data class DataObjectEnumValues(private val values: List) + + @Serializable + sealed class BaseClass { + abstract val stringField: String + } + + @Serializable + @SerialName("child") + class ChildClass(override val stringField: String) : BaseClass() + + @Configuration(proxyBeanMethods = false) + class CustomKotlinSerializationConfig { + + @Bean + @OptIn(ExperimentalSerializationApi::class) + fun customKotlinSerializationJson(): Json { + return Json { namingStrategy = JsonNamingStrategy.SnakeCase } + } + + } + +} diff --git a/module/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.json.AutoConfigureJson.imports b/module/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.json.AutoConfigureJson.imports index 266fa78b1ace..4fdb89872097 100644 --- a/module/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.json.AutoConfigureJson.imports +++ b/module/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.json.AutoConfigureJson.imports @@ -2,3 +2,4 @@ optional:org.springframework.boot.gson.autoconfigure.GsonAutoConfiguration org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration optional:org.springframework.boot.jsonb.autoconfigure.JsonbAutoConfiguration +optional:org.springframework.boot.kotlin.serialization.autoconfigure.KotlinSerializationAutoConfiguration diff --git a/platform/spring-boot-dependencies/build.gradle b/platform/spring-boot-dependencies/build.gradle index 72087a36a6fe..0411198b5576 100644 --- a/platform/spring-boot-dependencies/build.gradle +++ b/platform/spring-boot-dependencies/build.gradle @@ -2038,6 +2038,7 @@ bom { "spring-boot-jpa", "spring-boot-jsonb", "spring-boot-kafka", + "spring-boot-kotlin-serialization", "spring-boot-ldap", "spring-boot-liquibase", "spring-boot-loader", @@ -2118,6 +2119,7 @@ bom { "spring-boot-starter-json", "spring-boot-starter-jsonb", "spring-boot-starter-kafka", + "spring-boot-starter-kotlin-serialization", "spring-boot-starter-ldap", "spring-boot-starter-liquibase", "spring-boot-starter-log4j2", diff --git a/settings.gradle b/settings.gradle index 65e82571407d..998c80c873d0 100644 --- a/settings.gradle +++ b/settings.gradle @@ -29,6 +29,9 @@ pluginManagement { if (requested.id.id == "org.jetbrains.kotlin.plugin.spring") { useVersion "${kotlinVersion}" } + if (requested.id.id == "org.jetbrains.kotlin.plugin.serialization") { + useVersion "${kotlinVersion}" + } } } includeBuild("gradle/plugins") @@ -124,6 +127,7 @@ include "module:spring-boot-jooq" include "module:spring-boot-jpa" include "module:spring-boot-jsonb" include "module:spring-boot-kafka" +include "module:spring-boot-kotlin-serialization" include "module:spring-boot-ldap" include "module:spring-boot-liquibase" include "module:spring-boot-mail" @@ -216,6 +220,7 @@ include "starter:spring-boot-starter-jooq" include "starter:spring-boot-starter-json" include "starter:spring-boot-starter-jsonb" include "starter:spring-boot-starter-kafka" +include "starter:spring-boot-starter-kotlin-serialization" include "starter:spring-boot-starter-ldap" include "starter:spring-boot-starter-liquibase" include "starter:spring-boot-starter-log4j2" diff --git a/starter/spring-boot-starter-kotlin-serialization/build.gradle b/starter/spring-boot-starter-kotlin-serialization/build.gradle new file mode 100644 index 000000000000..b8bd6d8192ae --- /dev/null +++ b/starter/spring-boot-starter-kotlin-serialization/build.gradle @@ -0,0 +1,27 @@ +/* + * 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. + */ + +plugins { + id "org.springframework.boot.starter" +} + +description = "Starter for Kotlin Serialization" + +dependencies { + api(project(":starter:spring-boot-starter")) + + api(project(":module:spring-boot-kotlin-serialization")) +}