Skip to content

Commit 3f01af6

Browse files
committed
Add kotlinx.serialization JSON support to Spring Messaging
Closes gh-25883
1 parent f329748 commit 3f01af6

File tree

5 files changed

+333
-1
lines changed

5 files changed

+333
-1
lines changed

spring-messaging/spring-messaging.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
description = "Spring Messaging"
22

33
apply plugin: "kotlin"
4+
apply plugin: "kotlinx-serialization"
45

56
dependencies {
67
compile(project(":spring-beans"))
@@ -17,6 +18,7 @@ dependencies {
1718
optional("javax.xml.bind:jaxb-api")
1819
optional("com.google.protobuf:protobuf-java-util")
1920
optional("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
21+
optional("org.jetbrains.kotlinx:kotlinx-serialization-json")
2022
testCompile(project(":kotlin-coroutines"))
2123
testCompile(testFixtures(project(":spring-core")))
2224
testCompile("javax.inject:javax.inject-tck")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/*
2+
* Copyright 2002-2020 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.messaging.converter;
18+
19+
import java.io.IOException;
20+
import java.io.Reader;
21+
import java.io.Writer;
22+
import java.lang.reflect.Type;
23+
import java.util.Map;
24+
25+
import kotlinx.serialization.KSerializer;
26+
import kotlinx.serialization.SerializersKt;
27+
import kotlinx.serialization.json.Json;
28+
29+
import org.springframework.util.ConcurrentReferenceHashMap;
30+
import org.springframework.util.FileCopyUtils;
31+
32+
/**
33+
* Implementation of {@link MessageConverter} that can read and write JSON
34+
* using <a href="https://github.com/Kotlin/kotlinx.serialization">kotlinx.serialization</a>.
35+
*
36+
* <p>This converter can be used to bind {@code @Serializable} Kotlin classes.
37+
*
38+
* @author Sebastien Deleuze
39+
* @since 5.3
40+
*/
41+
public class KotlinSerializationJsonMessageConverter extends AbstractJsonMessageConverter {
42+
43+
private static final Map<Type, KSerializer<Object>> serializerCache = new ConcurrentReferenceHashMap<>();
44+
45+
private final Json json;
46+
47+
48+
/**
49+
* Construct a new {@code KotlinSerializationJsonMessageConverter} with default configuration.
50+
*/
51+
public KotlinSerializationJsonMessageConverter() {
52+
this(Json.Default);
53+
}
54+
55+
/**
56+
* Construct a new {@code KotlinSerializationJsonMessageConverter} with the given delegate.
57+
* @param json the Json instance to use
58+
*/
59+
public KotlinSerializationJsonMessageConverter(Json json) {
60+
this.json = json;
61+
}
62+
63+
@Override
64+
protected Object fromJson(Reader reader, Type resolvedType) {
65+
try {
66+
return fromJson(FileCopyUtils.copyToString(reader), resolvedType);
67+
}
68+
catch (IOException ex) {
69+
throw new MessageConversionException("Could not read JSON: " + ex.getMessage(), ex);
70+
}
71+
}
72+
73+
@Override
74+
protected Object fromJson(String payload, Type resolvedType) {
75+
return this.json.decodeFromString(serializer(resolvedType), payload);
76+
}
77+
78+
@Override
79+
protected void toJson(Object payload, Type resolvedType, Writer writer) {
80+
try {
81+
writer.write(toJson(payload, resolvedType).toCharArray());
82+
}
83+
catch (IOException ex) {
84+
throw new MessageConversionException("Could not write JSON: " + ex.getMessage(), ex);
85+
}
86+
}
87+
88+
@Override
89+
protected String toJson(Object payload, Type resolvedType) {
90+
return this.json.encodeToString(serializer(resolvedType), payload);
91+
}
92+
93+
/**
94+
* Tries to find a serializer that can marshall or unmarshall instances of the given type
95+
* using kotlinx.serialization. If no serializer can be found, an exception is thrown.
96+
* <p>Resolved serializers are cached and cached results are returned on successive calls.
97+
* @param type the type to find a serializer for
98+
* @return a resolved serializer for the given type
99+
* @throws RuntimeException if no serializer supporting the given type can be found
100+
*/
101+
private KSerializer<Object> serializer(Type type) {
102+
KSerializer<Object> serializer = serializerCache.get(type);
103+
if (serializer == null) {
104+
serializer = SerializersKt.serializer(type);
105+
serializerCache.put(type, serializer);
106+
}
107+
return serializer;
108+
}
109+
}

spring-messaging/src/main/java/org/springframework/messaging/simp/config/AbstractMessageBrokerConfiguration.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import org.springframework.messaging.converter.DefaultContentTypeResolver;
3737
import org.springframework.messaging.converter.GsonMessageConverter;
3838
import org.springframework.messaging.converter.JsonbMessageConverter;
39+
import org.springframework.messaging.converter.KotlinSerializationJsonMessageConverter;
3940
import org.springframework.messaging.converter.MappingJackson2MessageConverter;
4041
import org.springframework.messaging.converter.MessageConverter;
4142
import org.springframework.messaging.converter.StringMessageConverter;
@@ -101,13 +102,16 @@ public abstract class AbstractMessageBrokerConfiguration implements ApplicationC
101102

102103
private static final boolean jsonbPresent;
103104

105+
private static final boolean kotlinSerializationJsonPresent;
106+
104107

105108
static {
106109
ClassLoader classLoader = AbstractMessageBrokerConfiguration.class.getClassLoader();
107110
jackson2Present = ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", classLoader) &&
108111
ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", classLoader);
109112
gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", classLoader);
110113
jsonbPresent = ClassUtils.isPresent("javax.json.bind.Jsonb", classLoader);
114+
kotlinSerializationJsonPresent = ClassUtils.isPresent("kotlinx.serialization.json.Json", classLoader);
111115
}
112116

113117

@@ -411,6 +415,9 @@ else if (gsonPresent) {
411415
else if (jsonbPresent) {
412416
converters.add(new JsonbMessageConverter());
413417
}
418+
else if (kotlinSerializationJsonPresent) {
419+
converters.add(new KotlinSerializationJsonMessageConverter());
420+
}
414421
}
415422
return new CompositeMessageConverter(converters);
416423
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
/*
2+
* Copyright 2002-2019 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.messaging.converter
18+
19+
import kotlinx.serialization.Serializable
20+
import org.assertj.core.api.Assertions
21+
import org.junit.jupiter.api.Test
22+
import org.springframework.core.MethodParameter
23+
import org.springframework.messaging.support.MessageBuilder
24+
import java.nio.charset.StandardCharsets
25+
import kotlin.reflect.typeOf
26+
27+
@Suppress("UsePropertyAccessSyntax")
28+
class KotlinSerializationJsonMessageConverterTests {
29+
30+
private val converter = KotlinSerializationJsonMessageConverter()
31+
32+
@Test
33+
fun readObject() {
34+
val payload = """
35+
{
36+
"bytes": [
37+
1,
38+
2
39+
],
40+
"array": [
41+
"Foo",
42+
"Bar"
43+
],
44+
"number": 42,
45+
"string": "Foo",
46+
"bool": true,
47+
"fraction": 42
48+
}
49+
""".trimIndent()
50+
val message = MessageBuilder.withPayload(payload.toByteArray(StandardCharsets.UTF_8)).build()
51+
val result = converter.fromMessage(message, SerializableBean::class.java) as SerializableBean
52+
53+
Assertions.assertThat(result.bytes).containsExactly(0x1, 0x2)
54+
Assertions.assertThat(result.array).containsExactly("Foo", "Bar")
55+
Assertions.assertThat(result.number).isEqualTo(42)
56+
Assertions.assertThat(result.string).isEqualTo("Foo")
57+
Assertions.assertThat(result.bool).isTrue()
58+
Assertions.assertThat(result.fraction).isEqualTo(42.0f)
59+
}
60+
61+
@Test
62+
@Suppress("UNCHECKED_CAST")
63+
fun readArrayOfObjects() {
64+
val payload = """
65+
[
66+
{
67+
"bytes": [
68+
1,
69+
2
70+
],
71+
"array": [
72+
"Foo",
73+
"Bar"
74+
],
75+
"number": 42,
76+
"string": "Foo",
77+
"bool": true,
78+
"fraction": 42
79+
}
80+
]
81+
""".trimIndent()
82+
val message = MessageBuilder.withPayload(payload.toByteArray(StandardCharsets.UTF_8)).build()
83+
val result = converter.fromMessage(message, Array<SerializableBean>::class.java) as Array<SerializableBean>
84+
85+
Assertions.assertThat(result).hasSize(1)
86+
Assertions.assertThat(result[0].bytes).containsExactly(0x1, 0x2)
87+
Assertions.assertThat(result[0].array).containsExactly("Foo", "Bar")
88+
Assertions.assertThat(result[0].number).isEqualTo(42)
89+
Assertions.assertThat(result[0].string).isEqualTo("Foo")
90+
Assertions.assertThat(result[0].bool).isTrue()
91+
Assertions.assertThat(result[0].fraction).isEqualTo(42.0f)
92+
}
93+
94+
@Test
95+
@Suppress("UNCHECKED_CAST")
96+
@ExperimentalStdlibApi
97+
fun readGenericCollection() {
98+
val payload = """
99+
[
100+
{
101+
"bytes": [
102+
1,
103+
2
104+
],
105+
"array": [
106+
"Foo",
107+
"Bar"
108+
],
109+
"number": 42,
110+
"string": "Foo",
111+
"bool": true,
112+
"fraction": 42
113+
}
114+
]
115+
""".trimIndent()
116+
val method = javaClass.getDeclaredMethod("handleList", List::class.java)
117+
val param = MethodParameter(method, 0)
118+
val message = MessageBuilder.withPayload(payload.toByteArray(StandardCharsets.UTF_8)).build()
119+
val result = converter.fromMessage(message, typeOf<List<SerializableBean>>()::class.java, param) as List<SerializableBean>
120+
121+
Assertions.assertThat(result).hasSize(1)
122+
Assertions.assertThat(result[0].bytes).containsExactly(0x1, 0x2)
123+
Assertions.assertThat(result[0].array).containsExactly("Foo", "Bar")
124+
Assertions.assertThat(result[0].number).isEqualTo(42)
125+
Assertions.assertThat(result[0].string).isEqualTo("Foo")
126+
Assertions.assertThat(result[0].bool).isTrue()
127+
Assertions.assertThat(result[0].fraction).isEqualTo(42.0f)
128+
}
129+
130+
@Test
131+
fun readFailsOnInvalidJson() {
132+
val payload = """
133+
this is an invalid JSON document
134+
""".trimIndent()
135+
136+
val message = MessageBuilder.withPayload(payload.toByteArray(StandardCharsets.UTF_8)).build()
137+
Assertions.assertThatExceptionOfType(MessageConversionException::class.java).isThrownBy {
138+
converter.fromMessage(message, SerializableBean::class.java)
139+
}
140+
}
141+
142+
@Test
143+
fun writeObject() {
144+
val serializableBean = SerializableBean(byteArrayOf(0x1, 0x2), arrayOf("Foo", "Bar"), 42, "Foo", true, 42.0f)
145+
val message = converter.toMessage(serializableBean, null)
146+
val result = String((message!!.payload as ByteArray), StandardCharsets.UTF_8)
147+
148+
Assertions.assertThat(result)
149+
.contains("\"bytes\":[1,2]")
150+
.contains("\"array\":[\"Foo\",\"Bar\"]")
151+
.contains("\"number\":42")
152+
.contains("\"string\":\"Foo\"")
153+
.contains("\"bool\":true")
154+
.contains("\"fraction\":42.0")
155+
}
156+
157+
@Test
158+
fun writeObjectWithNullableProperty() {
159+
val serializableBean = SerializableBean(byteArrayOf(0x1, 0x2), arrayOf("Foo", "Bar"), 42, null, true, 42.0f)
160+
val message = converter.toMessage(serializableBean, null)
161+
val result = String((message!!.payload as ByteArray), StandardCharsets.UTF_8)
162+
163+
Assertions.assertThat(result)
164+
.contains("\"bytes\":[1,2]")
165+
.contains("\"array\":[\"Foo\",\"Bar\"]")
166+
.contains("\"number\":42")
167+
.contains("\"string\":null")
168+
.contains("\"bool\":true")
169+
.contains("\"fraction\":42.0")
170+
}
171+
172+
@Test
173+
fun writeArrayOfObjects() {
174+
val serializableBean = SerializableBean(byteArrayOf(0x1, 0x2), arrayOf("Foo", "Bar"), 42, "Foo", true, 42.0f)
175+
val expectedJson = """
176+
[{"bytes":[1,2],"array":["Foo","Bar"],"number":42,"string":"Foo","bool":true,"fraction":42.0}]
177+
""".trimIndent()
178+
179+
val message = converter.toMessage(arrayOf(serializableBean), null)
180+
val result = String((message!!.payload as ByteArray), StandardCharsets.UTF_8)
181+
182+
Assertions.assertThat(result).isEqualTo(expectedJson)
183+
}
184+
185+
@Test
186+
@ExperimentalStdlibApi
187+
fun writeGenericCollection() {
188+
val serializableBean = SerializableBean(byteArrayOf(0x1, 0x2), arrayOf("Foo", "Bar"), 42, "Foo", true, 42.0f)
189+
val expectedJson = """
190+
[{"bytes":[1,2],"array":["Foo","Bar"],"number":42,"string":"Foo","bool":true,"fraction":42.0}]
191+
""".trimIndent()
192+
193+
val method = javaClass.getDeclaredMethod("handleList", List::class.java)
194+
val param = MethodParameter(method, 0)
195+
val message = converter.toMessage(arrayListOf(serializableBean), null, param)
196+
val result = String((message!!.payload as ByteArray), StandardCharsets.UTF_8)
197+
198+
Assertions.assertThat(result).isEqualTo(expectedJson)
199+
}
200+
201+
@Suppress("UNUSED_PARAMETER")
202+
fun handleList(payload: List<SerializableBean>) {}
203+
204+
@Serializable
205+
@Suppress("ArrayInDataClass")
206+
data class SerializableBean(
207+
val bytes: ByteArray,
208+
val array: Array<String>,
209+
val number: Int,
210+
val string: String?,
211+
val bool: Boolean,
212+
val fraction: Float
213+
)
214+
}

src/docs/asciidoc/languages/kotlin.adoc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,7 @@ project for more details.
389389
=== Kotlin multiplatform serialization
390390

391391
As of Spring Framework 5.3, https://github.com/Kotlin/kotlinx.serialization[Kotlin multiplatform serialization] is
392-
supported in Spring MVC and Spring WebFlux. The builtin support currently only targets JSON format.
392+
supported in Spring MVC, Spring WebFlux and Spring Messaging. The builtin support currently only targets JSON format.
393393

394394
To enable it, follow https://github.com/Kotlin/kotlinx.serialization#setup[those instructions] and make sure neither
395395
Jackson, GSON or JSONB are in the classpath.

0 commit comments

Comments
 (0)