Introduce Kotlin Serialization auto-configuration

See gh-46546

Signed-off-by: Dmitry Sulman <dmitry.sulman@gmail.com>
This commit is contained in:
Dmitry Sulman 2025-07-25 00:32:54 +03:00 committed by Brian Clozel
parent 4b0b9e5e36
commit c7621bb6be
23 changed files with 1018 additions and 8 deletions

View File

@ -118,6 +118,7 @@ public abstract class DocumentConfigurationProperties extends DefaultTask {
private void jsonPrefixes(Config config) {
config.accept("spring.jackson");
config.accept("spring.gson");
config.accept("spring.kotlin-serialization");
}
private void dataPrefixes(Config config) {

View File

@ -6,6 +6,7 @@ Spring Boot provides integration with three JSON mapping libraries:
- Gson
- Jackson 3
- 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.

View File

@ -154,6 +154,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
}

View File

@ -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.google.code.gson:gson")
optional("jakarta.json.bind:jakarta.json.bind-api")
optional("org.springframework:spring-webmvc")

View File

@ -91,6 +91,12 @@ class GsonHttpMessageConvertersConfiguration {
}
@ConditionalOnProperty(name = HttpMessageConvertersAutoConfiguration.PREFERRED_MAPPER_PROPERTY,
havingValue = "kotlin-serialization")
static class KotlinxSerialization {
}
}
}

View File

@ -71,9 +71,11 @@ public class HttpMessageConverters implements Iterable<HttpMessageConverter<?>>
MultiValueMap<Class<?>, 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);
}

View File

@ -45,15 +45,17 @@ import org.springframework.http.converter.StringHttpMessageConverter;
* @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";

View File

@ -23,6 +23,7 @@ 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.context.annotation.Bean;
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration;
@ -66,8 +67,32 @@ class JsonbHttpMessageConvertersConfiguration {
}
@ConditionalOnMissingBean({ JacksonJsonHttpMessageConverter.class, GsonHttpMessageConverter.class })
static class JacksonAndGsonMissing {
@Conditional(JacksonAndGsonAndKotlinSerializationMissingCondition.class)
static class JacksonAndGsonAndKotlinSerializationMissing {
}
}
private static class JacksonAndGsonAndKotlinSerializationMissingCondition extends NoneNestedConditions {
JacksonAndGsonAndKotlinSerializationMissingCondition() {
super(ConfigurationPhase.REGISTER_BEAN);
}
@ConditionalOnBean(JacksonJsonHttpMessageConverter.class)
static class JacksonAvailable {
}
@ConditionalOnBean(GsonHttpMessageConverter.class)
static class GsonAvailable {
}
@ConditionalOnProperty(name = HttpMessageConvertersAutoConfiguration.PREFERRED_MAPPER_PROPERTY,
havingValue = "kotlin-serialization")
static class KotlinxPreferred {
}

View File

@ -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);
}
}
}

View File

@ -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": [

View File

@ -21,6 +21,7 @@ import java.nio.charset.StandardCharsets;
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 tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
@ -46,6 +47,7 @@ import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.http.converter.json.GsonHttpMessageConverter;
import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter;
import org.springframework.http.converter.json.JsonbHttpMessageConverter;
import org.springframework.http.converter.json.KotlinSerializationJsonHttpMessageConverter;
import org.springframework.http.converter.xml.JacksonXmlHttpMessageConverter;
import static org.assertj.core.api.Assertions.assertThat;
@ -61,6 +63,7 @@ import static org.assertj.core.api.Assertions.assertThat;
* @author Eddú Meléndez
* @author Moritz Halbritter
* @author Sebastien Deleuze
* @author Dmitry Sulman
*/
class HttpMessageConvertersAutoConfigurationTests {
@ -128,6 +131,7 @@ class HttpMessageConvertersAutoConfigurationTests {
assertConverterBeanRegisteredWithHttpMessageConverters(context, GsonHttpMessageConverter.class);
assertThat(context).doesNotHaveBean(JsonbHttpMessageConverter.class);
assertThat(context).doesNotHaveBean(JacksonJsonHttpMessageConverter.class);
assertThat(context).doesNotHaveBean(KotlinSerializationJsonHttpMessageConverter.class);
});
}
@ -159,6 +163,37 @@ class HttpMessageConvertersAutoConfigurationTests {
assertConverterBeanRegisteredWithHttpMessageConverters(context, JsonbHttpMessageConverter.class);
assertThat(context).doesNotHaveBean(GsonHttpMessageConverter.class);
assertThat(context).doesNotHaveBean(JacksonJsonHttpMessageConverter.class);
assertThat(context).doesNotHaveBean(KotlinSerializationJsonHttpMessageConverter.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(JacksonJsonHttpMessageConverter.class);
});
}
@ -205,6 +240,7 @@ class HttpMessageConvertersAutoConfigurationTests {
assertConverterBeanRegisteredWithHttpMessageConverters(context, JacksonJsonHttpMessageConverter.class);
assertThat(context).doesNotHaveBean(GsonHttpMessageConverter.class);
assertThat(context).doesNotHaveBean(JsonbHttpMessageConverter.class);
assertThat(context).doesNotHaveBean(KotlinSerializationJsonHttpMessageConverter.class);
});
}
@ -215,6 +251,7 @@ class HttpMessageConvertersAutoConfigurationTests {
assertConverterBeanExists(context, GsonHttpMessageConverter.class, "gsonHttpMessageConverter");
assertConverterBeanRegisteredWithHttpMessageConverters(context, GsonHttpMessageConverter.class);
assertThat(context).doesNotHaveBean(JsonbHttpMessageConverter.class);
assertThat(context).doesNotHaveBean(KotlinSerializationJsonHttpMessageConverter.class);
});
}
@ -266,7 +303,8 @@ class HttpMessageConvertersAutoConfigurationTests {
private ApplicationContextRunner allOptionsRunner() {
return this.contextRunner.withBean(Gson.class)
.withBean(JsonMapper.class)
.withBean(Jsonb.class, JsonbBuilder::create);
.withBean(Jsonb.class, JsonbBuilder::create)
.withBean(Json.class, () -> Json.Default);
}
private ContextConsumer<AssertableApplicationContext> assertConverter(
@ -364,6 +402,16 @@ class HttpMessageConvertersAutoConfigurationTests {
}
@Configuration(proxyBeanMethods = false)
static class KotlinSerializationConverterConfig {
@Bean
KotlinSerializationJsonHttpMessageConverter customKotlinSerializationJsonHttpMessageConverter(Json json) {
return new KotlinSerializationJsonHttpMessageConverter(json);
}
}
@Configuration(proxyBeanMethods = false)
static class StringConverterConfig {

View File

@ -31,6 +31,7 @@ import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.http.converter.cbor.JacksonCborHttpMessageConverter;
import org.springframework.http.converter.json.GsonHttpMessageConverter;
import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter;
import org.springframework.http.converter.json.KotlinSerializationJsonHttpMessageConverter;
import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter;
import org.springframework.http.converter.xml.JacksonXmlHttpMessageConverter;
@ -88,6 +89,25 @@ class HttpMessageConvertersTests {
JacksonJsonHttpMessageConverter.class);
}
@Test
void addBeforeExistingAnotherEquivalentConverter() {
KotlinSerializationJsonHttpMessageConverter converter1 = new KotlinSerializationJsonHttpMessageConverter();
HttpMessageConverters converters = new HttpMessageConverters(converter1);
Stream<Class<?>> converterClasses = converters.getConverters().stream().map(HttpMessageConverter::getClass);
assertThat(converterClasses).containsSequence(KotlinSerializationJsonHttpMessageConverter.class,
JacksonJsonHttpMessageConverter.class);
}
@Test
void addBeforeExistingMultipleEquivalentConverters() {
GsonHttpMessageConverter converter1 = new GsonHttpMessageConverter();
KotlinSerializationJsonHttpMessageConverter converter2 = new KotlinSerializationJsonHttpMessageConverter();
HttpMessageConverters converters = new HttpMessageConverters(converter1, converter2);
Stream<Class<?>> converterClasses = converters.getConverters().stream().map(HttpMessageConverter::getClass);
assertThat(converterClasses).containsSequence(GsonHttpMessageConverter.class,
KotlinSerializationJsonHttpMessageConverter.class, JacksonJsonHttpMessageConverter.class);
}
@Test
void addNewConverters() {
HttpMessageConverter<?> converter1 = mock(HttpMessageConverter.class);

View File

@ -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') }
}
}

View File

@ -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<KotlinSerializationJsonBuilderCustomizer> customizers) {
Function1<JsonBuilder, Unit> 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<KotlinSerializationProperties.JsonNamingStrategy> setNamingStrategy(JsonBuilder builder) {
return (strategy) -> {
JsonNamingStrategy namingStrategy = switch (strategy) {
case SNAKE_CASE -> JsonNamingStrategy.Builtins.getSnakeCase();
case KEBAB_CASE -> JsonNamingStrategy.Builtins.getKebabCase();
};
builder.setNamingStrategy(namingStrategy);
};
}
}
}

View File

@ -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);
}

View File

@ -0,0 +1,258 @@
/*
* 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.jspecify.annotations.Nullable;
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 @Nullable JsonNamingStrategy namingStrategy;
/**
* Whether resulting JSON should be pretty-printed: formatted and optimized for human
* readability.
*/
private @Nullable Boolean prettyPrint;
/**
* Enable lenient mode that removes JSON specification restriction (RFC-4627) and
* makes parser more liberal to the malformed input.
*/
private @Nullable Boolean lenient;
/**
* Whether encounters of unknown properties in the input JSON should be ignored
* instead of throwing SerializationException.
*/
private @Nullable Boolean ignoreUnknownKeys;
/**
* Whether default values of Kotlin properties should be encoded.
*/
private @Nullable Boolean encodeDefaults;
/**
* Whether null values should be encoded for nullable properties and must be present
* in JSON object during decoding.
*/
private @Nullable Boolean explicitNulls;
/**
* Enable coercing incorrect JSON values.
*/
private @Nullable 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 @Nullable 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 @Nullable Boolean allowSpecialFloatingPointValues;
/**
* Name of the class descriptor property for polymorphic serialization.
*/
private @Nullable String classDiscriminator;
/**
* Defines which classes and objects should have class discriminator added to the
* output.
*/
private @Nullable ClassDiscriminatorMode classDiscriminatorMode;
/**
* Enable decoding enum values in a case-insensitive manner.
*/
private @Nullable Boolean decodeEnumsCaseInsensitive;
/**
* Whether Json instance makes use of JsonNames annotation.
*/
private @Nullable Boolean useAlternativeNames;
/**
* Whether to allow parser to accept trailing (ending) commas in JSON objects and
* arrays.
*/
private @Nullable Boolean allowTrailingComma;
/**
* Whether to allow parser to accept C/Java-style comments in JSON input.
*/
private @Nullable Boolean allowComments;
public @Nullable JsonNamingStrategy getNamingStrategy() {
return this.namingStrategy;
}
public void setNamingStrategy(@Nullable JsonNamingStrategy namingStrategy) {
this.namingStrategy = namingStrategy;
}
public @Nullable Boolean getPrettyPrint() {
return this.prettyPrint;
}
public void setPrettyPrint(@Nullable Boolean prettyPrint) {
this.prettyPrint = prettyPrint;
}
public @Nullable Boolean getLenient() {
return this.lenient;
}
public void setLenient(@Nullable Boolean lenient) {
this.lenient = lenient;
}
public @Nullable Boolean getIgnoreUnknownKeys() {
return this.ignoreUnknownKeys;
}
public void setIgnoreUnknownKeys(@Nullable Boolean ignoreUnknownKeys) {
this.ignoreUnknownKeys = ignoreUnknownKeys;
}
public @Nullable Boolean getEncodeDefaults() {
return this.encodeDefaults;
}
public void setEncodeDefaults(@Nullable Boolean encodeDefaults) {
this.encodeDefaults = encodeDefaults;
}
public @Nullable Boolean getExplicitNulls() {
return this.explicitNulls;
}
public void setExplicitNulls(@Nullable Boolean explicitNulls) {
this.explicitNulls = explicitNulls;
}
public @Nullable Boolean getCoerceInputValues() {
return this.coerceInputValues;
}
public void setCoerceInputValues(@Nullable Boolean coerceInputValues) {
this.coerceInputValues = coerceInputValues;
}
public @Nullable Boolean getAllowStructuredMapKeys() {
return this.allowStructuredMapKeys;
}
public void setAllowStructuredMapKeys(@Nullable Boolean allowStructuredMapKeys) {
this.allowStructuredMapKeys = allowStructuredMapKeys;
}
public @Nullable Boolean getAllowSpecialFloatingPointValues() {
return this.allowSpecialFloatingPointValues;
}
public void setAllowSpecialFloatingPointValues(@Nullable Boolean allowSpecialFloatingPointValues) {
this.allowSpecialFloatingPointValues = allowSpecialFloatingPointValues;
}
public @Nullable String getClassDiscriminator() {
return this.classDiscriminator;
}
public void setClassDiscriminator(@Nullable String classDiscriminator) {
this.classDiscriminator = classDiscriminator;
}
public @Nullable ClassDiscriminatorMode getClassDiscriminatorMode() {
return this.classDiscriminatorMode;
}
public void setClassDiscriminatorMode(@Nullable ClassDiscriminatorMode classDiscriminatorMode) {
this.classDiscriminatorMode = classDiscriminatorMode;
}
public @Nullable Boolean getDecodeEnumsCaseInsensitive() {
return this.decodeEnumsCaseInsensitive;
}
public void setDecodeEnumsCaseInsensitive(@Nullable Boolean decodeEnumsCaseInsensitive) {
this.decodeEnumsCaseInsensitive = decodeEnumsCaseInsensitive;
}
public @Nullable Boolean getUseAlternativeNames() {
return this.useAlternativeNames;
}
public void setUseAlternativeNames(@Nullable Boolean useAlternativeNames) {
this.useAlternativeNames = useAlternativeNames;
}
public @Nullable Boolean getAllowTrailingComma() {
return this.allowTrailingComma;
}
public void setAllowTrailingComma(@Nullable Boolean allowTrailingComma) {
this.allowTrailingComma = allowTrailingComma;
}
public @Nullable Boolean getAllowComments() {
return this.allowComments;
}
public void setAllowComments(@Nullable 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
}
}

View File

@ -0,0 +1,23 @@
/*
* 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.
*/
@NullMarked
package org.springframework.boot.kotlin.serialization.autoconfigure;
import org.jspecify.annotations.NullMarked;

View File

@ -0,0 +1 @@
org.springframework.boot.kotlin.serialization.autoconfigure.KotlinSerializationAutoConfiguration

View File

@ -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<DataObject>("""{"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<DataObject>("""{"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<DataObject>("""{"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<DataObjectWithDefault>("""{"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<DataObjectEnumValues>("""{"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<DataObject>("""{"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<DataObject>("""{"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<DataObject>("""{"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<EnumValue>)
@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 }
}
}
}

View File

@ -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

View File

@ -2037,6 +2037,7 @@ bom {
"spring-boot-jpa",
"spring-boot-jsonb",
"spring-boot-kafka",
"spring-boot-kotlin-serialization",
"spring-boot-ldap",
"spring-boot-liquibase",
"spring-boot-loader",
@ -2117,6 +2118,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",

View File

@ -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")
@ -123,6 +126,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"
@ -213,6 +217,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"

View File

@ -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"))
}