-
Notifications
You must be signed in to change notification settings - Fork 724
SONARJAVA-6758: Fix FPs in S5673 for controllers without mappings and redundant annotations #5925
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| { | ||
| "ruleKey": "S5673", | ||
| "hasTruePositives": false, | ||
| "falseNegatives": 20, | ||
| "falseNegatives": 17, | ||
| "falsePositives": 0 | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,17 +18,48 @@ | |
|
|
||
| import java.util.List; | ||
| import java.util.Optional; | ||
| import java.util.Set; | ||
| import javax.annotation.CheckForNull; | ||
| import org.sonar.check.Rule; | ||
| import org.sonar.java.checks.helpers.SpringUtils; | ||
| import org.sonar.plugins.java.api.IssuableSubscriptionVisitor; | ||
| import org.sonar.plugins.java.api.semantic.Type; | ||
| import org.sonar.plugins.java.api.tree.AnnotationTree; | ||
| import org.sonar.plugins.java.api.tree.ClassTree; | ||
| import org.sonar.plugins.java.api.tree.MethodTree; | ||
| import org.sonar.plugins.java.api.tree.Tree; | ||
|
|
||
| @Rule(key = "S5673") | ||
| public class SpringComponentSpecializationCheck extends IssuableSubscriptionVisitor { | ||
|
|
||
| private static final Set<String> SPECIALIZED_STEREOTYPE_ANNOTATIONS = Set.of( | ||
| SpringUtils.CONTROLLER_ANNOTATION, | ||
| SpringUtils.REST_CONTROLLER_ANNOTATION, | ||
| SpringUtils.SERVICE_ANNOTATION, | ||
| SpringUtils.REPOSITORY_ANNOTATION); | ||
|
|
||
| private static final List<String> REQUEST_MAPPING_ANNOTATIONS = List.of( | ||
| "org.springframework.web.bind.annotation.RequestMapping", | ||
| "org.springframework.web.bind.annotation.GetMapping", | ||
| "org.springframework.web.bind.annotation.PostMapping", | ||
| "org.springframework.web.bind.annotation.PutMapping", | ||
| "org.springframework.web.bind.annotation.DeleteMapping", | ||
| "org.springframework.web.bind.annotation.PatchMapping"); | ||
|
|
||
| private static final List<String> NON_WEB_FRAMEWORK_INTERFACES = List.of( | ||
| "org.springframework.boot.ApplicationRunner", | ||
| "org.springframework.boot.CommandLineRunner", | ||
| "org.springframework.boot.actuate.health.HealthIndicator", | ||
| "org.springframework.boot.actuate.health.ReactiveHealthIndicator"); | ||
|
|
||
| private static final String CONTROLLER = "Controller"; | ||
| private static final String REST_CONTROLLER = "RestController"; | ||
|
|
||
| private static final List<String> NON_WEB_FRAMEWORK_ANNOTATIONS = List.of( | ||
| "org.springframework.boot.actuate.endpoint.annotation.Endpoint", | ||
| "org.springframework.boot.actuate.endpoint.web.annotation.RestControllerEndpoint", | ||
| "org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpoint"); | ||
|
|
||
| @Override | ||
| public List<Tree.Kind> nodesToVisit() { | ||
| return List.of(Tree.Kind.CLASS, Tree.Kind.INTERFACE); | ||
|
|
@@ -46,23 +77,70 @@ public void visitNode(Tree tree) { | |
| return; | ||
| } | ||
|
|
||
| if (hasSpecializedStereotypeAnnotation(classTree)) { | ||
| return; | ||
| } | ||
|
|
||
| String className = classTree.simpleName().name(); | ||
| String suggestedAnnotation = getSuggestedAnnotation(className); | ||
|
|
||
| if (suggestedAnnotation != null) { | ||
| if (suggestedAnnotation != null && shouldRaise(suggestedAnnotation, classTree)) { | ||
| reportIssue(componentAnnotation.get(), String.format("Use @%s instead of @Component, or rename this type if the @Component annotation is intentional", suggestedAnnotation)); | ||
| } | ||
| } | ||
|
|
||
| private static boolean hasSpecializedStereotypeAnnotation(ClassTree classTree) { | ||
| return classTree.modifiers().annotations().stream() | ||
| .anyMatch(a -> SPECIALIZED_STEREOTYPE_ANNOTATIONS.contains(a.annotationType().symbolType().fullyQualifiedName())); | ||
| } | ||
|
|
||
| private static boolean shouldRaise(String suggestedAnnotation, ClassTree classTree) { | ||
| if (CONTROLLER.equals(suggestedAnnotation) || REST_CONTROLLER.equals(suggestedAnnotation)) { | ||
| return hasRequestMappingMethod(classTree) && !implementsNonWebFrameworkInterface(classTree) && !hasNonWebFrameworkAnnotation(classTree); | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| private static boolean hasRequestMappingMethod(ClassTree classTree) { | ||
| for (Tree member : classTree.members()) { | ||
| if (member instanceof MethodTree method) { | ||
| for (AnnotationTree annotation : method.modifiers().annotations()) { | ||
| if (REQUEST_MAPPING_ANNOTATIONS.contains(annotation.annotationType().symbolType().fullyQualifiedName())) { | ||
| return true; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
|
Comment on lines
+104
to
+115
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Edge Case: Class-level @RequestMapping not detected as a mapping methodhasRequestMappingMethod only inspects method-level annotations, so a controller that declares its request mapping at the class level (e.g. @component @RequestMapping("/api") with handler methods that carry no mapping annotation, or methods inherited from a base class) will no longer be flagged. This trades the fixed FPs for potential false negatives on legitimate controllers. Consider also checking for a class-level @RequestMapping / mapping annotation before concluding the class handles no HTTP requests. Was this helpful? React with 👍 / 👎 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Class-level Inherited mappings are a real gap: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed on both points. I'm updating
Comment on lines
+104
to
+115
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This only inspects public abstract class BaseController {
@GetMapping("/status")
public String status() { return "ok"; }
}
@Component // FN after this PR; previously raised
public class StatusController extends BaseController {
}
|
||
|
|
||
| private static boolean implementsNonWebFrameworkInterface(ClassTree classTree) { | ||
| Type classType = classTree.symbol().type(); | ||
| if (classType == null) { | ||
| return false; | ||
| } | ||
| for (String interfaceFqn : NON_WEB_FRAMEWORK_INTERFACES) { | ||
| if (classType.isSubtypeOf(interfaceFqn)) { | ||
| return true; | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| private static boolean hasNonWebFrameworkAnnotation(ClassTree classTree) { | ||
| return classTree.modifiers().annotations().stream() | ||
| .anyMatch(a -> NON_WEB_FRAMEWORK_ANNOTATIONS.contains(a.annotationType().symbolType().fullyQualifiedName())); | ||
| } | ||
|
|
||
| @CheckForNull | ||
| private static String getSuggestedAnnotation(String className) { | ||
| // Check RestController first to avoid false matches with Controller | ||
| if (endsWithIgnoreCase(className, "RestController") || endsWithIgnoreCase(className, "RestControllerImpl")) { | ||
| return "RestController"; | ||
| if (endsWithIgnoreCase(className, REST_CONTROLLER) || endsWithIgnoreCase(className, REST_CONTROLLER + "Impl")) { | ||
| return REST_CONTROLLER; | ||
| } | ||
|
|
||
| if (endsWithIgnoreCase(className, "Controller") || endsWithIgnoreCase(className, "ControllerImpl")) { | ||
| return "Controller"; | ||
| if (endsWithIgnoreCase(className, CONTROLLER) || endsWithIgnoreCase(className, CONTROLLER + "Impl")) { | ||
| return CONTROLLER; | ||
| } | ||
|
|
||
| if (endsWithIgnoreCase(className, "Service") || | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@RestControllerEndpointand@ControllerEndpointare in the exclusion list but untested. Those are the cases where this extra exclusion actually matters: they can carry@GetMappingand must not be rewritten to@Controller/@RestController.Please add sample classes for both. The
@Endpoint/HealthIndicator/ApplicationRunnercases are already covered, and those exclusions are mostly redundant with the mapping-method guard (the original FPs had no mapping annotations).