Writing a custom Plugin SonarQube

Hi all! I recently decided to experiment with SonarQube and create my own custom plugin to check code against my development guidelines. In this article, I will share my experience with you and show you how you too can create such a plugin with your own hands.

Small introductory notes

Disclaimer: This article repeats much of the official guide.

Let's start with a fictitious use-case:

We need a tool for checking code during MR, which can detect non-compliance with the established code style of the team.

Here we actually have 2 options.

Using ready-made Lint tools

Examples of such tools include ESLint (for JavaScript/TypeScript) or Checkstyle (for Java). This option, in my opinion, is the simplest and most convenient. In cases where a custom check is required, the process is also simple: develop your check and add it to the plugins or extensions directory.

Pros:

Minuses:

  • It is difficult to separate code review rules across different teams with different development standards. In such cases, the rules of one team may conflict with the rules of another.

Using SonarQube with a custom plugin

This option attracts me because all the checks related to code styling and code security are in one place and combined in a beautiful interface. SonarQube is great for companies with a large number of development teams, as it allows you to create custom profiles.

Pros:

  • All code reviews in one place.

  • Convenient and beautiful interface.

  • Suitable for large companies with many development teams due to the ability to create custom profiles.

Minuses:

  • Infrastructure requirements: SonarQube requires a separate server and resources to operate, which can be problematic for small teams or projects.

Preparing the local environment

Let's start developing our first custom check by pulling the project from Github: link.

I've cleaned up and modified the project a bit to make the development process easier and save you from having to deal with Maven configuration.

As recommended by the SonarQube developers, we should follow the approach TDD (test-driven development) when writing our custom checks. Personally, I'm not a big fan of this approach, but in this case it works very well.

In order for our custom check to work correctly after integration into SonarQube, it needs to be tested. For this we will use the tool CheckVerifier. With its help, we can run our tests on real cases, which, in my opinion, is much more convenient than writing mocks in Mockito.

Let's write tests for a check that will check if the endpoint URLs are written in kebab-case.

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.DeleteMapping;

public class MyController {

    @GetMapping("/kebab-case-url") // Compliant
    public String getCompliant() {
        return "compliant";
    }

    @GetMapping("/camelCaseUrl") // Noncompliant
    public String getNonCompliant() {
        return "noncompliant";
    }

    @PostMapping("/another-kebab-case-url") // Compliant
    public void postCompliant() {
    }

    @PostMapping("/AnotherCamelCaseUrl") // Noncompliant
    public void postNonCompliant() {
    }

    @PutMapping("/yet-another-kebab-case-url") // Compliant
    public void putCompliant() {
    }

    @PutMapping("/YetAnotherCamelCaseUrl") // Noncompliant
    public void putNonCompliant() {
    }

    @DeleteMapping("/final-kebab-case-url") // Compliant
    public void deleteCompliant() {
    }

    @DeleteMapping("/FinalCamelCaseUrl") // Noncompliant
    public void deleteNonCompliant() {
    }
}

Important aspects of test cases

  1. Test locations:

  2. Test scenarios:

    • Be sure to describe both negative and positive scenarios.

    • Mark negative scenarios as // Noncompliantand positive ones like // Compliant.

  3. Imports:

Now let's write the kebab-case check itself.

@Rule(key = "KebabCaseUrlCheck")
public class KebabCaseUrlCheck extends BaseTreeVisitor implements JavaFileScanner {

    private static final Pattern KEBAB_CASE_PATTERN = Pattern.compile("^(/[a-z0-9]+(-[a-z0-9]+)*)*$");

    private JavaFileScannerContext context;

    @Override
    public void scanFile(JavaFileScannerContext context) {
        this.context = context;
        scan(context.getTree());
    }

    @Override
    public void visitMethod(MethodTree tree) {
        List<AnnotationTree> annotations = tree.modifiers().annotations();
        for (AnnotationTree annotationTree : annotations) {
            TypeTree annotationType = annotationTree.annotationType();
            if (annotationType.is(Tree.Kind.IDENTIFIER)) {
                IdentifierTree identifier = (IdentifierTree) annotationType;
                String annotationName = identifier.name();
                if (annotationName.equals("GetMapping") || annotationName.equals("PostMapping") ||
                        annotationName.equals("PutMapping") || annotationName.equals("DeleteMapping")) {
                    checkUrl(annotationTree);
                }
            }
        }
        super.visitMethod(tree);
    }

    private void checkUrl(AnnotationTree annotationTree) {
        if (annotationTree.arguments().isEmpty()) {
            return;
        }

        Tree argument = annotationTree.arguments().get(0);
        if (argument.is(Tree.Kind.ASSIGNMENT)) {
            argument = ((org.sonar.plugins.java.api.tree.AssignmentExpressionTree) argument).expression();
        }

        if (argument.is(Tree.Kind.STRING_LITERAL)) {
            LiteralTree literal = (LiteralTree) argument;
            String url = literal.value().substring(1, literal.value().length() - 1);

            if (!KEBAB_CASE_PATTERN.matcher(url).matches()) {
                context.reportIssue(this, literal, "The URL should be in kebab-case.");
            }
        }
    }
}

Let's start with the @Rule(key = “KebabCaseUrlCheck”) annotation, which defines the key of the rule used in SonarQube to identify this check. Class KebabCaseUrlCheck inherited from BaseTreeVisitor and implements the JavaFileScanner interface, which allows it to scan Java files.

The pattern for checking whether a URL matches the kebab-case format is defined using a regular expression:

private static final Pattern KEBAB_CASE_PATTERN = Pattern.compile("^(/[a-z0-9]+(-[a-z0-9]+))$");.

A scanning context is also created, which is used for error reporting:

private JavaFileScannerContext context;

The main method for scanning a file, scanFile, saves the context and begins the process of scanning the file's syntax tree:

@Override
public void scanFile(JavaFileScannerContext context);

Scanning starts with a call

scan(context.getTree());

Method visitMethod is overridden to handle each method in the file:

 public void visitMethod(MethodTree tree);

It gets all the method annotations:

List<AnnotationTree> annotations = tree.modifiers().annotations();

In the for loop, each annotation is checked and if it is one of @GetMapping, @PostMapping, @PutMapping or @DeleteMappingthe method is called checkUrl.

Method checkUrl checks the URL in the annotation. It starts by checking if the annotation has arguments. If there are no arguments, the method exits. The first argument of the annotation is then retrieved and checked to see if it is a string literal. If so, the string value is retrieved without quotes:

String url = literal.value().substring(1, literal.value().length() - 1);

Next, a regular expression is used to check whether the URL matches the kebab-case pattern: if (!KEBAB_CASE_PATTERN.matcher(url).matches()). If the URL does not match the pattern, an error report is generated with the message “The URL should be in kebab-case.”

Let's run our first test

After we have configured the test cases and the verification class itself, we need to run this test directly.
To do this in the directory src/test/java/org/sonar/samples/java/checks create a new class KebabCaseUrlTest and write a simple construction for the test:

package org.sonar.samples.java.checks;

import org.junit.jupiter.api.Test;
import org.sonar.java.checks.verifier.CheckVerifier;

public class KebabCaseUrlCheckTest {

    @Test
    void test() {
        CheckVerifier.newVerifier()
                .onFile("src/test/files/KebabCaseUrlCheck.java")
                .withCheck(new KebabCaseUrlCheck())
                .verifyIssues();
    }
}

Next, we run the test and are happy that our url checking method works!

Final touches

Let's start with the naming configuration of our plugin. Let's go to this file:

src/main/java/org/sonar/samples/java/MyJavaRulesDefinition.java and change the REPOSITORY_KEY and REPOSITORY_NAME fields to the ones you need.

public static final String REPOSITORY_KEY = "sonar-plugin-habr";
public static final String REPOSITORY_NAME = "Habr Guide Plugin";

Next, Before compiling our .jar file, we need to register our custom plugin. To do this, go to this file: src/main/java/org/sonar/samples/java/RulesList.java

and into the method getJavaChecks add our KebabCaseUrlCheck.

public static List<Class<? extends JavaCheck>> getJavaChecks() {
    return Collections.unmodifiableList(Arrays.asList(
            SpringControllerRequestMappingEntityRule.class,
            AvoidAnnotationRule.class,
            AvoidBrandInMethodNamesRule.class,
            AvoidMethodDeclarationRule.class,
            AvoidSuperClassRule.class,
            AvoidTreeListRule.class,
            MyCustomSubscriptionRule.class,
            KebabCaseUrlCheck.class,
            SecurityAnnotationMandatoryRule.class));
  }

Next, we need to add our check file to the test that checks the registration of rules. By default, it uses the method containsExactly() which, in my opinion, makes the implementation of checks more complex and unnecessary; for this, if you wish, you can change the method to containsExactlyInAnyOrder() which will ultimately not throw an error if you register your rules out of order.

class MyJavaFileCheckRegistrarTest {

  @Test
  void checkRegisteredRulesKeysAndClasses() {
    TestCheckRegistrarContext context = new TestCheckRegistrarContext();

    MyJavaFileCheckRegistrar registrar = new MyJavaFileCheckRegistrar();
    registrar.register(context);

    assertThat(context.mainRuleKeys).extracting(RuleKey::toString).containsExactlyInAnyOrder(
            "omni-sonar:SpringControllerRequestMappingEntity",
            "omni-sonar:AvoidAnnotation",
            "omni-sonar:AvoidBrandInMethodNames",
            "omni-sonar:AvoidMethodDeclaration",
            "omni-sonar:AvoidSuperClass",
            "omni-sonar:AvoidTreeList",
            "omni-sonar:AvoidMethodWithSameTypeInArgument",
            "omni-sonar:KebabCaseUrlCheck",
            "omni-sonar:SecurityAnnotationMandatory");

    assertThat(context.mainCheckClasses).extracting(Class::getSimpleName).containsExactlyInAnyOrder(
            "SpringControllerRequestMappingEntityRule",
            "AvoidAnnotationRule",
            "AvoidBrandInMethodNamesRule",
            "AvoidMethodDeclarationRule",
            "AvoidSuperClassRule",
            "AvoidTreeListRule",
            "MyCustomSubscriptionRule",
            "KebabCaseUrlCheck",
            "SecurityAnnotationMandatoryRule");

    assertThat(context.testRuleKeys).extracting(RuleKey::toString).containsExactly(
            "omni-sonar:NoIfStatementInTests");

    assertThat(context.testCheckClasses).extracting(Class::getSimpleName).containsExactly(
            "NoIfStatementInTestsRule");
  }

}

As a final point, we need to add documentation to our custom plugin. This is done quite easily, we create 2 files (html and json) in the directory src/main/resources/org/sonar/l10n/java/rules/java with the identical name of our check. In our case, the files will be called: KebabCaseUrlCheck.html and KebabCaseUrlCheck.json.

In the HTML file, we need to describe our check and how it works, place the code in the

 tag.

<h1>Kebab-Case URL Check</h1>
<p>This rule ensures that the URLs in <code>@GetMapping</code>, <code>@PostMapping</code>, <code>@PutMapping</code>, and <code>@DeleteMapping</code> annotations are in kebab-case format.</p>

<h2>Noncompliant Code Example</h2>
<pre>
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.DeleteMapping;

public class MyController {

    @GetMapping("/camelCaseUrl") // Noncompliant
    public String getNonCompliant() {
        return "noncompliant";
    }

    @PostMapping("/AnotherCamelCaseUrl") // Noncompliant
    public void postNonCompliant() {
    }

    @PutMapping("/YetAnotherCamelCaseUrl") // Noncompliant
    public void putNonCompliant() {
    }

    @DeleteMapping("/FinalCamelCaseUrl") // Noncompliant
    public void deleteNonCompliant() {
    }

    @GetMapping("/nested/camelCaseUrl") // Noncompliant
    public String getNestedNonCompliant() {
        return "noncompliant";
    }
}
    </pre>

<h2>Compliant Solution</h2>
<pre>
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.DeleteMapping;

public class MyController {

    @GetMapping("/kebab-case-url") // Compliant
    public String getCompliant() {
        return "compliant";
    }

    @PostMapping("/another-kebab-case-url") // Compliant
    public void postCompliant() {
    }

    @PutMapping("/yet-another-kebab-case-url") // Compliant
    public void putCompliant() {
    }

    @DeleteMapping("/final-kebab-case-url") // Compliant
    public void deleteCompliant() {
    }

    @GetMapping("/nested/kebab-case-url") // Compliant
    public String getNestedCompliant() {
        return "compliant";
    }
}
</pre>

In Json you need to provide your validation metadata:

{
  "title": "URLs in @GetMapping, @PostMapping, @PutMapping, @DeleteMapping should be in kebab-case",
  "type": "code_smell",
  "status": "ready",
  "tags": [
    "convention",
    "style",
    "rest"
  ],
  "defaultSeverity": "Minor"
}

Everything is intuitive here; you can find out about type at link.

As a final touch, let's launch mvn clean install and get a .jar file in the directory /target

Integration into SonarQube

First, let's deploy sonar locally and add our .jar file.
To do this, use this Dockerfile:

# Use the official SonarQube image as the base image
FROM sonarqube:latest

# Set the SonarQube home directory as an environment variable
ENV SONAR_HOME=/opt/sonarqube

# Copy the custom rules JAR file to the SonarQube plugins directory
COPY target/java-custom-rules-example-1.0.0-SNAPSHOT.jar $SONAR_HOME/extensions/plugins/

and run it with the following command:

docker build . -t sonar-habr
docker run -d -p 9000:9000 sonar-habr

Activate the Plugin

  1. Go to Quality Profiles and make a filter for the Java language.

  2. Go to your profile SonarWay and copy it and name it habr.

  3. In profile habr click activate more and in the Repository tab you will see our plugin "Habr Guide Plugin".

  4. Find our check and activate it, it will be called URLs in @GetMapping, @PostMapping, @PutMapping, @DeleteMapping should be in kebab-case.

Congratulations, you did it all!

Congratulations, you did it all!

You can find all our code here link.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *