Support Kotlin parameter default values in handler methods

This commit adds support for Kotlin parameter default values
in handler methods. It allows to write:
@RequestParam value: String = "default"
as an alternative to:
@RequestParam(defaultValue = "default") value: String

Both Spring MVC and WebFlux are supported, including on
suspending functions.

Closes gh-21139
This commit is contained in:
Sébastien Deleuze
2023-06-21 18:49:11 +02:00
parent 254fb39567
commit f06cf21341
12 changed files with 679 additions and 41 deletions

View File

@@ -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.
@@ -21,16 +21,21 @@ import io.mockk.mockk
import kotlinx.coroutines.delay
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.core.ReactiveAdapterRegistry
import org.springframework.http.HttpStatus
import org.springframework.http.server.reactive.ServerHttpResponse
import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest.get
import org.springframework.web.testfixture.server.MockServerWebExchange
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.reactive.BindingContext
import org.springframework.web.reactive.HandlerResult
import org.springframework.web.reactive.result.method.HandlerMethodArgumentResolver
import org.springframework.web.reactive.result.method.InvocableHandlerMethod
import org.springframework.web.reactive.result.method.annotation.ContinuationHandlerMethodArgumentResolver
import org.springframework.web.reactive.result.method.annotation.RequestParamMethodArgumentResolver
import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest.get
import org.springframework.web.testfixture.server.MockServerWebExchange
import reactor.core.publisher.Mono
import reactor.test.StepVerifier
import java.lang.reflect.Method
@@ -39,9 +44,10 @@ import kotlin.reflect.jvm.javaMethod
class KotlinInvocableHandlerMethodTests {
private val exchange = MockServerWebExchange.from(get("http://localhost:8080/path"))
private var exchange = MockServerWebExchange.from(get("http://localhost:8080/path"))
private val resolvers = mutableListOf<HandlerMethodArgumentResolver>(ContinuationHandlerMethodArgumentResolver())
private val resolvers = mutableListOf<HandlerMethodArgumentResolver>(ContinuationHandlerMethodArgumentResolver(),
RequestParamMethodArgumentResolver(null, ReactiveAdapterRegistry.getSharedInstance(), false))
@Test
fun resolveNoArg() {
@@ -104,6 +110,58 @@ class KotlinInvocableHandlerMethodTests {
assertHandlerResultValue(result, "success:foo")
}
@Test
fun defaultValue() {
this.resolvers.add(stubResolver(Mono.empty()))
val method = DefaultValueController::handle.javaMethod!!
val result = invoke(DefaultValueController(), method)
assertHandlerResultValue(result, "default")
}
@Test
fun defaultValueOverridden() {
this.resolvers.add(stubResolver(Mono.empty()))
val method = DefaultValueController::handle.javaMethod!!
exchange = MockServerWebExchange.from(get("http://localhost:8080/path").queryParam("value", "override"))
val result = invoke(DefaultValueController(), method)
assertHandlerResultValue(result, "override")
}
@Test
fun defaultValues() {
this.resolvers.add(stubResolver(Mono.empty()))
val method = DefaultValueController::handleMultiple.javaMethod!!
val result = invoke(DefaultValueController(), method)
assertHandlerResultValue(result, "10-20")
}
@Test
fun defaultValuesOverridden() {
this.resolvers.add(stubResolver(Mono.empty()))
val method = DefaultValueController::handleMultiple.javaMethod!!
exchange = MockServerWebExchange.from(get("http://localhost:8080/path").queryParam("limit2", "40"))
val result = invoke(DefaultValueController(), method)
assertHandlerResultValue(result, "10-40")
}
@Test
fun suspendingDefaultValue() {
this.resolvers.add(stubResolver(Mono.empty()))
val method = DefaultValueController::handleSuspending.javaMethod!!
val result = invoke(DefaultValueController(), method)
assertHandlerResultValue(result, "default")
}
@Test
fun suspendingDefaultValueOverridden() {
this.resolvers.add(stubResolver(Mono.empty()))
val method = DefaultValueController::handleSuspending.javaMethod!!
exchange = MockServerWebExchange.from(get("http://localhost:8080/path").queryParam("value", "override"))
val result = invoke(DefaultValueController(), method)
assertHandlerResultValue(result, "override")
}
private fun invokeForResult(handler: Any, method: Method, vararg providedArgs: Any): HandlerResult? {
return invoke(handler, method, *providedArgs).block(Duration.ofSeconds(5))
}
@@ -127,8 +185,13 @@ class KotlinInvocableHandlerMethodTests {
private fun assertHandlerResultValue(mono: Mono<HandlerResult>, expected: String) {
StepVerifier.create(mono)
.consumeNextWith { StepVerifier.create(it.returnValue as Mono<*>).expectNext(expected).verifyComplete() }
.verifyComplete()
.consumeNextWith {
if (it.returnValue is Mono<*>) {
StepVerifier.create(it.returnValue as Mono<*>).expectNext(expected).verifyComplete()
} else {
assertThat(it.returnValue).isEqualTo(expected)
}
}.verifyComplete()
}
class CoroutinesController {
@@ -166,4 +229,16 @@ class KotlinInvocableHandlerMethodTests {
return "success:$q"
}
}
@RestController
class DefaultValueController {
fun handle(@RequestParam value: String = "default") = value
fun handleMultiple(@RequestParam(defaultValue = "10") limit1: Int, @RequestParam limit2: Int = 20) = "${limit1}-${limit2}"
@Suppress("RedundantSuspendModifier")
suspend fun handleSuspending(@RequestParam value: String = "default") = value
}
}

View File

@@ -46,6 +46,13 @@ class RequestParamMethodArgumentResolverKotlinTests {
lateinit var nonNullableParamRequired: MethodParameter
lateinit var nonNullableParamNotRequired: MethodParameter
lateinit var defaultValueBooleanParamRequired: MethodParameter
lateinit var defaultValueBooleanParamNotRequired: MethodParameter
lateinit var defaultValueIntParamRequired: MethodParameter
lateinit var defaultValueIntParamNotRequired: MethodParameter
lateinit var defaultValueStringParamRequired: MethodParameter
lateinit var defaultValueStringParamNotRequired: MethodParameter
@BeforeEach
fun setup() {
@@ -54,13 +61,22 @@ class RequestParamMethodArgumentResolverKotlinTests {
initializer.conversionService = DefaultFormattingConversionService()
bindingContext = BindingContext(initializer)
val method = ReflectionUtils.findMethod(javaClass, "handle", String::class.java,
String::class.java, String::class.java, String::class.java)!!
val method = ReflectionUtils.findMethod(javaClass, "handle",
String::class.java, String::class.java, String::class.java, String::class.java,
Boolean::class.java, Boolean::class.java, Int::class.java, Int::class.java,
String::class.java, String::class.java)!!
nullableParamRequired = SynthesizingMethodParameter(method, 0)
nullableParamNotRequired = SynthesizingMethodParameter(method, 1)
nonNullableParamRequired = SynthesizingMethodParameter(method, 2)
nonNullableParamNotRequired = SynthesizingMethodParameter(method, 3)
defaultValueBooleanParamRequired = SynthesizingMethodParameter(method, 4)
defaultValueBooleanParamNotRequired = SynthesizingMethodParameter(method, 5)
defaultValueIntParamRequired = SynthesizingMethodParameter(method, 6)
defaultValueIntParamNotRequired = SynthesizingMethodParameter(method, 7)
defaultValueStringParamRequired = SynthesizingMethodParameter(method, 8)
defaultValueStringParamNotRequired = SynthesizingMethodParameter(method, 9)
}
@Test
@@ -119,13 +135,104 @@ class RequestParamMethodArgumentResolverKotlinTests {
StepVerifier.create(result).expectComplete().verify()
}
@Test
fun resolveDefaultValueRequiredWithBooleanParameter() {
val exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/path?value=false"))
val result = resolver.resolveArgument(defaultValueBooleanParamRequired, bindingContext, exchange)
StepVerifier.create(result).expectNext(false).expectComplete().verify()
}
@Test
fun resolveDefaultValueRequiredWithoutBooleanParameter() {
val exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/"))
val result = resolver.resolveArgument(defaultValueBooleanParamRequired, bindingContext, exchange)
StepVerifier.create(result).expectComplete().verify()
}
@Test
fun resolveDefaultValueNotRequiredWithBooleanParameter() {
val exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/path?value=false"))
val result = resolver.resolveArgument(defaultValueBooleanParamNotRequired, bindingContext, exchange)
StepVerifier.create(result).expectNext(false).expectComplete().verify()
}
@Test
fun resolveDefaultValueNotRequiredWithoutBooleanParameter() {
val exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/"))
val result = resolver.resolveArgument(defaultValueBooleanParamNotRequired, bindingContext, exchange)
StepVerifier.create(result).expectComplete().verify()
}
@Test
fun resolveDefaultValueRequiredWithIntParameter() {
val exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/path?value=123"))
val result = resolver.resolveArgument(defaultValueIntParamRequired, bindingContext, exchange)
StepVerifier.create(result).expectNext(123).expectComplete().verify()
}
@Test
fun resolveDefaultValueRequiredWithoutIntParameter() {
val exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/"))
val result = resolver.resolveArgument(defaultValueIntParamRequired, bindingContext, exchange)
StepVerifier.create(result).expectComplete().verify()
}
@Test
fun resolveDefaultValueNotRequiredWithIntParameter() {
val exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/path?value=123"))
val result = resolver.resolveArgument(defaultValueIntParamNotRequired, bindingContext, exchange)
StepVerifier.create(result).expectNext(123).expectComplete().verify()
}
@Test
fun resolveDefaultValueNotRequiredWithoutIntParameter() {
val exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/"))
val result = resolver.resolveArgument(defaultValueIntParamNotRequired, bindingContext, exchange)
StepVerifier.create(result).expectComplete().verify()
}
@Test
fun resolveDefaultValueRequiredWithStringParameter() {
val exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/path?value=123"))
val result = resolver.resolveArgument(defaultValueStringParamRequired, bindingContext, exchange)
StepVerifier.create(result).expectNext("123").expectComplete().verify()
}
@Test
fun resolveDefaultValueRequiredWithoutStringParameter() {
val exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/"))
val result = resolver.resolveArgument(defaultValueStringParamRequired, bindingContext, exchange)
StepVerifier.create(result).expectComplete().verify()
}
@Test
fun resolveDefaultValueNotRequiredWithStringParameter() {
val exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/path?value=123"))
val result = resolver.resolveArgument(defaultValueStringParamNotRequired, bindingContext, exchange)
StepVerifier.create(result).expectNext("123").expectComplete().verify()
}
@Test
fun resolveDefaultValueNotRequiredWithoutStringParameter() {
val exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/"))
val result = resolver.resolveArgument(defaultValueStringParamNotRequired, bindingContext, exchange)
StepVerifier.create(result).expectComplete().verify()
}
@Suppress("unused_parameter")
fun handle(
@RequestParam("name") nullableParamRequired: String?,
@RequestParam("name", required = false) nullableParamNotRequired: String?,
@RequestParam("name") nonNullableParamRequired: String,
@RequestParam("name", required = false) nonNullableParamNotRequired: String) {
@RequestParam("name", required = false) nonNullableParamNotRequired: String,
@RequestParam("value") withDefaultValueBooleanParamRequired: Boolean = true,
@RequestParam("value", required = false) withDefaultValueBooleanParamNotRequired: Boolean = true,
@RequestParam("value") withDefaultValueIntParamRequired: Int = 20,
@RequestParam("value", required = false) withDefaultValueIntParamNotRequired: Int = 20,
@RequestParam("value") withDefaultValueStringParamRequired: String = "default",
@RequestParam("value", required = false) withDefaultValueStringParamNotRequired: String = "default") {
}
}