Skip to content

Commit 5e04e9c

Browse files
authored
feat: add reflection hints for ClientCallable types (#22909)
* feat: add reflection hints for ClientCallable types Adds a Spring AOT processor that scans application code for ClientCallable annotated methods and registers reflection hints for native build for method return type and parameter types. Fixes #22879 * resolve types recursively
1 parent 61d4f94 commit 5e04e9c

File tree

5 files changed

+878
-0
lines changed

5 files changed

+878
-0
lines changed

vaadin-spring/src/main/java/com/vaadin/flow/spring/SpringBootAutoConfiguration.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939

4040
import com.vaadin.flow.server.Constants;
4141
import com.vaadin.flow.server.VaadinServlet;
42+
import com.vaadin.flow.spring.springnative.ClientCallableAotProcessor;
4243
import com.vaadin.flow.spring.springnative.VaadinBeanFactoryInitializationAotProcessor;
4344

4445
/**
@@ -63,6 +64,11 @@ static VaadinBeanFactoryInitializationAotProcessor flowBeanFactoryInitialization
6364
return new VaadinBeanFactoryInitializationAotProcessor();
6465
}
6566

67+
@Bean
68+
static ClientCallableAotProcessor flowClientCallableFactoryInitializationAotProcessor() {
69+
return new ClientCallableAotProcessor();
70+
}
71+
6672
/**
6773
* Creates a {@link ServletContextInitializer} instance.
6874
*

vaadin-spring/src/main/java/com/vaadin/flow/spring/VaadinConfigurationProperties.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,23 @@ public static List<String> getExcludedUrls(Environment environment) {
6666
.orElse(null);
6767
}
6868

69+
/**
70+
* Gets the allowed packages using the given environment.
71+
*
72+
* This is needed only when VaadinConfigurationProperties is not available
73+
* for injection, e.g. in AOT processors.
74+
*
75+
* @param environment
76+
* the application environment
77+
* @return the allowed packages or an empty list if none is defined
78+
*/
79+
public static List<String> getAllowedPackages(Environment environment) {
80+
return Binder.get(environment)
81+
.bind("vaadin", VaadinConfigurationProperties.class)
82+
.map(VaadinConfigurationProperties::getAllowedPackages)
83+
.orElse(Collections.emptyList());
84+
}
85+
6986
/**
7087
* Base URL mapping of the Vaadin servlet.
7188
*/
Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
/*
2+
* Copyright 2000-2025 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.flow.spring.springnative;
17+
18+
import java.lang.reflect.GenericArrayType;
19+
import java.lang.reflect.Method;
20+
import java.lang.reflect.ParameterizedType;
21+
import java.lang.reflect.Type;
22+
import java.lang.reflect.TypeVariable;
23+
import java.lang.reflect.WildcardType;
24+
import java.util.ArrayList;
25+
import java.util.Collection;
26+
import java.util.Comparator;
27+
import java.util.HashSet;
28+
import java.util.LinkedHashSet;
29+
import java.util.List;
30+
import java.util.Set;
31+
32+
import org.slf4j.Logger;
33+
import org.slf4j.LoggerFactory;
34+
import org.springframework.aot.hint.BindingReflectionHintsRegistrar;
35+
import org.springframework.aot.hint.ReflectionHints;
36+
import org.springframework.beans.factory.aot.BeanFactoryInitializationAotContribution;
37+
import org.springframework.beans.factory.aot.BeanFactoryInitializationAotProcessor;
38+
import org.springframework.beans.factory.config.BeanDefinition;
39+
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
40+
import org.springframework.boot.autoconfigure.AutoConfigurationPackages;
41+
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
42+
import org.springframework.core.env.ConfigurableEnvironment;
43+
import org.springframework.core.type.filter.AssignableTypeFilter;
44+
import org.springframework.util.ClassUtils;
45+
46+
import com.vaadin.flow.component.ClientCallable;
47+
import com.vaadin.flow.component.Component;
48+
import com.vaadin.flow.spring.VaadinConfigurationProperties;
49+
50+
/**
51+
* AOT processor that registers reflection hints for types used in
52+
* {@link ClientCallable} methods.
53+
* <p>
54+
* This processor scans component classes for methods annotated with
55+
* {@code @ClientCallable} and registers reflection hints for all types used in
56+
* their signatures. This ensures that parameter and return types can be
57+
* properly serialized and deserialized when the application runs as a native
58+
* image.
59+
* <p>
60+
* The processor handles complex generic types including parameterized types,
61+
* wildcards, and type variables, recursively extracting all concrete types that
62+
* require reflection access.
63+
*
64+
* @see ClientCallable
65+
* @see BeanFactoryInitializationAotProcessor
66+
*/
67+
public class ClientCallableAotProcessor
68+
implements BeanFactoryInitializationAotProcessor {
69+
70+
private static final Logger LOGGER = LoggerFactory
71+
.getLogger(ClientCallableAotProcessor.class);
72+
73+
@Override
74+
public BeanFactoryInitializationAotContribution processAheadOfTime(
75+
ConfigurableListableBeanFactory beanFactory) {
76+
77+
Set<Class<?>> usedTypes = new HashSet<>();
78+
Collection<String> packagesToScan = getPackagesToScan(beanFactory);
79+
LOGGER.info("Scanning packages {} for @ClientCallable methods",
80+
packagesToScan);
81+
ClassPathScanningCandidateComponentProvider scanner = new ClassPathScanningCandidateComponentProvider(
82+
false);
83+
configureScanner(scanner);
84+
for (String packageName : packagesToScan) {
85+
Set<BeanDefinition> candidates = scanner
86+
.findCandidateComponents(packageName);
87+
LOGGER.debug("Found {} candidate components for package {}",
88+
candidates.size(), packageName);
89+
for (BeanDefinition bd : candidates) {
90+
if (bd.getBeanClassName() != null) {
91+
LOGGER.debug(
92+
"Inspecting component class {} for @ClientCallable methods",
93+
bd.getBeanClassName());
94+
try {
95+
Class<?> clazz = ClassUtils.forName(
96+
bd.getBeanClassName(),
97+
beanFactory.getBeanClassLoader());
98+
processClass(clazz, usedTypes);
99+
} catch (ClassNotFoundException e) {
100+
LOGGER.warn("Could not load class {}",
101+
bd.getBeanClassName(), e);
102+
}
103+
}
104+
}
105+
}
106+
if (usedTypes.isEmpty()) {
107+
LOGGER.debug(
108+
"No @ClientCallable types to register for reflection found");
109+
return null;
110+
}
111+
LOGGER.debug(
112+
"Found @ClientCallable types to register for reflection: {}",
113+
usedTypes);
114+
return (generationContext, beanFactoryInitializationCode) -> {
115+
// Recursively register all types that require reflection hints
116+
BindingReflectionHintsRegistrar registrar = new BindingReflectionHintsRegistrar();
117+
ReflectionHints reflectionHints = generationContext
118+
.getRuntimeHints().reflection();
119+
registrar.registerReflectionHints(reflectionHints,
120+
usedTypes.toArray(new Class[0]));
121+
};
122+
}
123+
124+
// Visible for testing
125+
void configureScanner(ClassPathScanningCandidateComponentProvider scanner) {
126+
scanner.addIncludeFilter(new AssignableTypeFilter(Component.class));
127+
}
128+
129+
/**
130+
* Gets the list of packages to scan for Vaadin components.
131+
* <p>
132+
* This method returns a list of packages that includes:
133+
* <ul>
134+
* <li>The com.vaadin package</li>
135+
* <li>Auto-configuration packages from Spring Boot</li>
136+
* <li>Allowed packages from vaadin.allowed-packages configuration
137+
* property</li>
138+
* </ul>
139+
*
140+
* @param beanFactory
141+
* the bean factory
142+
* @return set of packages to scan
143+
*/
144+
private static Collection<String> getPackagesToScan(
145+
ConfigurableListableBeanFactory beanFactory) {
146+
List<String> packages = new ArrayList<>();
147+
packages.add("com.vaadin");
148+
packages.addAll(AutoConfigurationPackages.get(beanFactory));
149+
150+
// Add allowed packages from the configuration if set
151+
ConfigurableEnvironment environment = beanFactory
152+
.getBean(ConfigurableEnvironment.class);
153+
List<String> allowedPackages = VaadinConfigurationProperties
154+
.getAllowedPackages(environment);
155+
if (allowedPackages != null && !allowedPackages.isEmpty()) {
156+
packages.addAll(allowedPackages);
157+
}
158+
159+
// Remove duplicates and redundant packages (e.g. ignore com.vaadin.xyz
160+
// if com.vaadin is already registered)
161+
packages.sort(Comparator.comparingInt(String::length));
162+
Set<String> result = new LinkedHashSet<>();
163+
for (String pkg : packages) {
164+
if (result.isEmpty() || result.stream().noneMatch(
165+
registeredPkg -> pkg.startsWith(registeredPkg + "."))) {
166+
result.add(pkg);
167+
}
168+
}
169+
return result;
170+
}
171+
172+
/**
173+
* Processes a single class, extracting types from all
174+
* {@code @ClientCallable} methods.
175+
*
176+
* @param clazz
177+
* the class to process
178+
* @param types
179+
* the set to accumulate discovered types
180+
*/
181+
private void processClass(Class<?> clazz, Set<Class<?>> types) {
182+
// Flow looks for ClientCallable methods only in classes that extend
183+
// Component
184+
if (!Component.class.isAssignableFrom(clazz)) {
185+
return;
186+
}
187+
while (Component.class != clazz) {
188+
for (Method method : clazz.getDeclaredMethods()) {
189+
if (method.isAnnotationPresent(ClientCallable.class)) {
190+
processMethod(method, types);
191+
}
192+
}
193+
clazz = clazz.getSuperclass();
194+
}
195+
}
196+
197+
/**
198+
* Processes a single {@code @ClientCallable} method, extracting types from
199+
* its return type and parameters.
200+
*
201+
* @param method
202+
* the method to process
203+
* @param types
204+
* the set to accumulate discovered types
205+
*/
206+
private void processMethod(Method method, Set<Class<?>> types) {
207+
LOGGER.info("Processing @ClientCallable method {}", method);
208+
// Process return type
209+
Type returnType = method.getGenericReturnType();
210+
processType(returnType, types);
211+
212+
// Process parameter types
213+
Type[] paramTypes = method.getGenericParameterTypes();
214+
for (Type paramType : paramTypes) {
215+
processType(paramType, types);
216+
}
217+
}
218+
219+
/**
220+
* Processes a type, resolving all concrete classes that require reflection
221+
* hints and filtering out types that don't need registration.
222+
*
223+
* @param type
224+
* the type to process
225+
* @param types
226+
* the set to accumulate types that need reflection hints
227+
*/
228+
private void processType(Type type, Set<Class<?>> types) {
229+
Set<Class<?>> typesToRegister = new HashSet<>();
230+
resolveTypes(type, typesToRegister);
231+
232+
for (Class<?> typeToRegister : typesToRegister) {
233+
if (shouldRegisterType(typeToRegister)) {
234+
types.add(typeToRegister);
235+
} else {
236+
LOGGER.trace(
237+
"Ignoring @ClientCallable return/parameter type {}",
238+
typeToRegister);
239+
}
240+
}
241+
}
242+
243+
/**
244+
* Recursively resolves all concrete classes from a generic type.
245+
* <p>
246+
* Handles:
247+
* <ul>
248+
* <li>Plain classes</li>
249+
* <li>Parameterized types (e.g., {@code List<String>})</li>
250+
* <li>Wildcard types (e.g., {@code ? extends Number})</li>
251+
* <li>Type variables (e.g., {@code T extends Comparable})</li>
252+
* </ul>
253+
*
254+
* @param type
255+
* the type to resolve
256+
* @param result
257+
* the set to accumulate resolved classes
258+
*/
259+
private void resolveTypes(Type type, Set<Class<?>> result) {
260+
if (type instanceof Class<?> clazz) {
261+
if (clazz.isArray()) {
262+
clazz = clazz.getComponentType();
263+
}
264+
result.add(clazz);
265+
} else if (type instanceof ParameterizedType paramType) {
266+
267+
// Add raw type
268+
Type rawType = paramType.getRawType();
269+
if (rawType instanceof Class) {
270+
result.add((Class<?>) rawType);
271+
}
272+
273+
// Process type arguments
274+
for (Type typeArg : paramType.getActualTypeArguments()) {
275+
resolveTypes(typeArg, result);
276+
}
277+
278+
} else if (type instanceof WildcardType wildcardType) {
279+
280+
// Process upper bounds (extends)
281+
for (Type upperBound : wildcardType.getUpperBounds()) {
282+
resolveTypes(upperBound, result);
283+
}
284+
285+
// Process lower bounds (super)
286+
for (Type lowerBound : wildcardType.getLowerBounds()) {
287+
resolveTypes(lowerBound, result);
288+
}
289+
290+
} else if (type instanceof TypeVariable<?> typeVar) {
291+
292+
// Process bounds
293+
for (Type bound : typeVar.getBounds()) {
294+
resolveTypes(bound, result);
295+
}
296+
} else if (type instanceof GenericArrayType arrayType) {
297+
resolveTypes(arrayType.getGenericComponentType(), result);
298+
}
299+
}
300+
301+
/**
302+
* Determines whether a type should be registered for reflection hints.
303+
* <p>
304+
* Filters out types that don't need registration:
305+
* <ul>
306+
* <li>Primitive types</li>
307+
* <li>Void</li>
308+
* <li>Types from standard packages ({@code java.*}, {@code javax.*},
309+
* {@code jakarta.*})</li>
310+
* <li>Array types</li>
311+
* </ul>
312+
*
313+
* @param type
314+
* the type to check
315+
* @return {@code true} if the type should be registered, {@code false}
316+
* otherwise
317+
*/
318+
private boolean shouldRegisterType(Class<?> type) {
319+
// Ignore primitive types
320+
if (type.isPrimitive()) {
321+
return false;
322+
}
323+
324+
// Ignore void
325+
if (type == Void.class || type == void.class) {
326+
return false;
327+
}
328+
329+
// Ignore common types
330+
String packageName = type.getPackageName();
331+
if (packageName.startsWith("java.") || packageName.startsWith("javax.")
332+
|| packageName.startsWith("jakarta.")
333+
|| packageName.startsWith("tools.jackson.")) {
334+
return false;
335+
}
336+
337+
// Ignore array types (register component type instead)
338+
if (type.isArray()) {
339+
return false;
340+
}
341+
342+
return true;
343+
}
344+
}

vaadin-spring/src/test/java/com/vaadin/flow/spring/SpringClassesSerializableTest.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ protected Stream<String> getExcludedPatterns() {
9494
"com\\.vaadin\\.flow\\.spring\\.VaadinConfigurationProperties",
9595
"com\\.vaadin\\.flow\\.spring\\.SpringDevToolsPortHandler",
9696
"com\\.vaadin\\.flow\\.spring\\.springnative\\.AtmosphereHintsRegistrar",
97+
"com\\.vaadin\\.flow\\.spring\\.springnative\\.ClientCallableAotProcessor",
9798
"com\\.vaadin\\.flow\\.spring\\.springnative\\.VaadinBeanFactoryInitializationAotProcessor",
9899
"com\\.vaadin\\.flow\\.spring\\.springnative\\.VaadinBeanFactoryInitializationAotProcessor\\$Marker",
99100
"com\\.vaadin\\.flow\\.spring\\.springnative\\.VaadinHintsRegistrar",

0 commit comments

Comments
 (0)