Skip to content

Commit cd6085a

Browse files
aahlenstsdeleuze
andcommitted
Add kotlinx.serialization JSON support to Spring MVC
Closes gh-21188 Co-authored-by: Sebastien Deleuze <[email protected]>
1 parent b8c12a3 commit cd6085a

File tree

8 files changed

+495
-1
lines changed

8 files changed

+495
-1
lines changed

Diff for: build.gradle

+4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ plugins {
88
id "io.freefair.aspectj" version '5.1.1' apply false
99
id "com.github.ben-manes.versions" version '0.28.0'
1010
id "me.champeau.gradle.jmh" version "0.5.0" apply false
11+
id "org.jetbrains.kotlin.plugin.serialization" version "1.4.0" apply false
1112
}
1213

1314
ext {
@@ -87,6 +88,9 @@ configure(allprojects) { project ->
8788
}
8889
dependency "org.ogce:xpp3:1.1.6"
8990
dependency "org.yaml:snakeyaml:1.26"
91+
dependencySet(group: 'org.jetbrains.kotlinx', version: '1.0.0-RC') {
92+
entry 'kotlinx-serialization-core'
93+
}
9094

9195
dependency "com.h2database:h2:1.4.200"
9296
dependency "com.github.ben-manes.caffeine:caffeine:2.8.5"

Diff for: spring-web/spring-web.gradle

+2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
description = "Spring Web"
22

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

56
dependencies {
67
compile(project(":spring-beans"))
@@ -54,6 +55,7 @@ dependencies {
5455
optional("org.codehaus.groovy:groovy")
5556
optional("org.jetbrains.kotlin:kotlin-reflect")
5657
optional("org.jetbrains.kotlin:kotlin-stdlib")
58+
optional("org.jetbrains.kotlinx:kotlinx-serialization-core")
5759
testCompile(testFixtures(project(":spring-beans")))
5860
testCompile(testFixtures(project(":spring-context")))
5961
testCompile(testFixtures(project(":spring-core")))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
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.http.converter.json;
18+
19+
import java.io.IOException;
20+
import java.lang.reflect.Type;
21+
import java.nio.charset.Charset;
22+
import java.nio.charset.StandardCharsets;
23+
import java.util.Map;
24+
25+
import kotlinx.serialization.KSerializer;
26+
import kotlinx.serialization.SerializationException;
27+
import kotlinx.serialization.SerializersKt;
28+
import kotlinx.serialization.json.Json;
29+
30+
import org.springframework.http.HttpInputMessage;
31+
import org.springframework.http.HttpOutputMessage;
32+
import org.springframework.http.MediaType;
33+
import org.springframework.http.converter.AbstractGenericHttpMessageConverter;
34+
import org.springframework.http.converter.HttpMessageNotReadableException;
35+
import org.springframework.http.converter.HttpMessageNotWritableException;
36+
import org.springframework.lang.Nullable;
37+
import org.springframework.util.ConcurrentReferenceHashMap;
38+
import org.springframework.util.StreamUtils;
39+
40+
/**
41+
* Implementation of {@link org.springframework.http.converter.HttpMessageConverter} that can read and write JSON using
42+
* <a href="https://github.com/Kotlin/kotlinx.serialization">kotlinx.serialization</a>.
43+
*
44+
* <p>This converter can be used to bind {@code @Serializable} Kotlin classes. It supports {@code application/json} and
45+
* {@code application/*+json} with various character sets, {@code UTF-8} being the default.
46+
*
47+
* @author Andreas Ahlenstorf
48+
* @author Sebastien Deleuze
49+
* @since 5.3
50+
*/
51+
public class KotlinSerializationJsonHttpMessageConverter extends AbstractGenericHttpMessageConverter<Object> {
52+
53+
private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
54+
55+
private static final Map<Type, KSerializer<Object>> serializerCache = new ConcurrentReferenceHashMap<>();
56+
57+
private final Json json;
58+
59+
/**
60+
* Construct a new {@code KotlinSerializationJsonHttpMessageConverter} with the default configuration.
61+
*/
62+
public KotlinSerializationJsonHttpMessageConverter() {
63+
this(Json.Default);
64+
}
65+
66+
/**
67+
* Construct a new {@code KotlinSerializationJsonHttpMessageConverter} with a custom configuration.
68+
*/
69+
public KotlinSerializationJsonHttpMessageConverter(Json json) {
70+
super(MediaType.APPLICATION_JSON, new MediaType("application", "*+json"));
71+
this.json = json;
72+
}
73+
74+
@Override
75+
protected boolean supports(Class<?> clazz) {
76+
try {
77+
resolve(clazz);
78+
return true;
79+
}
80+
catch (Exception ex) {
81+
return false;
82+
}
83+
}
84+
85+
@Override
86+
protected Object readInternal(Class<?> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
87+
return this.read(clazz, null, inputMessage);
88+
}
89+
90+
@Override
91+
public Object read(Type type, @Nullable Class<?> contextClass, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
92+
MediaType contentType = inputMessage.getHeaders().getContentType();
93+
String jsonText = StreamUtils.copyToString(inputMessage.getBody(), getCharsetToUse(contentType));
94+
try {
95+
// TODO Use stream based API when available
96+
return this.json.decodeFromString(resolve(type), jsonText);
97+
}
98+
catch (SerializationException ex) {
99+
throw new HttpMessageNotReadableException("Could not read JSON: " + ex.getMessage(), ex, inputMessage);
100+
}
101+
}
102+
103+
@Override
104+
protected void writeInternal(Object o, HttpOutputMessage outputMessage) throws HttpMessageNotWritableException {
105+
try {
106+
this.writeInternal(o, o.getClass(), outputMessage);
107+
}
108+
catch (IOException ex) {
109+
throw new HttpMessageNotWritableException("Could not write JSON: " + ex.getMessage(), ex);
110+
}
111+
}
112+
113+
@Override
114+
protected void writeInternal(Object o, @Nullable Type type, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {
115+
try {
116+
String json = this.json.encodeToString(resolve(type), o);
117+
MediaType contentType = outputMessage.getHeaders().getContentType();
118+
outputMessage.getBody().write(json.getBytes(getCharsetToUse(contentType)));
119+
outputMessage.getBody().flush();
120+
}
121+
catch (IOException ex) {
122+
throw ex;
123+
}
124+
catch (Exception ex) {
125+
throw new HttpMessageNotWritableException("Could not write JSON: " + ex.getMessage(), ex);
126+
}
127+
}
128+
129+
private Charset getCharsetToUse(@Nullable MediaType contentType) {
130+
if (contentType != null && contentType.getCharset() != null) {
131+
return contentType.getCharset();
132+
}
133+
return DEFAULT_CHARSET;
134+
}
135+
136+
/**
137+
* Tries to find a serializer that can marshall or unmarshall instances of the given type using
138+
* kotlinx.serialization. If no serializer can be found, an exception is thrown.
139+
* <p>
140+
* Resolved serializers are cached and cached results are returned on successive calls.
141+
*
142+
* @param type to find a serializer for.
143+
* @return resolved serializer for the given type.
144+
* @throws RuntimeException if no serializer supporting the given type can be found.
145+
*/
146+
private KSerializer<Object> resolve(Type type) {
147+
KSerializer<Object> serializer = serializerCache.get(type);
148+
if (serializer == null) {
149+
serializer = SerializersKt.serializer(type);
150+
serializerCache.put(type, serializer);
151+
}
152+
return serializer;
153+
}
154+
}

Diff for: spring-web/src/main/java/org/springframework/http/converter/support/AllEncompassingFormHttpMessageConverter.java

+7
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import org.springframework.http.converter.FormHttpMessageConverter;
2121
import org.springframework.http.converter.json.GsonHttpMessageConverter;
2222
import org.springframework.http.converter.json.JsonbHttpMessageConverter;
23+
import org.springframework.http.converter.json.KotlinSerializationJsonHttpMessageConverter;
2324
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
2425
import org.springframework.http.converter.smile.MappingJackson2SmileHttpMessageConverter;
2526
import org.springframework.http.converter.xml.Jaxb2RootElementHttpMessageConverter;
@@ -57,6 +58,8 @@ public class AllEncompassingFormHttpMessageConverter extends FormHttpMessageConv
5758

5859
private static final boolean jsonbPresent;
5960

61+
private static final boolean kotlinSerializationJsonPresent;
62+
6063
static {
6164
ClassLoader classLoader = AllEncompassingFormHttpMessageConverter.class.getClassLoader();
6265
jaxb2Present = ClassUtils.isPresent("javax.xml.bind.Binder", classLoader);
@@ -66,6 +69,7 @@ public class AllEncompassingFormHttpMessageConverter extends FormHttpMessageConv
6669
jackson2SmilePresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.smile.SmileFactory", classLoader);
6770
gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", classLoader);
6871
jsonbPresent = ClassUtils.isPresent("javax.json.bind.Jsonb", classLoader);
72+
kotlinSerializationJsonPresent = ClassUtils.isPresent("kotlinx.serialization.json.Json", classLoader);
6973
}
7074

7175

@@ -92,6 +96,9 @@ else if (gsonPresent) {
9296
else if (jsonbPresent) {
9397
addPartConverter(new JsonbHttpMessageConverter());
9498
}
99+
else if (kotlinSerializationJsonPresent) {
100+
addPartConverter(new KotlinSerializationJsonHttpMessageConverter());
101+
}
95102

96103
if (jackson2XmlPresent && !shouldIgnoreXml) {
97104
addPartConverter(new MappingJackson2XmlHttpMessageConverter());

Diff for: spring-web/src/main/java/org/springframework/web/client/RestTemplate.java

+7
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
import org.springframework.http.converter.feed.RssChannelHttpMessageConverter;
5151
import org.springframework.http.converter.json.GsonHttpMessageConverter;
5252
import org.springframework.http.converter.json.JsonbHttpMessageConverter;
53+
import org.springframework.http.converter.json.KotlinSerializationJsonHttpMessageConverter;
5354
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
5455
import org.springframework.http.converter.smile.MappingJackson2SmileHttpMessageConverter;
5556
import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter;
@@ -114,6 +115,8 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat
114115

115116
private static final boolean jsonbPresent;
116117

118+
private static final boolean kotlinSerializationJsonPresent;
119+
117120
static {
118121
ClassLoader classLoader = RestTemplate.class.getClassLoader();
119122
romePresent = ClassUtils.isPresent("com.rometools.rome.feed.WireFeed", classLoader);
@@ -126,6 +129,7 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat
126129
jackson2CborPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.cbor.CBORFactory", classLoader);
127130
gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", classLoader);
128131
jsonbPresent = ClassUtils.isPresent("javax.json.bind.Jsonb", classLoader);
132+
kotlinSerializationJsonPresent = ClassUtils.isPresent("kotlinx.serialization.json.Json", classLoader);
129133
}
130134

131135

@@ -179,6 +183,9 @@ else if (gsonPresent) {
179183
else if (jsonbPresent) {
180184
this.messageConverters.add(new JsonbHttpMessageConverter());
181185
}
186+
else if (kotlinSerializationJsonPresent) {
187+
this.messageConverters.add(new KotlinSerializationJsonHttpMessageConverter());
188+
}
182189

183190
if (jackson2SmilePresent) {
184191
this.messageConverters.add(new MappingJackson2SmileHttpMessageConverter());

0 commit comments

Comments
 (0)