Skip to content

Commit f573024

Browse files
authored
Find exceptions from API functions (#16)
* Add ability to extract exceptions from api functions * Add tests for it * Add api docs * Remove unnessecary if
1 parent 317e50a commit f573024

4 files changed

Lines changed: 177 additions & 17 deletions

File tree

docs/advance.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,27 @@ app = FastAPI()
4949
# add your routers ....
5050
app.openapi = enrich_openapi(app, ["your_module_name", "some-other-package"])
5151
```
52+
53+
### Finding exceptions in specific API functions of FastAPI
54+
55+
If you want to limit the search of exceptions to specific functions, you can pass the list of functions to the `find_exceptions_in_api_functions` function.
56+
57+
```python
58+
from richapi import find_exceptions_in_api_functions, BaseHTTPException
59+
from fastapi import FastAPI
60+
61+
app = FastAPI()
62+
63+
class NotFoundException(BaseHTTPException):
64+
status_code = 404
65+
detail: str = "Item not found"
66+
67+
@app.get("/items/{item_id}")
68+
async def read_item(item_id: int):
69+
if item_id != 42:
70+
raise NotFoundException()
71+
return {"item_id": item_id}
72+
73+
exceptions = find_exceptions_in_api_functions(funcs=[read_item], fastapi_app=app)
74+
print(exceptions) # {NotFoundException}
75+
```

richapi/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
from richapi.exc_parser.handler import add_exc_handler
2-
from richapi.exc_parser.openapi import enrich_openapi, load_openapi
2+
from richapi.exc_parser.openapi import (
3+
enrich_openapi,
4+
find_exceptions_in_api_functions,
5+
load_openapi,
6+
)
37
from richapi.exc_parser.protocol import BaseHTTPException, RichHTTPException
48

59
__all__ = [
@@ -8,4 +12,5 @@
812
"BaseHTTPException",
913
"RichHTTPException",
1014
"add_exc_handler",
15+
"find_exceptions_in_api_functions",
1116
]

richapi/exc_parser/openapi.py

Lines changed: 90 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,38 @@ def load_openapi(
3737
return lambda: openapi_json
3838

3939

40+
def _validate_target_module(
41+
target_module: Union[list[str], str, None],
42+
target_obj: FastAPI | Callable,
43+
) -> list[str]:
44+
if target_module is not None:
45+
return [target_module] if isinstance(target_module, str) else target_module
46+
47+
if isinstance(target_obj, FastAPI):
48+
target_module = _find_module_name_where_app_defined_in(target_obj)
49+
else:
50+
target_module = _find_module_name_where_func_defined_in(target_obj)
51+
52+
if target_module is not None:
53+
target_module = target_module.split(".")[0] # get the top-level module
54+
55+
if target_module is None or target_module == "__main__":
56+
if isinstance(target_obj, FastAPI):
57+
raise BaseRichAPIException(
58+
"Could not determine the module where the FastAPI instance was created.\n"
59+
"Please provide the module name as a string or list of strings.\n"
60+
"Example: enrich_openapi(app, target_module='src')\n"
61+
)
62+
else:
63+
raise BaseRichAPIException(
64+
"Could not determine the module where the function was created.\n"
65+
"Please provide the module name as a string or list of strings.\n"
66+
"Example: find_exceptions_in_api_functions([func], target_module='src')\n"
67+
)
68+
69+
return [target_module]
70+
71+
4072
def enrich_openapi(
4173
app: FastAPI,
4274
target_module: Union[list[str], str, None] = None,
@@ -47,16 +79,7 @@ def enrich_openapi(
4779
routes=app.routes,
4880
),
4981
) -> Callable:
50-
if target_module is None:
51-
target_module = _find_module_name_where_app_defined_in(app)
52-
if target_module is None or target_module == "__main__":
53-
raise BaseRichAPIException(
54-
"Could not determine the module where the FastAPI instance was created.\n"
55-
"Please provide the module name as a string or list of strings.\n"
56-
"Example: enrich_openapi(app, target_module='src')\n"
57-
)
58-
59-
target_module = target_module.split(".")[0] # get the top-level module
82+
target_module = _validate_target_module(target_module, app)
6083

6184
def _custom_openapi() -> dict:
6285
if app.openapi_schema: # pragma: no cover
@@ -72,7 +95,7 @@ def _custom_openapi() -> dict:
7295

7396
def compile_openapi_from_fastapi(
7497
app: FastAPI,
75-
target_module: Union[list[str], str],
98+
target_module: list[str] | str,
7699
open_api_getter: Callable[[FastAPI], dict] = lambda app: _get_openapi(
77100
title=app.title,
78101
version=app.version,
@@ -81,22 +104,58 @@ def compile_openapi_from_fastapi(
81104
),
82105
) -> dict:
83106
target_module = [target_module] if isinstance(target_module, str) else target_module
84-
target_module.append("fastapi")
107+
85108
openapi_schema = open_api_getter(app)
86109
for route in app.routes:
87110
if not isinstance(route, APIRoute):
88111
continue
89112

90113
if route.include_in_schema:
91-
exceptions = _extract_starlette_exceptions(route, target_module)
92-
114+
exceptions = _extract_starlette_exceptions(
115+
route, target_module + ["fastapi"]
116+
)
93117
_fill_openapi_with_excpetions(openapi_schema, route, exceptions)
94118

95119
ExceptionFinder.clear_cache()
96120

97121
return openapi_schema
98122

99123

124+
def _find_fastapi_route_from_callback(
125+
app: FastAPI, callback: Callable
126+
) -> Union[APIRoute, None]:
127+
for route in app.routes:
128+
if not isinstance(route, APIRoute):
129+
continue
130+
if route.endpoint is callback:
131+
return route
132+
133+
return None
134+
135+
136+
def find_exceptions_in_api_functions(
137+
funcs: list[Callable],
138+
fastapi_app: FastAPI,
139+
target_module: str | list[str] | None = None,
140+
) -> set[type[StarletteHTTPException]]:
141+
result: set[type[StarletteHTTPException]] = set()
142+
for func in funcs:
143+
this_func_target_module = _validate_target_module(target_module, func)
144+
this_route = _find_fastapi_route_from_callback(fastapi_app, func)
145+
if this_route is None:
146+
raise BaseRichAPIException(
147+
f"Could not find the route for function {func} in FastAPI app {fastapi_app}"
148+
)
149+
150+
exceptions = _extract_starlette_exceptions(
151+
this_route, this_func_target_module + ["fastapi"]
152+
)
153+
for exc, _ in exceptions:
154+
result.add(exc)
155+
156+
return result
157+
158+
100159
def _find_module_name_where_app_defined_in(app: FastAPI) -> Union[str, None]:
101160
frame = inspect.currentframe()
102161
target_module = None
@@ -112,6 +171,21 @@ def _find_module_name_where_app_defined_in(app: FastAPI) -> Union[str, None]:
112171
return target_module
113172

114173

174+
def _find_module_name_where_func_defined_in(func: Callable) -> Union[str, None]:
175+
frame = inspect.currentframe()
176+
target_module = None
177+
while frame:
178+
for var_name, var_value in frame.f_globals.items():
179+
if isinstance(var_value, Callable) and var_value is func:
180+
target_module = frame.f_globals["__name__"]
181+
break
182+
if target_module:
183+
break
184+
frame = frame.f_back
185+
186+
return target_module
187+
188+
115189
def _resolve_status_and_detail_from_exc_type(
116190
exc_type: type[Exception],
117191
ast_raise: ast.Raise,
@@ -142,7 +216,7 @@ def _resolve_status_and_detail_from_exc_type(
142216
status_code_value.value,
143217
(int, str, NoneType),
144218
):
145-
raise ValueError(
219+
raise BaseRichAPIException(
146220
f"Status code value must be an integer, string or None, got {type(status_code_value.value)}"
147221
)
148222
if status_code_value.value is not None:
@@ -166,7 +240,7 @@ def _resolve_status_and_detail_from_exc_type(
166240
detail_value = kwarg.value
167241
if isinstance(detail_value, ast.Constant):
168242
if not isinstance(detail_value.value, (str, NoneType)):
169-
raise ValueError(
243+
raise BaseRichAPIException(
170244
f"Detail value must be a string or None, got {type(detail_value.value)}"
171245
)
172246

tests/test_router_exception.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# from dataclasses import dataclass
2+
# from typing import Literal
3+
4+
import random
5+
from dataclasses import dataclass
6+
7+
from fastapi import FastAPI
8+
from fastapi.encoders import jsonable_encoder
9+
from fastapi.responses import JSONResponse
10+
from pydantic import BaseModel
11+
12+
from richapi.exc_parser.handler import add_exc_handler
13+
from richapi.exc_parser.openapi import enrich_openapi, find_exceptions_in_api_functions
14+
from richapi.exc_parser.protocol import RichHTTPException
15+
16+
17+
@dataclass
18+
class Exception1(RichHTTPException):
19+
status_code = 409
20+
21+
22+
@dataclass
23+
class Exception2(RichHTTPException):
24+
status_code = 408
25+
26+
27+
class NWrapper(BaseModel):
28+
value: int
29+
30+
31+
def lol():
32+
if random.randint(1, 3) == 2:
33+
raise Exception1()
34+
return lol()
35+
36+
37+
app = FastAPI()
38+
39+
app.openapi = enrich_openapi(app, target_module="tests.test_recursive_func")
40+
add_exc_handler(app)
41+
42+
43+
@app.get("/{n}", response_model=NWrapper)
44+
async def index(n: int) -> JSONResponse:
45+
lol()
46+
if n % 2 == 0:
47+
raise Exception2()
48+
return JSONResponse(content=jsonable_encoder(NWrapper(value=n)))
49+
50+
51+
def test_find_exceptions_in_api_functions():
52+
result = find_exceptions_in_api_functions(
53+
[index],
54+
target_module="tests.test_router_exception",
55+
fastapi_app=app,
56+
)
57+
assert result == {Exception1, Exception2}

0 commit comments

Comments
 (0)