From 84c43bd61d35fff17bc558bdf025d293bc8a40ad Mon Sep 17 00:00:00 2001
From: asya-vorobeva
Date: Tue, 10 Mar 2026 11:53:22 +0100
Subject: [PATCH] SONARJAVA-5975 S6856: Add support for record component
extraction
- Support @BindParam annotation for custom binding names on record components
- Records are only processed for Spring Web >= 5.3
- Update rule description and title
Co-Authored-By: Claude Sonnet 4.5
---
.../resources/autoscan/diffs/diff_S6856.json | 2 +-
...ariableAnnotationCheck_ModelAttribute.java | 21 ++++-
.../ExtractRecordPropertiesTestData.java | 25 ++++++
...ariableAnnotationCheck_classAndRecord.java | 70 +++++++++++++++
.../MissingPathVariableAnnotationCheck.java | 85 +++++++++++++++---
...issingPathVariableAnnotationCheckTest.java | 70 +++++++++++++++
.../org/sonar/l10n/java/rules/java/S6856.html | 90 ++++++++++++++++++-
.../org/sonar/l10n/java/rules/java/S6856.json | 2 +-
8 files changed, 346 insertions(+), 19 deletions(-)
create mode 100644 java-checks-test-sources/spring-3.2/src/main/java/checks/ExtractRecordPropertiesTestData.java
create mode 100644 java-checks-test-sources/spring-3.2/src/main/java/checks/MissingPathVariableAnnotationCheck_classAndRecord.java
diff --git a/its/autoscan/src/test/resources/autoscan/diffs/diff_S6856.json b/its/autoscan/src/test/resources/autoscan/diffs/diff_S6856.json
index 0e4003d577c..39ef299c909 100644
--- a/its/autoscan/src/test/resources/autoscan/diffs/diff_S6856.json
+++ b/its/autoscan/src/test/resources/autoscan/diffs/diff_S6856.json
@@ -1,6 +1,6 @@
{
"ruleKey": "S6856",
"hasTruePositives": false,
- "falseNegatives": 59,
+ "falseNegatives": 61,
"falsePositives": 0
}
diff --git a/java-checks-test-sources/default/src/main/java/checks/spring/s6856/MissingPathVariableAnnotationCheck_ModelAttribute.java b/java-checks-test-sources/default/src/main/java/checks/spring/s6856/MissingPathVariableAnnotationCheck_ModelAttribute.java
index 667b85aef90..2562d81aaa5 100644
--- a/java-checks-test-sources/default/src/main/java/checks/spring/s6856/MissingPathVariableAnnotationCheck_ModelAttribute.java
+++ b/java-checks-test-sources/default/src/main/java/checks/spring/s6856/MissingPathVariableAnnotationCheck_ModelAttribute.java
@@ -10,20 +10,22 @@ public class MissingPathVariableAnnotationCheck_ModelAttribute {
class ParentController {
@ModelAttribute("viewCfg")
- public String getView(@PathVariable("view") final String view){
+ public String getView(@PathVariable("view") final String view) {
return "";
}
}
+
class ChildController extends ParentController {
@GetMapping("/model/{view}") //Compliant, parent class defines 'view' path var in the model attribute
- public String list(@ModelAttribute("viewCfg") final String viewConfig){
+ public String list(@ModelAttribute("viewCfg") final String viewConfig) {
return "";
}
}
+
class MissingParentChildController extends MissingPathVariableParentInDifferentSample {
@GetMapping("/model/{view}") // Noncompliant
// FP: parent class in different file, cannot collect the model attribute
- public String list(@ModelAttribute("parentView") final String viewConfig){
+ public String list(@ModelAttribute("parentView") final String viewConfig) {
return "";
}
}
@@ -35,7 +37,7 @@ public String getUser(@PathVariable String id, @PathVariable String name) { // a
}
@ModelAttribute("empty")
- public String emptyModel(String notPathVariable){
+ public String emptyModel(String notPathVariable) {
return "";
}
@@ -210,4 +212,15 @@ public String process(
return "result";
}
}
+
+ // Test case: Records: must be noncompliant for spring-web < 5.3
+ record ReportRecord(String project, int year, String month) {
+ }
+
+ static class RecordBinding {
+ @GetMapping("/reports/{project}/{year}/{month}") // Noncompliant
+ public String getReport(ReportRecord record) {
+ return "reportDetails";
+ }
+ }
}
diff --git a/java-checks-test-sources/spring-3.2/src/main/java/checks/ExtractRecordPropertiesTestData.java b/java-checks-test-sources/spring-3.2/src/main/java/checks/ExtractRecordPropertiesTestData.java
new file mode 100644
index 00000000000..3691b85e8ad
--- /dev/null
+++ b/java-checks-test-sources/spring-3.2/src/main/java/checks/ExtractRecordPropertiesTestData.java
@@ -0,0 +1,25 @@
+package checks;
+
+import org.springframework.web.bind.annotation.BindParam;
+
+public class ExtractRecordPropertiesTestData {
+ // Record with components
+ record RecordWithComponents(String project, int year, String month) {
+ }
+
+ // Empty record
+ record EmptyRecord() {
+ }
+
+ // Record with @BindParam annotation
+ record RecordWithBindParam(@BindParam("order-name") String orderName, String details) {
+ }
+
+ // Record with mixed @BindParam and regular components
+ record RecordMixedBindParam(
+ @BindParam("project-id") String projectId,
+ String name,
+ @BindParam("user-id") String userId
+ ) {
+ }
+}
diff --git a/java-checks-test-sources/spring-3.2/src/main/java/checks/MissingPathVariableAnnotationCheck_classAndRecord.java b/java-checks-test-sources/spring-3.2/src/main/java/checks/MissingPathVariableAnnotationCheck_classAndRecord.java
new file mode 100644
index 00000000000..502b97da5ba
--- /dev/null
+++ b/java-checks-test-sources/spring-3.2/src/main/java/checks/MissingPathVariableAnnotationCheck_classAndRecord.java
@@ -0,0 +1,70 @@
+package checks;
+
+import org.springframework.web.bind.annotation.BindParam;
+import org.springframework.web.bind.annotation.GetMapping;
+
+public class MissingPathVariableAnnotationCheck_classAndRecord {
+ static class ReportPeriod {
+ private String project;
+ private int year;
+ private String month;
+
+ public String getProject() {
+ return project;
+ }
+
+ public int getYear() {
+ return year;
+ }
+
+ public String getMonth() {
+ return month;
+ }
+
+ public void setProject(String project) {
+ this.project = project;
+ }
+
+ public void setYear(int year) {
+ this.year = year;
+ }
+
+ public void setMonth(String month) {
+ this.month = month;
+ }
+ }
+
+ record ReportPeriodRecord(String project, int year, String month) {
+ }
+
+ static class ReportPeriodBind {
+ @GetMapping("/reports/{project}/{year}/{month}")
+ public String getReport(ReportPeriod period) {
+ // Spring sees {project} in the URL and calls period.setProject()
+ // Spring sees {year} in the URL and calls period.setYear()
+ return "reportDetails";
+ }
+
+ @GetMapping("/reports/{project}/{year}/{month}")
+ public String getAnotherReport(ReportPeriodRecord period) {
+ // Spring sees {project} in the URL and calls period.project()
+ // Spring sees {year} in the URL and calls period.year()
+ return "reportDetails";
+ }
+
+ public record Order(@BindParam("order-name") String orderName, String details){}
+
+ @GetMapping("/{order-name}/details")
+ public String getOrderDetails(Order order){
+ // Spring sees {order-name} in the URL and calls order.orderName()
+ return order.details();
+ }
+
+ @GetMapping("/{orderName}/details") // Noncompliant {{Bind template variable "orderName" to a method parameter.}}
+ public String getOrderDetailsWrongParameterName(Order order){
+ // Spring sees {orderName} in the URL and can't find order's orderName because of the wrong binding
+ return order.details();
+ }
+ }
+
+}
diff --git a/java-checks/src/main/java/org/sonar/java/checks/spring/MissingPathVariableAnnotationCheck.java b/java-checks/src/main/java/org/sonar/java/checks/spring/MissingPathVariableAnnotationCheck.java
index 0aff085b81d..76d8833f8c9 100644
--- a/java-checks/src/main/java/org/sonar/java/checks/spring/MissingPathVariableAnnotationCheck.java
+++ b/java-checks/src/main/java/org/sonar/java/checks/spring/MissingPathVariableAnnotationCheck.java
@@ -21,13 +21,17 @@
import java.util.HashSet;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.Set;
+import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.annotation.Nullable;
import org.sonar.check.Rule;
import org.sonar.java.annotations.VisibleForTesting;
+import org.sonar.plugins.java.api.DependencyVersionAware;
import org.sonar.plugins.java.api.IssuableSubscriptionVisitor;
+import org.sonar.plugins.java.api.Version;
import org.sonar.plugins.java.api.semantic.Symbol;
import org.sonar.plugins.java.api.semantic.SymbolMetadata;
import org.sonar.plugins.java.api.semantic.Type;
@@ -41,7 +45,7 @@
import static org.sonar.java.checks.helpers.MethodTreeUtils.isSetterLike;
@Rule(key = "S6856")
-public class MissingPathVariableAnnotationCheck extends IssuableSubscriptionVisitor {
+public class MissingPathVariableAnnotationCheck extends IssuableSubscriptionVisitor implements DependencyVersionAware {
private static final String PATH_VARIABLE_ANNOTATION = "org.springframework.web.bind.annotation.PathVariable";
private static final String MAP = "java.util.Map";
private static final String MODEL_ATTRIBUTE_ANNOTATION = "org.springframework.web.bind.annotation.ModelAttribute";
@@ -60,6 +64,10 @@ public class MissingPathVariableAnnotationCheck extends IssuableSubscriptionVisi
"lombok.Data",
"lombok.Setter");
+ private static final String BIND_PARAM_ANNOTATION = "org.springframework.web.bind.annotation.BindParam";
+
+ private SpringWebVersion springWebVersion;
+
@Override
public List nodesToVisit() {
return List.of(Tree.Kind.CLASS);
@@ -191,14 +199,14 @@ private void checkParametersAndPathTemplate(MethodTree method, Set model
return;
}
- // finally, we handle the case where a uri parameter (/{aParam}/) doesn't match to path- or ModelAttribute- inherited variables
+ // finally, we handle the case where a uri parameter (/{aParam}/) doesn't match to path-, ModelAttribute-, or class / record inherited variables
Set allPathVariables = methodParameters.stream()
.map(ParameterInfo::value)
.collect(Collectors.toSet());
// Add properties inherited from @ModelAttribute methods
allPathVariables.addAll(modelAttributeMethodParameters);
- // Add properties inherited from @ModelAttribute class parameters
- allPathVariables.addAll(extractModelAttributeClassProperties(method));
+ // Add properties inherited from class and record parameters
+ allPathVariables.addAll(extractClassAndRecordProperties(method));
templateVariables.stream()
.filter(uri -> !allPathVariables.containsAll(uri.value()))
@@ -278,20 +286,29 @@ private static String removePropertyPlaceholder(String path){
return path.replaceAll(PROPERTY_PLACEHOLDER_PATTERN, "");
}
- private static Set extractModelAttributeClassProperties(MethodTree method) {
+ private boolean requiresModelAttributeAnnotation(SymbolMetadata metadata) {
+ // for spring-web < 5.3 we need to use ModelAttribute annotation to extract properties from classes / records
+ return springWebVersion == SpringWebVersion.LESS_THAN_5_3 && !metadata.isAnnotatedWith(MODEL_ATTRIBUTE_ANNOTATION);
+ }
+
+ private Set extractClassAndRecordProperties(MethodTree method) {
Set properties = new HashSet<>();
for (var parameter : method.parameters()) {
- SymbolMetadata metadata = parameter.symbol().metadata();
Type parameterType = parameter.type().symbolType();
-
- if (!metadata.isAnnotatedWith(MODEL_ATTRIBUTE_ANNOTATION) || parameterType.isUnknown()
- || isStandardDataType(parameterType) || parameterType.isSubtypeOf(MAP)) {
+ if (parameterType.isUnknown()
+ || isStandardDataType(parameterType) || parameterType.isSubtypeOf(MAP)
+ || requiresModelAttributeAnnotation(parameter.symbol().metadata())) {
continue;
}
- // Extract setter properties from the class
- properties.addAll(extractSetterProperties(parameterType));
+ if (parameterType.isSubtypeOf("java.lang.Record") && springWebVersion != SpringWebVersion.LESS_THAN_5_3) {
+ // Extract record's components
+ properties.addAll(extractRecordProperties(parameterType));
+ } else if (parameterType.isClass()) {
+ // Extract setter properties from the class
+ properties.addAll(extractSetterProperties(parameterType));
+ }
}
return properties;
@@ -345,6 +362,33 @@ private static Set checkForLombokSetters(Symbol.TypeSymbol typeSymbol) {
return properties;
}
+ @VisibleForTesting
+ static Set extractRecordProperties(Type type) {
+ Set properties = new HashSet<>();
+ // For records, extract component names from the record components
+ // Records automatically generate accessor methods for their components
+ type.symbol().memberSymbols().stream()
+ .filter(Symbol::isVariableSymbol)
+ .map(Symbol.VariableSymbol.class::cast)
+ .filter(f -> !f.isStatic())
+ .forEach(field -> properties.add(getComponentName(field)));
+
+ return properties;
+ }
+
+ private static String getComponentName(Symbol.VariableSymbol field) {
+ // Check if the component has @BindParam annotation for custom binding name
+ String componentName = field.name();
+ var bindParamValues = field.metadata().valuesForAnnotation(BIND_PARAM_ANNOTATION);
+ if (bindParamValues != null) {
+ Object value = bindParamValues.get(0).value();
+ if (value instanceof String bindParamName && !bindParamName.isEmpty()) {
+ componentName = bindParamName;
+ }
+ }
+ return componentName;
+ }
+
static class PathPatternParser {
private PathPatternParser() {
}
@@ -474,4 +518,23 @@ private static String substringToCurrentChar(int start) {
}
}
+
+ @Override
+ public boolean isCompatibleWithDependencies(Function> dependencyFinder) {
+ Optional springWebCurrentVersion = dependencyFinder.apply("spring-web");
+ if (springWebCurrentVersion.isEmpty()) {
+ return false;
+ }
+ springWebVersion = getSpringWebVersion(springWebCurrentVersion.get());
+ return true;
+ }
+
+ private static SpringWebVersion getSpringWebVersion(Version springWebVersion) {
+ return (springWebVersion.isLowerThan("5.3") ? SpringWebVersion.LESS_THAN_5_3 : SpringWebVersion.START_FROM_5_3);
+ }
+
+ private enum SpringWebVersion {
+ LESS_THAN_5_3,
+ START_FROM_5_3;
+ }
}
diff --git a/java-checks/src/test/java/org/sonar/java/checks/spring/MissingPathVariableAnnotationCheckTest.java b/java-checks/src/test/java/org/sonar/java/checks/spring/MissingPathVariableAnnotationCheckTest.java
index 60d444a6798..ad6bc0347b2 100644
--- a/java-checks/src/test/java/org/sonar/java/checks/spring/MissingPathVariableAnnotationCheckTest.java
+++ b/java-checks/src/test/java/org/sonar/java/checks/spring/MissingPathVariableAnnotationCheckTest.java
@@ -36,14 +36,18 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.sonar.java.checks.verifier.TestUtils.mainCodeSourcesPath;
+import static org.sonar.java.checks.verifier.TestUtils.mainCodeSourcesPathInModule;
+import static org.sonar.java.test.classpath.TestClasspathUtils.SPRING_32_MODULE;
class MissingPathVariableAnnotationCheckTest {
private static final String TEST_SOURCE_FILE = mainCodeSourcesPath("checks/spring/s6856/MissingPathVariableAnnotationCheck_PathVariable.java");
private static final String TEST_SOURCE_FILE_MODEL_ATTRIBUTE = mainCodeSourcesPath("checks/spring/s6856/MissingPathVariableAnnotationCheck_ModelAttribute.java");
private static final String SETTER_PROPERTIES_TEST_FILE = mainCodeSourcesPath("checks/spring/s6856/ExtractSetterPropertiesTestData.java");
+ private static final String RECORD_COMPONENTS_TEST_FILE = mainCodeSourcesPathInModule(SPRING_32_MODULE, "checks/ExtractRecordPropertiesTestData.java");
private static final JavaFileScanner check = new MissingPathVariableAnnotationCheck();
private static final Map testTypes = new HashMap<>();
+ private static final Map testRecords = new HashMap<>();
@BeforeAll
static void scanTestFile() {
@@ -67,6 +71,29 @@ public void visitNode(Tree tree) {
.verifyNoIssues();
}
+ @BeforeAll
+ static void scanRecordTestFile() {
+ IssuableSubscriptionVisitor recordCollector = new IssuableSubscriptionVisitor() {
+ @Override
+ public java.util.List nodesToVisit() {
+ return java.util.List.of(Tree.Kind.CLASS, Tree.Kind.RECORD);
+ }
+
+ @Override
+ public void visitNode(Tree tree) {
+ ClassTree classTree = (ClassTree) tree;
+ Symbol.TypeSymbol symbol = classTree.symbol();
+ testRecords.put(symbol.name(), symbol.type());
+ }
+ };
+
+ CheckVerifier.newVerifier()
+ .onFile(RECORD_COMPONENTS_TEST_FILE)
+ .withCheck(recordCollector)
+ .withClassPath(SPRING_32_MODULE.getClassPath())
+ .verifyNoIssues();
+ }
+
@Test
void test_compiling() {
CheckVerifier.newVerifier()
@@ -83,6 +110,15 @@ void test_model_attribute() {
.verifyIssues();
}
+ @Test
+ void test_classes_and_records() {
+ CheckVerifier.newVerifier()
+ .onFile(mainCodeSourcesPathInModule(SPRING_32_MODULE, "checks/MissingPathVariableAnnotationCheck_classAndRecord.java"))
+ .withCheck(check)
+ .withClassPath(SPRING_32_MODULE.getClassPath())
+ .verifyIssues();
+ }
+
@Test
void test_without_semantic() {
CheckVerifier.newVerifier()
@@ -224,4 +260,38 @@ private static Stream provideExtractSetterPropertiesTestCases() {
);
}
+ @ParameterizedTest(name = "[{index}] {0}")
+ @MethodSource("provideExtractRecordPropertiesTestCases")
+ void test_extractRecordProperties(String typeName, Set expectedComponents) {
+ Type type = testRecords.get(typeName);
+ Set components = MissingPathVariableAnnotationCheck.extractRecordProperties(type);
+
+ if (!expectedComponents.isEmpty()) {
+ assertThat(components).containsExactlyInAnyOrderElementsOf(expectedComponents);
+ } else {
+ assertThat(components).isEmpty();
+ }
+ }
+
+ private static Stream provideExtractRecordPropertiesTestCases() {
+ return Stream.of(
+ Arguments.of(
+ "RecordWithComponents",
+ Set.of("project", "year", "month")
+ ),
+ Arguments.of(
+ "EmptyRecord",
+ Set.of()
+ ),
+ Arguments.of(
+ "RecordWithBindParam",
+ Set.of("order-name", "details")
+ ),
+ Arguments.of(
+ "RecordMixedBindParam",
+ Set.of("project-id", "name", "user-id")
+ )
+ );
+ }
+
}
diff --git a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S6856.html b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S6856.html
index 72a66199423..8351dde38ea 100644
--- a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S6856.html
+++ b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S6856.html
@@ -5,8 +5,14 @@ Why is this an issue?
module and are commonly used to define the routes for different HTTP operations in a RESTful API.
If a method has a path template containing a placeholder, like "/api/resource/{id}", and there’s no @PathVariable annotation on a
method parameter to capture the id path variable, Spring will disregard the id variable.
-This rule will raise an issue if a method has a path template with a placeholder, but no corresponding @PathVariable, or
-vice-versa.
+Path variables can also be automatically bound to class or record properties when used as method parameters (without @PathVariable).
+For Spring Web versions prior to 5.3, the @ModelAttribute annotation is required on the parameter to enable binding to class properties.
+Starting with Spring Web 5.3, this binding happens automatically without requiring @ModelAttribute. For classes, Spring binds path
+variables to properties that have setter methods (including Lombok-generated setters). For records (Spring Web 5.3+), Spring binds path variables to
+record components through their accessor methods. The @BindParam annotation can be used on record components to specify custom binding
+names.
+This rule will raise an issue if a method has a path template with a placeholder, but no corresponding @PathVariable, matching class
+property, or matching record component, or vice-versa.
How to fix it
Code examples
Noncompliant code example
@@ -33,12 +39,92 @@ Compliant solution
return ResponseEntity.ok("Fetching asset with ID: " + id);
}
+Noncompliant code example
+
+// Spring Web < 5.3: Class properties require @ModelAttribute
+@Data
+class UserRequest {
+ private String userId;
+ private String accountId;
+}
+
+@GetMapping("/api/user/{userId}/{accountId}")
+public ResponseEntity<String> getUser(UserRequest request) { // Noncompliant - @ModelAttribute annotation is required for Spring Web < 5.3
+ return ResponseEntity.ok("User: " + request.getUserId());
+}
+
+Compliant solution
+
+// Spring Web < 5.3: Class properties require @ModelAttribute
+@Data
+class UserRequest {
+ private String userId;
+ private String accountId;
+}
+
+@GetMapping("/api/user/{userId}/{accountId}")
+public ResponseEntity<String> getUser(@ModelAttribute UserRequest request) { // Compliant - @ModelAttribute enables binding to class properties
+ return ResponseEntity.ok("User: " + request.getUserId());
+}
+
+Noncompliant code example
+
+// Spring Web 5.3+: Class properties
+@Data
+class UserRequest {
+ private String userId;
+ private String accountId;
+}
+
+@GetMapping("/api/user/{userId}/{companyId}")
+public ResponseEntity<String> getUser(UserRequest request) { // Noncompliant - 'companyId' path variable doesn't match any property with a setter
+ return ResponseEntity.ok("User: " + request.getUserId());
+}
+
+Compliant solution
+
+// Spring Web 5.3+: Class properties
+@Data
+class UserRequest {
+ private String userId;
+ private String accountId;
+}
+
+@GetMapping("/api/user/{userId}/{accountId}")
+public ResponseEntity<String> getUser(UserRequest request) { // Compliant - path variables match properties with setters
+ return ResponseEntity.ok("User: " + request.getUserId());
+}
+
+Noncompliant code example
+
+// Spring Web 5.3+: Record components
+record OrderRequest(String orderId, String customerId) {}
+
+@GetMapping("/api/order/{order-id}/{customer-id}")
+public ResponseEntity<String> getOrder(OrderRequest request) { // Noncompliant - Record component names don't match path variables
+ return ResponseEntity.ok("Order: " + request.orderId());
+}
+
+Compliant solution
+
+// Spring Web 5.3+: Record components
+record OrderRequest(@BindParam("order-id") String orderId, @BindParam("customer-id") String customerId) {}
+
+@GetMapping("/api/order/{order-id}/{customer-id}")
+public ResponseEntity<String> getOrder(OrderRequest request) { // Compliant - @BindParam maps component names to path variables
+ return ResponseEntity.ok("Order: " + request.orderId());
+}
+
Resources
Documentation