richapi 0.1.0__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {richapi-0.1.0 → richapi-0.2.1}/.gitignore +1 -0
- {richapi-0.1.0 → richapi-0.2.1}/PKG-INFO +4 -3
- {richapi-0.1.0 → richapi-0.2.1}/README.md +1 -0
- {richapi-0.1.0 → richapi-0.2.1}/docs/advance.md +18 -0
- {richapi-0.1.0 → richapi-0.2.1}/docs/conventions.md +24 -5
- richapi-0.2.1/docs/implementation.md +55 -0
- {richapi-0.1.0 → richapi-0.2.1}/docs/index.md +2 -2
- {richapi-0.1.0 → richapi-0.2.1}/mkdocs.yml +1 -0
- {richapi-0.1.0 → richapi-0.2.1}/pyproject.toml +2 -3
- richapi-0.2.1/richapi/__version__.py +1 -0
- {richapi-0.1.0 → richapi-0.2.1}/richapi/exc_parser/compiler.py +107 -47
- {richapi-0.1.0 → richapi-0.2.1}/richapi/exc_parser/openapi.py +12 -2
- {richapi-0.1.0 → richapi-0.2.1}/richapi/exc_parser/protocol.py +20 -3
- {richapi-0.1.0 → richapi-0.2.1}/tests/test_exc_raising_cases.py +1 -15
- richapi-0.2.1/tests/test_fastapi_auth_functions.py +30 -0
- {richapi-0.1.0 → richapi-0.2.1}/uv.lock +3 -27
- richapi-0.1.0/.python-version +0 -1
- richapi-0.1.0/richapi/__version__.py +0 -1
- {richapi-0.1.0 → richapi-0.2.1}/.envrc +0 -0
- {richapi-0.1.0 → richapi-0.2.1}/.github/workflows/deploy-docs-on-demand.yml +0 -0
- {richapi-0.1.0 → richapi-0.2.1}/.github/workflows/main.yml +0 -0
- {richapi-0.1.0 → richapi-0.2.1}/.github/workflows/on-release-main.yml +0 -0
- {richapi-0.1.0 → richapi-0.2.1}/deploy_docs.py +0 -0
- {richapi-0.1.0 → richapi-0.2.1}/docs/images/with-richapi.png +0 -0
- {richapi-0.1.0 → richapi-0.2.1}/docs/images/without-richapi.png +0 -0
- {richapi-0.1.0 → richapi-0.2.1}/makefile +0 -0
- {richapi-0.1.0 → richapi-0.2.1}/richapi/__init__.py +0 -0
- {richapi-0.1.0 → richapi-0.2.1}/richapi/cli.py +0 -0
- {richapi-0.1.0 → richapi-0.2.1}/richapi/exc_parser/__init__.py +0 -0
- {richapi-0.1.0 → richapi-0.2.1}/richapi/exceptions.py +0 -0
- {richapi-0.1.0 → richapi-0.2.1}/tests/__init__.py +0 -0
- {richapi-0.1.0 → richapi-0.2.1}/tests/test_exception_edge_cases.py +0 -0
- {richapi-0.1.0 → richapi-0.2.1}/tox.ini +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: richapi
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Find HTTPExceptions and turn them into documented responses!
|
|
5
5
|
Project-URL: repository, https://github.com/ManiMozaffar/richapi
|
|
6
6
|
Project-URL: Homepage, https://github.com/ManiMozaffar/richapi
|
|
@@ -8,8 +8,8 @@ Project-URL: Documentation, https://github.com/ManiMozaffar/richapi
|
|
|
8
8
|
Project-URL: Source, https://github.com/ManiMozaffar/richapi
|
|
9
9
|
License-Expression: MIT
|
|
10
10
|
Requires-Python: >=3.9
|
|
11
|
-
Requires-Dist:
|
|
12
|
-
Requires-Dist: typer>=0.12
|
|
11
|
+
Requires-Dist: fastapi>=0.105
|
|
12
|
+
Requires-Dist: typer>=0.12
|
|
13
13
|
Description-Content-Type: text/markdown
|
|
14
14
|
|
|
15
15
|
### Installation
|
|
@@ -28,6 +28,7 @@ See [documentation](https://manimozaffar.github.io/richapi/latest/) for more det
|
|
|
28
28
|
|
|
29
29
|
RichAPI is designed to simplify FastAPI projects by automatically generating documented HTTP responses from HTTPException without extra effort! 🎉
|
|
30
30
|
|
|
31
|
+
This project is inspired and completer version of [Kludex's Fast-Response project](https://github.com/Kludex/fastapi-responses)
|
|
31
32
|
This package is planned to have more features for now that might be essential for any FastAPI project later.
|
|
32
33
|
|
|
33
34
|
What would you see WITHOUT RichAPI:
|
|
@@ -14,6 +14,7 @@ See [documentation](https://manimozaffar.github.io/richapi/latest/) for more det
|
|
|
14
14
|
|
|
15
15
|
RichAPI is designed to simplify FastAPI projects by automatically generating documented HTTP responses from HTTPException without extra effort! 🎉
|
|
16
16
|
|
|
17
|
+
This project is inspired and completer version of [Kludex's Fast-Response project](https://github.com/Kludex/fastapi-responses)
|
|
17
18
|
This package is planned to have more features for now that might be essential for any FastAPI project later.
|
|
18
19
|
|
|
19
20
|
What would you see WITHOUT RichAPI:
|
|
@@ -34,3 +34,21 @@ class YourException(BaseHTTPException):
|
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
Remember that the responsbility of raising and returning the correct data on FastAPI is not library responsibility, but the responsibility of the developer. The library is only responsible for generating the OpenAPI schema by looking at the functions and classes that are raising exceptions.
|
|
37
|
+
|
|
38
|
+
### Finding exceptions in other modules
|
|
39
|
+
|
|
40
|
+
By default, the library only looks at the FastAPI app module to search for exceptions.
|
|
41
|
+
This is to avoid parsing countless dependency modules that are not related to the FastAPI app.
|
|
42
|
+
But sometimes you may be using some other libraries that raises HTTPExceptions.
|
|
43
|
+
For example using a third library for JWT authentication.
|
|
44
|
+
|
|
45
|
+
In that case, you can simply pass the module name to the `parse_exceptions` function.
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from richapi import enrich_openapi
|
|
49
|
+
from fastapi import FastAPI
|
|
50
|
+
|
|
51
|
+
app = FastAPI()
|
|
52
|
+
# add your routers ....
|
|
53
|
+
app.openapi = enrich_openapi(app, ["your_module_name", "some-other-package"])
|
|
54
|
+
```
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
# Conventions
|
|
2
2
|
|
|
3
3
|
This is guideline for how to raise exceptions so that the library can generate the OpenAPI schema from them.
|
|
4
4
|
|
|
@@ -6,7 +6,7 @@ Since the library is a mini compiler using Abstract Syntax Tree and functions gl
|
|
|
6
6
|
|
|
7
7
|
Keep in mind that if you don't follow these conventions, there would be alot false positive and false negative cases, and the library may not be able to extract the exceptions and their responses correctly.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
## Inherit from `richapi.BaseHTTPException`
|
|
10
10
|
|
|
11
11
|
Exceptions are better to be very concrete, without having any dynamic data in them.
|
|
12
12
|
So that using `raise Class` is enough to raise them.
|
|
@@ -23,7 +23,11 @@ class UserNotFound(BaseHTTPException):
|
|
|
23
23
|
|
|
24
24
|
And then, only writing `raise UserNotFound` is enough to raise the exception. This is most ideal case.
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
!!! info "Expected Behavior"
|
|
27
|
+
|
|
28
|
+
Library **MIGHT skip** adding the exception to the OpenAPI schema if it could not gather necessary and missing informations from the abstract syntax tree.
|
|
29
|
+
|
|
30
|
+
## Avoiding dynamic data in the constructor of the exceptions and stringifying errors
|
|
27
31
|
|
|
28
32
|
Exceptions should not have any dynamic data in them.
|
|
29
33
|
For example the code below is NOT ideal:
|
|
@@ -35,9 +39,16 @@ error_message = f"User {user_id} is not allowed to perform this action"
|
|
|
35
39
|
raise FooException(detail=error_message)
|
|
36
40
|
```
|
|
37
41
|
|
|
42
|
+
This is not even a good practice to begin with, because ideally your RestAPI should not be aware of what text shown to the user. The error message should be handled by the client, not the server. There is a better way you can do to pass information to client about the error.
|
|
43
|
+
Read the [advance usage](advance.md) section for more information.
|
|
44
|
+
|
|
38
45
|
Since the library is using static analyzers to extract the exceptions, it is not possible to extract the dynamic data from the exceptions. (or rather very hard to do so)
|
|
39
46
|
|
|
40
|
-
|
|
47
|
+
!!! info "Expected Behavior"
|
|
48
|
+
|
|
49
|
+
Library **WILL add** the exception to the OpenAPI schema, but the detail of response won't be precisely shown and instead you will see `str` as the response type. However, if you follow this convention, library exactly show the string that you have written in the `detail` attribute of the exception as a **literal string type**.
|
|
50
|
+
|
|
51
|
+
## Avoiding usage of magic methods
|
|
41
52
|
|
|
42
53
|
Magic methods are implicit, therefore extracting them from the abstract syntax tree is exteremly hard and equal to rewriting the Python interpreter.
|
|
43
54
|
|
|
@@ -52,7 +63,11 @@ class Foo:
|
|
|
52
63
|
raise HTTPException(status_code=400, detail="This is not allowed")
|
|
53
64
|
```
|
|
54
65
|
|
|
55
|
-
|
|
66
|
+
!!! info "Expected Behavior"
|
|
67
|
+
|
|
68
|
+
Library **WILL skip** adding the exception to the OpenAPI schema.
|
|
69
|
+
|
|
70
|
+
## Keep it simple
|
|
56
71
|
|
|
57
72
|
The ideal way to raise exceptions is to keep it simple from syntax point of view;
|
|
58
73
|
|
|
@@ -70,3 +85,7 @@ raise exc()(status_code=400, detail="This is not allowed")
|
|
|
70
85
|
```
|
|
71
86
|
|
|
72
87
|
Keep it simple, keep the exception type next to raise keyword; without any dynamic data in the constructor. This help the library to extract the exceptions and their responses and guarantee that they are correctly compiled into the OpenAPI schema.
|
|
88
|
+
|
|
89
|
+
!!! info "Expected Behavior"
|
|
90
|
+
|
|
91
|
+
Library **MIGHT skip** adding the exception to the OpenAPI schema depending on how complicated the exception raising is.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Implementation detail
|
|
2
|
+
|
|
3
|
+
Library is made of few components working together to generate the OpenAPI schema.
|
|
4
|
+
|
|
5
|
+
## Exception Finder
|
|
6
|
+
|
|
7
|
+
- **Input**: A function
|
|
8
|
+
- **Output**: List of exception type with their `raise` abstract syntax tree nodes
|
|
9
|
+
|
|
10
|
+
1. Check if this function was ever analyzed before, if yes, then skip.
|
|
11
|
+
2. Get source code of the callable object.
|
|
12
|
+
3. Parse the source code to AST.
|
|
13
|
+
4. Start visiting every node in the callable.
|
|
14
|
+
5. Track assigmnets to a variable.
|
|
15
|
+
6. On visiting a node ...
|
|
16
|
+
1. If the node is a `raise` statement, then extract the exception type and return as output.
|
|
17
|
+
2. If a node is a `call` statement, then recursively visit the callable that is being called.
|
|
18
|
+
3. If a node is an `assign` statement, then track the variable name and the assigned value.
|
|
19
|
+
4. If a node is an `attribute` statement, then track the attribute name and the parent object. Might have to recursively visit the parent object.
|
|
20
|
+
|
|
21
|
+
Note that the only way to extract the exception type is to use the tracked assigmnets with `eval` function so that the type is returned.
|
|
22
|
+
That's why it's best to run the library on CLI rather than on the fly. To understand this better look at below code;
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
|
|
26
|
+
def foo():
|
|
27
|
+
err = Exception
|
|
28
|
+
raise err
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The only way that you can understand err is Exception is to keep tracking the assignments and then use `eval` to get the type. Running `eval("err")` will get you the type of the error which is a requirement to function properly.
|
|
32
|
+
|
|
33
|
+
## FastAPI Excption Parser
|
|
34
|
+
|
|
35
|
+
- **Input**: An excpetion with its `raise` AST node
|
|
36
|
+
- **Output**: Json schema
|
|
37
|
+
|
|
38
|
+
1. Check if the exception type already has a schema defined by itself.
|
|
39
|
+
- If yes, then simply construct the Json schema from that.
|
|
40
|
+
- If not, continue to the next step.
|
|
41
|
+
2. Analyze and extract arg and kwargs from the `raise` AST node.
|
|
42
|
+
3. Try to construct the exception schema from the arguments.
|
|
43
|
+
4. Try to dynamically generate the schema using Pydantic.
|
|
44
|
+
5. Return the schema if successfully generated, otherwise return None.
|
|
45
|
+
|
|
46
|
+
## OpenAPI Generator
|
|
47
|
+
|
|
48
|
+
- **Input**: FastAPI Module, Previous OpenAPI schema
|
|
49
|
+
- **Output**: OpenAPI schema
|
|
50
|
+
|
|
51
|
+
1. Iterating over all fastapi routers to analyze each at a time.
|
|
52
|
+
2. Each router has few callable dependency. All callable dependencies are analyzed using `Exception Finder`.
|
|
53
|
+
3. Result of the `Exception Finder` is passed to the `FastAPI Excption Parser` to get the Json schema.
|
|
54
|
+
4. Using the Json schema, the OpenAPI schema is extended with new responses for that router.
|
|
55
|
+
5. The result is cached so that the same router is not analyzed again.
|
|
@@ -8,8 +8,8 @@ pip install richapi
|
|
|
8
8
|
|
|
9
9
|
### Usage
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
RichAPI is designed to simplify FastAPI projects by automatically generating documented HTTP responses from HTTPException without extra effort! 🎉
|
|
12
|
+
|
|
13
13
|
This package is planned to have more features for now that might be essential for any FastAPI project later.
|
|
14
14
|
|
|
15
15
|
There are multiple way you can do that,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "richapi"
|
|
3
|
-
version = "0.1
|
|
3
|
+
version = "0.2.1"
|
|
4
4
|
description = "Find HTTPExceptions and turn them into documented responses!"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.9"
|
|
7
|
-
dependencies = ["
|
|
7
|
+
dependencies = ["typer>=0.12", "fastapi>=0.105"]
|
|
8
8
|
license = 'MIT'
|
|
9
9
|
|
|
10
10
|
[project.urls]
|
|
@@ -44,7 +44,6 @@ dev-dependencies = [
|
|
|
44
44
|
"mkdocstrings[python]>=0.25.1",
|
|
45
45
|
"mkdocs-simple-hooks>=0.1.5",
|
|
46
46
|
"mkdocs-material-extensions>=1.3.1",
|
|
47
|
-
"fastapi>=0.115.0",
|
|
48
47
|
"mdx-truly-sane-lists>=1.3",
|
|
49
48
|
"mike==2.0.0",
|
|
50
49
|
"psutil>=6.0.0",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.1"
|
|
@@ -60,7 +60,7 @@ def is_in_module(module: str, target_modules: list[str]) -> bool:
|
|
|
60
60
|
|
|
61
61
|
def find_explicit_exceptions(
|
|
62
62
|
func: Callable,
|
|
63
|
-
target_modules:
|
|
63
|
+
target_modules: list[str],
|
|
64
64
|
should_search_module_pred: Union[Callable[[str], bool], None] = None,
|
|
65
65
|
) -> List[Tuple[Optional[type[Exception]], ast.Raise]]:
|
|
66
66
|
"""
|
|
@@ -73,9 +73,6 @@ def find_explicit_exceptions(
|
|
|
73
73
|
- The ast.Raise node
|
|
74
74
|
"""
|
|
75
75
|
|
|
76
|
-
if isinstance(target_modules, str):
|
|
77
|
-
target_modules = [target_modules]
|
|
78
|
-
|
|
79
76
|
if should_search_module_pred is None:
|
|
80
77
|
|
|
81
78
|
def should_search_module_pred_func(target):
|
|
@@ -83,21 +80,51 @@ def find_explicit_exceptions(
|
|
|
83
80
|
|
|
84
81
|
should_search_module_pred = should_search_module_pred_func
|
|
85
82
|
|
|
83
|
+
return _find_explicit_expection_recursively(func, should_search_module_pred)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def get_func_name(func: Callable) -> str:
|
|
87
|
+
return func.__name__ if hasattr(func, "__name__") else func.__str__()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _find_all_class_exceptions(
|
|
91
|
+
cls: type,
|
|
92
|
+
to_filter_predicate: Callable[[str], bool],
|
|
93
|
+
tree: Optional[ast.AST] = None,
|
|
94
|
+
):
|
|
86
95
|
try:
|
|
87
|
-
|
|
88
|
-
except
|
|
89
|
-
|
|
96
|
+
tree = tree or ast.parse(inspect.getsource(cls))
|
|
97
|
+
except Exception as error:
|
|
98
|
+
logger.exception(f"Failed to parse source code for {cls.__name__}.", exc_info=error)
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
result = _find_explicit_expection_recursively(cls.__init__, to_filter_predicate, tree)
|
|
102
|
+
if hasattr(cls, "__call__"):
|
|
103
|
+
# Not sure if __call__ was called or init, so check for both
|
|
90
104
|
|
|
91
|
-
|
|
105
|
+
result.extend(
|
|
106
|
+
_find_explicit_expection_recursively(
|
|
107
|
+
cls.__call__, # type: ignore
|
|
108
|
+
to_filter_predicate,
|
|
109
|
+
tree,
|
|
110
|
+
)
|
|
111
|
+
)
|
|
92
112
|
|
|
93
|
-
|
|
113
|
+
ExceptionFinder.visited = set() # clean up memory
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def is_absolutely_callable_object(obj: Callable) -> bool:
|
|
118
|
+
return (
|
|
119
|
+
inspect.isclass(type(obj)) and (not inspect.isfunction(obj)) and (not inspect.ismethod(obj))
|
|
120
|
+
)
|
|
94
121
|
|
|
95
122
|
|
|
96
123
|
def _find_explicit_expection_recursively(
|
|
97
124
|
func_obj: Callable,
|
|
98
|
-
expr: ast.AST,
|
|
99
125
|
to_filter_predicate: Callable[[str], bool],
|
|
100
|
-
|
|
126
|
+
tree: Optional[ast.AST] = None,
|
|
127
|
+
) -> list[tuple[Optional[type[Exception]], ast.Raise]]:
|
|
101
128
|
if func_obj in ExceptionFinder.visited:
|
|
102
129
|
return []
|
|
103
130
|
|
|
@@ -108,9 +135,42 @@ def _find_explicit_expection_recursively(
|
|
|
108
135
|
if module and to_filter_predicate(module.__name__) is False:
|
|
109
136
|
return []
|
|
110
137
|
|
|
111
|
-
|
|
112
|
-
|
|
138
|
+
if is_absolutely_callable_object(func_obj):
|
|
139
|
+
cls_module = inspect.getmodule(type(func_obj))
|
|
140
|
+
if cls_module and is_stdlib(cls_module.__name__):
|
|
141
|
+
return []
|
|
142
|
+
if cls_module and to_filter_predicate(cls_module.__name__) is False:
|
|
143
|
+
return []
|
|
144
|
+
|
|
145
|
+
return _find_all_class_exceptions(type(func_obj), to_filter_predicate, tree)
|
|
146
|
+
|
|
113
147
|
ExceptionFinder.visited.add(func_obj)
|
|
148
|
+
|
|
149
|
+
if tree is None:
|
|
150
|
+
try:
|
|
151
|
+
source = inspect.getsource(func_obj)
|
|
152
|
+
except (OSError, TypeError) as error:
|
|
153
|
+
logger.exception(
|
|
154
|
+
f"Failed to get source code for {get_func_name(func_obj)}.",
|
|
155
|
+
exc_info=error,
|
|
156
|
+
)
|
|
157
|
+
return []
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
tree = ast.parse(source)
|
|
161
|
+
except IndentationError as error:
|
|
162
|
+
logger.exception(
|
|
163
|
+
f"Failed to get source code for {get_func_name(func_obj)}.",
|
|
164
|
+
exc_info=error,
|
|
165
|
+
)
|
|
166
|
+
return []
|
|
167
|
+
|
|
168
|
+
finder = ExceptionFinder(func_obj, to_filter_predicate)
|
|
169
|
+
try:
|
|
170
|
+
finder.visit(tree)
|
|
171
|
+
except Exception as error:
|
|
172
|
+
logger.exception(f"Failed to analyze function {get_func_name(func_obj)}.", exc_info=error)
|
|
173
|
+
|
|
114
174
|
return finder.exceptions
|
|
115
175
|
|
|
116
176
|
|
|
@@ -187,9 +247,7 @@ def _resolve_function_from_call_node(
|
|
|
187
247
|
elif isinstance(node, ast.Attribute):
|
|
188
248
|
attr_chain = _resolve_full_attribute_path(node)
|
|
189
249
|
if attr_chain is None:
|
|
190
|
-
logger.debug(
|
|
191
|
-
f"Failed to get full attribute name: {ast.dump(node)} in {func.__name__}"
|
|
192
|
-
)
|
|
250
|
+
logger.debug(f"Failed to get full attribute name: {ast.dump(node)} in {func.__name__}")
|
|
193
251
|
return None
|
|
194
252
|
|
|
195
253
|
try:
|
|
@@ -209,12 +267,9 @@ def _resolve_function_from_call_node(
|
|
|
209
267
|
parent_attr = assignments.get(node_id, None)
|
|
210
268
|
|
|
211
269
|
if parent_attr is None:
|
|
212
|
-
if
|
|
213
|
-
parent_part == "self"
|
|
214
|
-
): # don't need to log -> we already know this is class
|
|
270
|
+
if parent_part == "self": # don't need to log -> we already know this is class
|
|
215
271
|
return None
|
|
216
272
|
|
|
217
|
-
print(attr_chain, parent_part)
|
|
218
273
|
logger.debug(
|
|
219
274
|
f"Failed to resolve parent attribute {parent_part} for parts {attr_chain}. Chain: {ast.dump(node)} for func {func.__name__}"
|
|
220
275
|
)
|
|
@@ -240,9 +295,7 @@ def _extract_node_name(
|
|
|
240
295
|
Extract the exception name a node recursively.
|
|
241
296
|
"""
|
|
242
297
|
if isinstance(node, ast.Call):
|
|
243
|
-
return _extract_node_name(
|
|
244
|
-
typing.cast(SupportedAstNodes, node.func), assignments
|
|
245
|
-
)
|
|
298
|
+
return _extract_node_name(typing.cast(SupportedAstNodes, node.func), assignments)
|
|
246
299
|
elif isinstance(node, ast.Name):
|
|
247
300
|
var_name = get_node_identifier(node)
|
|
248
301
|
return assignments.get(var_name, var_name)
|
|
@@ -250,9 +303,7 @@ def _extract_node_name(
|
|
|
250
303
|
return _resolve_full_attribute_path(node)
|
|
251
304
|
|
|
252
305
|
elif isinstance(node, ast.Await):
|
|
253
|
-
return _extract_node_name(
|
|
254
|
-
typing.cast(SupportedAstNodes, node.value), assignments
|
|
255
|
-
)
|
|
306
|
+
return _extract_node_name(typing.cast(SupportedAstNodes, node.value), assignments)
|
|
256
307
|
|
|
257
308
|
elif isinstance(
|
|
258
309
|
node,
|
|
@@ -300,10 +351,8 @@ def is_stdlib(module_name: str) -> bool:
|
|
|
300
351
|
class ExceptionFinder(ast.NodeVisitor):
|
|
301
352
|
visited: set[Callable] = set()
|
|
302
353
|
|
|
303
|
-
def __init__(
|
|
304
|
-
self
|
|
305
|
-
):
|
|
306
|
-
self.exceptions: List[Tuple[Optional[type[Exception]], ast.Raise]] = []
|
|
354
|
+
def __init__(self, func: Callable, should_search_module_pred: Callable[[str], bool]):
|
|
355
|
+
self.exceptions: list[tuple[Optional[type[Exception]], ast.Raise]] = []
|
|
307
356
|
self.assignments: dict[NodeIdentifier, str] = {}
|
|
308
357
|
self.func = func
|
|
309
358
|
self.func_globals = func.__globals__
|
|
@@ -420,19 +469,24 @@ class ExceptionFinder(ast.NodeVisitor):
|
|
|
420
469
|
return
|
|
421
470
|
|
|
422
471
|
source = inspect.getsource(module)
|
|
423
|
-
|
|
472
|
+
try:
|
|
473
|
+
tree = ast.parse(source)
|
|
474
|
+
except BaseException as error:
|
|
475
|
+
logger.exception(
|
|
476
|
+
f"Failed to parse source code for {func_obj.__name__}.",
|
|
477
|
+
exc_info=error,
|
|
478
|
+
)
|
|
479
|
+
self.generic_visit(node)
|
|
480
|
+
return
|
|
424
481
|
|
|
425
|
-
|
|
482
|
+
founded_func_ast = _find_in_module(
|
|
426
483
|
tree,
|
|
427
|
-
lambda n: (
|
|
428
|
-
isinstance(n, ast.AsyncFunctionDef)
|
|
429
|
-
or isinstance(n, ast.FunctionDef)
|
|
430
|
-
)
|
|
484
|
+
lambda n: (isinstance(n, ast.AsyncFunctionDef) or isinstance(n, ast.FunctionDef))
|
|
431
485
|
and n.name == func_obj.__name__,
|
|
432
486
|
)
|
|
433
|
-
if
|
|
487
|
+
if founded_func_ast:
|
|
434
488
|
excs = _find_explicit_expection_recursively(
|
|
435
|
-
func_obj,
|
|
489
|
+
func_obj, self.should_search_module_pred, founded_func_ast
|
|
436
490
|
)
|
|
437
491
|
self.exceptions.extend(excs)
|
|
438
492
|
else:
|
|
@@ -455,9 +509,7 @@ class ExceptionFinder(ast.NodeVisitor):
|
|
|
455
509
|
try:
|
|
456
510
|
caller_type = _exctact_type(caller_node_name, self.func_globals)
|
|
457
511
|
except Exception:
|
|
458
|
-
logger.critical(
|
|
459
|
-
f"Failed to resolve attribute chain: {caller_node_name}"
|
|
460
|
-
)
|
|
512
|
+
logger.critical(f"Failed to resolve attribute chain: {caller_node_name}")
|
|
461
513
|
self.generic_visit(node)
|
|
462
514
|
return
|
|
463
515
|
|
|
@@ -476,18 +528,26 @@ class ExceptionFinder(ast.NodeVisitor):
|
|
|
476
528
|
return
|
|
477
529
|
|
|
478
530
|
source = inspect.getsource(module)
|
|
479
|
-
|
|
531
|
+
try:
|
|
532
|
+
tree = ast.parse(source)
|
|
533
|
+
except BaseException as error:
|
|
534
|
+
logger.exception(
|
|
535
|
+
f"Failed to parse source code for {caller_type.__name__}.",
|
|
536
|
+
exc_info=error,
|
|
537
|
+
)
|
|
538
|
+
self.generic_visit(node)
|
|
539
|
+
return
|
|
540
|
+
|
|
480
541
|
cls_ast = _find_in_module(
|
|
481
542
|
tree,
|
|
482
|
-
lambda n: isinstance(n, ast.ClassDef)
|
|
483
|
-
and n.name == caller_type.__name__,
|
|
543
|
+
lambda n: isinstance(n, ast.ClassDef) and n.name == caller_type.__name__,
|
|
484
544
|
)
|
|
485
545
|
if cls_ast:
|
|
486
546
|
casted_cls_ast = typing.cast(ast.ClassDef, cls_ast)
|
|
487
|
-
|
|
488
|
-
if
|
|
547
|
+
submethod_ast = _find_method_node_in_cls(casted_cls_ast, node.attr)
|
|
548
|
+
if submethod_ast:
|
|
489
549
|
excs = _find_explicit_expection_recursively(
|
|
490
|
-
method_func,
|
|
550
|
+
method_func, self.should_search_module_pred, submethod_ast
|
|
491
551
|
)
|
|
492
552
|
self.exceptions.extend(excs)
|
|
493
553
|
|
|
@@ -44,12 +44,15 @@ def enrich_openapi(
|
|
|
44
44
|
) -> Callable:
|
|
45
45
|
if target_module is None:
|
|
46
46
|
target_module = _find_module_name_where_app_defined_in(app)
|
|
47
|
+
|
|
47
48
|
if target_module is None or target_module == "__main__":
|
|
48
49
|
raise BaseRichAPIException(
|
|
49
50
|
"Could not determine the module where the FastAPI instance was created.\n"
|
|
50
51
|
"Please provide the module name as a string or list of strings."
|
|
51
52
|
)
|
|
52
53
|
|
|
54
|
+
target_module = target_module.split(".")[0] # get the top-level module
|
|
55
|
+
|
|
53
56
|
def _custom_openapi() -> dict:
|
|
54
57
|
if app.openapi_schema: # pragma: no cover
|
|
55
58
|
return app.openapi_schema
|
|
@@ -72,6 +75,9 @@ def compile_openapi_from_fastapi(
|
|
|
72
75
|
routes=app.routes,
|
|
73
76
|
),
|
|
74
77
|
) -> dict:
|
|
78
|
+
target_module = [target_module] if isinstance(target_module, str) else target_module
|
|
79
|
+
target_module.append("fastapi")
|
|
80
|
+
|
|
75
81
|
openapi_schema = open_api_getter(app)
|
|
76
82
|
for route in app.routes:
|
|
77
83
|
if not isinstance(route, APIRoute):
|
|
@@ -209,6 +215,8 @@ def _fill_openapi_with_excpetions(
|
|
|
209
215
|
route: APIRoute,
|
|
210
216
|
exceptions: list[tuple[type[StarletteHTTPException], ast.Raise]],
|
|
211
217
|
) -> None:
|
|
218
|
+
added_schema_names = set()
|
|
219
|
+
|
|
212
220
|
for exc, ast_node in exceptions:
|
|
213
221
|
if hasattr(exc, "get_json_schema"):
|
|
214
222
|
casted_exc = typing.cast(type[BaseHTTPException], exc)
|
|
@@ -216,9 +224,11 @@ def _fill_openapi_with_excpetions(
|
|
|
216
224
|
else:
|
|
217
225
|
schema = _extract_json_schema(ast_node, exc)
|
|
218
226
|
|
|
219
|
-
if schema is None:
|
|
227
|
+
if schema is None or schema.schema_name in added_schema_names:
|
|
220
228
|
continue
|
|
221
229
|
|
|
230
|
+
added_schema_names.add(schema.schema_name)
|
|
231
|
+
|
|
222
232
|
str_status_code = str(schema.status_code)
|
|
223
233
|
component_schemas = api_schema.get("components", {}).get("schemas", {})
|
|
224
234
|
if schema.schema_name not in component_schemas:
|
|
@@ -290,7 +300,7 @@ def flatten(to_be_flatten: list[list[T]]) -> list[T]:
|
|
|
290
300
|
|
|
291
301
|
|
|
292
302
|
def _extract_starlette_exceptions(
|
|
293
|
-
route: APIRoute, target_module:
|
|
303
|
+
route: APIRoute, target_module: list[str]
|
|
294
304
|
) -> list[tuple[type[StarletteHTTPException], ast.Raise]]:
|
|
295
305
|
dependency_tree = build_dependency_tree(route.dependant)
|
|
296
306
|
exceptions = flatten(
|
|
@@ -27,12 +27,27 @@ class BaseHTTPException(HTTPException):
|
|
|
27
27
|
@classmethod
|
|
28
28
|
def get_json_schema(cls) -> HTTPExceptionSchema:
|
|
29
29
|
value = _generic_json_schema_builder(cls)
|
|
30
|
-
assert
|
|
31
|
-
value is not None
|
|
32
|
-
) # should never happen because status code is always set
|
|
30
|
+
assert value is not None # should never happen because status code is always set
|
|
33
31
|
return value
|
|
34
32
|
|
|
35
33
|
|
|
34
|
+
def try_to_camel_case(string: str) -> str:
|
|
35
|
+
final_str = string
|
|
36
|
+
if " " in final_str: # with space
|
|
37
|
+
components = string.split(" ")
|
|
38
|
+
final_str = components[0] + "".join(x.title() for x in components[1:])
|
|
39
|
+
|
|
40
|
+
if "_" in final_str: # snake case
|
|
41
|
+
components = string.split("_")
|
|
42
|
+
final_str = components[0] + "".join(x.title() for x in components[1:])
|
|
43
|
+
|
|
44
|
+
if "-" in final_str: # kebab case
|
|
45
|
+
components = string.split("-")
|
|
46
|
+
final_str = components[0] + "".join(x.title() for x in components[1:])
|
|
47
|
+
|
|
48
|
+
return final_str
|
|
49
|
+
|
|
50
|
+
|
|
36
51
|
def _generic_json_schema_builder(
|
|
37
52
|
cls: type[Exception],
|
|
38
53
|
detail: str | None = None,
|
|
@@ -51,6 +66,8 @@ def _generic_json_schema_builder(
|
|
|
51
66
|
|
|
52
67
|
if detail is not None:
|
|
53
68
|
detail_type = Literal[detail] # type: ignore
|
|
69
|
+
|
|
70
|
+
name = f"{try_to_camel_case(detail)}Schema"
|
|
54
71
|
else:
|
|
55
72
|
detail_type = str
|
|
56
73
|
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import logging
|
|
2
|
-
import sys
|
|
3
2
|
from functools import lru_cache
|
|
4
3
|
|
|
5
4
|
from fastapi import Depends, FastAPI, HTTPException, status
|
|
@@ -10,12 +9,8 @@ from richapi.exc_parser.protocol import BaseHTTPException
|
|
|
10
9
|
|
|
11
10
|
app = FastAPI()
|
|
12
11
|
|
|
13
|
-
root = logging.getLogger()
|
|
14
|
-
root.setLevel(logging.DEBUG)
|
|
15
12
|
|
|
16
|
-
|
|
17
|
-
handler.setLevel(logging.DEBUG)
|
|
18
|
-
root.addHandler(handler)
|
|
13
|
+
logging.getLogger().addHandler(logging.StreamHandler())
|
|
19
14
|
|
|
20
15
|
|
|
21
16
|
class FirstException(HTTPException): ...
|
|
@@ -104,12 +99,3 @@ def test_case():
|
|
|
104
99
|
assert "407" in home_responses
|
|
105
100
|
assert "408" in home_responses
|
|
106
101
|
assert "409" in home_responses
|
|
107
|
-
schemas = openapi_json["components"]["schemas"]
|
|
108
|
-
assert "SecondExceptionErrorSchema" in schemas
|
|
109
|
-
assert "ThirdExceptionErrorSchema" in schemas
|
|
110
|
-
assert "FourthExceptionErrorSchema" in schemas
|
|
111
|
-
assert "FifthExceptionErrorSchema" in schemas
|
|
112
|
-
assert "SixthExceptionErrorSchema" in schemas
|
|
113
|
-
assert "NoNeedParanthesisErrorSchema" in schemas
|
|
114
|
-
assert "SeventhExceptionErrorSchema" in schemas
|
|
115
|
-
assert "EighthExceptionErrorSchema" in schemas
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import fastapi
|
|
2
|
+
import fastapi.security
|
|
3
|
+
|
|
4
|
+
from richapi.exc_parser.openapi import compile_openapi_from_fastapi
|
|
5
|
+
|
|
6
|
+
app = fastapi.FastAPI()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
http_bearer = fastapi.security.HTTPBearer()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_at_11(
|
|
13
|
+
token: fastapi.security.HTTPAuthorizationCredentials = fastapi.Depends(http_bearer),
|
|
14
|
+
) -> str:
|
|
15
|
+
return "12312"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.get("/refresh")
|
|
19
|
+
async def refresh_token(
|
|
20
|
+
token_data: str = fastapi.Depends(get_at_11),
|
|
21
|
+
) -> None:
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_fastapi_call_detected():
|
|
26
|
+
openapi_json = compile_openapi_from_fastapi(
|
|
27
|
+
app, target_module="tests.test_fastapi_auth_functions"
|
|
28
|
+
)
|
|
29
|
+
home_responses = openapi_json["paths"]["/refresh"]["get"]["responses"]
|
|
30
|
+
assert "403" in home_responses
|
|
@@ -327,19 +327,6 @@ wheels = [
|
|
|
327
327
|
{ url = "https://files.pythonhosted.org/packages/31/80/3a54838c3fb461f6fec263ebf3a3a41771bd05190238de3486aae8540c36/jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d", size = 133271 },
|
|
328
328
|
]
|
|
329
329
|
|
|
330
|
-
[[package]]
|
|
331
|
-
name = "loguru"
|
|
332
|
-
version = "0.7.2"
|
|
333
|
-
source = { registry = "https://pypi.org/simple" }
|
|
334
|
-
dependencies = [
|
|
335
|
-
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
336
|
-
{ name = "win32-setctime", marker = "sys_platform == 'win32'" },
|
|
337
|
-
]
|
|
338
|
-
sdist = { url = "https://files.pythonhosted.org/packages/9e/30/d87a423766b24db416a46e9335b9602b054a72b96a88a241f2b09b560fa8/loguru-0.7.2.tar.gz", hash = "sha256:e671a53522515f34fd406340ee968cb9ecafbc4b36c679da03c18fd8d0bd51ac", size = 145103 }
|
|
339
|
-
wheels = [
|
|
340
|
-
{ url = "https://files.pythonhosted.org/packages/03/0a/4f6fed21aa246c6b49b561ca55facacc2a44b87d65b8b92362a8e99ba202/loguru-0.7.2-py3-none-any.whl", hash = "sha256:003d71e3d3ed35f0f8984898359d65b79e5b21943f78af86aa5491210429b8eb", size = 62549 },
|
|
341
|
-
]
|
|
342
|
-
|
|
343
330
|
[[package]]
|
|
344
331
|
name = "markdown"
|
|
345
332
|
version = "3.7"
|
|
@@ -1071,13 +1058,12 @@ name = "richapi"
|
|
|
1071
1058
|
version = "0.1.0"
|
|
1072
1059
|
source = { editable = "." }
|
|
1073
1060
|
dependencies = [
|
|
1074
|
-
{ name = "
|
|
1061
|
+
{ name = "fastapi" },
|
|
1075
1062
|
{ name = "typer" },
|
|
1076
1063
|
]
|
|
1077
1064
|
|
|
1078
1065
|
[package.dev-dependencies]
|
|
1079
1066
|
dev = [
|
|
1080
|
-
{ name = "fastapi" },
|
|
1081
1067
|
{ name = "httpx" },
|
|
1082
1068
|
{ name = "mdx-truly-sane-lists" },
|
|
1083
1069
|
{ name = "mike" },
|
|
@@ -1098,13 +1084,12 @@ dev = [
|
|
|
1098
1084
|
|
|
1099
1085
|
[package.metadata]
|
|
1100
1086
|
requires-dist = [
|
|
1101
|
-
{ name = "
|
|
1102
|
-
{ name = "typer", specifier = ">=0.12
|
|
1087
|
+
{ name = "fastapi", specifier = ">=0.105" },
|
|
1088
|
+
{ name = "typer", specifier = ">=0.12" },
|
|
1103
1089
|
]
|
|
1104
1090
|
|
|
1105
1091
|
[package.metadata.requires-dev]
|
|
1106
1092
|
dev = [
|
|
1107
|
-
{ name = "fastapi", specifier = ">=0.115.0" },
|
|
1108
1093
|
{ name = "httpx", specifier = ">=0.27.2" },
|
|
1109
1094
|
{ name = "mdx-truly-sane-lists", specifier = ">=1.3" },
|
|
1110
1095
|
{ name = "mike", specifier = "==2.0.0" },
|
|
@@ -1326,15 +1311,6 @@ wheels = [
|
|
|
1326
1311
|
{ url = "https://files.pythonhosted.org/packages/38/b8/0aa69337651b3005f161f7f494e59188a1d8d94171666900d26d29d10f69/watchdog-5.0.3-py3-none-win_ia64.whl", hash = "sha256:49f4d36cb315c25ea0d946e018c01bb028048023b9e103d3d3943f58e109dd45", size = 79324 },
|
|
1327
1312
|
]
|
|
1328
1313
|
|
|
1329
|
-
[[package]]
|
|
1330
|
-
name = "win32-setctime"
|
|
1331
|
-
version = "1.1.0"
|
|
1332
|
-
source = { registry = "https://pypi.org/simple" }
|
|
1333
|
-
sdist = { url = "https://files.pythonhosted.org/packages/6b/dd/f95a13d2b235a28d613ba23ebad55191514550debb968b46aab99f2e3a30/win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2", size = 3676 }
|
|
1334
|
-
wheels = [
|
|
1335
|
-
{ url = "https://files.pythonhosted.org/packages/0a/e6/a7d828fef907843b2a5773ebff47fb79ac0c1c88d60c0ca9530ee941e248/win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad", size = 3604 },
|
|
1336
|
-
]
|
|
1337
|
-
|
|
1338
1314
|
[[package]]
|
|
1339
1315
|
name = "zipp"
|
|
1340
1316
|
version = "3.20.2"
|
richapi-0.1.0/.python-version
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
3.9.19
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|