Skip to content

Commit 326240c

Browse files
joke1196claude
authored andcommitted
SONARPY-3768 Create rule S8438: Django view functions should declare URL parameters explicitly (#872)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> GitOrigin-RevId: 3976c172a3fe0f38fbdef787ebf2e8471cc8d84d
1 parent adade47 commit 326240c

File tree

11 files changed

+325
-84
lines changed

11 files changed

+325
-84
lines changed

python-checks/src/main/java/org/sonar/python/checks/FastAPIPathParametersCheck.java

Lines changed: 5 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,11 @@
3131
import org.sonar.plugins.python.api.tree.Expression;
3232
import org.sonar.plugins.python.api.tree.FunctionDef;
3333
import org.sonar.plugins.python.api.tree.Tree;
34-
import org.sonar.plugins.python.api.types.v2.FunctionType;
35-
import org.sonar.plugins.python.api.types.v2.ParameterV2;
36-
import org.sonar.plugins.python.api.types.v2.PythonType;
3734
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
3835
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
3936
import org.sonar.python.checks.utils.Expressions;
37+
import org.sonar.python.checks.utils.FunctionParameterUtils;
38+
import org.sonar.python.checks.utils.FunctionParameterUtils.FunctionParameterInfo;
4039
import org.sonar.python.tree.TreeUtils;
4140

4241
@Rule(key = "S8411")
@@ -57,12 +56,6 @@ public class FastAPIPathParametersCheck extends PythonSubscriptionCheck {
5756
TypeMatchers.isType("fastapi.APIRouter." + method)))
5857
);
5958

60-
private record FunctionParameterInfo(Set<String> allParams, Set<String> positionalOnlyParams, boolean hasVariadicKeyword) {
61-
static FunctionParameterInfo empty() {
62-
return new FunctionParameterInfo(Set.of(), Set.of(), false);
63-
}
64-
}
65-
6659
@Override
6760
public void initialize(Context context) {
6861
context.registerSyntaxNodeConsumer(Tree.Kind.FUNCDEF, FastAPIPathParametersCheck::checkFunction);
@@ -90,7 +83,7 @@ private static void checkDecorator(SubscriptionContext ctx, Decorator decorator,
9083
return;
9184
}
9285

93-
FunctionParameterInfo paramInfo = extractFunctionParameters(functionDef);
86+
FunctionParameterInfo paramInfo = FunctionParameterUtils.extractFunctionParameters(functionDef);
9487
reportIssues(ctx, functionDef, pathParams, paramInfo);
9588
}
9689

@@ -114,54 +107,13 @@ private static Optional<String> extractStringValue(Expression expression) {
114107
.map(Expressions::unescape);
115108
}
116109

117-
private static FunctionParameterInfo extractFunctionParameters(FunctionDef functionDef) {
118-
return getFunctionType(functionDef)
119-
.map(FastAPIPathParametersCheck::buildParameterInfo)
120-
.orElse(FunctionParameterInfo.empty());
121-
}
122-
123-
private static Optional<FunctionType> getFunctionType(FunctionDef functionDef) {
124-
PythonType functionType = functionDef.name().typeV2();
125-
if (functionType instanceof FunctionType funcType) {
126-
return Optional.of(funcType);
127-
}
128-
return Optional.empty();
129-
}
130-
131-
private static FunctionParameterInfo buildParameterInfo(FunctionType functionType) {
132-
Set<String> allParams = new HashSet<>();
133-
Set<String> positionalOnlyParams = new HashSet<>();
134-
boolean hasVariadicKeyword = functionType.parameters().stream()
135-
.anyMatch(param -> param.isVariadic() && param.isKeywordVariadic());
136-
137-
functionType.parameters().stream()
138-
.filter(param -> !param.isVariadic())
139-
.forEach(param -> addParameter(param, allParams, positionalOnlyParams));
140-
141-
return new FunctionParameterInfo(allParams, positionalOnlyParams, hasVariadicKeyword);
142-
}
143-
144-
private static void addParameter(ParameterV2 param, Set<String> allParams, Set<String> positionalOnlyParams) {
145-
String paramName = param.name();
146-
if (paramName != null) {
147-
allParams.add(paramName);
148-
if (param.isPositionalOnly()) {
149-
positionalOnlyParams.add(paramName);
150-
}
151-
}
152-
}
153-
154110
private static void reportIssues(SubscriptionContext ctx, FunctionDef functionDef, Set<String> pathParams, FunctionParameterInfo paramInfo) {
155111
pathParams.stream()
156-
.filter(param -> isMissingFromSignature(param, paramInfo))
112+
.filter(paramInfo::isMissingFromSignature)
157113
.forEach(param -> ctx.addIssue(functionDef.name(), String.format(MISSING_PARAM_MESSAGE, param)));
158114

159115
pathParams.stream()
160-
.filter(paramInfo.positionalOnlyParams::contains)
116+
.filter(paramInfo.positionalOnlyParams()::contains)
161117
.forEach(param -> ctx.addIssue(functionDef.name(), String.format(POSITIONAL_ONLY_MESSAGE, param)));
162118
}
163-
164-
private static boolean isMissingFromSignature(String pathParam, FunctionParameterInfo paramInfo) {
165-
return !paramInfo.allParams.contains(pathParam) && !paramInfo.hasVariadicKeyword;
166-
}
167119
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
package org.sonar.python.checks.utils;
18+
19+
import java.util.HashSet;
20+
import java.util.Optional;
21+
import java.util.Set;
22+
import org.sonar.plugins.python.api.tree.FunctionDef;
23+
import org.sonar.plugins.python.api.types.v2.FunctionType;
24+
import org.sonar.plugins.python.api.types.v2.ParameterV2;
25+
import org.sonar.plugins.python.api.types.v2.PythonType;
26+
27+
/**
28+
* Utility class for extracting function parameter information.
29+
* Shared between FastAPIPathParametersCheck and DjangoViewUrlParametersCheck.
30+
*/
31+
public final class FunctionParameterUtils {
32+
33+
private FunctionParameterUtils() {
34+
// Utility class
35+
}
36+
37+
/**
38+
* Information about a function's parameters for path/URL parameter checks.
39+
*/
40+
public record FunctionParameterInfo(Set<String> allParams, Set<String> positionalOnlyParams, boolean hasVariadicKeyword) {
41+
public static FunctionParameterInfo empty() {
42+
return new FunctionParameterInfo(Set.of(), Set.of(), false);
43+
}
44+
45+
public boolean isMissingFromSignature(String param) {
46+
return !allParams.contains(param) && !hasVariadicKeyword;
47+
}
48+
}
49+
50+
public static FunctionParameterInfo extractFunctionParameters(FunctionDef functionDef) {
51+
return getFunctionType(functionDef)
52+
.map(FunctionParameterUtils::buildParameterInfo)
53+
.orElse(FunctionParameterInfo.empty());
54+
}
55+
56+
public static Optional<FunctionType> getFunctionType(FunctionDef functionDef) {
57+
PythonType functionType = functionDef.name().typeV2();
58+
if (functionType instanceof FunctionType funcType) {
59+
return Optional.of(funcType);
60+
}
61+
return Optional.empty();
62+
}
63+
64+
private static FunctionParameterInfo buildParameterInfo(FunctionType functionType) {
65+
Set<String> allParams = new HashSet<>();
66+
Set<String> positionalOnlyParams = new HashSet<>();
67+
boolean hasVariadicKeyword = functionType.parameters().stream()
68+
.anyMatch(param -> param.isVariadic() && param.isKeywordVariadic());
69+
70+
functionType.parameters().stream()
71+
.filter(param -> !param.isVariadic())
72+
.forEach(param -> addParameter(param, allParams, positionalOnlyParams));
73+
74+
return new FunctionParameterInfo(allParams, positionalOnlyParams, hasVariadicKeyword);
75+
}
76+
77+
private static void addParameter(ParameterV2 param, Set<String> allParams, Set<String> positionalOnlyParams) {
78+
String paramName = param.name();
79+
if (paramName != null) {
80+
allParams.add(paramName);
81+
if (param.isPositionalOnly()) {
82+
positionalOnlyParams.add(paramName);
83+
}
84+
}
85+
}
86+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
package org.sonar.plugins.python.api;
18+
19+
import java.util.HashSet;
20+
import java.util.Set;
21+
22+
/**
23+
* Holds metadata about a Django view function discovered during project analysis.
24+
*/
25+
public record DjangoViewInfo(Set<String> urlPatterns) {
26+
27+
public DjangoViewInfo {
28+
// Defensive copy to ensure immutability
29+
urlPatterns = Set.copyOf(urlPatterns);
30+
}
31+
32+
public static DjangoViewInfo withoutPatterns() {
33+
return new DjangoViewInfo(Set.of());
34+
}
35+
36+
public static DjangoViewInfo withPattern(String urlPattern) {
37+
return new DjangoViewInfo(Set.of(urlPattern));
38+
}
39+
40+
public DjangoViewInfo addPattern(String urlPattern) {
41+
var newPatterns = new HashSet<>(urlPatterns);
42+
newPatterns.add(urlPattern);
43+
return new DjangoViewInfo(newPatterns);
44+
}
45+
}

python-frontend/src/main/java/org/sonar/plugins/python/api/PythonInputFileContext.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,8 @@ public File workingDirectory() {
7474
public SonarProduct sonarProduct() {
7575
return sonarProduct;
7676
}
77+
78+
protected ProjectLevelSymbolTable projectLevelSymbolTable() {
79+
return projectLevelSymbolTable;
80+
}
7781
}

python-frontend/src/main/java/org/sonar/plugins/python/api/PythonVisitorContext.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ public CallGraph callGraph() {
124124
return callGraph;
125125
}
126126

127+
public Optional<DjangoViewInfo> getDjangoViewInfo(String fqn) {
128+
return projectLevelSymbolTable().getDjangoViewInfo(fqn);
129+
}
130+
127131
public static class Builder {
128132
private final PythonFile pythonFile;
129133
private final FileInput rootTree;

python-frontend/src/main/java/org/sonar/plugins/python/api/SubscriptionContext.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import com.google.common.annotations.Beta;
2020
import java.io.File;
2121
import java.util.Collection;
22+
import java.util.Optional;
2223
import java.util.Set;
2324
import javax.annotation.CheckForNull;
2425
import javax.annotation.Nullable;
@@ -76,4 +77,11 @@ public interface SubscriptionContext {
7677
ProjectConfiguration projectConfiguration();
7778

7879
CallGraph callGraph();
80+
81+
/**
82+
* Returns Django view information for the given fully qualified function name.
83+
* @param fqn the fully qualified name of a function
84+
* @return Optional containing DjangoViewInfo if the function is a Django view, empty otherwise
85+
*/
86+
Optional<DjangoViewInfo> getDjangoViewInfo(String fqn);
7987
}

python-frontend/src/main/java/org/sonar/python/SubscriptionVisitor.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@
2525
import java.util.EnumMap;
2626
import java.util.HashMap;
2727
import java.util.List;
28+
import java.util.Optional;
2829
import java.util.Set;
2930
import java.util.function.Consumer;
3031
import javax.annotation.CheckForNull;
3132
import javax.annotation.Nullable;
33+
import org.sonar.plugins.python.api.DjangoViewInfo;
3234
import org.sonar.plugins.python.api.IssueLocation;
3335
import org.sonar.plugins.python.api.LocationInFile;
3436
import org.sonar.plugins.python.api.ProjectPythonVersion;
@@ -207,5 +209,10 @@ public RegexParseResult regexForStringElement(StringElement stringElement, FlagS
207209
return regexCache.computeIfAbsent(stringElement.hashCode() + "-" + flagSet.getMask(),
208210
s -> new RegexParser(new PythonAnalyzerRegexSource(stringElement), flagSet).parse());
209211
}
212+
213+
@Override
214+
public Optional<DjangoViewInfo> getDjangoViewInfo(String fqn) {
215+
return pythonVisitorContext.getDjangoViewInfo(fqn);
216+
}
210217
}
211218
}

python-frontend/src/main/java/org/sonar/python/semantic/FunctionSymbolImpl.java

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import java.util.Optional;
2525
import javax.annotation.CheckForNull;
2626
import javax.annotation.Nullable;
27+
import org.sonar.plugins.python.api.DjangoViewInfo;
2728
import org.sonar.plugins.python.api.LocationInFile;
2829
import org.sonar.plugins.python.api.PythonFile;
2930
import org.sonar.plugins.python.api.symbols.FunctionSymbol;
@@ -64,7 +65,8 @@ public class FunctionSymbolImpl extends SymbolImpl implements FunctionSymbol {
6465
private Symbol owner;
6566
private static final String CLASS_METHOD_DECORATOR = "classmethod";
6667
private static final String STATIC_METHOD_DECORATOR = "staticmethod";
67-
private boolean isDjangoView = false;
68+
@Nullable
69+
private DjangoViewInfo djangoViewInfo = null;
6870
private boolean hasReadDeclaredReturnType = false;
6971

7072
FunctionSymbolImpl(FunctionDef functionDef, @Nullable String fullyQualifiedName, PythonFile pythonFile) {
@@ -121,7 +123,6 @@ public FunctionSymbolImpl(SymbolsProtos.FunctionSymbol functionSymbolProto, @Nul
121123
functionDefinitionLocation = null;
122124
declaredReturnType = anyType();
123125
isStub = true;
124-
isDjangoView = false;
125126
this.validForPythonVersions = new HashSet<>(validFor);
126127
}
127128

@@ -168,7 +169,7 @@ public void addParameter(ParameterImpl parameter) {
168169
declaredReturnType = functionSymbolImpl.declaredReturnType();
169170
}
170171
isStub = functionSymbol.isStub();
171-
isDjangoView = functionSymbolImpl.isDjangoView();
172+
djangoViewInfo = functionSymbolImpl.djangoViewInfo;
172173
validForPythonVersions = functionSymbolImpl.validForPythonVersions;
173174

174175
}
@@ -361,11 +362,11 @@ public void setOwner(Symbol owner) {
361362
}
362363

363364
public boolean isDjangoView() {
364-
return isDjangoView;
365+
return djangoViewInfo != null;
365366
}
366367

367-
public void setIsDjangoView(boolean isDjangoView) {
368-
this.isDjangoView = isDjangoView;
368+
public void setDjangoViewInfo(@Nullable DjangoViewInfo djangoViewInfo) {
369+
this.djangoViewInfo = djangoViewInfo;
369370
}
370371

371372
public static class ParameterImpl implements Parameter {

0 commit comments

Comments
 (0)