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-09-10 20:53:40 +02:00
committed by Brian Clozel
parent 4b0b9e5e36
commit c7621bb6be
23 changed files with 1018 additions and 8 deletions
@@ -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);
};
}
}
}
@@ -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);
}
@@ -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
}
}
@@ -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;
@@ -0,0 +1 @@
org.springframework.boot.kotlin.serialization.autoconfigure.KotlinSerializationAutoConfiguration
@@ -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 }
}
}
}