Skip to content

Commit 2fb6b32

Browse files
authored
Use TypeChecker for detecting fastapi routes (#15093)
1 parent fd4bea5 commit 2fb6b32

5 files changed

Lines changed: 88 additions & 11 deletions

File tree

crates/ruff_linter/resources/test/fixtures/fastapi/FAST001.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,18 @@ async def create_item(item: Item) -> Dict[str, str]:
108108
@app.post("/items/", response_model=Item)
109109
async def create_item(item: Item) -> Item:
110110
return item
111+
112+
113+
# Routes might be defined inside functions
114+
115+
116+
def setup_app(app_arg: FastAPI, non_app: str) -> None:
117+
# Error
118+
@app_arg.get("/", response_model=str)
119+
async def get_root() -> str:
120+
return "Hello World!"
121+
122+
# Ok
123+
@non_app.get("/", response_model=str)
124+
async def get_root() -> str:
125+
return "Hello World!"

crates/ruff_linter/resources/test/fixtures/ruff/RUF029.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,20 @@ async def test() -> str:
8080
return ",".join(vals)
8181

8282

83+
# FastApi routes can be async without actually using await
84+
8385
from fastapi import FastAPI
8486

8587
app = FastAPI()
8688

8789

8890
@app.post("/count")
89-
async def fastapi_route(): # Ok: FastApi routes can be async without actually using await
91+
async def fastapi_route():
9092
return 1
93+
94+
95+
def setup_app(app_arg: FastAPI, non_app: str) -> None:
96+
@app_arg.get("/")
97+
async def get_root() -> str:
98+
return "Hello World!"
99+

crates/ruff_linter/src/rules/fastapi/rules/mod.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ mod fastapi_redundant_response_model;
77
mod fastapi_unused_path_parameter;
88

99
use ruff_python_ast as ast;
10-
use ruff_python_semantic::analyze::typing::resolve_assignment;
10+
use ruff_python_semantic::analyze::typing;
1111
use ruff_python_semantic::SemanticModel;
1212

1313
/// Returns `true` if the function is a FastAPI route.
@@ -41,11 +41,11 @@ pub(crate) fn is_fastapi_route_call(call_expr: &ast::ExprCall, semantic: &Semant
4141
) {
4242
return false;
4343
}
44-
45-
resolve_assignment(value, semantic).is_some_and(|qualified_name| {
46-
matches!(
47-
qualified_name.segments(),
48-
["fastapi", "FastAPI" | "APIRouter"]
49-
)
50-
})
44+
let Some(name) = value.as_name_expr() else {
45+
return false;
46+
};
47+
let Some(binding_id) = semantic.resolve_name(name) else {
48+
return false;
49+
};
50+
typing::is_fastapi_route(semantic.binding(binding_id), semantic)
5151
}

crates/ruff_linter/src/rules/fastapi/snapshots/ruff_linter__rules__fastapi__tests__fast-api-redundant-response-model_FAST001.py.snap

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
---
22
source: crates/ruff_linter/src/rules/fastapi/mod.rs
3-
snapshot_kind: text
43
---
54
FAST001.py:17:22: FAST001 [*] FastAPI route with redundant `response_model` argument
65
|
@@ -172,4 +171,25 @@ FAST001.py:53:24: FAST001 [*] FastAPI route with redundant `response_model` argu
172171
53 |+@router.get("/items/")
173172
54 54 | async def create_item(item: Item) -> Item:
174173
55 55 | return item
175-
56 56 |
174+
56 56 |
175+
176+
FAST001.py:118:23: FAST001 [*] FastAPI route with redundant `response_model` argument
177+
|
178+
116 | def setup_app(app_arg: FastAPI, non_app: str) -> None:
179+
117 | # Error
180+
118 | @app_arg.get("/", response_model=str)
181+
| ^^^^^^^^^^^^^^^^^^ FAST001
182+
119 | async def get_root() -> str:
183+
120 | return "Hello World!"
184+
|
185+
= help: Remove argument
186+
187+
Unsafe fix
188+
115 115 |
189+
116 116 | def setup_app(app_arg: FastAPI, non_app: str) -> None:
190+
117 117 | # Error
191+
118 |- @app_arg.get("/", response_model=str)
192+
118 |+ @app_arg.get("/")
193+
119 119 | async def get_root() -> str:
194+
120 120 | return "Hello World!"
195+
121 121 |

crates/ruff_python_semantic/src/analyze/typing.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,35 @@ impl TypeChecker for PathlibPathChecker {
786786
}
787787
}
788788

789+
pub struct FastApiRouteChecker;
790+
791+
impl FastApiRouteChecker {
792+
fn is_fastapi_route_constructor(semantic: &SemanticModel, expr: &Expr) -> bool {
793+
let Some(qualified_name) = semantic.resolve_qualified_name(expr) else {
794+
return false;
795+
};
796+
797+
matches!(
798+
qualified_name.segments(),
799+
["fastapi", "FastAPI" | "APIRouter"]
800+
)
801+
}
802+
}
803+
804+
impl TypeChecker for FastApiRouteChecker {
805+
fn match_annotation(annotation: &Expr, semantic: &SemanticModel) -> bool {
806+
Self::is_fastapi_route_constructor(semantic, annotation)
807+
}
808+
809+
fn match_initializer(initializer: &Expr, semantic: &SemanticModel) -> bool {
810+
let Expr::Call(ast::ExprCall { func, .. }) = initializer else {
811+
return false;
812+
};
813+
814+
Self::is_fastapi_route_constructor(semantic, func)
815+
}
816+
}
817+
789818
pub struct TypeVarLikeChecker;
790819

791820
impl TypeVarLikeChecker {
@@ -914,6 +943,10 @@ pub fn is_pathlib_path(binding: &Binding, semantic: &SemanticModel) -> bool {
914943
check_type::<PathlibPathChecker>(binding, semantic)
915944
}
916945

946+
pub fn is_fastapi_route(binding: &Binding, semantic: &SemanticModel) -> bool {
947+
check_type::<FastApiRouteChecker>(binding, semantic)
948+
}
949+
917950
/// Test whether the given binding is for an old-style `TypeVar`, `TypeVarTuple` or a `ParamSpec`.
918951
pub fn is_type_var_like(binding: &Binding, semantic: &SemanticModel) -> bool {
919952
check_type::<TypeVarLikeChecker>(binding, semantic)

0 commit comments

Comments
 (0)