From 3f01af6f7c7738c0148d1022ac6dbb189f13c7dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Mon, 26 Oct 2020 20:06:07 +0100 Subject: [PATCH] Add kotlinx.serialization JSON support to Spring Messaging Closes gh-25883 --- spring-messaging/spring-messaging.gradle | 2 + ...tlinSerializationJsonMessageConverter.java | 109 +++++++++ .../AbstractMessageBrokerConfiguration.java | 7 + ...nSerializationJsonMessageConverterTests.kt | 214 ++++++++++++++++++ src/docs/asciidoc/languages/kotlin.adoc | 2 +- 5 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/converter/KotlinSerializationJsonMessageConverter.java create mode 100644 spring-messaging/src/test/kotlin/org/springframework/messaging/converter/KotlinSerializationJsonMessageConverterTests.kt diff --git a/spring-messaging/spring-messaging.gradle b/spring-messaging/spring-messaging.gradle index c1ed06cb23..2973da3149 100644 --- a/spring-messaging/spring-messaging.gradle +++ b/spring-messaging/spring-messaging.gradle @@ -1,6 +1,7 @@ description = "Spring Messaging" apply plugin: "kotlin" +apply plugin: "kotlinx-serialization" dependencies { compile(project(":spring-beans")) @@ -17,6 +18,7 @@ dependencies { optional("javax.xml.bind:jaxb-api") optional("com.google.protobuf:protobuf-java-util") optional("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") + optional("org.jetbrains.kotlinx:kotlinx-serialization-json") testCompile(project(":kotlin-coroutines")) testCompile(testFixtures(project(":spring-core"))) testCompile("javax.inject:javax.inject-tck") diff --git a/spring-messaging/src/main/java/org/springframework/messaging/converter/KotlinSerializationJsonMessageConverter.java b/spring-messaging/src/main/java/org/springframework/messaging/converter/KotlinSerializationJsonMessageConverter.java new file mode 100644 index 0000000000..213e424edf --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/converter/KotlinSerializationJsonMessageConverter.java @@ -0,0 +1,109 @@ +/* + * 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.messaging.converter; + +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.lang.reflect.Type; +import java.util.Map; + +import kotlinx.serialization.KSerializer; +import kotlinx.serialization.SerializersKt; +import kotlinx.serialization.json.Json; + +import org.springframework.util.ConcurrentReferenceHashMap; +import org.springframework.util.FileCopyUtils; + +/** + * Implementation of {@link MessageConverter} that can read and write JSON + * using kotlinx.serialization. + * + *

This converter can be used to bind {@code @Serializable} Kotlin classes. + * + * @author Sebastien Deleuze + * @since 5.3 + */ +public class KotlinSerializationJsonMessageConverter extends AbstractJsonMessageConverter { + + private static final Map> serializerCache = new ConcurrentReferenceHashMap<>(); + + private final Json json; + + + /** + * Construct a new {@code KotlinSerializationJsonMessageConverter} with default configuration. + */ + public KotlinSerializationJsonMessageConverter() { + this(Json.Default); + } + + /** + * Construct a new {@code KotlinSerializationJsonMessageConverter} with the given delegate. + * @param json the Json instance to use + */ + public KotlinSerializationJsonMessageConverter(Json json) { + this.json = json; + } + + @Override + protected Object fromJson(Reader reader, Type resolvedType) { + try { + return fromJson(FileCopyUtils.copyToString(reader), resolvedType); + } + catch (IOException ex) { + throw new MessageConversionException("Could not read JSON: " + ex.getMessage(), ex); + } + } + + @Override + protected Object fromJson(String payload, Type resolvedType) { + return this.json.decodeFromString(serializer(resolvedType), payload); + } + + @Override + protected void toJson(Object payload, Type resolvedType, Writer writer) { + try { + writer.write(toJson(payload, resolvedType).toCharArray()); + } + catch (IOException ex) { + throw new MessageConversionException("Could not write JSON: " + ex.getMessage(), ex); + } + } + + @Override + protected String toJson(Object payload, Type resolvedType) { + return this.json.encodeToString(serializer(resolvedType), payload); + } + + /** + * 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 the type to find a serializer for + * @return a resolved serializer for the given type + * @throws RuntimeException if no serializer supporting the given type can be found + */ + private KSerializer serializer(Type type) { + KSerializer serializer = serializerCache.get(type); + if (serializer == null) { + serializer = SerializersKt.serializer(type); + serializerCache.put(type, serializer); + } + return serializer; + } +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/config/AbstractMessageBrokerConfiguration.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/config/AbstractMessageBrokerConfiguration.java index 86398a82ef..04db543c23 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/config/AbstractMessageBrokerConfiguration.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/config/AbstractMessageBrokerConfiguration.java @@ -36,6 +36,7 @@ import org.springframework.messaging.converter.CompositeMessageConverter; import org.springframework.messaging.converter.DefaultContentTypeResolver; import org.springframework.messaging.converter.GsonMessageConverter; import org.springframework.messaging.converter.JsonbMessageConverter; +import org.springframework.messaging.converter.KotlinSerializationJsonMessageConverter; import org.springframework.messaging.converter.MappingJackson2MessageConverter; import org.springframework.messaging.converter.MessageConverter; import org.springframework.messaging.converter.StringMessageConverter; @@ -101,6 +102,8 @@ public abstract class AbstractMessageBrokerConfiguration implements ApplicationC private static final boolean jsonbPresent; + private static final boolean kotlinSerializationJsonPresent; + static { ClassLoader classLoader = AbstractMessageBrokerConfiguration.class.getClassLoader(); @@ -108,6 +111,7 @@ public abstract class AbstractMessageBrokerConfiguration implements ApplicationC ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", 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); } @@ -411,6 +415,9 @@ public abstract class AbstractMessageBrokerConfiguration implements ApplicationC else if (jsonbPresent) { converters.add(new JsonbMessageConverter()); } + else if (kotlinSerializationJsonPresent) { + converters.add(new KotlinSerializationJsonMessageConverter()); + } } return new CompositeMessageConverter(converters); } diff --git a/spring-messaging/src/test/kotlin/org/springframework/messaging/converter/KotlinSerializationJsonMessageConverterTests.kt b/spring-messaging/src/test/kotlin/org/springframework/messaging/converter/KotlinSerializationJsonMessageConverterTests.kt new file mode 100644 index 0000000000..dec7bf9cc1 --- /dev/null +++ b/spring-messaging/src/test/kotlin/org/springframework/messaging/converter/KotlinSerializationJsonMessageConverterTests.kt @@ -0,0 +1,214 @@ +/* + * Copyright 2002-2019 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.messaging.converter + +import kotlinx.serialization.Serializable +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +import org.springframework.core.MethodParameter +import org.springframework.messaging.support.MessageBuilder +import java.nio.charset.StandardCharsets +import kotlin.reflect.typeOf + +@Suppress("UsePropertyAccessSyntax") +class KotlinSerializationJsonMessageConverterTests { + + private val converter = KotlinSerializationJsonMessageConverter() + + @Test + fun readObject() { + val payload = """ + { + "bytes": [ + 1, + 2 + ], + "array": [ + "Foo", + "Bar" + ], + "number": 42, + "string": "Foo", + "bool": true, + "fraction": 42 + } + """.trimIndent() + val message = MessageBuilder.withPayload(payload.toByteArray(StandardCharsets.UTF_8)).build() + val result = converter.fromMessage(message, SerializableBean::class.java) as SerializableBean + + Assertions.assertThat(result.bytes).containsExactly(0x1, 0x2) + Assertions.assertThat(result.array).containsExactly("Foo", "Bar") + Assertions.assertThat(result.number).isEqualTo(42) + Assertions.assertThat(result.string).isEqualTo("Foo") + Assertions.assertThat(result.bool).isTrue() + Assertions.assertThat(result.fraction).isEqualTo(42.0f) + } + + @Test + @Suppress("UNCHECKED_CAST") + fun readArrayOfObjects() { + val payload = """ + [ + { + "bytes": [ + 1, + 2 + ], + "array": [ + "Foo", + "Bar" + ], + "number": 42, + "string": "Foo", + "bool": true, + "fraction": 42 + } + ] + """.trimIndent() + val message = MessageBuilder.withPayload(payload.toByteArray(StandardCharsets.UTF_8)).build() + val result = converter.fromMessage(message, Array::class.java) as Array + + Assertions.assertThat(result).hasSize(1) + Assertions.assertThat(result[0].bytes).containsExactly(0x1, 0x2) + Assertions.assertThat(result[0].array).containsExactly("Foo", "Bar") + Assertions.assertThat(result[0].number).isEqualTo(42) + Assertions.assertThat(result[0].string).isEqualTo("Foo") + Assertions.assertThat(result[0].bool).isTrue() + Assertions.assertThat(result[0].fraction).isEqualTo(42.0f) + } + + @Test + @Suppress("UNCHECKED_CAST") + @ExperimentalStdlibApi + fun readGenericCollection() { + val payload = """ + [ + { + "bytes": [ + 1, + 2 + ], + "array": [ + "Foo", + "Bar" + ], + "number": 42, + "string": "Foo", + "bool": true, + "fraction": 42 + } + ] + """.trimIndent() + val method = javaClass.getDeclaredMethod("handleList", List::class.java) + val param = MethodParameter(method, 0) + val message = MessageBuilder.withPayload(payload.toByteArray(StandardCharsets.UTF_8)).build() + val result = converter.fromMessage(message, typeOf>()::class.java, param) as List + + Assertions.assertThat(result).hasSize(1) + Assertions.assertThat(result[0].bytes).containsExactly(0x1, 0x2) + Assertions.assertThat(result[0].array).containsExactly("Foo", "Bar") + Assertions.assertThat(result[0].number).isEqualTo(42) + Assertions.assertThat(result[0].string).isEqualTo("Foo") + Assertions.assertThat(result[0].bool).isTrue() + Assertions.assertThat(result[0].fraction).isEqualTo(42.0f) + } + + @Test + fun readFailsOnInvalidJson() { + val payload = """ + this is an invalid JSON document + """.trimIndent() + + val message = MessageBuilder.withPayload(payload.toByteArray(StandardCharsets.UTF_8)).build() + Assertions.assertThatExceptionOfType(MessageConversionException::class.java).isThrownBy { + converter.fromMessage(message, SerializableBean::class.java) + } + } + + @Test + fun writeObject() { + val serializableBean = SerializableBean(byteArrayOf(0x1, 0x2), arrayOf("Foo", "Bar"), 42, "Foo", true, 42.0f) + val message = converter.toMessage(serializableBean, null) + val result = String((message!!.payload as ByteArray), StandardCharsets.UTF_8) + + Assertions.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 serializableBean = SerializableBean(byteArrayOf(0x1, 0x2), arrayOf("Foo", "Bar"), 42, null, true, 42.0f) + val message = converter.toMessage(serializableBean, null) + val result = String((message!!.payload as ByteArray), StandardCharsets.UTF_8) + + Assertions.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 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() + + val message = converter.toMessage(arrayOf(serializableBean), null) + val result = String((message!!.payload as ByteArray), StandardCharsets.UTF_8) + + Assertions.assertThat(result).isEqualTo(expectedJson) + } + + @Test + @ExperimentalStdlibApi + fun writeGenericCollection() { + 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() + + val method = javaClass.getDeclaredMethod("handleList", List::class.java) + val param = MethodParameter(method, 0) + val message = converter.toMessage(arrayListOf(serializableBean), null, param) + val result = String((message!!.payload as ByteArray), StandardCharsets.UTF_8) + + Assertions.assertThat(result).isEqualTo(expectedJson) + } + + @Suppress("UNUSED_PARAMETER") + fun handleList(payload: List) {} + + @Serializable + @Suppress("ArrayInDataClass") + data class SerializableBean( + val bytes: ByteArray, + val array: Array, + val number: Int, + val string: String?, + val bool: Boolean, + val fraction: Float + ) +} diff --git a/src/docs/asciidoc/languages/kotlin.adoc b/src/docs/asciidoc/languages/kotlin.adoc index 1581546641..01ea4208db 100644 --- a/src/docs/asciidoc/languages/kotlin.adoc +++ b/src/docs/asciidoc/languages/kotlin.adoc @@ -389,7 +389,7 @@ 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 and Spring WebFlux. The builtin support currently only targets JSON format. +supported in Spring MVC, Spring WebFlux and Spring Messaging. 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.