From 29a4dabbe7a97ee82b6623444efcb34edee3fcf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Wed, 13 Sep 2023 17:49:14 +0200 Subject: [PATCH] Support @ModelAttribute with suspending function in WebFlux Closes gh-30894 --- .../method/annotation/ModelInitializer.java | 17 ++- .../annotation/ModelInitializerKotlinTests.kt | 100 ++++++++++++++++++ 2 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 spring-webflux/src/test/kotlin/org/springframework/web/reactive/result/method/annotation/ModelInitializerKotlinTests.kt diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ModelInitializer.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ModelInitializer.java index 9b0e2924a18a..e718c00d8908 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ModelInitializer.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ModelInitializer.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2023 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. @@ -16,6 +16,7 @@ package org.springframework.web.reactive.result.method.annotation; +import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -25,6 +26,7 @@ import reactor.core.publisher.Mono; import org.springframework.core.Conventions; +import org.springframework.core.KotlinDetector; import org.springframework.core.MethodParameter; import org.springframework.core.ReactiveAdapter; import org.springframework.core.ReactiveAdapterRegistry; @@ -45,6 +47,7 @@ * default model initialization through {@code @ModelAttribute} methods. * * @author Rossen Stoyanchev + * @author Sebastien Deleuze * @since 5.0 */ class ModelInitializer { @@ -119,18 +122,22 @@ private Mono handleResult(HandlerResult handlerResult, BindingContext bind Object value = handlerResult.getReturnValue(); if (value != null) { ResolvableType type = handlerResult.getReturnType(); + MethodParameter typeSource = handlerResult.getReturnTypeSource(); ReactiveAdapter adapter = this.adapterRegistry.getAdapter(type.resolve(), value); - if (isAsyncVoidType(type, adapter)) { + if (isAsyncVoidType(type, typeSource, adapter)) { return Mono.from(adapter.toPublisher(value)); } - String name = getAttributeName(handlerResult.getReturnTypeSource()); + String name = getAttributeName(typeSource); bindingContext.getModel().asMap().putIfAbsent(name, value); } return Mono.empty(); } - private boolean isAsyncVoidType(ResolvableType type, @Nullable ReactiveAdapter adapter) { - return (adapter != null && (adapter.isNoValue() || type.resolveGeneric() == Void.class)); + + private boolean isAsyncVoidType(ResolvableType type, MethodParameter typeSource, @Nullable ReactiveAdapter adapter) { + Method method = typeSource.getMethod(); + return (adapter != null && (adapter.isNoValue() || type.resolveGeneric() == Void.class)) || + (method != null && KotlinDetector.isSuspendingFunction(method) && typeSource.getParameterType() == void.class); } private String getAttributeName(MethodParameter param) { diff --git a/spring-webflux/src/test/kotlin/org/springframework/web/reactive/result/method/annotation/ModelInitializerKotlinTests.kt b/spring-webflux/src/test/kotlin/org/springframework/web/reactive/result/method/annotation/ModelInitializerKotlinTests.kt new file mode 100644 index 000000000000..f8fec8f6a797 --- /dev/null +++ b/spring-webflux/src/test/kotlin/org/springframework/web/reactive/result/method/annotation/ModelInitializerKotlinTests.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2002-2023 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.web.reactive.result.method.annotation + +import kotlinx.coroutines.delay +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.context.support.StaticApplicationContext +import org.springframework.core.ReactiveAdapterRegistry +import org.springframework.ui.Model +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.ModelAttribute +import org.springframework.web.bind.support.ConfigurableWebBindingInitializer +import org.springframework.web.method.HandlerMethod +import org.springframework.web.server.ServerWebExchange +import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest +import org.springframework.web.testfixture.method.ResolvableMethod +import org.springframework.web.testfixture.server.MockServerWebExchange +import reactor.core.publisher.Mono +import java.time.Duration + +/** + * Kotlin test fixture for [ModelInitializer]. + * + * @author Sebastien Deleuze + */ +class ModelInitializerKotlinTests { + + private val timeout = Duration.ofMillis(5000) + + private lateinit var modelInitializer: ModelInitializer + + private val exchange: ServerWebExchange = MockServerWebExchange.from(MockServerHttpRequest.get("/path")) + + @BeforeEach + fun setup() { + val adapterRegistry = ReactiveAdapterRegistry.getSharedInstance() + val resolverConfigurer = ArgumentResolverConfigurer() + resolverConfigurer.addCustomResolver(ModelMethodArgumentResolver(adapterRegistry)) + val methodResolver = ControllerMethodResolver(resolverConfigurer, adapterRegistry, StaticApplicationContext(), + emptyList()) + modelInitializer = ModelInitializer(methodResolver, adapterRegistry) + } + + @Test + @Suppress("UNCHECKED_CAST") + fun modelAttributeMethods() { + val controller = TestController() + val method = ResolvableMethod.on(TestController::class.java).annotPresent(GetMapping::class.java) + .resolveMethod() + val handlerMethod = HandlerMethod(controller, method) + val context = InitBinderBindingContext(ConfigurableWebBindingInitializer(), emptyList()) + this.modelInitializer.initModel(handlerMethod, context, this.exchange).block(timeout) + val model = context.model.asMap() + Assertions.assertThat(model).hasSize(2) + val monoValue = model["suspendingReturnValue"] as Mono + Assertions.assertThat(monoValue.block(timeout)!!.name).isEqualTo("Suspending return value") + val value = model["suspendingModelParameter"] as TestBean + Assertions.assertThat(value.name).isEqualTo("Suspending model parameter") + } + + + private data class TestBean(val name: String) + + private class TestController { + + @ModelAttribute("suspendingReturnValue") + suspend fun suspendingReturnValue(): TestBean { + delay(1) + return TestBean("Suspending return value") + } + + @ModelAttribute + suspend fun suspendingModelParameter(model: Model) { + delay(1) + model.addAttribute("suspendingModelParameter", TestBean("Suspending model parameter")) + } + + @GetMapping + fun handleGet() { + } + + } + +}