Skip to content

Commit f06cf21

Browse files
committed
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
1 parent 254fb39 commit f06cf21

File tree

12 files changed

+679
-41
lines changed

12 files changed

+679
-41
lines changed

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

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import java.lang.reflect.InvocationTargetException;
2020
import java.lang.reflect.Method;
21+
import java.util.Map;
2122
import java.util.Objects;
2223

2324
import kotlin.Unit;
@@ -26,6 +27,7 @@
2627
import kotlin.reflect.KClass;
2728
import kotlin.reflect.KClassifier;
2829
import kotlin.reflect.KFunction;
30+
import kotlin.reflect.KParameter;
2931
import kotlin.reflect.full.KCallables;
3032
import kotlin.reflect.jvm.KCallablesJvm;
3133
import kotlin.reflect.jvm.ReflectJvmMapping;
@@ -42,6 +44,7 @@
4244
import reactor.core.publisher.Mono;
4345

4446
import org.springframework.util.Assert;
47+
import org.springframework.util.CollectionUtils;
4548

4649
/**
4750
* Utilities for working with Kotlin Coroutines.
@@ -104,8 +107,22 @@ public static Publisher<?> invokeSuspendingFunction(CoroutineContext context, Me
104107
if (method.isAccessible() && !KCallablesJvm.isAccessible(function)) {
105108
KCallablesJvm.setAccessible(function, true);
106109
}
107-
Mono<Object> mono = MonoKt.mono(context, (scope, continuation) ->
108-
KCallables.callSuspend(function, getSuspendedFunctionArgs(method, target, args), continuation))
110+
Mono<Object> mono = MonoKt.mono(context, (scope, continuation) -> {
111+
Map<KParameter, Object> argMap = CollectionUtils.newHashMap(args.length + 1);
112+
int index = 0;
113+
for (KParameter parameter : function.getParameters()) {
114+
switch (parameter.getKind()) {
115+
case INSTANCE -> argMap.put(parameter, target);
116+
case VALUE -> {
117+
if (!parameter.isOptional() || args[index] != null) {
118+
argMap.put(parameter, args[index]);
119+
}
120+
index++;
121+
}
122+
}
123+
}
124+
return KCallables.callSuspendBy(function, argMap, continuation);
125+
})
109126
.filter(result -> !Objects.equals(result, Unit.INSTANCE))
110127
.onErrorMap(InvocationTargetException.class, InvocationTargetException::getTargetException);
111128

@@ -125,14 +142,6 @@ else if (returnType instanceof KClass<?> kClass &&
125142
return mono;
126143
}
127144

128-
private static Object[] getSuspendedFunctionArgs(Method method, Object target, Object... args) {
129-
int length = (args.length == method.getParameterCount() - 1 ? args.length + 1 : args.length);
130-
Object[] functionArgs = new Object[length];
131-
functionArgs[0] = target;
132-
System.arraycopy(args, 0, functionArgs, 1, length - 1);
133-
return functionArgs;
134-
}
135-
136145
private static Flux<?> asFlux(Object flow) {
137146
return ReactorFlowKt.asFlux(((Flow<?>) flow));
138147
}

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2019 the original author or authors.
2+
* Copyright 2002-2023 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -38,6 +38,8 @@ class KotlinMethodParameterTests {
3838

3939
private val nonNullableMethod = javaClass.getMethod("nonNullable", String::class.java)
4040

41+
private val withDefaultValueMethod: Method = javaClass.getMethod("withDefaultValue", String::class.java)
42+
4143
private val innerClassConstructor = InnerClass::class.java.getConstructor(KotlinMethodParameterTests::class.java)
4244

4345
private val innerClassWithParametersConstructor = InnerClassWithParameter::class.java
@@ -52,6 +54,16 @@ class KotlinMethodParameterTests {
5254
assertThat(MethodParameter(nonNullableMethod, 0).isOptional).isFalse()
5355
}
5456

57+
@Test
58+
fun `Method parameter with default value`() {
59+
assertThat(MethodParameter(withDefaultValueMethod, 0).isOptional).isTrue()
60+
}
61+
62+
@Test
63+
fun `Method parameter without default value`() {
64+
assertThat(MethodParameter(nonNullableMethod, 0).isOptional).isFalse()
65+
}
66+
5567
@Test
5668
fun `Method return type nullability`() {
5769
assertThat(MethodParameter(nullableMethod, -1).isOptional).isTrue()
@@ -123,6 +135,8 @@ class KotlinMethodParameterTests {
123135
@Suppress("unused_parameter")
124136
fun nonNullable(nonNullable: String): Int = 42
125137

138+
fun withDefaultValue(withDefaultValue: String = "default") = withDefaultValue
139+
126140
inner class InnerClass
127141

128142
@Suppress("unused_parameter")

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

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,22 @@
1616

1717
package org.springframework.web.method.annotation;
1818

19+
import java.lang.reflect.Method;
1920
import java.util.Map;
21+
import java.util.Objects;
2022
import java.util.concurrent.ConcurrentHashMap;
2123

2224
import jakarta.servlet.ServletException;
25+
import kotlin.reflect.KFunction;
26+
import kotlin.reflect.KParameter;
27+
import kotlin.reflect.jvm.ReflectJvmMapping;
2328

2429
import org.springframework.beans.ConversionNotSupportedException;
2530
import org.springframework.beans.TypeMismatchException;
2631
import org.springframework.beans.factory.config.BeanExpressionContext;
2732
import org.springframework.beans.factory.config.BeanExpressionResolver;
2833
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
34+
import org.springframework.core.KotlinDetector;
2935
import org.springframework.core.MethodParameter;
3036
import org.springframework.lang.Nullable;
3137
import org.springframework.web.bind.ServletRequestBindingException;
@@ -60,6 +66,7 @@
6066
* @author Arjen Poutsma
6167
* @author Rossen Stoyanchev
6268
* @author Juergen Hoeller
69+
* @author Sebastien Deleuze
6370
* @since 3.1
6471
*/
6572
public abstract class AbstractNamedValueMethodArgumentResolver implements HandlerMethodArgumentResolver {
@@ -98,6 +105,9 @@ public final Object resolveArgument(MethodParameter parameter, @Nullable ModelAn
98105

99106
NamedValueInfo namedValueInfo = getNamedValueInfo(parameter);
100107
MethodParameter nestedParameter = parameter.nestedIfOptional();
108+
boolean hasDefaultValue = KotlinDetector.isKotlinReflectPresent()
109+
&& KotlinDetector.isKotlinType(parameter.getDeclaringClass())
110+
&& KotlinDelegate.hasDefaultValue(nestedParameter);
101111

102112
Object resolvedName = resolveEmbeddedValuesAndExpressions(namedValueInfo.name);
103113
if (resolvedName == null) {
@@ -113,13 +123,15 @@ public final Object resolveArgument(MethodParameter parameter, @Nullable ModelAn
113123
else if (namedValueInfo.required && !nestedParameter.isOptional()) {
114124
handleMissingValue(namedValueInfo.name, nestedParameter, webRequest);
115125
}
116-
arg = handleNullValue(namedValueInfo.name, arg, nestedParameter.getNestedParameterType());
126+
if (!hasDefaultValue) {
127+
arg = handleNullValue(namedValueInfo.name, arg, nestedParameter.getNestedParameterType());
128+
}
117129
}
118130
else if ("".equals(arg) && namedValueInfo.defaultValue != null) {
119131
arg = resolveEmbeddedValuesAndExpressions(namedValueInfo.defaultValue);
120132
}
121133

122-
if (binderFactory != null) {
134+
if (binderFactory != null && (arg != null || !hasDefaultValue)) {
123135
WebDataBinder binder = binderFactory.createBinder(webRequest, null, namedValueInfo.name);
124136
try {
125137
arg = binder.convertIfNecessary(arg, parameter.getParameterType(), parameter);
@@ -304,4 +316,27 @@ public NamedValueInfo(String name, boolean required, @Nullable String defaultVal
304316
}
305317
}
306318

319+
/**
320+
* Inner class to avoid a hard dependency on Kotlin at runtime.
321+
*/
322+
private static class KotlinDelegate {
323+
324+
/**
325+
* Check whether the specified {@link MethodParameter} represents a nullable Kotlin type
326+
* or an optional parameter (with a default value in the Kotlin declaration).
327+
*/
328+
public static boolean hasDefaultValue(MethodParameter parameter) {
329+
Method method = Objects.requireNonNull(parameter.getMethod());
330+
KFunction<?> function = ReflectJvmMapping.getKotlinFunction(method);
331+
if (function != null) {
332+
int index = 0;
333+
for (KParameter kParameter : function.getParameters()) {
334+
if (KParameter.Kind.VALUE.equals(kParameter.getKind()) && parameter.getParameterIndex() == index++) {
335+
return kParameter.isOptional();
336+
}
337+
}
338+
}
339+
return false;
340+
}
341+
}
307342
}

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

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@
1919
import java.lang.reflect.InvocationTargetException;
2020
import java.lang.reflect.Method;
2121
import java.util.Arrays;
22+
import java.util.Map;
23+
import java.util.Objects;
2224

25+
import kotlin.reflect.KFunction;
26+
import kotlin.reflect.KParameter;
27+
import kotlin.reflect.jvm.KCallablesJvm;
28+
import kotlin.reflect.jvm.ReflectJvmMapping;
2329
import org.reactivestreams.Publisher;
2430

2531
import org.springframework.context.MessageSource;
@@ -29,6 +35,7 @@
2935
import org.springframework.core.MethodParameter;
3036
import org.springframework.core.ParameterNameDiscoverer;
3137
import org.springframework.lang.Nullable;
38+
import org.springframework.util.CollectionUtils;
3239
import org.springframework.util.ObjectUtils;
3340
import org.springframework.validation.beanvalidation.MethodValidator;
3441
import org.springframework.web.bind.WebDataBinder;
@@ -236,8 +243,13 @@ private Class<?>[] getValidationGroups() {
236243
protected Object doInvoke(Object... args) throws Exception {
237244
Method method = getBridgedMethod();
238245
try {
239-
if (KotlinDetector.isSuspendingFunction(method)) {
240-
return invokeSuspendingFunction(method, getBean(), args);
246+
if (KotlinDetector.isKotlinReflectPresent()) {
247+
if (KotlinDetector.isSuspendingFunction(method)) {
248+
return invokeSuspendingFunction(method, getBean(), args);
249+
}
250+
else if (KotlinDetector.isKotlinType(method.getDeclaringClass())) {
251+
return KotlinDelegate.invokeFunction(method, getBean(), args);
252+
}
241253
}
242254
return method.invoke(getBean(), args);
243255
}
@@ -279,4 +291,33 @@ protected Publisher<?> invokeSuspendingFunction(Method method, Object target, Ob
279291
return CoroutinesUtils.invokeSuspendingFunction(method, target, args);
280292
}
281293

294+
/**
295+
* Inner class to avoid a hard dependency on Kotlin at runtime.
296+
*/
297+
private static class KotlinDelegate {
298+
299+
@Nullable
300+
@SuppressWarnings("deprecation")
301+
public static Object invokeFunction(Method method, Object target, Object[] args) {
302+
KFunction<?> function = Objects.requireNonNull(ReflectJvmMapping.getKotlinFunction(method));
303+
if (method.isAccessible() && !KCallablesJvm.isAccessible(function)) {
304+
KCallablesJvm.setAccessible(function, true);
305+
}
306+
Map<KParameter, Object> argMap = CollectionUtils.newHashMap(args.length + 1);
307+
int index = 0;
308+
for (KParameter parameter : function.getParameters()) {
309+
switch (parameter.getKind()) {
310+
case INSTANCE -> argMap.put(parameter, target);
311+
case VALUE -> {
312+
if (!parameter.isOptional() || args[index] != null) {
313+
argMap.put(parameter, args[index]);
314+
}
315+
index++;
316+
}
317+
}
318+
}
319+
return function.callBy(argMap);
320+
}
321+
}
322+
282323
}

spring-web/src/test/java/org/springframework/web/method/support/StubArgumentResolver.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2018 the original author or authors.
2+
* Copyright 2002-2023 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -47,7 +47,7 @@ public StubArgumentResolver(Class<?> valueType) {
4747
this(valueType, null);
4848
}
4949

50-
public StubArgumentResolver(Class<?> valueType, Object value) {
50+
public StubArgumentResolver(Class<?> valueType, @Nullable Object value) {
5151
this.valueType = valueType;
5252
this.value = value;
5353
}

0 commit comments

Comments
 (0)