Skip to content

Commit 47d6101

Browse files
sonar-nigel[bot]Vibe Bot
authored andcommitted
SONARPY-3833 Fix S8411 false positive when path parameter is used in Depends() (#986)
Co-authored-by: Vibe Bot <vibe-bot@sonarsource.com> GitOrigin-RevId: b2dd5520b1acce2f16f12f7814e0ed777fb3129a
1 parent 57993cf commit 47d6101

File tree

2 files changed

+186
-1
lines changed

2 files changed

+186
-1
lines changed

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

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import java.util.HashSet;
2020
import java.util.List;
21+
import java.util.Objects;
2122
import java.util.Optional;
2223
import java.util.Set;
2324
import java.util.regex.Matcher;
@@ -26,11 +27,22 @@
2627
import org.sonar.check.Rule;
2728
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
2829
import org.sonar.plugins.python.api.SubscriptionContext;
30+
import org.sonar.plugins.python.api.symbols.v2.SymbolV2;
31+
import org.sonar.plugins.python.api.symbols.v2.UsageV2;
2932
import org.sonar.plugins.python.api.tree.CallExpression;
3033
import org.sonar.plugins.python.api.tree.Decorator;
3134
import org.sonar.plugins.python.api.tree.Expression;
3235
import org.sonar.plugins.python.api.tree.FunctionDef;
36+
import org.sonar.plugins.python.api.tree.Name;
37+
import org.sonar.plugins.python.api.tree.Parameter;
38+
import org.sonar.plugins.python.api.tree.ParameterList;
39+
import org.sonar.plugins.python.api.tree.QualifiedExpression;
40+
import org.sonar.plugins.python.api.tree.RegularArgument;
41+
import org.sonar.plugins.python.api.tree.SubscriptionExpression;
3342
import org.sonar.plugins.python.api.tree.Tree;
43+
import org.sonar.plugins.python.api.tree.TypeAnnotation;
44+
import org.sonar.plugins.python.api.types.v2.FunctionType;
45+
import org.sonar.plugins.python.api.types.v2.ParameterV2;
3446
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
3547
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
3648
import org.sonar.python.checks.utils.Expressions;
@@ -56,6 +68,9 @@ public class FastAPIPathParametersCheck extends PythonSubscriptionCheck {
5668
TypeMatchers.isType("fastapi.APIRouter." + method)))
5769
);
5870

71+
private static final TypeMatcher FASTAPI_DEPENDS_MATCHER = TypeMatchers.isType("fastapi.param_functions.Depends");
72+
private static final TypeMatcher TYPING_ANNOTATED_MATCHER = TypeMatchers.isType("typing.Annotated");
73+
5974
@Override
6075
public void initialize(Context context) {
6176
context.registerSyntaxNodeConsumer(Tree.Kind.FUNCDEF, FastAPIPathParametersCheck::checkFunction);
@@ -84,9 +99,89 @@ private static void checkDecorator(SubscriptionContext ctx, Decorator decorator,
8499
}
85100

86101
FunctionParameterInfo paramInfo = FunctionParameterUtils.extractFunctionParameters(functionDef);
102+
pathParams.removeAll(extractDependencyParameters(functionDef, ctx));
87103
reportIssues(ctx, functionDef, pathParams, paramInfo);
88104
}
89105

106+
private static Set<String> extractDependencyParameters(FunctionDef functionDef, SubscriptionContext ctx) {
107+
return extractDependencyParameters(functionDef, ctx, new HashSet<>());
108+
}
109+
110+
private static Set<String> extractDependencyParameters(FunctionDef functionDef, SubscriptionContext ctx, Set<FunctionDef> visited) {
111+
if (!visited.add(functionDef)) {
112+
return Set.of();
113+
}
114+
ParameterList parameterList = functionDef.parameters();
115+
if (parameterList == null) {
116+
return Set.of();
117+
}
118+
Set<String> dependencyParams = new HashSet<>();
119+
parameterList.nonTuple().stream()
120+
.map(param -> getDependsCall(param, ctx))
121+
.filter(Optional::isPresent)
122+
.map(Optional::get)
123+
.forEach(dependsCall -> collectDependencyParams(dependsCall, dependencyParams, visited, ctx));
124+
return dependencyParams;
125+
}
126+
127+
private static Optional<CallExpression> getDependsCall(Parameter param, SubscriptionContext ctx) {
128+
Expression defaultValue = param.defaultValue();
129+
if (defaultValue instanceof CallExpression callExpr && FASTAPI_DEPENDS_MATCHER.isTrueFor(callExpr.callee(), ctx)) {
130+
return Optional.of(callExpr);
131+
}
132+
TypeAnnotation typeAnnotation = param.typeAnnotation();
133+
if (typeAnnotation != null && typeAnnotation.expression() instanceof SubscriptionExpression subscriptionExpr
134+
&& TYPING_ANNOTATED_MATCHER.isTrueFor(subscriptionExpr.object(), ctx)) {
135+
return subscriptionExpr.subscripts().expressions().stream()
136+
.filter(e -> e instanceof CallExpression ce && FASTAPI_DEPENDS_MATCHER.isTrueFor(ce.callee(), ctx))
137+
.map(e -> (CallExpression) e)
138+
.findFirst();
139+
}
140+
return Optional.empty();
141+
}
142+
143+
private static void collectDependencyParams(CallExpression dependsCall, Set<String> dependencyParams, Set<FunctionDef> visited, SubscriptionContext ctx) {
144+
TreeUtils.nthArgumentOrKeywordOptional(0, "dependency", dependsCall.arguments())
145+
.map(RegularArgument::expression)
146+
.ifPresent(argExpr -> {
147+
if (argExpr.typeV2() instanceof FunctionType funcType) {
148+
funcType.parameters().stream()
149+
.filter(param -> !param.isVariadic())
150+
.map(ParameterV2::name)
151+
.filter(Objects::nonNull)
152+
.forEach(dependencyParams::add);
153+
}
154+
getFunctionDef(argExpr).ifPresent(depFuncDef ->
155+
dependencyParams.addAll(extractDependencyParameters(depFuncDef, ctx, visited))
156+
);
157+
});
158+
}
159+
160+
private static Optional<FunctionDef> getFunctionDef(Expression expression) {
161+
Name name;
162+
if (expression instanceof Name n) {
163+
name = n;
164+
} else if (expression instanceof QualifiedExpression qe) {
165+
name = qe.name();
166+
} else {
167+
name = null;
168+
}
169+
if (name == null) {
170+
return Optional.empty();
171+
}
172+
SymbolV2 symbol = name.symbolV2();
173+
if (symbol == null) {
174+
return Optional.empty();
175+
}
176+
return symbol.usages().stream()
177+
.filter(u -> u.kind() == UsageV2.Kind.FUNC_DECLARATION)
178+
.map(UsageV2::tree)
179+
.map(tree -> TreeUtils.firstAncestorOfKind(tree, Tree.Kind.FUNCDEF))
180+
.filter(Objects::nonNull)
181+
.map(FunctionDef.class::cast)
182+
.findFirst();
183+
}
184+
90185
private static Set<String> extractPathParameters(CallExpression callExpr) {
91186
Set<String> pathParams = new HashSet<>();
92187
String pathString = getPathArgument(callExpr).orElse("");

python-checks/src/test/resources/checks/fastapiPathParameters.py

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
from fastapi import FastAPI, APIRouter
1+
import typing
2+
from fastapi import FastAPI, APIRouter, Depends
3+
from typing import Annotated
24

35
app = FastAPI()
46
router = APIRouter()
@@ -167,3 +169,91 @@ def compliant_empty_path():
167169
@app.get(True)
168170
def compliant_path_is_not_a_string():
169171
pass
172+
173+
# --- Depends() path parameter delegation ---
174+
175+
def get_item(item_id: int):
176+
return {"item_id": item_id}
177+
178+
@router.get("/items/{item_id}")
179+
def compliant_depends_default_value(item=Depends(get_item)):
180+
return item
181+
182+
@app.get("/items/{item_id}")
183+
def compliant_depends_annotated(item: Annotated[dict, Depends(get_item)]):
184+
return item
185+
186+
@app.get("/users/{user_id}/items/{item_id}")
187+
def compliant_depends_partial(user_id: int, item=Depends(get_item)):
188+
return {"user_id": user_id, "item": item}
189+
190+
async def get_item_async(item_id: int):
191+
return {"item_id": item_id}
192+
193+
@router.get("/items/{item_id}")
194+
def compliant_depends_async_dependency(item=Depends(get_item_async)):
195+
return item
196+
197+
def dep_without_item_id(name: str):
198+
return name
199+
200+
@app.get("/items/{item_id}")
201+
def noncompliant_depends_param_not_in_dep(item=Depends(dep_without_item_id)): # Noncompliant {{Add path parameter "item_id" to the function signature.}}
202+
return item
203+
204+
@app.get("/items/{item_id}")
205+
def compliant_depends_annotated_qualified(item: typing.Annotated[dict, Depends(get_item)]):
206+
return item
207+
208+
# --- Nested/recursive Depends() ---
209+
210+
def get_item_wrapper(item=Depends(get_item)):
211+
return item
212+
213+
@router.get("/items/{item_id}")
214+
def compliant_nested_depends(item=Depends(get_item_wrapper)):
215+
return item
216+
217+
def get_item_annotated_wrapper(item: Annotated[dict, Depends(get_item)]):
218+
return item
219+
220+
@app.get("/items/{item_id}")
221+
def compliant_nested_depends_annotated(item=Depends(get_item_annotated_wrapper)):
222+
return item
223+
224+
def dep_level2_no_id(name: str):
225+
return name
226+
227+
def dep_level1_no_id(x=Depends(dep_level2_no_id)):
228+
return x
229+
230+
@app.get("/items/{item_id}")
231+
def noncompliant_nested_depends_not_covering(item=Depends(dep_level1_no_id)): # Noncompliant {{Add path parameter "item_id" to the function signature.}}
232+
return item
233+
234+
# --- Circular dependency detection (cycle in Depends chain) ---
235+
# dep_b_circular references dep_a_circular (defined below) - tests cycle detection at line 112
236+
def dep_b_circular(dep=Depends(dep_a_circular)):
237+
return dep
238+
239+
def dep_a_circular(item_id: int, dep=Depends(dep_b_circular)):
240+
return item_id
241+
242+
@app.get("/items/{item_id}")
243+
def compliant_circular_depends(dep=Depends(dep_a_circular)):
244+
return dep
245+
246+
# --- QualifiedExpression as Depends argument (tests lines 164-165 and 174) ---
247+
class ItemDepsHolder:
248+
@staticmethod
249+
def get_item(item_id: int):
250+
return item_id
251+
252+
@app.get("/items/{item_id}")
253+
def compliant_qualified_expr_depends(dep=Depends(ItemDepsHolder.get_item)):
254+
return dep
255+
256+
# --- Non-Name/non-QualifiedExpression as Depends argument (tests lines 167 and 170) ---
257+
@app.get("/items/{item_id}")
258+
def noncompliant_lambda_depends(dep=Depends(lambda: None)): # Noncompliant {{Add path parameter "item_id" to the function signature.}}
259+
return dep

0 commit comments

Comments
 (0)