Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.3.5] - 2026-04-08

### Fixed
- OAR102 - SecondPartBasePathCheck Test
- OAR101 - FirstPartBasePathCheck Test
- OAR034 - StandardPagedResponseSchemaCheck Test
- OAR029 - StandardResponseSchemaCheck Test
- OAR083 - ForbiddenQueryParamsCheck Test
- OAR084 - ForbiddenFormatsInQueryCheck Test
- OAR043 - ParsingErrorCheck Test
- OAR028 - FilterParameterCheck Test
- OAR073 - RateLimitCheck Test
- OAR079 - PathParameter404Check Test

- AbstractSchemaCheck
- AbstractForbiddenQueryCheck
- AbstractPathResponseCheck
- VerbPathMatcher

## [1.3.4] - 2026-04-01

### Fixed
Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@


# 🛠️ sonaropenapi-rules ![Release](https://img.shields.io/badge/release-1.3.4-purple) ![Java](https://img.shields.io/badge/java-%23ED8B00.svg?style=flat&logo=openjdk&logoColor=white) [![License: LGPL v3](https://img.shields.io/badge/license-LGPL_v3-blue.svg)](https://www.gnu.org/licenses/lgpl-3.0)
# 🛠️ sonaropenapi-rules ![Release](https://img.shields.io/badge/release-1.3.5-purple) ![Java](https://img.shields.io/badge/java-%23ED8B00.svg?style=flat&logo=openjdk&logoColor=white) [![License: LGPL v3](https://img.shields.io/badge/license-LGPL_v3-blue.svg)](https://www.gnu.org/licenses/lgpl-3.0)

This repository contains a set of custom SonarQube rules specifically designed to analyze and improve the quality of OpenAPI specifications. By integrating these rules, teams can ensure best practices, maintainability, and consistency in their API definitions.

Expand Down Expand Up @@ -31,7 +31,14 @@ Feel free to drop by and greet us on our GitHub discussion or Discord chat. You
[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/apiaddicts)


# 📑 Getting started
# 📑 Getting started

## ⚠️ Prerequisites

Before using this plugin, you must install the base **OpenAPI plugin** for SonarQube:
📦 [`sonar-openapi-plugin`](https://central.sonatype.com/artifact/org.apiaddicts.apitools.dosonarapi/sonar-openapi-plugin/versions)

> Make sure to install the **latest available version** of the plugin and that it is properly loaded in your SonarQube instance before adding this rules plugin.

## 🔍 Configure scanner

Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>org.apiaddicts.apitools.dosonarapi</groupId>
<artifactId>sonaropenapi-rules-community</artifactId>
<version>1.3.4</version>
<version>1.3.5</version>
<packaging>sonar-plugin</packaging>

<name>SonarQube OpenAPI Community Rules</name>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public OAR073RateLimitCheck() {
@Override
protected void validateOperation(JsonNode node, String currentPath) {
JsonNode responsesNode = node.get("responses");
if (responsesNode != null && responsesNode.get("429").isMissing()) {
if (responsesNode.get("429").isMissing()) {
addIssue(ruleKey, translate(messageKey), responsesNode.key());
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package apiaddicts.sonar.openapi.checks.schemas;

import com.sonar.sslr.api.AstNode;
import org.json.JSONArray;
import org.json.JSONObject;
import apiaddicts.sonar.openapi.checks.BaseCheck;
Expand Down Expand Up @@ -61,17 +60,6 @@ protected Optional<JsonNode> validateProperty(Map<String, JsonNode> properties,
return validateProperty(prop, propertyName, propertyType, parentNode);
}

protected Optional<JsonNode> validateProperty(JsonNode properties, String propertyName, String propertyType) {
return handleExternalRef.resolve(properties, resolvedProps -> {
if (resolvedProps.propertyNames().isEmpty()) {
addIssue(key, translate(GENERIC_PROPERTY_MISSING, propertyName), handleExternalRef.getTrueNode(resolvedProps));
return Optional.empty();
}
JsonNode prop = resolvedProps.get(propertyName);
return validateProperty(prop, propertyName, propertyType, handleExternalRef.getTrueNode(resolvedProps.key()));
});
}

protected Optional<JsonNode> validateProperty(JsonNode prop, String propertyName, String propertyType, JsonNode parentNode) {
if (prop.isMissing()) {
addIssue(key, translate(GENERIC_PROPERTY_MISSING, propertyName), handleExternalRef.getTrueNode(parentNode));
Expand Down Expand Up @@ -124,20 +112,6 @@ protected Optional<JsonNode> validateItems(JsonNode prop, String iType) {
});
}

protected void validateEnumValues(JsonNode property, Set<String> expected) {
Set<String> found = getEnumValues(property);
if (!expected.equals(found)) {
String propertyName = property.key().getTokenValue();
String values = expected.stream().sorted().collect(Collectors.joining(", "));
addIssue(key, translate("generic.enum-values", propertyName, values), handleExternalRef.getTrueNode(property.key()));
}
}

private Set<String> getEnumValues(JsonNode schema) {
return schema.get("enum").elements()
.stream().map(AstNode::getTokenValue).collect(Collectors.toSet());
}

private boolean matchesTypeViaAllOf(JsonNode node, String expectedType) {
JsonNode allOf = node.get("allOf");
if (allOf.isMissing()) return false;
Expand Down Expand Up @@ -205,7 +179,4 @@ private void validateArray(JSONObject propertySchema, JsonNode propertyNode) {
}
}

protected JsonNode getTrueNode (JsonNode node){
return handleExternalRef.getTrueNode(node);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ protected void handleOperation(JsonNode node, AstNodeType type) {
if (shouldExcludePath() || !isMethod) return;

JsonNode parameters = node.get("parameters");
if (parameters == null || parameters.isMissing() || parameters.isNull()) return;
if (parameters.isMissing() || parameters.isNull()) return;

validateParameters(parameters, type == OpenApi2Grammar.OPERATION);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ public OAR079PathParameter404Check() {
@Override
protected void validateOperation(JsonNode node, String currentPath) {
JsonNode parametersNode = node.get("parameters");
if (parametersNode == null || !parametersNode.isArray()) return;
if (!parametersNode.isArray()) return;

for (JsonNode parameterNode : parametersNode.elements()) {
JsonNode inNode = parameterNode.get("in");
if (inNode != null && "path".equals(inNode.getTokenValue())) {
if ("path".equals(inNode.getTokenValue())) {
JsonNode responsesNode = node.get("responses");
if (responsesNode.get("404").isMissing()) {
addIssue(ruleKey, translate(messageKey), responsesNode.key());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ protected void validateParameters(JsonNode parametersNode, boolean isV2) {

Set<String> queryParams = parametersNode.elements().stream()
.map(p -> p.get("name"))
.filter(n -> n != null && !n.isNull())
.filter(n -> !n.isNull())
.map(JsonNode::getTokenValue)
.collect(Collectors.toSet());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,11 @@ protected void validateParameters(JsonNode parametersNode, boolean isV2) {

parametersNode.elements().forEach(param -> {
JsonNode in = param.get("in");
if (in == null || !"query".equals(in.getTokenValue())) return;
if (!"query".equals(in.getTokenValue())) return;

JsonNode formatNode = isV2 ? param.get("format") : param.get("schema").get("format");

if (formatNode != null && !formatNode.isMissing() && !formatNode.isNull() &&
if (!formatNode.isMissing() && !formatNode.isNull() &&
forbiddenQueryFormats.contains(formatNode.getTokenValue())) {
addIssue(KEY, translate(MESSAGE, formatNode.getTokenValue()), formatNode);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import org.apiaddicts.apitools.dosonarapi.sslr.yaml.grammar.YamlParser;

import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.io.BufferedReader;
import java.io.InputStream;
Expand Down Expand Up @@ -89,7 +90,7 @@ private static String retriveExternalRefContent(String ref) {

HttpURLConnection conn = null;
try {
URL url = new URL(ref);
URL url = URI.create(ref).toURL();
conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,6 @@ private void processExclusions(String expression) {
}
}

public boolean matches(String verb, String path) {
return matchesWithValues(verb, path).isPresent();
}

public Optional<VerbPathMatcher.PatternGroup> matchesWithValues(String verb, String path) {
if (exclusionsByVerb.getOrDefault(verb, Collections.emptySet()).contains(path)) return Optional.empty();
return patternsByVerb.getOrDefault(verb, Collections.emptyList())
Expand All @@ -145,16 +141,14 @@ private Set<String> strToSet(String str) {

public boolean matches(String path) {
if (!hasAtMostOneMe(path)) return false;

return pattern.matcher(path).matches();
}

private boolean hasAtMostOneMe(String path) {
Matcher matcher = ME_WORD_PATTERN.matcher(path);
int count = 0;
while (matcher.find()) {
while (matcher.find())
if (++count > 1) return false;
}
return true;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,29 @@ public void verifyInV2() {
verifyV2("valid");
}

@Test
public void verifyFirstPartBasePathLogicInV2() {
((OAR101FirstPartBasePathCheck) check).firstPartValuesStr = "api-seguros";

verifyV2("invalid");
verifyV2("valid-with-values");
verifyV2("empty-path");
}

@Test
public void verifyInV3() {
verifyV3("valid");
}

@Test
public void verifyFirstPartBasePathLogicInV3() {
((OAR101FirstPartBasePathCheck) check).firstPartValuesStr = "api-seguros";

verifyV3("invalid");
verifyV3("valid-with-values");
verifyV3("empty-path");
}


@Override
public void verifyRule() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,29 @@ public void verifyInV2() {
verifyV2("valid");
}

@Test
public void verifySecondPartBasePathLogicInV2() {
((OAR102SecondPartBasePathCheck) check).secondPartValuesStr = "v1";

verifyV2("invalid");
verifyV2("valid-with-values");
verifyV2("one-part-path");
}

@Test
public void verifyInV3() {
verifyV3("valid");
}

@Test
public void verifySecondPartBasePathLogicInV3() {
((OAR102SecondPartBasePathCheck) check).secondPartValuesStr = "v1";

verifyV3("invalid");
verifyV3("valid-with-values");
verifyV3("one-part-path");
}

@Override
public void verifyRule() {
assertRuleProperties("OAR102 - SecondPartBasePath - The second part of the path should be one of the alloweds", RuleType.BUG, Severity.CRITICAL, tags("format"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,41 +21,100 @@

import com.sonar.sslr.api.RecognitionException;

import org.junit.BeforeClass;
import org.junit.Test;
import org.apiaddicts.apitools.dosonarapi.api.OpenApiFile;
import org.apiaddicts.apitools.dosonarapi.api.OpenApiVisitorContext;
import org.apiaddicts.apitools.dosonarapi.api.PreciseIssue;
import apiaddicts.sonar.openapi.I18nContext;
import apiaddicts.sonar.openapi.checks.BaseCheck;

import java.lang.reflect.Field;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;

public class OAR043ParsingErrorCheckTest {

@BeforeClass
public static void setupLang() throws Exception {
I18nContext.setLang("en");
Field field = BaseCheck.class.getDeclaredField("resourceBundle");
field.setAccessible(true);
field.set(null, null);
}

@Test
public void reports_parsing_errors() {
OpenApiVisitorContext context = new OpenApiVisitorContext(new TestFile(), new RecognitionException(3, "Parsing exception message"));
public void reports_parsing_errors_no_cause_no_numbers() {
OpenApiVisitorContext context = new OpenApiVisitorContext(new TestFile(),
new RecognitionException(3, "Parsing exception message"));
OAR043ParsingErrorCheck check = new OAR043ParsingErrorCheck();

List<PreciseIssue> issues = check.scanFileForIssues(context);

assertThat(issues)
.extracting(i -> i.primaryLocation().startLine(), i -> i.primaryLocation().message())
.contains(tuple(0, "OAR043: Error parsing line 0 column null"));
assertThat(issues).hasSize(1);
assertThat(issues.get(0).primaryLocation().startLine()).isZero();
assertThat(issues.get(0).primaryLocation().message()).isEqualTo("OAR043: Error parsing line 0 column null");
}

private static class TestFile implements OpenApiFile {
@Test
public void reports_parsing_errors_with_numbers_in_message() {
OpenApiVisitorContext context = new OpenApiVisitorContext(new TestFile(),
new RecognitionException(0, "Error at line 5 column 10"));
OAR043ParsingErrorCheck check = new OAR043ParsingErrorCheck();

List<PreciseIssue> issues = check.scanFileForIssues(context);

assertThat(issues).hasSize(1);
assertThat(issues.get(0).primaryLocation().startLine()).isEqualTo(4);
}

@Test
public void reports_parsing_errors_with_one_cause() {
RuntimeException cause = new RuntimeException("Error at line 10 column 3");
RecognitionException ex = new RecognitionException(0, "outer") {
@Override public synchronized Throwable getCause() { return cause; }
};
OpenApiVisitorContext context = new OpenApiVisitorContext(new TestFile(), ex);
OAR043ParsingErrorCheck check = new OAR043ParsingErrorCheck();

List<PreciseIssue> issues = check.scanFileForIssues(context);

assertThat(issues).hasSize(1);
assertThat(issues.get(0).primaryLocation().startLine()).isEqualTo(8);
}

@Test
public void reports_parsing_errors_with_two_causes() {
RuntimeException deepCause = new RuntimeException("Error at line 15 column 5");
RuntimeException shallowCause = new RuntimeException("wrapper") {
@Override public synchronized Throwable getCause() { return deepCause; }
};
RecognitionException ex = new RecognitionException(0, "outer") {
@Override public synchronized Throwable getCause() { return shallowCause; }
};
OpenApiVisitorContext context = new OpenApiVisitorContext(new TestFile(), ex);
OAR043ParsingErrorCheck check = new OAR043ParsingErrorCheck();

@Override
public String content() {
return null;
}
List<PreciseIssue> issues = check.scanFileForIssues(context);

assertThat(issues).hasSize(1);
assertThat(issues.get(0).primaryLocation().startLine()).isEqualTo(12);
}

@Override
public String fileName() {
return null;
}
@Test
public void reports_no_issues_when_no_exception_and_no_issues() {
OpenApiVisitorContext context = new OpenApiVisitorContext(new TestFile(), null);
OAR043ParsingErrorCheck check = new OAR043ParsingErrorCheck();

List<PreciseIssue> issues = check.scanFileForIssues(context);

assertThat(issues).isEmpty();
}

private static class TestFile implements OpenApiFile {
@Override public String content() { return null; }
@Override public String fileName() { return null; }
}
}

Loading
Loading