Skip to content

Introduce Kotlin Serialization auto-configuration #46546

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
3 changes: 3 additions & 0 deletions module/spring-boot-autoconfigure-classic-modules/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions module/spring-boot-http-converter/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ static class JsonbPreferred {

}

@ConditionalOnProperty(name = HttpMessageConvertersAutoConfiguration.PREFERRED_MAPPER_PROPERTY,
havingValue = "kotlin-serialization")
static class KotlinxSerialization {

}

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {

}

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -19,6 +19,9 @@
},
{
"value": "jsonb"
},
{
"value": "kotlin-serialization"
}
],
"providers": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -60,6 +62,7 @@
* @author Eddú Meléndez
* @author Moritz Halbritter
* @author Sebastien Deleuze
* @author Dmitry Sulman
*/
@SuppressWarnings("removal")
class HttpMessageConvertersAutoConfigurationTests {
Expand Down Expand Up @@ -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);
});
}
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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);
});
}

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

Expand Down Expand Up @@ -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<AssertableApplicationContext> assertConverter(
Expand Down Expand Up @@ -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 {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -89,6 +90,25 @@ void addBeforeExistingEquivalentConverter() {
MappingJackson2HttpMessageConverter.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,
MappingJackson2HttpMessageConverter.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, MappingJackson2HttpMessageConverter.class);
}

@Test
void addNewConverters() {
HttpMessageConverter<?> converter1 = mock(HttpMessageConverter.class);
Expand Down
Loading