diff --git a/build.gradle b/build.gradle index f25893cd4b9..59e19a37769 100644 --- a/build.gradle +++ b/build.gradle @@ -8,6 +8,7 @@ plugins { id "io.freefair.aspectj" version '5.1.1' apply false id "com.github.ben-manes.versions" version '0.28.0' id "me.champeau.gradle.jmh" version "0.5.0" apply false + id "org.jetbrains.kotlin.plugin.serialization" version "1.4.0" apply false } ext { @@ -87,6 +88,9 @@ configure(allprojects) { project -> } dependency "org.ogce:xpp3:1.1.6" dependency "org.yaml:snakeyaml:1.26" + dependencySet(group: 'org.jetbrains.kotlinx', version: '1.0.0-RC') { + entry 'kotlinx-serialization-core' + } dependency "com.h2database:h2:1.4.200" dependency "com.github.ben-manes.caffeine:caffeine:2.8.5" diff --git a/spring-web/spring-web.gradle b/spring-web/spring-web.gradle index 9b5c351e7c8..3ab5eddabe4 100644 --- a/spring-web/spring-web.gradle +++ b/spring-web/spring-web.gradle @@ -1,6 +1,7 @@ description = "Spring Web" apply plugin: "kotlin" +apply plugin: "kotlinx-serialization" dependencies { compile(project(":spring-beans")) @@ -54,6 +55,7 @@ dependencies { optional("org.codehaus.groovy:groovy") optional("org.jetbrains.kotlin:kotlin-reflect") optional("org.jetbrains.kotlin:kotlin-stdlib") + optional("org.jetbrains.kotlinx:kotlinx-serialization-core") testCompile(testFixtures(project(":spring-beans"))) testCompile(testFixtures(project(":spring-context"))) testCompile(testFixtures(project(":spring-core"))) diff --git a/spring-web/src/main/java/org/springframework/http/converter/json/KotlinSerializationJsonHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/json/KotlinSerializationJsonHttpMessageConverter.java new file mode 100644 index 00000000000..8db1830fa18 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/converter/json/KotlinSerializationJsonHttpMessageConverter.java @@ -0,0 +1,154 @@ +/* + * Copyright 2002-2020 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.http.converter.json; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import kotlinx.serialization.KSerializer; +import kotlinx.serialization.SerializationException; +import kotlinx.serialization.SerializersKt; +import kotlinx.serialization.json.Json; + +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.converter.AbstractGenericHttpMessageConverter; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.HttpMessageNotWritableException; +import org.springframework.lang.Nullable; +import org.springframework.util.ConcurrentReferenceHashMap; +import org.springframework.util.StreamUtils; + +/** + * Implementation of {@link org.springframework.http.converter.HttpMessageConverter} that can read and write JSON using + * kotlinx.serialization. + * + *

This converter can be used to bind {@code @Serializable} Kotlin classes. It supports {@code application/json} and + * {@code application/*+json} with various character sets, {@code UTF-8} being the default. + * + * @author Andreas Ahlenstorf + * @author Sebastien Deleuze + * @since 5.3 + */ +public class KotlinSerializationJsonHttpMessageConverter extends AbstractGenericHttpMessageConverter { + + private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; + + private static final Map> serializerCache = new ConcurrentReferenceHashMap<>(); + + private final Json json; + + /** + * Construct a new {@code KotlinSerializationJsonHttpMessageConverter} with the default configuration. + */ + public KotlinSerializationJsonHttpMessageConverter() { + this(Json.Default); + } + + /** + * Construct a new {@code KotlinSerializationJsonHttpMessageConverter} with a custom configuration. + */ + public KotlinSerializationJsonHttpMessageConverter(Json json) { + super(MediaType.APPLICATION_JSON, new MediaType("application", "*+json")); + this.json = json; + } + + @Override + protected boolean supports(Class clazz) { + try { + resolve(clazz); + return true; + } + catch (Exception ex) { + return false; + } + } + + @Override + protected Object readInternal(Class clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException { + return this.read(clazz, null, inputMessage); + } + + @Override + public Object read(Type type, @Nullable Class contextClass, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException { + MediaType contentType = inputMessage.getHeaders().getContentType(); + String jsonText = StreamUtils.copyToString(inputMessage.getBody(), getCharsetToUse(contentType)); + try { + // TODO Use stream based API when available + return this.json.decodeFromString(resolve(type), jsonText); + } + catch (SerializationException ex) { + throw new HttpMessageNotReadableException("Could not read JSON: " + ex.getMessage(), ex, inputMessage); + } + } + + @Override + protected void writeInternal(Object o, HttpOutputMessage outputMessage) throws HttpMessageNotWritableException { + try { + this.writeInternal(o, o.getClass(), outputMessage); + } + catch (IOException ex) { + throw new HttpMessageNotWritableException("Could not write JSON: " + ex.getMessage(), ex); + } + } + + @Override + protected void writeInternal(Object o, @Nullable Type type, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException { + try { + String json = this.json.encodeToString(resolve(type), o); + MediaType contentType = outputMessage.getHeaders().getContentType(); + outputMessage.getBody().write(json.getBytes(getCharsetToUse(contentType))); + outputMessage.getBody().flush(); + } + catch (IOException ex) { + throw ex; + } + catch (Exception ex) { + throw new HttpMessageNotWritableException("Could not write JSON: " + ex.getMessage(), ex); + } + } + + private Charset getCharsetToUse(@Nullable MediaType contentType) { + if (contentType != null && contentType.getCharset() != null) { + return contentType.getCharset(); + } + return DEFAULT_CHARSET; + } + + /** + * Tries to find a serializer that can marshall or unmarshall instances of the given type using + * kotlinx.serialization. If no serializer can be found, an exception is thrown. + *

+ * Resolved serializers are cached and cached results are returned on successive calls. + * + * @param type to find a serializer for. + * @return resolved serializer for the given type. + * @throws RuntimeException if no serializer supporting the given type can be found. + */ + private KSerializer resolve(Type type) { + KSerializer serializer = serializerCache.get(type); + if (serializer == null) { + serializer = SerializersKt.serializer(type); + serializerCache.put(type, serializer); + } + return serializer; + } +} diff --git a/spring-web/src/main/java/org/springframework/http/converter/support/AllEncompassingFormHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/support/AllEncompassingFormHttpMessageConverter.java index e626804d768..78d64e5da47 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/support/AllEncompassingFormHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/support/AllEncompassingFormHttpMessageConverter.java @@ -20,6 +20,7 @@ import org.springframework.core.SpringProperties; import org.springframework.http.converter.FormHttpMessageConverter; import org.springframework.http.converter.json.GsonHttpMessageConverter; 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.smile.MappingJackson2SmileHttpMessageConverter; import org.springframework.http.converter.xml.Jaxb2RootElementHttpMessageConverter; @@ -57,6 +58,8 @@ public class AllEncompassingFormHttpMessageConverter extends FormHttpMessageConv private static final boolean jsonbPresent; + private static final boolean kotlinSerializationJsonPresent; + static { ClassLoader classLoader = AllEncompassingFormHttpMessageConverter.class.getClassLoader(); jaxb2Present = ClassUtils.isPresent("javax.xml.bind.Binder", classLoader); @@ -66,6 +69,7 @@ public class AllEncompassingFormHttpMessageConverter extends FormHttpMessageConv jackson2SmilePresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.smile.SmileFactory", classLoader); gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", classLoader); jsonbPresent = ClassUtils.isPresent("javax.json.bind.Jsonb", classLoader); + kotlinSerializationJsonPresent = ClassUtils.isPresent("kotlinx.serialization.json.Json", classLoader); } @@ -92,6 +96,9 @@ public class AllEncompassingFormHttpMessageConverter extends FormHttpMessageConv else if (jsonbPresent) { addPartConverter(new JsonbHttpMessageConverter()); } + else if (kotlinSerializationJsonPresent) { + addPartConverter(new KotlinSerializationJsonHttpMessageConverter()); + } if (jackson2XmlPresent && !shouldIgnoreXml) { addPartConverter(new MappingJackson2XmlHttpMessageConverter()); diff --git a/spring-web/src/main/java/org/springframework/web/client/RestTemplate.java b/spring-web/src/main/java/org/springframework/web/client/RestTemplate.java index 3170f800e5d..b35efde97e2 100644 --- a/spring-web/src/main/java/org/springframework/web/client/RestTemplate.java +++ b/spring-web/src/main/java/org/springframework/web/client/RestTemplate.java @@ -50,6 +50,7 @@ import org.springframework.http.converter.feed.AtomFeedHttpMessageConverter; import org.springframework.http.converter.feed.RssChannelHttpMessageConverter; import org.springframework.http.converter.json.GsonHttpMessageConverter; 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.smile.MappingJackson2SmileHttpMessageConverter; import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter; @@ -114,6 +115,8 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat private static final boolean jsonbPresent; + private static final boolean kotlinSerializationJsonPresent; + static { ClassLoader classLoader = RestTemplate.class.getClassLoader(); romePresent = ClassUtils.isPresent("com.rometools.rome.feed.WireFeed", classLoader); @@ -126,6 +129,7 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat jackson2CborPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.cbor.CBORFactory", classLoader); gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", classLoader); jsonbPresent = ClassUtils.isPresent("javax.json.bind.Jsonb", classLoader); + kotlinSerializationJsonPresent = ClassUtils.isPresent("kotlinx.serialization.json.Json", classLoader); } @@ -179,6 +183,9 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat else if (jsonbPresent) { this.messageConverters.add(new JsonbHttpMessageConverter()); } + else if (kotlinSerializationJsonPresent) { + this.messageConverters.add(new KotlinSerializationJsonHttpMessageConverter()); + } if (jackson2SmilePresent) { this.messageConverters.add(new MappingJackson2SmileHttpMessageConverter()); diff --git a/spring-web/src/test/kotlin/org/springframework/http/converter/json/KotlinSerializationJsonHttpMessageConverterTests.kt b/spring-web/src/test/kotlin/org/springframework/http/converter/json/KotlinSerializationJsonHttpMessageConverterTests.kt new file mode 100644 index 00000000000..a2bf98c4b99 --- /dev/null +++ b/spring-web/src/test/kotlin/org/springframework/http/converter/json/KotlinSerializationJsonHttpMessageConverterTests.kt @@ -0,0 +1,299 @@ +/* + * Copyright 2002-2020 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.http.converter.json + +import kotlinx.serialization.Serializable +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatExceptionOfType +import org.junit.jupiter.api.Test +import org.springframework.http.MediaType +import org.springframework.http.MockHttpInputMessage +import org.springframework.http.MockHttpOutputMessage +import org.springframework.http.converter.HttpMessageNotReadableException +import java.nio.charset.StandardCharsets +import kotlin.reflect.javaType +import kotlin.reflect.typeOf + +/** + * Tests for the JSON conversion using kotlinx.serialization. + * + * @author Andreas Ahlenstorf + * @author Sebastien Deleuze + */ +class KotlinSerializationJsonHttpMessageConverterTests { + + private val converter = KotlinSerializationJsonHttpMessageConverter() + + @Test + fun canReadJson() { + assertThat(converter.canRead(SerializableBean::class.java, MediaType.APPLICATION_JSON)).isTrue() + assertThat(converter.canRead(SerializableBean::class.java, MediaType.APPLICATION_PDF)).isFalse() + assertThat(converter.canRead(String::class.java, MediaType.APPLICATION_JSON)).isTrue() + assertThat(converter.canRead(NotSerializableBean::class.java, MediaType.APPLICATION_JSON)).isFalse() + + assertThat(converter.canRead(Map::class.java, MediaType.APPLICATION_JSON)).isTrue() + assertThat(converter.canRead(List::class.java, MediaType.APPLICATION_JSON)).isTrue() + assertThat(converter.canRead(Set::class.java, MediaType.APPLICATION_JSON)).isTrue() + } + + @Test + fun canWriteJson() { + assertThat(converter.canWrite(SerializableBean::class.java, MediaType.APPLICATION_JSON)).isTrue() + assertThat(converter.canWrite(SerializableBean::class.java, MediaType.APPLICATION_PDF)).isFalse() + assertThat(converter.canWrite(String::class.java, MediaType.APPLICATION_JSON)).isTrue() + assertThat(converter.canWrite(NotSerializableBean::class.java, MediaType.APPLICATION_JSON)).isFalse() + + assertThat(converter.canWrite(Map::class.java, MediaType.APPLICATION_JSON)).isTrue() + assertThat(converter.canWrite(List::class.java, MediaType.APPLICATION_JSON)).isTrue() + assertThat(converter.canWrite(Set::class.java, MediaType.APPLICATION_JSON)).isTrue() + } + + @Test + fun canReadMicroformats() { + val jsonSubtype = MediaType("application", "vnd.test-micro-type+json") + assertThat(converter.canRead(SerializableBean::class.java, jsonSubtype)).isTrue() + } + + @Test + fun canWriteMicroformats() { + val jsonSubtype = MediaType("application", "vnd.test-micro-type+json") + assertThat(converter.canWrite(SerializableBean::class.java, jsonSubtype)).isTrue() + } + + @Test + fun readObject() { + val body = """ + { + "bytes": [ + 1, + 2 + ], + "array": [ + "Foo", + "Bar" + ], + "number": 42, + "string": "Foo", + "bool": true, + "fraction": 42 + } + """.trimIndent() + val inputMessage = MockHttpInputMessage(body.toByteArray(charset("UTF-8"))) + inputMessage.headers.contentType = MediaType.APPLICATION_JSON + val result = converter.read(SerializableBean::class.java, inputMessage) as SerializableBean + + assertThat(result.bytes).containsExactly(0x1, 0x2) + assertThat(result.array).containsExactly("Foo", "Bar") + assertThat(result.number).isEqualTo(42) + assertThat(result.string).isEqualTo("Foo") + assertThat(result.bool).isTrue() + assertThat(result.fraction).isEqualTo(42.0f) + } + + @Test + @Suppress("UNCHECKED_CAST") + fun readArrayOfObjects() { + val body = """ + [ + { + "bytes": [ + 1, + 2 + ], + "array": [ + "Foo", + "Bar" + ], + "number": 42, + "string": "Foo", + "bool": true, + "fraction": 42 + } + ] + """.trimIndent() + val inputMessage = MockHttpInputMessage(body.toByteArray(charset("UTF-8"))) + inputMessage.headers.contentType = MediaType.APPLICATION_JSON + val result = converter.read(Array::class.java, inputMessage) as Array + + assertThat(result).hasSize(1) + assertThat(result[0].bytes).containsExactly(0x1, 0x2) + assertThat(result[0].array).containsExactly("Foo", "Bar") + assertThat(result[0].number).isEqualTo(42) + assertThat(result[0].string).isEqualTo("Foo") + assertThat(result[0].bool).isTrue() + assertThat(result[0].fraction).isEqualTo(42.0f) + } + + @Test + @Suppress("UNCHECKED_CAST") + @ExperimentalStdlibApi + fun readGenericCollection() { + val body = """ + [ + { + "bytes": [ + 1, + 2 + ], + "array": [ + "Foo", + "Bar" + ], + "number": 42, + "string": "Foo", + "bool": true, + "fraction": 42 + } + ] + """.trimIndent() + val inputMessage = MockHttpInputMessage(body.toByteArray(charset("UTF-8"))) + inputMessage.headers.contentType = MediaType.APPLICATION_JSON + val result = converter.read(typeOf>().javaType, null, inputMessage) + as List + + assertThat(result).hasSize(1) + assertThat(result[0].bytes).containsExactly(0x1, 0x2) + assertThat(result[0].array).containsExactly("Foo", "Bar") + assertThat(result[0].number).isEqualTo(42) + assertThat(result[0].string).isEqualTo("Foo") + assertThat(result[0].bool).isTrue() + assertThat(result[0].fraction).isEqualTo(42.0f) + } + + @Test + fun readObjectInUtf16() { + val body = "\"H\u00e9llo W\u00f6rld\"" + val inputMessage = MockHttpInputMessage(body.toByteArray(StandardCharsets.UTF_16BE)) + inputMessage.headers.contentType = MediaType("application", "json", StandardCharsets.UTF_16BE) + + val result = this.converter.read(String::class.java, inputMessage) + + assertThat(result).isEqualTo("H\u00e9llo W\u00f6rld") + } + + @Test + fun readFailsOnInvalidJson() { + val body = """ + this is an invalid JSON document + """.trimIndent() + + val inputMessage = MockHttpInputMessage(body.toByteArray(StandardCharsets.UTF_8)) + inputMessage.headers.contentType = MediaType.APPLICATION_JSON + assertThatExceptionOfType(HttpMessageNotReadableException::class.java).isThrownBy { + converter.read(SerializableBean::class.java, inputMessage) + } + } + + @Test + fun writeObject() { + val outputMessage = MockHttpOutputMessage() + val serializableBean = SerializableBean(byteArrayOf(0x1, 0x2), arrayOf("Foo", "Bar"), 42, "Foo", true, 42.0f) + + this.converter.write(serializableBean, null, outputMessage) + + val result = outputMessage.getBodyAsString(StandardCharsets.UTF_8) + + assertThat(outputMessage.headers).containsEntry("Content-Type", listOf("application/json")) + assertThat(result) + .contains("\"bytes\":[1,2]") + .contains("\"array\":[\"Foo\",\"Bar\"]") + .contains("\"number\":42") + .contains("\"string\":\"Foo\"") + .contains("\"bool\":true") + .contains("\"fraction\":42.0") + } + + @Test + fun writeObjectWithNullableProperty() { + val outputMessage = MockHttpOutputMessage() + val serializableBean = SerializableBean(byteArrayOf(0x1, 0x2), arrayOf("Foo", "Bar"), 42, null, true, 42.0f) + + this.converter.write(serializableBean, null, outputMessage) + + val result = outputMessage.getBodyAsString(StandardCharsets.UTF_8) + + assertThat(outputMessage.headers).containsEntry("Content-Type", listOf("application/json")) + assertThat(result) + .contains("\"bytes\":[1,2]") + .contains("\"array\":[\"Foo\",\"Bar\"]") + .contains("\"number\":42") + .contains("\"string\":null") + .contains("\"bool\":true") + .contains("\"fraction\":42.0") + } + + @Test + fun writeArrayOfObjects() { + val outputMessage = MockHttpOutputMessage() + val serializableBean = SerializableBean(byteArrayOf(0x1, 0x2), arrayOf("Foo", "Bar"), 42, "Foo", true, 42.0f) + val expectedJson = """ + [{"bytes":[1,2],"array":["Foo","Bar"],"number":42,"string":"Foo","bool":true,"fraction":42.0}] + """.trimIndent() + + this.converter.write(arrayOf(serializableBean), null, outputMessage) + + val result = outputMessage.getBodyAsString(StandardCharsets.UTF_8) + + assertThat(outputMessage.headers).containsEntry("Content-Type", listOf("application/json")) + assertThat(result).isEqualTo(expectedJson) + } + + @Test + @ExperimentalStdlibApi + fun writeGenericCollection() { + val outputMessage = MockHttpOutputMessage() + val serializableBean = SerializableBean(byteArrayOf(0x1, 0x2), arrayOf("Foo", "Bar"), 42, "Foo", true, 42.0f) + val expectedJson = """ + [{"bytes":[1,2],"array":["Foo","Bar"],"number":42,"string":"Foo","bool":true,"fraction":42.0}] + """.trimIndent() + + this.converter.write(arrayListOf(serializableBean), typeOf>().javaType, null, + outputMessage) + + val result = outputMessage.getBodyAsString(StandardCharsets.UTF_8) + + assertThat(outputMessage.headers).containsEntry("Content-Type", listOf("application/json")) + assertThat(result).isEqualTo(expectedJson) + } + + @Test + fun writeObjectInUtf16() { + val outputMessage = MockHttpOutputMessage() + val utf16 = "H\u00e9llo W\u00f6rld" + val contentType = MediaType("application", "json", StandardCharsets.UTF_16BE) + + this.converter.write(utf16, contentType, outputMessage) + + val result = outputMessage.getBodyAsString(StandardCharsets.UTF_16BE) + + assertThat(outputMessage.headers).containsEntry("Content-Type", listOf(contentType.toString())) + assertThat(result).isEqualTo("\"H\u00e9llo W\u00f6rld\"") + } + + @Serializable + @Suppress("ArrayInDataClass") + data class SerializableBean( + val bytes: ByteArray, + val array: Array, + val number: Int, + val string: String?, + val bool: Boolean, + val fraction: Float + ) + + data class NotSerializableBean(val string: String) +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java index 5bcdbe4ae54..535ec014daa 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java @@ -52,6 +52,7 @@ import org.springframework.http.converter.feed.RssChannelHttpMessageConverter; 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.smile.MappingJackson2SmileHttpMessageConverter; import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter; @@ -208,6 +209,8 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv private static final boolean jsonbPresent; + private static final boolean kotlinSerializationJsonPresent; + static { ClassLoader classLoader = WebMvcConfigurationSupport.class.getClassLoader(); romePresent = ClassUtils.isPresent("com.rometools.rome.feed.WireFeed", classLoader); @@ -219,6 +222,7 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv jackson2CborPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.cbor.CBORFactory", classLoader); gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", classLoader); jsonbPresent = ClassUtils.isPresent("javax.json.bind.Jsonb", classLoader); + kotlinSerializationJsonPresent = ClassUtils.isPresent("kotlinx.serialization.json.Json", classLoader); } @@ -914,6 +918,9 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv else if (jsonbPresent) { messageConverters.add(new JsonbHttpMessageConverter()); } + else if (kotlinSerializationJsonPresent) { + messageConverters.add(new KotlinSerializationJsonHttpMessageConverter()); + } if (jackson2SmilePresent) { Jackson2ObjectMapperBuilder builder = Jackson2ObjectMapperBuilder.smile(); diff --git a/src/docs/asciidoc/languages/kotlin.adoc b/src/docs/asciidoc/languages/kotlin.adoc index 629e0fee0ad..0285a99204b 100644 --- a/src/docs/asciidoc/languages/kotlin.adoc +++ b/src/docs/asciidoc/languages/kotlin.adoc @@ -383,10 +383,24 @@ class KotlinScriptConfiguration { } ---- - See the https://github.com/sdeleuze/kotlin-script-templating[kotlin-script-templating] example project for more details. +=== Kotlin multiplatform serialization + +As of Spring Framework 5.3, https://github.com/Kotlin/kotlinx.serialization[Kotlin multiplatform serialization] is +supported in Spring MVC. The builtin support currently only targets JSON format. + +To enable it, follow https://github.com/Kotlin/kotlinx.serialization#setup[those instructions] and make sure neither +Jackson, GSON or JSONB are in the classpath. + +NOTE: For a typical Spring Boot web application, that can be achieved by excluding `spring-boot-starter-json` dependency. + +If you need Jackson, GSON or JSONB for other purposes, you can keep them on the classpath and +<> to remove `MappingJackson2HttpMessageConverter` and add +`KotlinSerializationJsonHttpMessageConverter`. + + == Coroutines Kotlin https://kotlinlang.org/docs/reference/coroutines-overview.html[Coroutines] are Kotlin