2121import java .util .HashSet ;
2222import java .util .List ;
2323import java .util .Map ;
24+ import java .util .Optional ;
2425import java .util .Set ;
26+ import java .util .function .Function ;
2527import java .util .stream .Collectors ;
2628import java .util .stream .Stream ;
2729import javax .annotation .Nullable ;
2830import org .sonar .check .Rule ;
2931import org .sonar .java .annotations .VisibleForTesting ;
32+ import org .sonar .plugins .java .api .DependencyVersionAware ;
3033import org .sonar .plugins .java .api .IssuableSubscriptionVisitor ;
34+ import org .sonar .plugins .java .api .Version ;
3135import org .sonar .plugins .java .api .semantic .Symbol ;
3236import org .sonar .plugins .java .api .semantic .SymbolMetadata ;
3337import org .sonar .plugins .java .api .semantic .Type ;
4145import static org .sonar .java .checks .helpers .MethodTreeUtils .isSetterLike ;
4246
4347@ Rule (key = "S6856" )
44- public class MissingPathVariableAnnotationCheck extends IssuableSubscriptionVisitor {
48+ public class MissingPathVariableAnnotationCheck extends IssuableSubscriptionVisitor implements DependencyVersionAware {
4549 private static final String PATH_VARIABLE_ANNOTATION = "org.springframework.web.bind.annotation.PathVariable" ;
4650 private static final String MAP = "java.util.Map" ;
4751 private static final String MODEL_ATTRIBUTE_ANNOTATION = "org.springframework.web.bind.annotation.ModelAttribute" ;
@@ -60,6 +64,10 @@ public class MissingPathVariableAnnotationCheck extends IssuableSubscriptionVisi
6064 "lombok.Data" ,
6165 "lombok.Setter" );
6266
67+ private static final String BIND_PARAM_ANNOTATION = "org.springframework.web.bind.annotation.BindParam" ;
68+
69+ private SpringWebVersion springWebVersion ;
70+
6371 @ Override
6472 public List <Tree .Kind > nodesToVisit () {
6573 return List .of (Tree .Kind .CLASS );
@@ -191,14 +199,14 @@ private void checkParametersAndPathTemplate(MethodTree method, Set<String> model
191199 return ;
192200 }
193201
194- // finally, we handle the case where a uri parameter (/{aParam}/) doesn't match to path- or ModelAttribute- inherited variables
202+ // finally, we handle the case where a uri parameter (/{aParam}/) doesn't match to path-, ModelAttribute-, or class / record inherited variables
195203 Set <String > allPathVariables = methodParameters .stream ()
196204 .map (ParameterInfo ::value )
197205 .collect (Collectors .toSet ());
198206 // Add properties inherited from @ModelAttribute methods
199207 allPathVariables .addAll (modelAttributeMethodParameters );
200- // Add properties inherited from @ModelAttribute class parameters
201- allPathVariables .addAll (extractModelAttributeClassProperties (method ));
208+ // Add properties inherited from class and record parameters
209+ allPathVariables .addAll (extractClassAndRecordProperties (method ));
202210
203211 templateVariables .stream ()
204212 .filter (uri -> !allPathVariables .containsAll (uri .value ()))
@@ -278,20 +286,29 @@ private static String removePropertyPlaceholder(String path){
278286 return path .replaceAll (PROPERTY_PLACEHOLDER_PATTERN , "" );
279287 }
280288
281- private static Set <String > extractModelAttributeClassProperties (MethodTree method ) {
289+ private boolean requiresModelAttributeAnnotation (SymbolMetadata metadata ) {
290+ // for spring-web < 5.3 we need to use ModelAttribute annotation to extract properties from classes / records
291+ return springWebVersion == SpringWebVersion .LESS_THAN_5_3 && !metadata .isAnnotatedWith (MODEL_ATTRIBUTE_ANNOTATION );
292+ }
293+
294+ private Set <String > extractClassAndRecordProperties (MethodTree method ) {
282295 Set <String > properties = new HashSet <>();
283296
284297 for (var parameter : method .parameters ()) {
285- SymbolMetadata metadata = parameter .symbol ().metadata ();
286298 Type parameterType = parameter .type ().symbolType ();
287-
288- if (! metadata . isAnnotatedWith ( MODEL_ATTRIBUTE_ANNOTATION ) || parameterType .isUnknown ( )
289- || isStandardDataType ( parameterType ) || parameterType . isSubtypeOf ( MAP )) {
299+ if ( parameterType . isUnknown ()
300+ || isStandardDataType ( parameterType ) || parameterType .isSubtypeOf ( MAP )
301+ || requiresModelAttributeAnnotation ( parameter . symbol (). metadata () )) {
290302 continue ;
291303 }
292304
293- // Extract setter properties from the class
294- properties .addAll (extractSetterProperties (parameterType ));
305+ if (parameterType .isSubtypeOf ("java.lang.Record" ) && springWebVersion != SpringWebVersion .LESS_THAN_5_3 ) {
306+ // Extract record's components
307+ properties .addAll (extractRecordProperties (parameterType ));
308+ } else if (parameterType .isClass ()) {
309+ // Extract setter properties from the class
310+ properties .addAll (extractSetterProperties (parameterType ));
311+ }
295312 }
296313
297314 return properties ;
@@ -345,6 +362,33 @@ private static Set<String> checkForLombokSetters(Symbol.TypeSymbol typeSymbol) {
345362 return properties ;
346363 }
347364
365+ @ VisibleForTesting
366+ static Set <String > extractRecordProperties (Type type ) {
367+ Set <String > properties = new HashSet <>();
368+ // For records, extract component names from the record components
369+ // Records automatically generate accessor methods for their components
370+ type .symbol ().memberSymbols ().stream ()
371+ .filter (Symbol ::isVariableSymbol )
372+ .map (Symbol .VariableSymbol .class ::cast )
373+ .filter (f -> !f .isStatic ())
374+ .forEach (field -> properties .add (getComponentName (field )));
375+
376+ return properties ;
377+ }
378+
379+ private static String getComponentName (Symbol .VariableSymbol field ) {
380+ // Check if the component has @BindParam annotation for custom binding name
381+ String componentName = field .name ();
382+ var bindParamValues = field .metadata ().valuesForAnnotation (BIND_PARAM_ANNOTATION );
383+ if (bindParamValues != null ) {
384+ Object value = bindParamValues .get (0 ).value ();
385+ if (value instanceof String bindParamName && !bindParamName .isEmpty ()) {
386+ componentName = bindParamName ;
387+ }
388+ }
389+ return componentName ;
390+ }
391+
348392 static class PathPatternParser {
349393 private PathPatternParser () {
350394 }
@@ -474,4 +518,23 @@ private static String substringToCurrentChar(int start) {
474518 }
475519
476520 }
521+
522+ @ Override
523+ public boolean isCompatibleWithDependencies (Function <String , Optional <Version >> dependencyFinder ) {
524+ Optional <Version > springWebCurrentVersion = dependencyFinder .apply ("spring-web" );
525+ if (springWebCurrentVersion .isEmpty ()) {
526+ return false ;
527+ }
528+ springWebVersion = getSpringWebVersion (springWebCurrentVersion .get ());
529+ return true ;
530+ }
531+
532+ private static SpringWebVersion getSpringWebVersion (Version springWebVersion ) {
533+ return (springWebVersion .isLowerThan ("5.3" ) ? SpringWebVersion .LESS_THAN_5_3 : SpringWebVersion .START_FROM_5_3 );
534+ }
535+
536+ private enum SpringWebVersion {
537+ LESS_THAN_5_3 ,
538+ START_FROM_5_3 ;
539+ }
477540}
0 commit comments