Skip to content

Commit 71291e9

Browse files
committed
Support Kotlin parameter default values in handler methods
Allows using default parameter values for any method parameters resolved via AbstractNamedValueMethodArgumentResolver, similar to defaultValue parameter of e.g. @RequestParam annotation, but skipping the converter chain. Issues: SPR-16598
1 parent bf71e86 commit 71291e9

File tree

5 files changed

+163
-20
lines changed

5 files changed

+163
-20
lines changed

spring-core/src/main/java/org/springframework/core/MethodParameter.java

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,14 @@ public boolean isOptional() {
342342
(KotlinDetector.isKotlinType(getContainingClass()) && KotlinDelegate.isOptional(this)));
343343
}
344344

345+
/**
346+
* Return whether this parameter declares a language-level default value,
347+
* such as a default parameter in Kotlin.
348+
*/
349+
public boolean hasDefaultValue() {
350+
return KotlinDetector.isKotlinType(getContainingClass()) && KotlinDelegate.hasDefaultValue(this);
351+
}
352+
345353
/**
346354
* Check whether this method parameter is annotated with any variant of a
347355
* {@code Nullable} annotation, e.g. {@code javax.annotation.Nullable} or
@@ -745,6 +753,28 @@ private static int validateIndex(Executable executable, int parameterIndex) {
745753
*/
746754
private static class KotlinDelegate {
747755

756+
private static KParameter getKotlinParameter(KFunction<?> function, int index) {
757+
List<KParameter> parameters = function.getParameters();
758+
return parameters
759+
.stream()
760+
.filter(p -> KParameter.Kind.VALUE.equals(p.getKind()))
761+
.collect(Collectors.toList())
762+
.get(index);
763+
}
764+
765+
@Nullable
766+
private static KFunction<?> getKotlinFunction(@Nullable Method method, @Nullable Constructor<?> ctor) {
767+
if (method != null) {
768+
return ReflectJvmMapping.getKotlinFunction(method);
769+
}
770+
else if (ctor != null) {
771+
return ReflectJvmMapping.getKotlinFunction(ctor);
772+
}
773+
else {
774+
return null;
775+
}
776+
}
777+
748778
/**
749779
* Check whether the specified {@link MethodParameter} represents a nullable Kotlin type
750780
* or an optional parameter (with a default value in the Kotlin declaration).
@@ -758,25 +788,34 @@ public static boolean isOptional(MethodParameter param) {
758788
return (function != null && function.getReturnType().isMarkedNullable());
759789
}
760790
else {
761-
KFunction<?> function = null;
762-
if (method != null) {
763-
function = ReflectJvmMapping.getKotlinFunction(method);
764-
}
765-
else if (ctor != null) {
766-
function = ReflectJvmMapping.getKotlinFunction(ctor);
767-
}
791+
KFunction<?> function = getKotlinFunction(method, ctor);
768792
if (function != null) {
769-
List<KParameter> parameters = function.getParameters();
770-
KParameter parameter = parameters
771-
.stream()
772-
.filter(p -> KParameter.Kind.VALUE.equals(p.getKind()))
773-
.collect(Collectors.toList())
774-
.get(index);
793+
KParameter parameter = getKotlinParameter(function, index);
775794
return (parameter.getType().isMarkedNullable() || parameter.isOptional());
776795
}
777796
}
778797
return false;
779798
}
799+
800+
/**
801+
* Check whether the specified {@link MethodParameter} has a default value in it's Kotlin declaration.
802+
*/
803+
public static boolean hasDefaultValue(MethodParameter param) {
804+
int index = param.getParameterIndex();
805+
if (index == -1) { // default value does not make sense for return types
806+
return false;
807+
}
808+
else {
809+
KFunction<?> function = getKotlinFunction(param.getMethod(), param.getConstructor());
810+
if (function != null) {
811+
KParameter parameter = getKotlinParameter(function, index);
812+
return parameter.isOptional();
813+
}
814+
else {
815+
return false;
816+
}
817+
}
818+
}
780819
}
781820

782821
}

spring-core/src/test/kotlin/org/springframework/core/KotlinMethodParameterTests.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,18 @@ class KotlinMethodParameterTests {
3636

3737
lateinit var nonNullableMethod: Method
3838

39+
lateinit var withDefaultParameterMethod: Method
40+
41+
lateinit var withoutDefaultParameterMethod: Method
42+
3943

4044
@Before
4145
@Throws(NoSuchMethodException::class)
4246
fun setup() {
4347
nullableMethod = javaClass.getMethod("nullable", String::class.java)
4448
nonNullableMethod = javaClass.getMethod("nonNullable", String::class.java)
49+
withDefaultParameterMethod = javaClass.getMethod("withDefaultParameter", String::class.java)
50+
withoutDefaultParameterMethod = javaClass.getMethod("withoutDefaultParameter", String::class.java)
4551
}
4652

4753

@@ -57,11 +63,22 @@ class KotlinMethodParameterTests {
5763
assertFalse(MethodParameter(nonNullableMethod, -1).isOptional())
5864
}
5965

66+
@Test
67+
fun `Method parameter default value`() {
68+
assertTrue(MethodParameter(withDefaultParameterMethod, 0).hasDefaultValue())
69+
assertFalse(MethodParameter(withoutDefaultParameterMethod, 0).hasDefaultValue())
70+
}
6071

6172
@Suppress("unused", "unused_parameter")
6273
fun nullable(p1: String?): Int? = 42
6374

6475
@Suppress("unused", "unused_parameter")
6576
fun nonNullable(p1: String): Int = 42
6677

78+
@Suppress("unused", "unused_parameter")
79+
fun withDefaultParameter(p1: String = "42") = 42
80+
81+
@Suppress("unused", "unused_parameter")
82+
fun withoutDefaultParameter(p1: String) = 42
83+
6784
}

spring-web/src/main/java/org/springframework/web/method/annotation/AbstractNamedValueMethodArgumentResolver.java

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,15 @@ public final Object resolveArgument(MethodParameter parameter, @Nullable ModelAn
111111
else if (namedValueInfo.required && !nestedParameter.isOptional()) {
112112
handleMissingValue(namedValueInfo.name, nestedParameter, webRequest);
113113
}
114-
arg = handleNullValue(namedValueInfo.name, arg, nestedParameter.getNestedParameterType());
114+
arg = handleNullValue(namedValueInfo.name, arg, nestedParameter);
115115
}
116116
else if ("".equals(arg) && namedValueInfo.defaultValue != null) {
117117
arg = resolveStringValue(namedValueInfo.defaultValue);
118118
}
119119

120-
if (binderFactory != null) {
120+
// if we have a resolved arg here (not null), we must convert in any case
121+
// if we do not have a resolved arg here (null), only convert if we do not have a default value
122+
if (binderFactory != null && (arg != null || !nestedParameter.hasDefaultValue())) {
121123
WebDataBinder binder = binderFactory.createBinder(webRequest, null, namedValueInfo.name);
122124
try {
123125
arg = binder.convertIfNecessary(arg, parameter.getParameterType(), parameter);
@@ -235,12 +237,14 @@ protected void handleMissingValue(String name, MethodParameter parameter) throws
235237
* A {@code null} results in a {@code false} value for {@code boolean}s or an exception for other primitives.
236238
*/
237239
@Nullable
238-
private Object handleNullValue(String name, @Nullable Object value, Class<?> paramType) {
240+
private Object handleNullValue(String name, @Nullable Object value, MethodParameter param) {
241+
Class<?> paramType = param.getNestedParameterType();
239242
if (value == null) {
240243
if (Boolean.TYPE.equals(paramType)) {
241244
return Boolean.FALSE;
242245
}
243-
else if (paramType.isPrimitive()) {
246+
// primitive parameters may only be null if they have a default value
247+
else if (paramType.isPrimitive() && !param.hasDefaultValue()) {
244248
throw new IllegalStateException("Optional " + paramType.getSimpleName() + " parameter '" + name +
245249
"' is present but cannot be translated into a null value due to being declared as a " +
246250
"primitive type. Consider declaring it as object wrapper for the corresponding primitive type.");

spring-web/src/main/java/org/springframework/web/method/support/InvocableHandlerMethod.java

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,15 @@
1919
import java.lang.reflect.InvocationTargetException;
2020
import java.lang.reflect.Method;
2121
import java.util.Arrays;
22+
import java.util.HashMap;
23+
import java.util.Map;
24+
25+
import kotlin.reflect.KFunction;
26+
import kotlin.reflect.KParameter;
27+
import kotlin.reflect.jvm.ReflectJvmMapping;
2228

2329
import org.springframework.core.DefaultParameterNameDiscoverer;
30+
import org.springframework.core.KotlinDetector;
2431
import org.springframework.core.MethodParameter;
2532
import org.springframework.core.ParameterNameDiscoverer;
2633
import org.springframework.lang.Nullable;
@@ -55,6 +62,11 @@ public class InvocableHandlerMethod extends HandlerMethod {
5562

5663
private ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
5764

65+
private static final Object KOTLIN_NOT_CHECKED = new Object();
66+
67+
// has type Object to avoid hard dependency on Kotlin, only ever holds KFunction instances
68+
@Nullable
69+
private Object kotlinFunction = KOTLIN_NOT_CHECKED;
5870

5971
/**
6072
* Create an instance from a {@code HandlerMethod}.
@@ -205,7 +217,13 @@ private Object resolveProvidedArgument(MethodParameter parameter, @Nullable Obje
205217
*/
206218
protected Object doInvoke(Object... args) throws Exception {
207219
ReflectionUtils.makeAccessible(getBridgedMethod());
220+
if (kotlinFunction == KOTLIN_NOT_CHECKED) {
221+
initKotlinFunction();
222+
}
208223
try {
224+
if (kotlinFunction != null) {
225+
return KotlinDelegate.doKotlinCall(this, kotlinFunction, args);
226+
}
209227
return getBridgedMethod().invoke(getBean(), args);
210228
}
211229
catch (IllegalArgumentException ex) {
@@ -232,6 +250,15 @@ else if (targetException instanceof Exception) {
232250
}
233251
}
234252

253+
private void initKotlinFunction() {
254+
if (KotlinDetector.isKotlinPresent() && KotlinDetector.isKotlinType(getBeanType())) {
255+
kotlinFunction = KotlinDelegate.initKotlinCache(this);
256+
}
257+
else {
258+
kotlinFunction = null;
259+
}
260+
}
261+
235262
/**
236263
* Assert that the target bean class is an instance of the class where the given
237264
* method is declared. In some cases the actual controller instance at request-
@@ -279,4 +306,42 @@ protected String getDetailedErrorMessage(String text) {
279306
return sb.toString();
280307
}
281308

309+
private static class KotlinDelegate {
310+
311+
@Nullable
312+
public static Object initKotlinCache(InvocableHandlerMethod method) {
313+
KFunction<?> function = ReflectJvmMapping.getKotlinFunction(method.getBridgedMethod());
314+
// we only need to do the call via Kotlin reflection if we have at least one optional parameter
315+
if (function != null && function.getParameters().stream().anyMatch(KParameter::isOptional)) {
316+
return function;
317+
}
318+
else {
319+
return null;
320+
}
321+
}
322+
323+
public static Object doKotlinCall(InvocableHandlerMethod method, Object kotlinFunction, Object[] args) {
324+
KFunction<?> function = (KFunction<?>) kotlinFunction;
325+
Map<KParameter, Object> argMap = new HashMap<>();
326+
int index = 0;
327+
for (KParameter parameter : function.getParameters()) {
328+
switch (parameter.getKind()) {
329+
case INSTANCE:
330+
argMap.put(parameter, method.getBean());
331+
break;
332+
case VALUE:
333+
if (!parameter.isOptional() || args[index] != null) {
334+
argMap.put(parameter, args[index]);
335+
}
336+
index++;
337+
break;
338+
case EXTENSION_RECEIVER:
339+
default:
340+
throw new UnsupportedOperationException("Unsupported Kotlin function parameter type: " + parameter.getKind());
341+
}
342+
}
343+
return function.callBy(argMap);
344+
}
345+
}
346+
282347
}

spring-web/src/test/kotlin/org/springframework/web/method/annotation/RequestParamMethodArgumentResolverKotlinTests.kt

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ class RequestParamMethodArgumentResolverKotlinTests {
6262
lateinit var nonNullableMultipartParamRequired: MethodParameter
6363
lateinit var nonNullableMultipartParamNotRequired: MethodParameter
6464

65+
lateinit var defaultParam: MethodParameter
66+
6567

6668
@Before
6769
fun setup() {
@@ -75,7 +77,8 @@ class RequestParamMethodArgumentResolverKotlinTests {
7577
val method = ReflectionUtils.findMethod(javaClass, "handle", String::class.java,
7678
String::class.java, String::class.java, String::class.java,
7779
MultipartFile::class.java, MultipartFile::class.java,
78-
MultipartFile::class.java, MultipartFile::class.java)!!
80+
MultipartFile::class.java, MultipartFile::class.java,
81+
String::class.java)!!
7982

8083
nullableParamRequired = SynthesizingMethodParameter(method, 0)
8184
nullableParamNotRequired = SynthesizingMethodParameter(method, 1)
@@ -86,6 +89,8 @@ class RequestParamMethodArgumentResolverKotlinTests {
8689
nullableMultipartParamNotRequired = SynthesizingMethodParameter(method, 5)
8790
nonNullableMultipartParamRequired = SynthesizingMethodParameter(method, 6)
8891
nonNullableMultipartParamNotRequired = SynthesizingMethodParameter(method, 7)
92+
93+
defaultParam = SynthesizingMethodParameter(method, 8)
8994
}
9095

9196
@Test
@@ -138,6 +143,18 @@ class RequestParamMethodArgumentResolverKotlinTests {
138143
resolver.resolveArgument(nonNullableParamNotRequired, null, webRequest, binderFactory) as String
139144
}
140145

146+
@Test
147+
fun resolveDefaultValueWithoutParameter() {
148+
val result = resolver.resolveArgument(defaultParam, null, webRequest, binderFactory)
149+
assertNull(result)
150+
}
151+
152+
@Test
153+
fun resolveDefaultValueWithParameter() {
154+
request.addParameter("name", "123")
155+
val result = resolver.resolveArgument(defaultParam, null, webRequest, binderFactory)
156+
assertEquals("123", result)
157+
}
141158

142159
@Test
143160
fun resolveNullableRequiredWithMultipartParameter() {
@@ -215,7 +232,6 @@ class RequestParamMethodArgumentResolverKotlinTests {
215232
resolver.resolveArgument(nonNullableMultipartParamNotRequired, null, webRequest, binderFactory) as MultipartFile
216233
}
217234

218-
219235
@Suppress("unused_parameter")
220236
fun handle(
221237
@RequestParam("name") nullableParamRequired: String?,
@@ -226,7 +242,9 @@ class RequestParamMethodArgumentResolverKotlinTests {
226242
@RequestParam("mfile") nullableMultipartParamRequired: MultipartFile?,
227243
@RequestParam("mfile", required = false) nullableMultipartParamNotRequired: MultipartFile?,
228244
@RequestParam("mfile") nonNullableMultipartParamRequired: MultipartFile,
229-
@RequestParam("mfile", required = false) nonNullableMultipartParamNotRequired: MultipartFile) {
245+
@RequestParam("mfile", required = false) nonNullableMultipartParamNotRequired: MultipartFile,
246+
247+
@RequestParam("name") paramDefaultValue: String = "42") {
230248
}
231249

232250
}

0 commit comments

Comments
 (0)