Skip to content

Commit a5253aa

Browse files
Seppli11sonartech
authored andcommitted
SONARPY-3695 Create rule S8415: HTTPException responses should be documented in endpoint metadata (#817)
GitOrigin-RevId: 3895ac56ae46da4bc03ff13740ed4daee33118c3
1 parent f29d7f1 commit a5253aa

7 files changed

Lines changed: 742 additions & 1 deletion

File tree

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
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;
18+
19+
import java.util.ArrayList;
20+
import java.util.HashSet;
21+
import java.util.List;
22+
import java.util.Optional;
23+
import java.util.Set;
24+
import java.util.stream.Stream;
25+
import org.sonar.check.Rule;
26+
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
27+
import org.sonar.plugins.python.api.SubscriptionContext;
28+
import org.sonar.plugins.python.api.tree.BaseTreeVisitor;
29+
import org.sonar.plugins.python.api.tree.CallExpression;
30+
import org.sonar.plugins.python.api.tree.Decorator;
31+
import org.sonar.plugins.python.api.tree.DictionaryLiteral;
32+
import org.sonar.plugins.python.api.tree.DictionaryLiteralElement;
33+
import org.sonar.plugins.python.api.tree.Expression;
34+
import org.sonar.plugins.python.api.tree.FunctionDef;
35+
import org.sonar.plugins.python.api.tree.KeyValuePair;
36+
import org.sonar.plugins.python.api.tree.LambdaExpression;
37+
import org.sonar.plugins.python.api.tree.Name;
38+
import org.sonar.plugins.python.api.tree.NumericLiteral;
39+
import org.sonar.plugins.python.api.tree.RaiseStatement;
40+
import org.sonar.plugins.python.api.tree.RegularArgument;
41+
import org.sonar.plugins.python.api.tree.StatementList;
42+
import org.sonar.plugins.python.api.tree.StringLiteral;
43+
import org.sonar.plugins.python.api.tree.Tree;
44+
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
45+
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
46+
import org.sonar.python.checks.utils.Expressions;
47+
import org.sonar.python.tree.TreeUtils;
48+
49+
@Rule(key = "S8415")
50+
public class FastAPIHTTPExceptionDocumentedCheck extends PythonSubscriptionCheck {
51+
52+
private static final String MESSAGE = "Document this HTTPException with status code %d in the \"responses\" parameter.";
53+
54+
private static final String FASTAPI_MODULE = "fastapi.applications.FastAPI";
55+
private static final String API_ROUTER_MODULE = "fastapi.routing.APIRouter";
56+
private static final Set<String> ROUTES = Set.of(
57+
"get", "post", "put", "delete", "patch", "options", "head", "trace");
58+
59+
private static final TypeMatcher FASTAPI_ROUTE_MATCHER = TypeMatchers.any(
60+
Stream.concat(
61+
ROUTES.stream().map(methodName -> TypeMatchers.isType(FASTAPI_MODULE + "." + methodName)),
62+
ROUTES.stream().map(methodName -> TypeMatchers.isType(API_ROUTER_MODULE + "." + methodName))));
63+
64+
private static final TypeMatcher IS_HTTP_EXCEPTION = TypeMatchers.any(
65+
TypeMatchers.isType("fastapi.exceptions.HTTPException"),
66+
TypeMatchers.isType("fastapi.HTTPException"));
67+
68+
private static final int MAX_RECURSION_DEPTH = 5;
69+
70+
@Override
71+
public void initialize(Context context) {
72+
context.registerSyntaxNodeConsumer(Tree.Kind.FUNCDEF, FastAPIHTTPExceptionDocumentedCheck::checkFunctionDef);
73+
}
74+
75+
private static void checkFunctionDef(SubscriptionContext ctx) {
76+
FunctionDef functionDef = (FunctionDef) ctx.syntaxNode();
77+
78+
DecoratorAnalysisResult analysisResult = new DecoratorAnalysis(ctx, functionDef).analyze();
79+
80+
if (!analysisResult.isFastApiEndpoint() || !analysisResult.canAnalyzeResponses()) {
81+
return;
82+
}
83+
84+
List<RaiseInfo> httpExceptions = new RaiseInfoCollector(ctx, functionDef).collect();
85+
86+
reportUndocumentedExceptions(ctx, httpExceptions, analysisResult.documentedStatusCodes);
87+
}
88+
89+
private static class DecoratorAnalysis {
90+
private SubscriptionContext ctx;
91+
private FunctionDef functionDef;
92+
93+
private boolean canAnalyzeResponses = true;
94+
95+
public DecoratorAnalysis(SubscriptionContext ctx, FunctionDef functionDef) {
96+
this.ctx = ctx;
97+
this.functionDef = functionDef;
98+
}
99+
100+
public DecoratorAnalysisResult analyze() {
101+
List<CallExpression> fastApiRouteDecorators = functionDef.decorators().stream()
102+
.map(Decorator::expression)
103+
.flatMap(TreeUtils.toStreamInstanceOfMapper(CallExpression.class))
104+
.filter(callExpr -> isFastApiRouteDecorator(callExpr, ctx))
105+
.toList();
106+
107+
Set<Integer> documentedStatusCodes = new HashSet<>();
108+
boolean isFastApiEndpoint = !fastApiRouteDecorators.isEmpty();
109+
canAnalyzeResponses = true;
110+
111+
for (CallExpression fastApiRouteDecorator : fastApiRouteDecorators) {
112+
documentedStatusCodes.addAll(processDecorator(fastApiRouteDecorator));
113+
}
114+
115+
return new DecoratorAnalysisResult(isFastApiEndpoint, canAnalyzeResponses, documentedStatusCodes);
116+
}
117+
118+
private static boolean isFastApiRouteDecorator(CallExpression callExpr, SubscriptionContext ctx) {
119+
return FASTAPI_ROUTE_MATCHER.isTrueFor(callExpr.callee(), ctx);
120+
}
121+
122+
private Set<Integer> processDecorator(CallExpression callExpr) {
123+
RegularArgument responsesArg = TreeUtils.argumentByKeyword("responses", callExpr.arguments());
124+
if (responsesArg != null) {
125+
Expression responsesExpr = responsesArg.expression();
126+
if (responsesExpr instanceof DictionaryLiteral) {
127+
return extractDocumentedStatusCodes(responsesExpr);
128+
} else {
129+
canAnalyzeResponses = false;
130+
}
131+
}
132+
return Set.of();
133+
}
134+
135+
private static Set<Integer> extractDocumentedStatusCodes(Expression responsesExpr) {
136+
Set<Integer> statusCodes = new HashSet<>();
137+
138+
if (responsesExpr instanceof DictionaryLiteral dictLiteral) {
139+
for (DictionaryLiteralElement element : dictLiteral.elements()) {
140+
if (element instanceof KeyValuePair keyValuePair) {
141+
Expression key = keyValuePair.key();
142+
extractStatusCode(key).ifPresent(statusCodes::add);
143+
}
144+
}
145+
}
146+
147+
return statusCodes;
148+
}
149+
150+
}
151+
152+
private static void reportUndocumentedExceptions(
153+
SubscriptionContext ctx,
154+
List<RaiseInfo> httpExceptions,
155+
Set<Integer> documentedStatusCodes) {
156+
for (RaiseInfo raiseInfo : httpExceptions) {
157+
if (!documentedStatusCodes.contains(raiseInfo.statusCode)) {
158+
ctx.addIssue(raiseInfo.httpExceptionExpression, String.format(MESSAGE, raiseInfo.statusCode));
159+
}
160+
}
161+
}
162+
163+
private record DecoratorAnalysisResult(
164+
boolean isFastApiEndpoint,
165+
boolean canAnalyzeResponses,
166+
Set<Integer> documentedStatusCodes) {
167+
}
168+
169+
private static class RaiseInfoCollector {
170+
private final SubscriptionContext ctx;
171+
private final FunctionDef functionDef;
172+
private final Set<FunctionDef> visited = new HashSet<>();
173+
174+
RaiseInfoCollector(SubscriptionContext ctx, FunctionDef functionDef) {
175+
this.ctx = ctx;
176+
this.functionDef = functionDef;
177+
}
178+
179+
public List<RaiseInfo> collect() {
180+
return collect(functionDef, 0);
181+
}
182+
183+
private List<RaiseInfo> collect(FunctionDef functionDef, int depth) {
184+
List<RaiseInfo> result = new ArrayList<>();
185+
186+
if (visited.contains(functionDef) || depth > MAX_RECURSION_DEPTH) {
187+
return result;
188+
}
189+
visited.add(functionDef);
190+
191+
StatementList body = functionDef.body();
192+
if (body == null) {
193+
return result;
194+
}
195+
196+
HTTPExceptionVisitor visitor = new HTTPExceptionVisitor(ctx);
197+
body.accept(visitor);
198+
result.addAll(visitor.httpExceptions);
199+
200+
return result;
201+
}
202+
}
203+
204+
private static class HTTPExceptionVisitor extends BaseTreeVisitor {
205+
private final SubscriptionContext ctx;
206+
private final List<RaiseInfo> httpExceptions = new ArrayList<>();
207+
208+
HTTPExceptionVisitor(SubscriptionContext ctx) {
209+
this.ctx = ctx;
210+
}
211+
212+
@Override
213+
public void visitRaiseStatement(RaiseStatement raiseStmt) {
214+
List<RaiseInfo> raiseInfos = raiseStmt.expressions().stream()
215+
.flatMap(TreeUtils.toStreamInstanceOfMapper(CallExpression.class))
216+
.filter(callExpr -> IS_HTTP_EXCEPTION.isTrueFor(callExpr.callee(), ctx))
217+
.flatMap(HTTPExceptionVisitor::extractRaiseInfos)
218+
.toList();
219+
220+
httpExceptions.addAll(raiseInfos);
221+
super.visitRaiseStatement(raiseStmt);
222+
}
223+
224+
private static Stream<RaiseInfo> extractRaiseInfos(CallExpression callExpr) {
225+
return extractStatusCodeFromHTTPException(callExpr).map(statusCode -> new RaiseInfo(callExpr.callee(), statusCode));
226+
}
227+
228+
private static Stream<Integer> extractStatusCodeFromHTTPException(CallExpression callExpr) {
229+
RegularArgument statusCodeArg = TreeUtils.nthArgumentOrKeyword(0, "status_code", callExpr.arguments());
230+
231+
if (statusCodeArg == null) {
232+
return Stream.empty();
233+
}
234+
235+
return extractStatusCode(statusCodeArg.expression()).stream();
236+
}
237+
238+
@Override
239+
public void visitFunctionDef(FunctionDef pyFunctionDefTree) {
240+
// don't decend into nested functions
241+
}
242+
243+
@Override
244+
public void visitLambda(LambdaExpression pyLambdaExpressionTree) {
245+
// don't decend into nested lambdas
246+
}
247+
}
248+
249+
private static Optional<Integer> extractStatusCode(Expression statusCodeExpr) {
250+
if (statusCodeExpr instanceof Name name) {
251+
Expression singleAssignedValue = Expressions.singleAssignedValue(name);
252+
if (singleAssignedValue != null) {
253+
return extractStatusCode(singleAssignedValue);
254+
}
255+
} else if (statusCodeExpr instanceof NumericLiteral numericLiteral) {
256+
return Optional.of((int) numericLiteral.valueAsLong());
257+
} else if (statusCodeExpr instanceof StringLiteral stringLiteral) {
258+
try {
259+
return Optional.of(Integer.parseInt(stringLiteral.trimmedQuotesValue()));
260+
} catch (NumberFormatException e) {
261+
return Optional.empty();
262+
}
263+
}
264+
return Optional.empty();
265+
}
266+
267+
private static class RaiseInfo {
268+
final Expression httpExceptionExpression;
269+
final int statusCode;
270+
271+
RaiseInfo(Expression httpExceptionExpression, int statusCode) {
272+
this.httpExceptionExpression = httpExceptionExpression;
273+
this.statusCode = statusCode;
274+
}
275+
}
276+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ public Stream<Class<?>> getChecks() {
222222
FastAPIDependencyAnnotatedCheck.class,
223223
FastAPIFileUploadFormCheck.class,
224224
FastAPIGenericRouteDecoratorCheck.class,
225+
FastAPIHTTPExceptionDocumentedCheck.class,
225226
FastApiImportStringCheck.class,
226227
FastAPIRedundantResponseModelCheck.class,
227228
FastAPIPathParametersCheck.class,

0 commit comments

Comments
 (0)