From 546ae15a443795a47642ae5a851430d1f15aedbf Mon Sep 17 00:00:00 2001 From: Dmitry Sulman Date: Sat, 4 Apr 2026 14:16:43 +0300 Subject: [PATCH 1/4] Support JSON list deserialization in KotlinSerializationStringDecoder See gh-36597 Signed-off-by: Dmitry Sulman --- spring-test/spring-test.gradle | 2 + .../server/WebTestClientKotlinTests.kt | 37 +++++++++++++++++++ .../KotlinSerializationStringDecoder.java | 16 ++++---- .../KotlinSerializationJsonDecoderTests.kt | 14 +++++++ 4 files changed, 60 insertions(+), 9 deletions(-) create mode 100644 spring-test/src/test/kotlin/org/springframework/test/web/reactive/server/WebTestClientKotlinTests.kt diff --git a/spring-test/spring-test.gradle b/spring-test/spring-test.gradle index ce8e3c6bd3f..58284a65fd3 100644 --- a/spring-test/spring-test.gradle +++ b/spring-test/spring-test.gradle @@ -1,6 +1,7 @@ description = "Spring TestContext Framework" apply plugin: "kotlin" +apply plugin: "kotlinx-serialization" dependencies { api(project(":spring-core")) @@ -81,6 +82,7 @@ dependencies { testImplementation("org.hibernate.orm:hibernate-core") testImplementation("org.hibernate.validator:hibernate-validator") testImplementation("org.hsqldb:hsqldb") + testImplementation("org.jetbrains.kotlinx:kotlinx-serialization-json") testImplementation("org.junit.platform:junit-platform-testkit") testImplementation("tools.jackson.core:jackson-databind") testRuntimeOnly("com.sun.xml.bind:jaxb-core") diff --git a/spring-test/src/test/kotlin/org/springframework/test/web/reactive/server/WebTestClientKotlinTests.kt b/spring-test/src/test/kotlin/org/springframework/test/web/reactive/server/WebTestClientKotlinTests.kt new file mode 100644 index 00000000000..69353f358d1 --- /dev/null +++ b/spring-test/src/test/kotlin/org/springframework/test/web/reactive/server/WebTestClientKotlinTests.kt @@ -0,0 +1,37 @@ +package org.springframework.test.web.reactive.server + +import kotlinx.serialization.Serializable +import org.junit.jupiter.api.Test +import org.springframework.http.MediaType +import org.springframework.http.codec.json.KotlinSerializationJsonDecoder +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +class WebTestClientKotlinTests { + + @Test + fun expectBodyListKotlinSerialization() { + val client = WebTestClient.bindToController(TestController::class.java) + .configureClient() + .codecs { + it.registerDefaults(false) + it.customCodecs().register(KotlinSerializationJsonDecoder()) + }.build() + + client.get().uri("/test") + .accept(MediaType.APPLICATION_JSON) + .exchangeSuccessfully() + .expectBodyList() + .hasSize(2) + .contains(Response("Hello"), Response("World")) + } + + @Serializable + data class Response(val message: String) + + @RestController + class TestController { + @GetMapping("test") + fun test(): List = listOf(Response("Hello"), Response("World")) + } +} \ No newline at end of file diff --git a/spring-web/src/main/java/org/springframework/http/codec/KotlinSerializationStringDecoder.java b/spring-web/src/main/java/org/springframework/http/codec/KotlinSerializationStringDecoder.java index 2529a81997a..e234ca4c688 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/KotlinSerializationStringDecoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/KotlinSerializationStringDecoder.java @@ -116,23 +116,21 @@ public abstract class KotlinSerializationStringDecoder e } @Override + @SuppressWarnings("unchecked") public Flux decode(Publisher inputStream, ResolvableType elementType, @Nullable MimeType mimeType, @Nullable Map hints) { return Flux.defer(() -> { KSerializer serializer = serializer(elementType); - if (serializer == null) { + KSerializer listSerializer = serializer(ResolvableType.forClassWithGenerics(List.class, elementType)); + if (serializer == null || listSerializer == null) { return Mono.error(new DecodingException("Could not find KSerializer for " + elementType)); } return this.stringDecoder .decode(inputStream, elementType, mimeType, hints) - .handle((string, sink) -> { - try { - sink.next(format().decodeFromString(serializer, string)); - } - catch (IllegalArgumentException ex) { - sink.error(processException(ex)); - } - }); + .flatMapIterable(string -> string.startsWith("[") ? + (List) format().decodeFromString(listSerializer, string) : + List.of(format().decodeFromString(serializer, string))) + .onErrorMap(IllegalArgumentException.class, this::processException); }); } diff --git a/spring-web/src/test/kotlin/org/springframework/http/codec/json/KotlinSerializationJsonDecoderTests.kt b/spring-web/src/test/kotlin/org/springframework/http/codec/json/KotlinSerializationJsonDecoderTests.kt index 28c8f87ec5b..f2e6a93800f 100644 --- a/spring-web/src/test/kotlin/org/springframework/http/codec/json/KotlinSerializationJsonDecoderTests.kt +++ b/spring-web/src/test/kotlin/org/springframework/http/codec/json/KotlinSerializationJsonDecoderTests.kt @@ -197,6 +197,20 @@ class KotlinSerializationJsonDecoderTests : AbstractDecoderTests Date: Fri, 10 Apr 2026 11:42:41 +0200 Subject: [PATCH 2/4] Refine Kotlin serialization contribution This commit moves the JSON specific code to KotlinSerializationJsonDecoder, uses switchOnFirst operator to keep the existing behavior and derives the list serializer from the element one. Closes gh-36597 --- .../server/WebTestClientKotlinTests.kt | 18 +++++++- .../KotlinSerializationStringDecoder.java | 20 +++++---- .../json/KotlinSerializationJsonDecoder.java | 44 +++++++++++++++++++ .../KotlinSerializationJsonDecoderTests.kt | 14 ++++++ 4 files changed, 86 insertions(+), 10 deletions(-) diff --git a/spring-test/src/test/kotlin/org/springframework/test/web/reactive/server/WebTestClientKotlinTests.kt b/spring-test/src/test/kotlin/org/springframework/test/web/reactive/server/WebTestClientKotlinTests.kt index 69353f358d1..8e22ea41bbd 100644 --- a/spring-test/src/test/kotlin/org/springframework/test/web/reactive/server/WebTestClientKotlinTests.kt +++ b/spring-test/src/test/kotlin/org/springframework/test/web/reactive/server/WebTestClientKotlinTests.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2002-present 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.test.web.reactive.server import kotlinx.serialization.Serializable @@ -34,4 +50,4 @@ class WebTestClientKotlinTests { @GetMapping("test") fun test(): List = listOf(Response("Hello"), Response("World")) } -} \ No newline at end of file +} diff --git a/spring-web/src/main/java/org/springframework/http/codec/KotlinSerializationStringDecoder.java b/spring-web/src/main/java/org/springframework/http/codec/KotlinSerializationStringDecoder.java index e234ca4c688..69fb226442e 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/KotlinSerializationStringDecoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/KotlinSerializationStringDecoder.java @@ -55,7 +55,7 @@ public abstract class KotlinSerializationStringDecoder e implements Decoder { // String decoding needed for now, see https://github.com/Kotlin/kotlinx.serialization/issues/204 for more details - private final StringDecoder stringDecoder = StringDecoder.allMimeTypes(StringDecoder.DEFAULT_DELIMITERS, false); + protected final StringDecoder stringDecoder = StringDecoder.allMimeTypes(StringDecoder.DEFAULT_DELIMITERS, false); /** @@ -116,21 +116,23 @@ public abstract class KotlinSerializationStringDecoder e } @Override - @SuppressWarnings("unchecked") public Flux decode(Publisher inputStream, ResolvableType elementType, @Nullable MimeType mimeType, @Nullable Map hints) { return Flux.defer(() -> { KSerializer serializer = serializer(elementType); - KSerializer listSerializer = serializer(ResolvableType.forClassWithGenerics(List.class, elementType)); - if (serializer == null || listSerializer == null) { + if (serializer == null) { return Mono.error(new DecodingException("Could not find KSerializer for " + elementType)); } return this.stringDecoder .decode(inputStream, elementType, mimeType, hints) - .flatMapIterable(string -> string.startsWith("[") ? - (List) format().decodeFromString(listSerializer, string) : - List.of(format().decodeFromString(serializer, string))) - .onErrorMap(IllegalArgumentException.class, this::processException); + .handle((string, sink) -> { + try { + sink.next(format().decodeFromString(serializer, string)); + } + catch (IllegalArgumentException ex) { + sink.error(processException(ex)); + } + }); }); } @@ -156,7 +158,7 @@ public abstract class KotlinSerializationStringDecoder e }); } - private CodecException processException(IllegalArgumentException ex) { + protected CodecException processException(IllegalArgumentException ex) { return new DecodingException("Decoding error: " + ex.getMessage(), ex); } diff --git a/spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonDecoder.java b/spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonDecoder.java index 227f320987c..7198e1992b5 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonDecoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonDecoder.java @@ -16,11 +16,22 @@ package org.springframework.http.codec.json; +import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.function.Predicate; +import kotlinx.serialization.KSerializer; +import kotlinx.serialization.builtins.BuiltinSerializersKt; import kotlinx.serialization.json.Json; +import org.jspecify.annotations.Nullable; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; import org.springframework.core.ResolvableType; +import org.springframework.core.codec.DecodingException; +import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.MediaType; import org.springframework.http.codec.KotlinSerializationStringDecoder; import org.springframework.util.MimeType; @@ -95,4 +106,37 @@ public class KotlinSerializationJsonDecoder extends KotlinSerializationStringDec super(json, typePredicate, DEFAULT_JSON_MIME_TYPES); } + @Override + public Flux decode(Publisher inputStream, ResolvableType elementType, + @Nullable MimeType mimeType, @Nullable Map hints) { + return Flux.defer(() -> { + KSerializer serializer = serializer(elementType); + if (serializer == null) { + return Mono.error(new DecodingException("Could not find KSerializer for " + elementType)); + } + return this.stringDecoder + .decode(inputStream, elementType, mimeType, hints) + .switchOnFirst((signal, flux) -> { + if (signal.hasValue()) { + String value = Objects.requireNonNull(signal.get()); + if (value.stripLeading().startsWith("[") && !List.class.isAssignableFrom(elementType.toClass())) { + KSerializer> listSerializer = BuiltinSerializersKt.ListSerializer(serializer); + return flux + .flatMapIterable(string -> format().decodeFromString(listSerializer, string)) + .onErrorMap(IllegalArgumentException.class, this::processException); + } + return flux.handle((string, sink) -> { + try { + sink.next(format().decodeFromString(serializer, string)); + } + catch (IllegalArgumentException ex) { + sink.error(processException(ex)); + } + }); + } + return flux; + }); + }); + } + } diff --git a/spring-web/src/test/kotlin/org/springframework/http/codec/json/KotlinSerializationJsonDecoderTests.kt b/spring-web/src/test/kotlin/org/springframework/http/codec/json/KotlinSerializationJsonDecoderTests.kt index f2e6a93800f..ce53bb9a0ac 100644 --- a/spring-web/src/test/kotlin/org/springframework/http/codec/json/KotlinSerializationJsonDecoderTests.kt +++ b/spring-web/src/test/kotlin/org/springframework/http/codec/json/KotlinSerializationJsonDecoderTests.kt @@ -211,6 +211,20 @@ class KotlinSerializationJsonDecoderTests : AbstractDecoderTests Date: Fri, 13 Mar 2026 17:21:15 +0900 Subject: [PATCH 3/4] Fix nullable value class handling in CoroutinesUtils Closes gh-36449 Signed-off-by: T45K --- .../java/org/springframework/core/CoroutinesUtils.java | 4 ++-- .../org/springframework/core/CoroutinesUtilsTests.kt | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/core/CoroutinesUtils.java b/spring-core/src/main/java/org/springframework/core/CoroutinesUtils.java index 2d1e509b52d..568d94b6c03 100644 --- a/spring-core/src/main/java/org/springframework/core/CoroutinesUtils.java +++ b/spring-core/src/main/java/org/springframework/core/CoroutinesUtils.java @@ -134,7 +134,7 @@ public abstract class CoroutinesUtils { Object arg = args[index]; if (!(parameter.isOptional() && arg == null)) { KType type = parameter.getType(); - if (!(type.isMarkedNullable() && arg == null) && + if (!type.isMarkedNullable() && type.getClassifier() instanceof KClass kClass && KotlinDetector.isInlineClass(JvmClassMappingKt.getJavaClass(kClass))) { arg = box(kClass, arg); @@ -166,7 +166,7 @@ public abstract class CoroutinesUtils { private static Object box(KClass kClass, @Nullable Object arg) { KFunction constructor = Objects.requireNonNull(KClasses.getPrimaryConstructor(kClass)); KType type = constructor.getParameters().get(0).getType(); - if (!(type.isMarkedNullable() && arg == null) && + if (!type.isMarkedNullable() && type.getClassifier() instanceof KClass parameterClass && KotlinDetector.isInlineClass(JvmClassMappingKt.getJavaClass(parameterClass))) { arg = box(parameterClass, arg); diff --git a/spring-core/src/test/kotlin/org/springframework/core/CoroutinesUtilsTests.kt b/spring-core/src/test/kotlin/org/springframework/core/CoroutinesUtilsTests.kt index c03a2039d30..6e0a13342f6 100644 --- a/spring-core/src/test/kotlin/org/springframework/core/CoroutinesUtilsTests.kt +++ b/spring-core/src/test/kotlin/org/springframework/core/CoroutinesUtilsTests.kt @@ -229,6 +229,13 @@ class CoroutinesUtilsTests { } } + @Test + suspend fun invokeSuspendingFunctionWithNullableValueClassParameterWithNonnullObject() { + val method = CoroutinesUtilsTests::class.java.declaredMethods.first { it.name.startsWith("suspendingFunctionWithNullableValueClass") } + val mono = CoroutinesUtils.invokeSuspendingFunction(method, this, ValueClass("foo"), null) as Mono + Assertions.assertThat(mono.awaitSingleOrNull()).isEqualTo("foo") + } + @Test suspend fun invokeSuspendingFunctionWithNullableValueClassParameter() { val method = CoroutinesUtilsTests::class.java.declaredMethods.first { it.name.startsWith("suspendingFunctionWithNullableValueClass") } From efb85d68ef9f2e62d7fdbca2f03f1a064955e89a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Fri, 10 Apr 2026 12:59:17 +0200 Subject: [PATCH 4/4] Apply nullable value class fix to InvocableHandlerMethod See gh-36449 --- .../org/springframework/core/CoroutinesUtilsTests.kt | 2 +- .../web/method/support/InvocableHandlerMethod.java | 4 ++-- .../method/support/InvocableHandlerMethodKotlinTests.kt | 7 +++++++ .../reactive/result/method/InvocableHandlerMethod.java | 6 +++--- .../reactive/result/InvocableHandlerMethodKotlinTests.kt | 8 ++++++++ 5 files changed, 21 insertions(+), 6 deletions(-) diff --git a/spring-core/src/test/kotlin/org/springframework/core/CoroutinesUtilsTests.kt b/spring-core/src/test/kotlin/org/springframework/core/CoroutinesUtilsTests.kt index 6e0a13342f6..4cc2fdeefdb 100644 --- a/spring-core/src/test/kotlin/org/springframework/core/CoroutinesUtilsTests.kt +++ b/spring-core/src/test/kotlin/org/springframework/core/CoroutinesUtilsTests.kt @@ -230,7 +230,7 @@ class CoroutinesUtilsTests { } @Test - suspend fun invokeSuspendingFunctionWithNullableValueClassParameterWithNonnullObject() { + suspend fun invokeSuspendingFunctionWithNullableValueClassParameterAndNonNullParameter() { val method = CoroutinesUtilsTests::class.java.declaredMethods.first { it.name.startsWith("suspendingFunctionWithNullableValueClass") } val mono = CoroutinesUtils.invokeSuspendingFunction(method, this, ValueClass("foo"), null) as Mono Assertions.assertThat(mono.awaitSingleOrNull()).isEqualTo("foo") diff --git a/spring-web/src/main/java/org/springframework/web/method/support/InvocableHandlerMethod.java b/spring-web/src/main/java/org/springframework/web/method/support/InvocableHandlerMethod.java index 07a4b78790d..6dca729698e 100644 --- a/spring-web/src/main/java/org/springframework/web/method/support/InvocableHandlerMethod.java +++ b/spring-web/src/main/java/org/springframework/web/method/support/InvocableHandlerMethod.java @@ -316,7 +316,7 @@ public class InvocableHandlerMethod extends HandlerMethod { Object arg = args[index]; if (!(parameter.isOptional() && arg == null)) { KType type = parameter.getType(); - if (!(type.isMarkedNullable() && arg == null) && + if (!type.isMarkedNullable() && type.getClassifier() instanceof KClass kClass && KotlinDetector.isInlineClass(JvmClassMappingKt.getJavaClass(kClass))) { arg = box(kClass, arg); @@ -337,7 +337,7 @@ public class InvocableHandlerMethod extends HandlerMethod { private static Object box(KClass kClass, @Nullable Object arg) { KFunction constructor = Objects.requireNonNull(KClasses.getPrimaryConstructor(kClass)); KType type = constructor.getParameters().get(0).getType(); - if (!(type.isMarkedNullable() && arg == null) && + if (!type.isMarkedNullable() && type.getClassifier() instanceof KClass parameterClass && KotlinDetector.isInlineClass(JvmClassMappingKt.getJavaClass(parameterClass))) { arg = box(parameterClass, arg); diff --git a/spring-web/src/test/kotlin/org/springframework/web/method/support/InvocableHandlerMethodKotlinTests.kt b/spring-web/src/test/kotlin/org/springframework/web/method/support/InvocableHandlerMethodKotlinTests.kt index 4b15b492193..b7da2b669f2 100644 --- a/spring-web/src/test/kotlin/org/springframework/web/method/support/InvocableHandlerMethodKotlinTests.kt +++ b/spring-web/src/test/kotlin/org/springframework/web/method/support/InvocableHandlerMethodKotlinTests.kt @@ -148,6 +148,13 @@ class InvocableHandlerMethodKotlinTests { Assertions.assertThatIllegalArgumentException().isThrownBy { invocable.invokeForRequest(request, null) } } + @Test + fun valueClassWithNullableAndNonNullParameter() { + composite.addResolver(StubArgumentResolver(LongValueClass::class.java, LongValueClass(1))) + val value = getInvocable(ValueClassHandler::valueClassWithNullable.javaMethod!!).invokeForRequest(request, null) + Assertions.assertThat(value).isEqualTo(1L) + } + @Test fun valueClassWithNullable() { composite.addResolver(StubArgumentResolver(LongValueClass::class.java, null)) diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/InvocableHandlerMethod.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/InvocableHandlerMethod.java index 920e29b5ef8..c06ea62a2aa 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/InvocableHandlerMethod.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/InvocableHandlerMethod.java @@ -329,7 +329,7 @@ public class InvocableHandlerMethod extends HandlerMethod { private static final String COROUTINE_CONTEXT_ATTRIBUTE = "org.springframework.web.server.CoWebFilter.context"; @SuppressWarnings("DataFlowIssue") - public static @Nullable Object invokeFunction(Method method, Object target, Object[] args, boolean isSuspendingFunction, + public static @Nullable Object invokeFunction(Method method, Object target, @Nullable Object[] args, boolean isSuspendingFunction, ServerWebExchange exchange) throws InvocationTargetException, IllegalAccessException, NoSuchMethodException { if (isSuspendingFunction) { @@ -356,7 +356,7 @@ public class InvocableHandlerMethod extends HandlerMethod { Object arg = args[index]; if (!(parameter.isOptional() && arg == null)) { KType type = parameter.getType(); - if (!(type.isMarkedNullable() && arg == null) && + if (!type.isMarkedNullable() && type.getClassifier() instanceof KClass kClass && KotlinDetector.isInlineClass(JvmClassMappingKt.getJavaClass(kClass))) { arg = box(kClass, arg); @@ -378,7 +378,7 @@ public class InvocableHandlerMethod extends HandlerMethod { private static Object box(KClass kClass, @Nullable Object arg) { KFunction constructor = Objects.requireNonNull(KClasses.getPrimaryConstructor(kClass)); KType type = constructor.getParameters().get(0).getType(); - if (!(type.isMarkedNullable() && arg == null) && + if (!type.isMarkedNullable() && type.getClassifier() instanceof KClass parameterClass && KotlinDetector.isInlineClass(JvmClassMappingKt.getJavaClass(parameterClass))) { arg = box(parameterClass, arg); diff --git a/spring-webflux/src/test/kotlin/org/springframework/web/reactive/result/InvocableHandlerMethodKotlinTests.kt b/spring-webflux/src/test/kotlin/org/springframework/web/reactive/result/InvocableHandlerMethodKotlinTests.kt index 95a5df9cbbc..9e41a222399 100644 --- a/spring-webflux/src/test/kotlin/org/springframework/web/reactive/result/InvocableHandlerMethodKotlinTests.kt +++ b/spring-webflux/src/test/kotlin/org/springframework/web/reactive/result/InvocableHandlerMethodKotlinTests.kt @@ -250,6 +250,14 @@ class InvocableHandlerMethodKotlinTests { assertExceptionThrown(result, IllegalArgumentException::class) } + @Test + fun valueClassWithNullableAndNonNullParameter() { + this.resolvers.add(stubResolver(LongValueClass(1), LongValueClass::class.java)) + val method = ValueClassController::valueClassWithNullable.javaMethod!! + val result = invoke(ValueClassController(), method) + assertHandlerResultValue(result, "1") + } + @Test fun valueClassWithNullable() { this.resolvers.add(stubResolver(null, LongValueClass::class.java))