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 ListWhy 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.
+// 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());
+}
+
+
+// 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());
+}
+
+
+// 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());
+}
+
+
+// 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());
+}
+
+
+// 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());
+}
+
+
+// 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());
+}
+