Skip to content

Commit 826041d

Browse files
committed
Add Kotlin body advices
This commit introduces KotlinRequestBodyAdvice and KotlinResponseBodyAdvice in order to set a KType hint when relevant. Closes gh-34923
1 parent 9f7a321 commit 826041d

File tree

7 files changed

+252
-49
lines changed

7 files changed

+252
-49
lines changed

spring-web/src/main/java/org/springframework/http/converter/AbstractKotlinSerializationHttpMessageConverter.java

Lines changed: 18 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -17,27 +17,20 @@
1717
package org.springframework.http.converter;
1818

1919
import java.io.IOException;
20-
import java.lang.reflect.Method;
2120
import java.lang.reflect.Type;
2221
import java.util.List;
2322
import java.util.Map;
2423

25-
import kotlin.reflect.KFunction;
2624
import kotlin.reflect.KType;
27-
import kotlin.reflect.full.KCallables;
28-
import kotlin.reflect.jvm.ReflectJvmMapping;
2925
import kotlinx.serialization.KSerializer;
3026
import kotlinx.serialization.SerialFormat;
3127
import kotlinx.serialization.SerializersKt;
3228
import org.jspecify.annotations.Nullable;
3329

34-
import org.springframework.core.KotlinDetector;
35-
import org.springframework.core.MethodParameter;
3630
import org.springframework.core.ResolvableType;
3731
import org.springframework.http.HttpInputMessage;
3832
import org.springframework.http.HttpOutputMessage;
3933
import org.springframework.http.MediaType;
40-
import org.springframework.util.Assert;
4134
import org.springframework.util.ConcurrentReferenceHashMap;
4235

4336

@@ -84,12 +77,12 @@ public List<MediaType> getSupportedMediaTypes(Class<?> clazz) {
8477

8578
@Override
8679
protected boolean supports(Class<?> clazz) {
87-
return serializer(ResolvableType.forClass(clazz)) != null;
80+
return serializer(ResolvableType.forClass(clazz), null) != null;
8881
}
8982

9083
@Override
9184
public boolean canRead(ResolvableType type, @Nullable MediaType mediaType) {
92-
if (!ResolvableType.NONE.equals(type) && serializer(type) != null) {
85+
if (!ResolvableType.NONE.equals(type) && serializer(type, null) != null) {
9386
return canRead(mediaType);
9487
}
9588
else {
@@ -99,7 +92,7 @@ public boolean canRead(ResolvableType type, @Nullable MediaType mediaType) {
9992

10093
@Override
10194
public boolean canWrite(ResolvableType type, Class<?> clazz, @Nullable MediaType mediaType) {
102-
if (!ResolvableType.NONE.equals(type) && serializer(type) != null) {
95+
if (!ResolvableType.NONE.equals(type) && serializer(type, null) != null) {
10396
return canWrite(mediaType);
10497
}
10598
else {
@@ -111,7 +104,7 @@ public boolean canWrite(ResolvableType type, Class<?> clazz, @Nullable MediaType
111104
public final Object read(ResolvableType type, HttpInputMessage inputMessage, @Nullable Map<String, Object> hints)
112105
throws IOException, HttpMessageNotReadableException {
113106

114-
KSerializer<Object> serializer = serializer(type);
107+
KSerializer<Object> serializer = serializer(type, hints);
115108
if (serializer == null) {
116109
throw new HttpMessageNotReadableException("Could not find KSerializer for " + type, inputMessage);
117110
}
@@ -129,7 +122,7 @@ protected final void writeInternal(Object object, ResolvableType type, HttpOutpu
129122
@Nullable Map<String, Object> hints) throws IOException, HttpMessageNotWritableException {
130123

131124
ResolvableType resolvableType = (ResolvableType.NONE.equals(type) ? ResolvableType.forInstance(object) : type);
132-
KSerializer<Object> serializer = serializer(resolvableType);
125+
KSerializer<Object> serializer = serializer(resolvableType, hints);
133126
if (serializer == null) {
134127
throw new HttpMessageNotWritableException("Could not find KSerializer for " + resolvableType);
135128
}
@@ -149,29 +142,21 @@ protected abstract void writeInternal(Object object, KSerializer<Object> seriali
149142
* @param resolvableType the type to find a serializer for
150143
* @return a resolved serializer for the given type, or {@code null}
151144
*/
152-
private @Nullable KSerializer<Object> serializer(ResolvableType resolvableType) {
153-
if (resolvableType.getSource() instanceof MethodParameter parameter) {
154-
Method method = parameter.getMethod();
155-
Assert.notNull(method, "Method must not be null");
156-
if (KotlinDetector.isKotlinType(method.getDeclaringClass())) {
157-
KFunction<?> function = ReflectJvmMapping.getKotlinFunction(method);
158-
if (function != null) {
159-
KType type = (parameter.getParameterIndex() == -1 ? function.getReturnType() :
160-
KCallables.getValueParameters(function).get(parameter.getParameterIndex()).getType());
161-
KSerializer<Object> serializer = this.kTypeSerializerCache.get(type);
162-
if (serializer == null) {
163-
try {
164-
serializer = SerializersKt.serializerOrNull(this.format.getSerializersModule(), type);
165-
}
166-
catch (IllegalArgumentException ignored) {
167-
}
168-
if (serializer != null) {
169-
this.kTypeSerializerCache.put(type, serializer);
170-
}
171-
}
172-
return serializer;
145+
private @Nullable KSerializer<Object> serializer(ResolvableType resolvableType, @Nullable Map<String, Object> hints) {
146+
if (hints != null && hints.containsKey(KType.class.getName())) {
147+
KType type = (KType) hints.get(KType.class.getName());
148+
KSerializer<Object> serializer = this.kTypeSerializerCache.get(type);
149+
if (serializer == null) {
150+
try {
151+
serializer = SerializersKt.serializerOrNull(this.format.getSerializersModule(), type);
152+
}
153+
catch (IllegalArgumentException ignored) {
154+
}
155+
if (serializer != null) {
156+
this.kTypeSerializerCache.put(type, serializer);
173157
}
174158
}
159+
return serializer;
175160
}
176161
Type type = resolvableType.getType();
177162
KSerializer<Object> serializer = this.typeSerializerCache.get(type);

spring-web/src/test/kotlin/org/springframework/http/converter/json/KotlinSerializationJsonHttpMessageConverterTests.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@ import org.springframework.web.testfixture.http.MockHttpOutputMessage
3333
import java.lang.reflect.ParameterizedType
3434
import java.math.BigDecimal
3535
import java.nio.charset.StandardCharsets
36+
import kotlin.reflect.KType
3637
import kotlin.reflect.javaType
3738
import kotlin.reflect.jvm.javaMethod
39+
import kotlin.reflect.jvm.jvmName
3840
import kotlin.reflect.typeOf
3941

4042
/**
@@ -246,7 +248,10 @@ class KotlinSerializationJsonHttpMessageConverterTests {
246248
val inputMessage = MockHttpInputMessage(body.toByteArray(StandardCharsets.UTF_8))
247249
inputMessage.headers.contentType = MediaType.APPLICATION_JSON
248250
val methodParameter = MethodParameter.forExecutable(::handleMapWithNullable::javaMethod.get()!!, 0)
249-
val result = converter.read(ResolvableType.forMethodParameter(methodParameter), inputMessage, null) as Map<String, String?>
251+
val hints = mapOf(KType::class.jvmName to typeOf<Map<String, String?>>())
252+
253+
val result = converter.read(ResolvableType.forMethodParameter(methodParameter), inputMessage,
254+
hints) as Map<String, String?>
250255

251256
assertThat(result).containsExactlyEntriesOf(mapOf("value" to null))
252257
}
@@ -400,9 +405,10 @@ class KotlinSerializationJsonHttpMessageConverterTests {
400405
val serializableBean = mapOf<String, String?>("value" to null)
401406
val expectedJson = """{"value":null}"""
402407
val methodParameter = MethodParameter.forExecutable(::handleMapWithNullable::javaMethod.get()!!, -1)
408+
val hints = mapOf(KType::class.jvmName to typeOf<Map<String, String?>>())
403409

404410
this.converter.write(serializableBean, ResolvableType.forMethodParameter(methodParameter), null,
405-
outputMessage, null)
411+
outputMessage, hints)
406412

407413
val result = outputMessage.getBodyAsString(StandardCharsets.UTF_8)
408414

spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
package org.springframework.web.servlet.config.annotation;
1818

1919
import java.util.ArrayList;
20-
import java.util.Collections;
2120
import java.util.HashMap;
2221
import java.util.List;
2322
import java.util.Locale;
@@ -106,8 +105,12 @@
106105
import org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver;
107106
import org.springframework.web.servlet.mvc.method.annotation.JsonViewRequestBodyAdvice;
108107
import org.springframework.web.servlet.mvc.method.annotation.JsonViewResponseBodyAdvice;
108+
import org.springframework.web.servlet.mvc.method.annotation.KotlinRequestBodyAdvice;
109+
import org.springframework.web.servlet.mvc.method.annotation.KotlinResponseBodyAdvice;
110+
import org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdvice;
109111
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter;
110112
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
113+
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
111114
import org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver;
112115
import org.springframework.web.servlet.resource.ResourceUrlProvider;
113116
import org.springframework.web.servlet.resource.ResourceUrlProviderExposingInterceptor;
@@ -225,6 +228,8 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv
225228

226229
private static final boolean jsonbPresent;
227230

231+
private static final boolean kotlinSerializationPresent;
232+
228233
private static final boolean kotlinSerializationCborPresent;
229234

230235
private static final boolean kotlinSerializationJsonPresent;
@@ -248,9 +253,10 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv
248253
jackson2YamlPresent = jackson2Present && ClassUtils.isPresent("com.fasterxml.jackson.dataformat.yaml.YAMLFactory", classLoader);
249254
gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", classLoader);
250255
jsonbPresent = ClassUtils.isPresent("jakarta.json.bind.Jsonb", classLoader);
251-
kotlinSerializationCborPresent = ClassUtils.isPresent("kotlinx.serialization.cbor.Cbor", classLoader);
252-
kotlinSerializationJsonPresent = ClassUtils.isPresent("kotlinx.serialization.json.Json", classLoader);
253-
kotlinSerializationProtobufPresent = ClassUtils.isPresent("kotlinx.serialization.protobuf.ProtoBuf", classLoader);
256+
kotlinSerializationPresent = ClassUtils.isPresent("kotlinx.serialization.Serializable", classLoader);
257+
kotlinSerializationCborPresent = kotlinSerializationPresent && ClassUtils.isPresent("kotlinx.serialization.cbor.Cbor", classLoader);
258+
kotlinSerializationJsonPresent = kotlinSerializationPresent && ClassUtils.isPresent("kotlinx.serialization.json.Json", classLoader);
259+
kotlinSerializationProtobufPresent = kotlinSerializationPresent && ClassUtils.isPresent("kotlinx.serialization.protobuf.ProtoBuf", classLoader);
254260
}
255261

256262

@@ -699,9 +705,19 @@ public RequestMappingHandlerAdapter requestMappingHandlerAdapter(
699705
adapter.setCustomReturnValueHandlers(getReturnValueHandlers());
700706
adapter.setErrorResponseInterceptors(getErrorResponseInterceptors());
701707

702-
if (jacksonPresent || jackson2Present) {
703-
adapter.setRequestBodyAdvice(Collections.singletonList(new JsonViewRequestBodyAdvice()));
704-
adapter.setResponseBodyAdvice(Collections.singletonList(new JsonViewResponseBodyAdvice()));
708+
if (jacksonPresent || jackson2Present || kotlinSerializationPresent) {
709+
List<RequestBodyAdvice> requestBodyAdvices = new ArrayList<>(2);
710+
List<ResponseBodyAdvice<?>> responseBodyAdvices = new ArrayList<>(2);
711+
if (jacksonPresent || jackson2Present) {
712+
requestBodyAdvices.add(new JsonViewRequestBodyAdvice());
713+
responseBodyAdvices.add(new JsonViewResponseBodyAdvice());
714+
}
715+
if (kotlinSerializationPresent) {
716+
requestBodyAdvices.add(new KotlinRequestBodyAdvice());
717+
responseBodyAdvices.add(new KotlinResponseBodyAdvice());
718+
}
719+
adapter.setRequestBodyAdvice(requestBodyAdvices);
720+
adapter.setResponseBodyAdvice(responseBodyAdvices);
705721
}
706722

707723
AsyncSupportConfigurer configurer = getAsyncSupportConfigurer();
@@ -1122,9 +1138,15 @@ protected final void addDefaultHandlerExceptionResolvers(List<HandlerExceptionRe
11221138
exceptionHandlerResolver.setCustomArgumentResolvers(getArgumentResolvers());
11231139
exceptionHandlerResolver.setCustomReturnValueHandlers(getReturnValueHandlers());
11241140
exceptionHandlerResolver.setErrorResponseInterceptors(getErrorResponseInterceptors());
1125-
if (jacksonPresent || jackson2Present) {
1126-
exceptionHandlerResolver.setResponseBodyAdvice(
1127-
Collections.singletonList(new JsonViewResponseBodyAdvice()));
1141+
if (jacksonPresent || jackson2Present || kotlinSerializationPresent) {
1142+
List<ResponseBodyAdvice<?>> responseBodyAdvices = new ArrayList<>(2);
1143+
if (jacksonPresent || jackson2Present) {
1144+
responseBodyAdvices.add(new JsonViewResponseBodyAdvice());
1145+
}
1146+
if (kotlinSerializationPresent) {
1147+
responseBodyAdvices.add(new KotlinResponseBodyAdvice());
1148+
}
1149+
exceptionHandlerResolver.setResponseBodyAdvice(responseBodyAdvices);
11281150
}
11291151
if (this.applicationContext != null) {
11301152
exceptionHandlerResolver.setApplicationContext(this.applicationContext);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* Copyright 2002-2025 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.web.servlet.mvc.method.annotation;
18+
19+
import java.lang.reflect.Type;
20+
import java.util.Collections;
21+
import java.util.Map;
22+
import java.util.Objects;
23+
24+
import kotlin.reflect.KFunction;
25+
import kotlin.reflect.KParameter;
26+
import kotlin.reflect.KType;
27+
import kotlin.reflect.jvm.ReflectJvmMapping;
28+
import org.jspecify.annotations.Nullable;
29+
30+
import org.springframework.core.MethodParameter;
31+
import org.springframework.http.converter.AbstractKotlinSerializationHttpMessageConverter;
32+
import org.springframework.http.converter.HttpMessageConverter;
33+
import org.springframework.http.converter.SmartHttpMessageConverter;
34+
35+
/**
36+
* A {@link RequestBodyAdvice} implementation that adds support for resolving
37+
* Kotlin {@link KType} from the parameter and providing it as a hint with a
38+
* {@code "kotlin.reflect.KType"} key.
39+
*
40+
* @author Sebastien Deleuze
41+
* @since 7.0
42+
* @see AbstractKotlinSerializationHttpMessageConverter
43+
*/
44+
@SuppressWarnings("removal")
45+
public class KotlinRequestBodyAdvice extends RequestBodyAdviceAdapter {
46+
47+
@Override
48+
public boolean supports(MethodParameter methodParameter, Type targetType,
49+
Class<? extends HttpMessageConverter<?>> converterType) {
50+
51+
return AbstractKotlinSerializationHttpMessageConverter.class.isAssignableFrom(converterType);
52+
}
53+
54+
@Override
55+
public @Nullable Map<String, Object> determineReadHints(MethodParameter parameter, Type targetType,
56+
Class<? extends SmartHttpMessageConverter<?>> converterType) {
57+
58+
KFunction<?> function = ReflectJvmMapping.getKotlinFunction(Objects.requireNonNull(parameter.getMethod()));
59+
int i = 0;
60+
int index = parameter.getParameterIndex();
61+
for (KParameter p : Objects.requireNonNull(function).getParameters()) {
62+
if (KParameter.Kind.VALUE.equals(p.getKind())) {
63+
if (index == i++) {
64+
return Collections.singletonMap(KType.class.getName(), p.getType());
65+
}
66+
}
67+
}
68+
return null;
69+
}
70+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* Copyright 2002-2025 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.web.servlet.mvc.method.annotation;
18+
19+
import java.util.Collections;
20+
import java.util.Map;
21+
import java.util.Objects;
22+
23+
import kotlin.reflect.KFunction;
24+
import kotlin.reflect.KType;
25+
import kotlin.reflect.jvm.ReflectJvmMapping;
26+
import org.jspecify.annotations.Nullable;
27+
28+
import org.springframework.core.MethodParameter;
29+
import org.springframework.http.MediaType;
30+
import org.springframework.http.converter.AbstractKotlinSerializationHttpMessageConverter;
31+
import org.springframework.http.converter.HttpMessageConverter;
32+
import org.springframework.http.server.ServerHttpRequest;
33+
import org.springframework.http.server.ServerHttpResponse;
34+
35+
/**
36+
* A {@link ResponseBodyAdvice} implementation that adds support for resolving
37+
* Kotlin {@link KType} from the return type and providing it as a hint with a
38+
* {@code "kotlin.reflect.KType"} key.
39+
*
40+
* @author Sebastien Deleuze
41+
* @since 7.0
42+
*/
43+
@SuppressWarnings("removal")
44+
public class KotlinResponseBodyAdvice implements ResponseBodyAdvice<Object> {
45+
46+
@Override
47+
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
48+
return AbstractKotlinSerializationHttpMessageConverter.class.isAssignableFrom(converterType);
49+
}
50+
51+
@Override
52+
public @Nullable Object beforeBodyWrite(@Nullable Object body, MethodParameter returnType, MediaType selectedContentType,
53+
Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
54+
55+
return body;
56+
}
57+
58+
@Override
59+
public @Nullable Map<String, Object> determineWriteHints(@Nullable Object body, MethodParameter returnType,
60+
MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType) {
61+
62+
KFunction<?> function = ReflectJvmMapping.getKotlinFunction(Objects.requireNonNull(returnType.getMethod()));
63+
KType type = Objects.requireNonNull(function).getReturnType();
64+
return Collections.singletonMap(KType.class.getName(), type);
65+
}
66+
67+
}

0 commit comments

Comments
 (0)