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..4cc2fdeefdb 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 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") + } + @Test suspend fun invokeSuspendingFunctionWithNullableValueClassParameter() { val method = CoroutinesUtilsTests::class.java.declaredMethods.first { it.name.startsWith("suspendingFunctionWithNullableValueClass") } 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..8e22ea41bbd --- /dev/null +++ b/spring-test/src/test/kotlin/org/springframework/test/web/reactive/server/WebTestClientKotlinTests.kt @@ -0,0 +1,53 @@ +/* + * 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 +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")) + } +} 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..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); /** @@ -158,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 dcce9489568..c0b1c123994 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; @@ -96,4 +107,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/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/http/codec/json/KotlinSerializationJsonDecoderTests.kt b/spring-web/src/test/kotlin/org/springframework/http/codec/json/KotlinSerializationJsonDecoderTests.kt index 28c8f87ec5b..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 @@ -197,6 +197,34 @@ class KotlinSerializationJsonDecoderTests : AbstractDecoderTests 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))