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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ 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.4.1-beta-5] - 2026-06-02

### Fixed

- OAR028 - FilterParameterCheck - Rewritten to extend `AbstractQueryParameterCheck`. Fires exactly once per GET operation when `$filter` query parameter is absent; does not fire if `$filter` is present alongside other parameters; resolves `$filter` referenced via `$ref` to components. Covers ALL collection GET endpoints except `/me` paths, terminal `/{id}` paths and health-check paths (`status`, `health`, `ping`).

## [1.4.1-beta-4] - 2026-05-31

### Fixed
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.4.1-beta-4</version>
<version>1.4.1-beta-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 @@ -216,7 +216,8 @@ private void visitSchemaNode2(JsonNode responseNode) {
if (props.isMissing() || !props.isObject()) return;

props.propertyMap().forEach((key, propertyNode) -> {
if (propertyNode.get(EXAMPLE).isMissing()) {
JsonNode type = getType(propertyNode);
if (!type.isMissing() && !isObjectType(type) && !isArrayType(type) && !isSchemaCovered(propertyNode)) {
addIssue(KEY, translate("OAR031.error-property"), handleExternalRef.getTrueNode(propertyNode.key()));
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,13 @@ private void visitV2Node(JsonNode node) {
JsonNode typeNode = node.get("type");
String type = typeNode.getTokenValue();
JsonNode formatNode = node.get("format");
String format = formatNode.isMissing() ? null : formatNode.getTokenValue();
validate(type, format, typeNode);
if (formatNode.isMissing()) {
validate(type, null, typeNode);
return;
}
String format = formatNode.getTokenValue();
if (format == null || format.isBlank()) return;
validate(type, format.trim(), typeNode);
}

public abstract void validate(String type, String format, JsonNode typeNode);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package apiaddicts.sonar.openapi.checks.parameters;

import org.apiaddicts.apitools.dosonarapi.sslr.yaml.grammar.JsonNode;

public abstract class AbstractCollectionQueryParameterCheck extends AbstractQueryParameterCheck {

protected AbstractCollectionQueryParameterCheck(
String ruleKey,
String messageKey,
String parameterName,
boolean applyToParameterizedPaths
) {
super(ruleKey, messageKey, parameterName, applyToParameterizedPaths);
}

@Override
public void visitNode(JsonNode node) {
if (!"get".equals(node.key().getTokenValue())) return;

String path = getPath(node);

if (endsWithPathParam(path)) return;
if (path.contains("/me/") || path.endsWith("/me")) return;
if (path.contains("status") || path.contains("health") || path.contains("ping")) return;

if (!hasParameterInNode(node)) {
addIssue(ruleKey, translate(messageKey, parameterName), node.key());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public abstract class AbstractQueryParameterCheck extends BaseCheck {

protected final String ruleKey;
protected final String messageKey;
protected final String parameterName;
protected String parameterName;
protected final boolean applyToParameterizedPaths;

protected Set<String> paths;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
package apiaddicts.sonar.openapi.checks.parameters;

import org.apiaddicts.apitools.dosonarapi.sslr.yaml.grammar.JsonNode;
import org.sonar.check.Rule;

@Rule(key = OAR020ExpandParameterCheck.KEY)
public class OAR020ExpandParameterCheck extends AbstractQueryParameterCheck {
public class OAR020ExpandParameterCheck extends AbstractCollectionQueryParameterCheck {

public static final String KEY = "OAR020";
private static final String MESSAGE = "OAR020.error";
Expand All @@ -13,19 +12,4 @@ public class OAR020ExpandParameterCheck extends AbstractQueryParameterCheck {
public OAR020ExpandParameterCheck() {
super(KEY, MESSAGE, PARAM_NAME, false);
}

@Override
public void visitNode(JsonNode node) {
if (!"get".equals(node.key().getTokenValue())) return;

String path = getPath(node);

if (endsWithPathParam(path)) return;
if (path.contains("/me/") || path.endsWith("/me")) return;
if (path.contains("status") || path.contains("health") || path.contains("ping")) return;

if (!hasParameterInNode(node)) {
addIssue(ruleKey, translate(messageKey, parameterName), node.key());
}
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
package apiaddicts.sonar.openapi.checks.parameters;

import org.apiaddicts.apitools.dosonarapi.sslr.yaml.grammar.JsonNode;
import org.sonar.check.Rule;

@Rule(key = OAR021ExcludeParameterCheck.KEY)
public class OAR021ExcludeParameterCheck extends AbstractQueryParameterCheck {
public class OAR021ExcludeParameterCheck extends AbstractCollectionQueryParameterCheck {

public static final String KEY = "OAR021";
private static final String MESSAGE = "OAR021.error";
Expand All @@ -13,19 +12,4 @@ public class OAR021ExcludeParameterCheck extends AbstractQueryParameterCheck {
public OAR021ExcludeParameterCheck() {
super(KEY, MESSAGE, PARAM_NAME, false);
}

@Override
public void visitNode(JsonNode node) {
if (!"get".equals(node.key().getTokenValue())) return;

String path = getPath(node);

if (endsWithPathParam(path)) return;
if (path.contains("/me/") || path.endsWith("/me")) return;
if (path.contains("status") || path.contains("health") || path.contains("ping")) return;

if (!hasParameterInNode(node)) {
addIssue(ruleKey, translate(messageKey, parameterName), node.key());
}
}
}
Original file line number Diff line number Diff line change
@@ -1,118 +1,30 @@
package apiaddicts.sonar.openapi.checks.parameters;

import java.util.Set;
import java.util.stream.Collectors;

import org.apiaddicts.apitools.dosonarapi.api.v2.OpenApi2Grammar;
import org.apiaddicts.apitools.dosonarapi.api.v3.OpenApi3Grammar;
import org.apiaddicts.apitools.dosonarapi.api.v31.OpenApi31Grammar;
import org.apiaddicts.apitools.dosonarapi.api.v32.OpenApi32Grammar;
import org.apiaddicts.apitools.dosonarapi.sslr.yaml.grammar.JsonNode;
import org.sonar.check.Rule;
import org.sonar.check.RuleProperty;
import apiaddicts.sonar.openapi.checks.BaseCheck;

import com.google.common.collect.ImmutableSet;
import com.sonar.sslr.api.AstNode;
import com.sonar.sslr.api.AstNodeType;

import java.util.Arrays;
import java.util.HashSet;

@Rule(key = OAR028FilterParameterCheck.KEY)
public class OAR028FilterParameterCheck extends BaseCheck {
public class OAR028FilterParameterCheck extends AbstractCollectionQueryParameterCheck {

public static final String KEY = "OAR028";
private static final String MESSAGE = "OAR028.error";
private static final String DEFAULT_PATH = "/examples";
private static final String PATH_STRATEGY = "/include";
private static final String PARAM_NAME = "$filter";

private static final String PATH_STRATEGY_EXCLUDE = "/exclude";
private static final String PATH_STRATEGY_INCLUDE = "/include";

@RuleProperty(
key = "paths",
description = "List of explicit paths to include/exclude from this rule separated by comma",
defaultValue = DEFAULT_PATH
)
private String pathsStr = DEFAULT_PATH;

@RuleProperty(
key = "pathValidationStrategy",
description = "Path validation strategy (include/exclude)",
defaultValue = PATH_STRATEGY
)
private String pathCheckStrategy = PATH_STRATEGY;
private static final String DEFAULT_PARAM_NAME = "$filter";

@RuleProperty(
key = "parameterName",
description = "Name of the parameter to be checked",
defaultValue = PARAM_NAME
description = "Name of the query parameter to be checked",
defaultValue = DEFAULT_PARAM_NAME
)
private String parameterName = PARAM_NAME;

private Set<String> paths;
private String filterParamName = DEFAULT_PARAM_NAME;

@Override
public Set<AstNodeType> subscribedKinds() {
return ImmutableSet.of(OpenApi2Grammar.PARAMETER, OpenApi3Grammar.PARAMETER, OpenApi31Grammar.PARAMETER, OpenApi32Grammar.PARAMETER);
public OAR028FilterParameterCheck() {
super(KEY, MESSAGE, DEFAULT_PARAM_NAME, false);
}

@Override
protected void visitFile(JsonNode root) {
paths = parsePaths(pathsStr);
this.parameterName = filterParamName;
super.visitFile(root);
}

@Override
public void visitNode(JsonNode node) {
visitParameterNode(node);
}

public void visitParameterNode(JsonNode node) {
JsonNode inNode = node.get("in");
JsonNode nameNode = node.get("name");

if (inNode != null && nameNode != null) {
if (!"query".equals(inNode.getTokenValue())) {
return;
}
String path = getPath(node);
if (shouldExcludePath(path) && !parameterName.equals(nameNode.getTokenValue())) {
addIssue(KEY, translate(MESSAGE, parameterName), nameNode);
}
}
}

private String getPath(JsonNode node) {
StringBuilder pathBuilder = new StringBuilder();
AstNode pathNode = node.getFirstAncestor(OpenApi2Grammar.PATH, OpenApi3Grammar.PATH, OpenApi31Grammar.PATH, OpenApi32Grammar.PATH);
if (pathNode != null) {
while (pathNode.getType() != OpenApi2Grammar.PATH && pathNode.getType() != OpenApi3Grammar.PATH && pathNode.getType() != OpenApi31Grammar.PATH && pathNode.getType() != OpenApi32Grammar.PATH) {
pathNode = pathNode.getParent();
}
pathBuilder.append(((JsonNode) pathNode).key().getTokenValue());
}
return pathBuilder.toString();
}

private boolean shouldExcludePath(String path) {
if (pathCheckStrategy.equals(PATH_STRATEGY_EXCLUDE)) {
return !paths.contains(path);
} else if (pathCheckStrategy.equals(PATH_STRATEGY_INCLUDE)) {
return paths.contains(path);
}
return false;
}

private Set<String> parsePaths(String pathsStr) {
if (!pathsStr.trim().isEmpty()) {
return Arrays.stream(pathsStr.split(","))
.map(String::trim)
.collect(Collectors.toSet());
} else {
return new HashSet<>();
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ <h2>Ejemplo de código no compatible (OpenAPI 2)</h2>
type: object
properties:
name:
type: string
type: string <span class="error-info" style="color: #FD8E18;"># No conforme {{OAR037: Las propiedades de tipo string deben definir un formato válido}} — format ausente</span>
date:
type: string
type: string <span class="error-info" style="color: #FD8E18;"># No conforme {{OAR037: Las propiedades de tipo string deben definir un formato válido}} — format inválido</span>
format: 'dd/mm/yyyy'
</pre>
<h2>Solución compatible (OpenAPI 2)</h2>
Expand Down Expand Up @@ -66,9 +66,9 @@ <h2>Ejemplo de código no compatible (OpenAPI 3)</h2>
type: object
properties:
name:
type: string
type: string <span class="error-info" style="color: #FD8E18;"># No conforme {{OAR037: Las propiedades de tipo string deben definir un formato válido}} — format ausente</span>
date:
type: string
type: string <span class="error-info" style="color: #FD8E18;"># No conforme {{OAR037: Las propiedades de tipo string deben definir un formato válido}} — format inválido</span>
format: dd/mm/yyyy
</pre>
<h2>Solución compatible (OpenAPI 3)</h2>
Expand Down
Loading