richapi 0.2.1__tar.gz → 0.3.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.2.1 → richapi-0.3.1}/PKG-INFO +1 -1
- {richapi-0.2.1 → richapi-0.3.1}/docs/advance.md +1 -4
- {richapi-0.2.1 → richapi-0.3.1}/docs/conventions.md +27 -1
- richapi-0.3.1/docs/images/custom-exception.png +0 -0
- {richapi-0.2.1 → richapi-0.3.1}/docs/index.md +51 -4
- {richapi-0.2.1 → richapi-0.3.1}/pyproject.toml +1 -1
- richapi-0.3.1/richapi/__init__.py +11 -0
- richapi-0.3.1/richapi/__version__.py +1 -0
- {richapi-0.2.1 → richapi-0.3.1}/richapi/exc_parser/compiler.py +218 -88
- richapi-0.3.1/richapi/exc_parser/handler.py +22 -0
- {richapi-0.2.1 → richapi-0.3.1}/richapi/exc_parser/openapi.py +5 -0
- {richapi-0.2.1 → richapi-0.3.1}/richapi/exc_parser/protocol.py +32 -3
- richapi-0.3.1/tests/app/__init__.py +0 -0
- richapi-0.3.1/tests/app/foo1.py +10 -0
- richapi-0.3.1/tests/test_builder_pattern.py +68 -0
- richapi-0.3.1/tests/test_class.py +72 -0
- richapi-0.3.1/tests/test_compiler.py +12 -0
- richapi-0.3.1/tests/test_custom_exc.py +52 -0
- richapi-0.3.1/tests/test_duplicated_function.py +0 -0
- richapi-0.3.1/tests/test_import_from_other_module.py +20 -0
- richapi-0.3.1/tests/test_instance_attr_call.py +82 -0
- richapi-0.2.1/richapi/__init__.py +0 -4
- richapi-0.2.1/richapi/__version__.py +0 -1
- {richapi-0.2.1 → richapi-0.3.1}/.envrc +0 -0
- {richapi-0.2.1 → richapi-0.3.1}/.github/workflows/deploy-docs-on-demand.yml +0 -0
- {richapi-0.2.1 → richapi-0.3.1}/.github/workflows/main.yml +0 -0
- {richapi-0.2.1 → richapi-0.3.1}/.github/workflows/on-release-main.yml +0 -0
- {richapi-0.2.1 → richapi-0.3.1}/.gitignore +0 -0
- {richapi-0.2.1 → richapi-0.3.1}/README.md +0 -0
- {richapi-0.2.1 → richapi-0.3.1}/deploy_docs.py +0 -0
- {richapi-0.2.1 → richapi-0.3.1}/docs/images/with-richapi.png +0 -0
- {richapi-0.2.1 → richapi-0.3.1}/docs/images/without-richapi.png +0 -0
- {richapi-0.2.1 → richapi-0.3.1}/docs/implementation.md +0 -0
- {richapi-0.2.1 → richapi-0.3.1}/makefile +0 -0
- {richapi-0.2.1 → richapi-0.3.1}/mkdocs.yml +0 -0
- {richapi-0.2.1 → richapi-0.3.1}/richapi/cli.py +0 -0
- {richapi-0.2.1 → richapi-0.3.1}/richapi/exc_parser/__init__.py +0 -0
- {richapi-0.2.1 → richapi-0.3.1}/richapi/exceptions.py +0 -0
- {richapi-0.2.1 → richapi-0.3.1}/tests/__init__.py +0 -0
- {richapi-0.2.1 → richapi-0.3.1}/tests/test_exc_raising_cases.py +0 -0
- {richapi-0.2.1 → richapi-0.3.1}/tests/test_exception_edge_cases.py +0 -0
- {richapi-0.2.1 → richapi-0.3.1}/tests/test_fastapi_auth_functions.py +0 -0
- {richapi-0.2.1 → richapi-0.3.1}/tox.ini +0 -0
- {richapi-0.2.1 → richapi-0.3.1}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: richapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -4,9 +4,6 @@ This is guideline for how to use the library in more advanced ways.
|
|
|
4
4
|
|
|
5
5
|
### Customization of the OpenAPI schema
|
|
6
6
|
|
|
7
|
-
Sometimes, your exceptions is more than 'details'.
|
|
8
|
-
In that case, you can very explicitly define the exception JSON schema inside the exception class. You may even use Pydantic to define the schema dynamically so you don't need to write the schema manually.
|
|
9
|
-
|
|
10
7
|
All you need to do, is to return type HTTPExceptionSchema correctly on the method `get_json_schema` of your exception class which you inherited from `BaseHTTPException`.
|
|
11
8
|
|
|
12
9
|
Imagine you are writing an exception that is returning a user balance and a time to retry again a failed bank transaction, you can define the schema like this:
|
|
@@ -33,7 +30,7 @@ class YourException(BaseHTTPException):
|
|
|
33
30
|
|
|
34
31
|
```
|
|
35
32
|
|
|
36
|
-
|
|
33
|
+
If your intention is to have a custom response schema, then recommended way is to use [custom exception feature](index.md#customization-of-exception).
|
|
37
34
|
|
|
38
35
|
### Finding exceptions in other modules
|
|
39
36
|
|
|
@@ -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
|
-
## Inherit from `richapi.BaseHTTPException`
|
|
9
|
+
## Inherit from `richapi.BaseHTTPException` or `richapi.RichHTTPException`
|
|
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.
|
|
@@ -89,3 +89,29 @@ Keep it simple, keep the exception type next to raise keyword; without any dynam
|
|
|
89
89
|
!!! info "Expected Behavior"
|
|
90
90
|
|
|
91
91
|
Library **MIGHT skip** adding the exception to the OpenAPI schema depending on how complicated the exception raising is.
|
|
92
|
+
|
|
93
|
+
## Always Provide Type Annotations For Class/Instance Attributes And Function Arguments
|
|
94
|
+
|
|
95
|
+
To enable accurate type resolution during static analysis, always provide type annotations for your variables, function parameters, and return types. Without type annotations, it's nearly impossible for the library to infer types correctly, which may lead to false positives or negatives. That stands for class attributes and function arguments.
|
|
96
|
+
|
|
97
|
+
Example:
|
|
98
|
+
|
|
99
|
+
```Python hl_lines="6 12"
|
|
100
|
+
class Foo:
|
|
101
|
+
def foo(self) -> None:
|
|
102
|
+
raise HTTPException(status_code=400, detail="This is not allowed")
|
|
103
|
+
|
|
104
|
+
class Baz:
|
|
105
|
+
foo: Foo # this is very important to have type annotations for class/instance attributes
|
|
106
|
+
|
|
107
|
+
def __init__(self, foo: Foo):
|
|
108
|
+
self.foo = foo
|
|
109
|
+
|
|
110
|
+
def foo(self) -> None:
|
|
111
|
+
# if you don't type annotate, RichAPI doesn't have any idea about the type of self.fo
|
|
112
|
+
self.foo.foo()
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
!!! info "Expected Behavior"
|
|
116
|
+
|
|
117
|
+
Library **WILL skip** adding exceptions to the OpenAPI schema if it cannot resolve types due to missing annotations.
|
|
Binary file
|
|
@@ -46,6 +46,57 @@ with open("openapi.json") as open_api_json:
|
|
|
46
46
|
|
|
47
47
|
I personally recommend the second way, because it is better to have it compiled and saved to file, because there are static analysis running on the OpanAPI generator and your source code which isn't very ideal to be running in production.
|
|
48
48
|
|
|
49
|
+
### Customization of Exception
|
|
50
|
+
|
|
51
|
+
Sometimes, your exceptions is more than 'details'. You may want to put some extra information for the client to understand and parse the error better. In that case, please follow these steps:
|
|
52
|
+
|
|
53
|
+
1. Add the exception handler from richapi to your FastAPI app.
|
|
54
|
+
2. Inhert from `richapi.RichHTTPException`, and make it a dataclass.
|
|
55
|
+
3. To override status, simply set the `status_code` attribute on the class without type annotation.
|
|
56
|
+
4. Raise that exception somewhere in your code and you'll see the schema on swagger!
|
|
57
|
+
|
|
58
|
+
Example:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
import fastapi
|
|
62
|
+
import fastapi.security
|
|
63
|
+
from pydantic import BaseModel
|
|
64
|
+
from pydantic.dataclasses import dataclass
|
|
65
|
+
|
|
66
|
+
from richapi.exc_parser.handler import add_exc_handler
|
|
67
|
+
from richapi.exc_parser.openapi import enrich_openapi
|
|
68
|
+
from richapi.exc_parser.protocol import RichHTTPException
|
|
69
|
+
|
|
70
|
+
app = fastapi.FastAPI()
|
|
71
|
+
app.openapi = enrich_openapi(app)
|
|
72
|
+
add_exc_handler(app)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class NotEnoughBalance(RichHTTPException):
|
|
77
|
+
"""Custom exception for user balance"""
|
|
78
|
+
|
|
79
|
+
user_id: int
|
|
80
|
+
balance: float
|
|
81
|
+
status_code = 403
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class SuccessResponse(BaseModel):
|
|
85
|
+
balance: float
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.post("/payment")
|
|
89
|
+
async def make_payment() -> SuccessResponse:
|
|
90
|
+
raise NotEnoughBalance(user_id=1, balance=0.5)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Final Result:
|
|
94
|
+

|
|
95
|
+
|
|
96
|
+
### Conventions (VERY IMPORTANT)
|
|
97
|
+
|
|
98
|
+
There are some conventions about how to raise exceptions and etc. You may read the [conventions](conventions.md) to know more about them. The way library works is that it is a mini compiler using Abstract Syntax Tree and functions global to extract exceptions and their responses, and then it generates the OpenAPI schema from them. So there are limitations to what it can do, and you may need to follow the conventions to get the best out of it. Anything that is not following the conventions MAY be ignored by the compiler, and there is no plan to support them in the future, because it is not possible to support all the ways you can raise exceptions in Python... To give you an example, you may download a source code from internet and then use 'eval' function to run it dynamically, and there is no way to know what exceptions you may raise in that code, so it is impossible to support all the ways you can raise exceptions in Python.
|
|
99
|
+
|
|
49
100
|
### Example
|
|
50
101
|
|
|
51
102
|
Imagine this application;
|
|
@@ -105,7 +156,3 @@ What would you see WITHOUT RichAPI:
|
|
|
105
156
|
|
|
106
157
|
After compiling OpenAPI WITH RichAPI (extended with all possible exceptions coming from your router):
|
|
107
158
|

|
|
108
|
-
|
|
109
|
-
### Conventions (VERY IMPORTANT)
|
|
110
|
-
|
|
111
|
-
There are some conventions about how to raise exceptions and etc. You may read the [conventions](conventions.md) to know more about them. The way library works is that it is a mini compiler using Abstract Syntax Tree and functions global to extract exceptions and their responses, and then it generates the OpenAPI schema from them. So there are limitations to what it can do, and you may need to follow the conventions to get the best out of it. Anything that is not following the conventions MAY be ignored by the compiler, and there is no plan to support them in the future, because it is not possible to support all the ways you can raise exceptions in Python... To give you an example, you may download a source code from internet and then use 'eval' function to run it dynamically, and there is no way to know what exceptions you may raise in that code, so it is impossible to support all the ways you can raise exceptions in Python.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from richapi.exc_parser.handler import add_exc_handler
|
|
2
|
+
from richapi.exc_parser.openapi import enrich_openapi, load_openapi
|
|
3
|
+
from richapi.exc_parser.protocol import BaseHTTPException, RichHTTPException
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"enrich_openapi",
|
|
7
|
+
"load_openapi",
|
|
8
|
+
"BaseHTTPException",
|
|
9
|
+
"RichHTTPException",
|
|
10
|
+
"add_exc_handler",
|
|
11
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.1"
|
|
@@ -4,9 +4,11 @@ import inspect
|
|
|
4
4
|
import logging
|
|
5
5
|
import sysconfig
|
|
6
6
|
import typing
|
|
7
|
-
from functools import lru_cache
|
|
8
7
|
from importlib.util import find_spec
|
|
9
|
-
from
|
|
8
|
+
from types import ModuleType
|
|
9
|
+
from typing import Annotated, Callable, Generic, List, NewType, Optional, Tuple, Union
|
|
10
|
+
|
|
11
|
+
import typing_extensions
|
|
10
12
|
|
|
11
13
|
logger = logging.getLogger(__name__)
|
|
12
14
|
|
|
@@ -88,17 +90,20 @@ def get_func_name(func: Callable) -> str:
|
|
|
88
90
|
|
|
89
91
|
|
|
90
92
|
def _find_all_class_exceptions(
|
|
91
|
-
cls: type,
|
|
93
|
+
cls: Union[type, Callable],
|
|
92
94
|
to_filter_predicate: Callable[[str], bool],
|
|
93
95
|
tree: Optional[ast.AST] = None,
|
|
94
96
|
):
|
|
95
97
|
try:
|
|
96
98
|
tree = tree or ast.parse(inspect.getsource(cls))
|
|
97
99
|
except Exception as error:
|
|
98
|
-
|
|
100
|
+
cls_name = cls.__name__ if hasattr(cls, "__name__") else cls.__str__()
|
|
101
|
+
logger.exception(f"Failed to parse source code for {cls_name}.", exc_info=error)
|
|
99
102
|
return []
|
|
100
103
|
|
|
101
|
-
result = _find_explicit_expection_recursively(
|
|
104
|
+
result = _find_explicit_expection_recursively(
|
|
105
|
+
cls.__init__, to_filter_predicate, tree
|
|
106
|
+
)
|
|
102
107
|
if hasattr(cls, "__call__"):
|
|
103
108
|
# Not sure if __call__ was called or init, so check for both
|
|
104
109
|
|
|
@@ -110,13 +115,14 @@ def _find_all_class_exceptions(
|
|
|
110
115
|
)
|
|
111
116
|
)
|
|
112
117
|
|
|
113
|
-
ExceptionFinder.visited = set() # clean up memory
|
|
114
118
|
return result
|
|
115
119
|
|
|
116
120
|
|
|
117
121
|
def is_absolutely_callable_object(obj: Callable) -> bool:
|
|
118
122
|
return (
|
|
119
|
-
inspect.isclass(type(obj))
|
|
123
|
+
inspect.isclass(type(obj))
|
|
124
|
+
and (not inspect.isfunction(obj))
|
|
125
|
+
and (not inspect.ismethod(obj))
|
|
120
126
|
)
|
|
121
127
|
|
|
122
128
|
|
|
@@ -126,25 +132,24 @@ def _find_explicit_expection_recursively(
|
|
|
126
132
|
tree: Optional[ast.AST] = None,
|
|
127
133
|
) -> list[tuple[Optional[type[Exception]], ast.Raise]]:
|
|
128
134
|
if func_obj in ExceptionFinder.visited:
|
|
129
|
-
return []
|
|
135
|
+
return ExceptionFinder.visited[func_obj]
|
|
130
136
|
|
|
131
137
|
module = inspect.getmodule(func_obj)
|
|
132
|
-
if module
|
|
133
|
-
return []
|
|
134
|
-
|
|
135
|
-
if module and to_filter_predicate(module.__name__) is False:
|
|
138
|
+
if module is None or _should_be_visited(module, to_filter_predicate) is False:
|
|
136
139
|
return []
|
|
137
140
|
|
|
138
141
|
if is_absolutely_callable_object(func_obj):
|
|
139
|
-
|
|
142
|
+
class_type: Union[type, Callable] = (
|
|
143
|
+
obj_type if (obj_type := type(func_obj)) is not type else func_obj
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
cls_module = inspect.getmodule(class_type)
|
|
140
147
|
if cls_module and is_stdlib(cls_module.__name__):
|
|
141
148
|
return []
|
|
142
149
|
if cls_module and to_filter_predicate(cls_module.__name__) is False:
|
|
143
150
|
return []
|
|
144
151
|
|
|
145
|
-
return _find_all_class_exceptions(
|
|
146
|
-
|
|
147
|
-
ExceptionFinder.visited.add(func_obj)
|
|
152
|
+
return _find_all_class_exceptions(class_type, to_filter_predicate, tree)
|
|
148
153
|
|
|
149
154
|
if tree is None:
|
|
150
155
|
try:
|
|
@@ -169,8 +174,11 @@ def _find_explicit_expection_recursively(
|
|
|
169
174
|
try:
|
|
170
175
|
finder.visit(tree)
|
|
171
176
|
except Exception as error:
|
|
172
|
-
logger.exception(
|
|
177
|
+
logger.exception(
|
|
178
|
+
f"Failed to analyze function {get_func_name(func_obj)}.", exc_info=error
|
|
179
|
+
)
|
|
173
180
|
|
|
181
|
+
ExceptionFinder.visited[func_obj] = finder.exceptions
|
|
174
182
|
return finder.exceptions
|
|
175
183
|
|
|
176
184
|
|
|
@@ -220,12 +228,52 @@ def _resolve_full_attribute_path(node: ast.AST) -> Union[str, None]:
|
|
|
220
228
|
return None
|
|
221
229
|
|
|
222
230
|
|
|
223
|
-
def
|
|
231
|
+
def _retrieve_from_annotations(func: Callable, var_name: str) -> Optional[type]:
|
|
232
|
+
result = func.__annotations__.get(var_name, None)
|
|
233
|
+
if result is None:
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
origin = typing.get_origin(result)
|
|
237
|
+
if origin is Annotated:
|
|
238
|
+
return result.__origin__
|
|
239
|
+
elif origin is not None and origin != Generic:
|
|
240
|
+
return origin
|
|
241
|
+
return result
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _resolve_type_from_assigment(
|
|
245
|
+
resolved_value: Union[str, type, None],
|
|
246
|
+
assignments: dict[NodeIdentifier, str],
|
|
247
|
+
func: Callable,
|
|
248
|
+
):
|
|
249
|
+
if resolved_value is None:
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
# because assigment are stored as string
|
|
253
|
+
# ! A string type could be also called like str("Foo").lower() so we fallback to parent_attr in case it remains as string
|
|
254
|
+
|
|
255
|
+
if isinstance(resolved_value, str):
|
|
256
|
+
resolved_value = func.__globals__.get(resolved_value, resolved_value)
|
|
257
|
+
|
|
258
|
+
if isinstance(resolved_value, str):
|
|
259
|
+
resolved_value = assignments.get(NodeIdentifier(resolved_value), resolved_value)
|
|
260
|
+
|
|
261
|
+
if isinstance(resolved_value, str):
|
|
262
|
+
resolved_value = (
|
|
263
|
+
_retrieve_from_annotations(func, resolved_value) or resolved_value
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
if isinstance(resolved_value, str): # last fallback
|
|
267
|
+
resolved_value = _exctact_type(resolved_value, func.__globals__)
|
|
268
|
+
|
|
269
|
+
return resolved_value
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _resolve_functions_from_call_node(
|
|
224
273
|
call_node: ast.Call,
|
|
225
|
-
func_globals: dict,
|
|
226
274
|
func: Callable,
|
|
227
275
|
assignments: dict[NodeIdentifier, str],
|
|
228
|
-
):
|
|
276
|
+
) -> list[type]:
|
|
229
277
|
"""
|
|
230
278
|
Attempt to resolve the function object from a call node.
|
|
231
279
|
Handles both simple function calls and method calls.
|
|
@@ -234,7 +282,7 @@ def _resolve_function_from_call_node(
|
|
|
234
282
|
|
|
235
283
|
if isinstance(node, ast.Name):
|
|
236
284
|
# Simple function call: func()
|
|
237
|
-
value =
|
|
285
|
+
value = func.__globals__.get(node.id, None)
|
|
238
286
|
|
|
239
287
|
if value is None:
|
|
240
288
|
value = getattr(builtins, node.id, None)
|
|
@@ -242,50 +290,99 @@ def _resolve_function_from_call_node(
|
|
|
242
290
|
if value is None:
|
|
243
291
|
logger.debug(f"Function '{node.id}' not found in {func.__name__}")
|
|
244
292
|
|
|
245
|
-
|
|
293
|
+
if value is not None:
|
|
294
|
+
return [value]
|
|
295
|
+
return []
|
|
246
296
|
|
|
247
297
|
elif isinstance(node, ast.Attribute):
|
|
248
298
|
attr_chain = _resolve_full_attribute_path(node)
|
|
249
299
|
if attr_chain is None:
|
|
250
|
-
logger.debug(
|
|
251
|
-
|
|
300
|
+
logger.debug(
|
|
301
|
+
f"Failed to get full attribute name: {ast.dump(node)} in {func.__name__}"
|
|
302
|
+
)
|
|
303
|
+
return []
|
|
252
304
|
|
|
253
305
|
try:
|
|
254
306
|
parts = attr_chain.split(".")
|
|
255
307
|
parent_part = parts[0]
|
|
256
308
|
|
|
257
|
-
parent_attr =
|
|
309
|
+
parent_attr = func.__globals__.get(parent_part, None)
|
|
310
|
+
|
|
311
|
+
if parent_attr is None:
|
|
312
|
+
node_id = NodeIdentifier(parent_part)
|
|
313
|
+
parent_attr = assignments.get(node_id, None)
|
|
258
314
|
|
|
259
315
|
if parent_attr is None:
|
|
260
316
|
if parent_part in func.__annotations__:
|
|
261
|
-
|
|
317
|
+
result = _retrieve_from_annotations(func, parent_part)
|
|
318
|
+
return [] if result is None else [result]
|
|
262
319
|
|
|
263
320
|
parent_attr = getattr(builtins, parent_part, None)
|
|
264
321
|
|
|
265
322
|
if parent_attr is None:
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
323
|
+
# use case where you're calling self.foo.do_something(), where self.foo is a class attribute that is a class itself
|
|
324
|
+
if parent_part == "self" or parent_part == "cls":
|
|
325
|
+
property_name = parts[1]
|
|
326
|
+
# ['class_name', 'method_name']
|
|
327
|
+
qual_names = func.__qualname__.split(".")
|
|
328
|
+
# ['class_name']
|
|
329
|
+
func_name_index = qual_names.index(func.__name__)
|
|
330
|
+
# 'class_name'
|
|
331
|
+
cls_name = qual_names[func_name_index - 1]
|
|
332
|
+
# class_name (type)
|
|
333
|
+
cls: Union[Callable, None] = func.__globals__.get(cls_name, None)
|
|
334
|
+
|
|
335
|
+
if cls is None:
|
|
336
|
+
logger.debug(
|
|
337
|
+
f"Failed to resolve parent class {cls} for parts {attr_chain}. Chain: {ast.dump(node)} for func {func.__name__}"
|
|
338
|
+
)
|
|
339
|
+
return []
|
|
340
|
+
attr_class = _retrieve_from_annotations(cls, property_name)
|
|
341
|
+
if attr_class is None: # part of convention
|
|
342
|
+
return []
|
|
343
|
+
|
|
344
|
+
results: list[type] = []
|
|
345
|
+
try:
|
|
346
|
+
obj = getattr(attr_class, "__init__", None)
|
|
347
|
+
if obj is not None:
|
|
348
|
+
results.append(obj)
|
|
349
|
+
except Exception:
|
|
350
|
+
pass
|
|
351
|
+
|
|
352
|
+
for attr in parts[2:]:
|
|
353
|
+
try:
|
|
354
|
+
obj = getattr(attr_class, attr, None)
|
|
355
|
+
|
|
356
|
+
if obj is not None:
|
|
357
|
+
results.append(obj)
|
|
358
|
+
except Exception:
|
|
359
|
+
break
|
|
360
|
+
|
|
361
|
+
return results
|
|
272
362
|
|
|
273
363
|
logger.debug(
|
|
274
364
|
f"Failed to resolve parent attribute {parent_part} for parts {attr_chain}. Chain: {ast.dump(node)} for func {func.__name__}"
|
|
275
365
|
)
|
|
276
|
-
return
|
|
366
|
+
return []
|
|
277
367
|
|
|
368
|
+
parent_attr = _resolve_type_from_assigment(parent_attr, assignments, func)
|
|
369
|
+
results: list[type] = []
|
|
278
370
|
for attr in parts[1:]:
|
|
279
371
|
try:
|
|
280
372
|
obj = getattr(parent_attr, attr, None)
|
|
373
|
+
if obj is not None:
|
|
374
|
+
results.append(obj)
|
|
375
|
+
|
|
281
376
|
except Exception:
|
|
282
377
|
break
|
|
283
378
|
|
|
284
|
-
|
|
379
|
+
return results
|
|
285
380
|
|
|
286
381
|
except Exception:
|
|
287
382
|
logger.exception(f"Failed to resolve attribute chain: {attr_chain}")
|
|
288
|
-
return
|
|
383
|
+
return []
|
|
384
|
+
|
|
385
|
+
return []
|
|
289
386
|
|
|
290
387
|
|
|
291
388
|
def _extract_node_name(
|
|
@@ -295,7 +392,9 @@ def _extract_node_name(
|
|
|
295
392
|
Extract the exception name a node recursively.
|
|
296
393
|
"""
|
|
297
394
|
if isinstance(node, ast.Call):
|
|
298
|
-
return _extract_node_name(
|
|
395
|
+
return _extract_node_name(
|
|
396
|
+
typing.cast(SupportedAstNodes, node.func), assignments
|
|
397
|
+
)
|
|
299
398
|
elif isinstance(node, ast.Name):
|
|
300
399
|
var_name = get_node_identifier(node)
|
|
301
400
|
return assignments.get(var_name, var_name)
|
|
@@ -303,7 +402,9 @@ def _extract_node_name(
|
|
|
303
402
|
return _resolve_full_attribute_path(node)
|
|
304
403
|
|
|
305
404
|
elif isinstance(node, ast.Await):
|
|
306
|
-
return _extract_node_name(
|
|
405
|
+
return _extract_node_name(
|
|
406
|
+
typing.cast(SupportedAstNodes, node.value), assignments
|
|
407
|
+
)
|
|
307
408
|
|
|
308
409
|
elif isinstance(
|
|
309
410
|
node,
|
|
@@ -334,7 +435,19 @@ def _exctact_type(var_tyep: str, func_globals: dict) -> Optional[type]:
|
|
|
334
435
|
logger.exception(f"Failed to resolve variable type: {var_tyep}")
|
|
335
436
|
|
|
336
437
|
|
|
337
|
-
|
|
438
|
+
def _should_be_visited(
|
|
439
|
+
module: ModuleType,
|
|
440
|
+
should_search_module_pred: Callable[[str], bool],
|
|
441
|
+
):
|
|
442
|
+
if (
|
|
443
|
+
is_stdlib(module.__name__)
|
|
444
|
+
or should_search_module_pred(module.__name__) is False
|
|
445
|
+
):
|
|
446
|
+
return False
|
|
447
|
+
|
|
448
|
+
return True
|
|
449
|
+
|
|
450
|
+
|
|
338
451
|
def is_stdlib(module_name: str) -> bool:
|
|
339
452
|
"""Check if a given module is part of the Python standard library."""
|
|
340
453
|
try:
|
|
@@ -349,16 +462,29 @@ def is_stdlib(module_name: str) -> bool:
|
|
|
349
462
|
|
|
350
463
|
|
|
351
464
|
class ExceptionFinder(ast.NodeVisitor):
|
|
352
|
-
visited:
|
|
465
|
+
visited: dict[Callable, list[tuple[Optional[type[Exception]], ast.Raise]]] = {}
|
|
466
|
+
# cached_assignments: dict[Callable, dict[NodeIdentifier, str]] = {}
|
|
353
467
|
|
|
354
|
-
def __init__(
|
|
468
|
+
def __init__(
|
|
469
|
+
self, func: Callable, should_search_module_pred: Callable[[str], bool]
|
|
470
|
+
):
|
|
355
471
|
self.exceptions: list[tuple[Optional[type[Exception]], ast.Raise]] = []
|
|
356
472
|
self.assignments: dict[NodeIdentifier, str] = {}
|
|
473
|
+
# self.cached_assignments[func] = self.assignments
|
|
357
474
|
self.func = func
|
|
358
|
-
self.func_globals = func.__globals__
|
|
359
475
|
self.should_search_module_pred = should_search_module_pred
|
|
360
476
|
logger.info(f"Analyzing function {func.__name__}")
|
|
361
477
|
|
|
478
|
+
@classmethod
|
|
479
|
+
def clear_cache(cls):
|
|
480
|
+
cls.visited.clear()
|
|
481
|
+
# cls.cached_assignments.clear()
|
|
482
|
+
|
|
483
|
+
def _should_be_visited(
|
|
484
|
+
self, module: ModuleType
|
|
485
|
+
) -> typing_extensions.TypeGuard[ModuleType]:
|
|
486
|
+
return _should_be_visited(module, self.should_search_module_pred)
|
|
487
|
+
|
|
362
488
|
def visit_Assign(self, node):
|
|
363
489
|
"""
|
|
364
490
|
Track assignments to variables that are exceptions or exception instances.
|
|
@@ -384,10 +510,11 @@ class ExceptionFinder(ast.NodeVisitor):
|
|
|
384
510
|
|
|
385
511
|
for target in targets:
|
|
386
512
|
if isinstance(target, ast.Name):
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
513
|
+
var_name = get_node_identifier(target)
|
|
514
|
+
var_result = _extract_node_name(value, self.assignments)
|
|
515
|
+
|
|
516
|
+
if var_result is not None:
|
|
517
|
+
self.assignments[var_name] = var_result
|
|
391
518
|
|
|
392
519
|
else:
|
|
393
520
|
# no need to log here. it would be very spammy
|
|
@@ -440,7 +567,7 @@ class ExceptionFinder(ast.NodeVisitor):
|
|
|
440
567
|
logger.debug(f"Failed to get exception name: {ast.dump(node)}")
|
|
441
568
|
return
|
|
442
569
|
|
|
443
|
-
exc_type = _exctact_type(exc_variable_name, self.
|
|
570
|
+
exc_type = _exctact_type(exc_variable_name, self.func.__globals__)
|
|
444
571
|
|
|
445
572
|
if exc_type:
|
|
446
573
|
self.exceptions.append((exc_type, node))
|
|
@@ -452,47 +579,47 @@ class ExceptionFinder(ast.NodeVisitor):
|
|
|
452
579
|
"""
|
|
453
580
|
Handle function calls and recursively analyze called functions.
|
|
454
581
|
"""
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
582
|
+
func_objs = _resolve_functions_from_call_node(node, self.func, self.assignments)
|
|
583
|
+
for func_obj in func_objs:
|
|
584
|
+
if inspect.isfunction(func_obj):
|
|
585
|
+
module = inspect.getmodule(func_obj)
|
|
586
|
+
if module is None or self._should_be_visited(module) is False:
|
|
587
|
+
continue
|
|
588
|
+
|
|
589
|
+
source = inspect.getsource(module)
|
|
590
|
+
try:
|
|
591
|
+
tree = ast.parse(source)
|
|
592
|
+
except BaseException as error:
|
|
593
|
+
logger.exception(
|
|
594
|
+
f"Failed to parse source code for {func_obj.__name__}.",
|
|
595
|
+
exc_info=error,
|
|
596
|
+
)
|
|
597
|
+
self.generic_visit(node)
|
|
598
|
+
return
|
|
599
|
+
|
|
600
|
+
founded_func_ast = _find_in_module(
|
|
601
|
+
tree,
|
|
602
|
+
lambda n: (
|
|
603
|
+
isinstance(n, ast.AsyncFunctionDef)
|
|
604
|
+
or isinstance(n, ast.FunctionDef)
|
|
605
|
+
)
|
|
606
|
+
and n.name == func_obj.__name__,
|
|
478
607
|
)
|
|
479
|
-
|
|
480
|
-
|
|
608
|
+
if founded_func_ast:
|
|
609
|
+
excs = _find_explicit_expection_recursively(
|
|
610
|
+
func_obj, self.should_search_module_pred, founded_func_ast
|
|
611
|
+
)
|
|
612
|
+
self.exceptions.extend(excs)
|
|
613
|
+
else:
|
|
614
|
+
logger.debug(
|
|
615
|
+
f"Failed to find function {func_obj.__name__} in module {module.__name__}"
|
|
616
|
+
)
|
|
481
617
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
and n.name == func_obj.__name__,
|
|
486
|
-
)
|
|
487
|
-
if founded_func_ast:
|
|
488
|
-
excs = _find_explicit_expection_recursively(
|
|
489
|
-
func_obj, self.should_search_module_pred, founded_func_ast
|
|
490
|
-
)
|
|
491
|
-
self.exceptions.extend(excs)
|
|
492
|
-
else:
|
|
493
|
-
logger.debug(
|
|
494
|
-
f"Failed to find function {func_obj.__name__} in module {module.__name__}"
|
|
618
|
+
elif inspect.isclass(func_obj):
|
|
619
|
+
_excs = _find_explicit_expection_recursively(
|
|
620
|
+
func_obj, self.should_search_module_pred
|
|
495
621
|
)
|
|
622
|
+
self.exceptions.extend(_excs)
|
|
496
623
|
|
|
497
624
|
self.generic_visit(node)
|
|
498
625
|
return
|
|
@@ -507,9 +634,11 @@ class ExceptionFinder(ast.NodeVisitor):
|
|
|
507
634
|
return
|
|
508
635
|
|
|
509
636
|
try:
|
|
510
|
-
caller_type = _exctact_type(caller_node_name, self.
|
|
637
|
+
caller_type = _exctact_type(caller_node_name, self.func.__globals__)
|
|
511
638
|
except Exception:
|
|
512
|
-
logger.critical(
|
|
639
|
+
logger.critical(
|
|
640
|
+
f"Failed to resolve attribute chain: {caller_node_name}"
|
|
641
|
+
)
|
|
513
642
|
self.generic_visit(node)
|
|
514
643
|
return
|
|
515
644
|
|
|
@@ -523,7 +652,7 @@ class ExceptionFinder(ast.NodeVisitor):
|
|
|
523
652
|
return
|
|
524
653
|
|
|
525
654
|
module = inspect.getmodule(caller_type)
|
|
526
|
-
if
|
|
655
|
+
if module is None or self._should_be_visited(module) is False:
|
|
527
656
|
self.generic_visit(node)
|
|
528
657
|
return
|
|
529
658
|
|
|
@@ -540,7 +669,8 @@ class ExceptionFinder(ast.NodeVisitor):
|
|
|
540
669
|
|
|
541
670
|
cls_ast = _find_in_module(
|
|
542
671
|
tree,
|
|
543
|
-
lambda n: isinstance(n, ast.ClassDef)
|
|
672
|
+
lambda n: isinstance(n, ast.ClassDef)
|
|
673
|
+
and n.name == caller_type.__name__,
|
|
544
674
|
)
|
|
545
675
|
if cls_ast:
|
|
546
676
|
casted_cls_ast = typing.cast(ast.ClassDef, cls_ast)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from fastapi import FastAPI
|
|
2
|
+
from fastapi.responses import JSONResponse
|
|
3
|
+
from pydantic import TypeAdapter
|
|
4
|
+
from starlette.requests import Request
|
|
5
|
+
from starlette.responses import Response
|
|
6
|
+
|
|
7
|
+
from richapi.exc_parser.protocol import RichHTTPException
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def _handle_rich_http_exception(request: Request, exc: Exception) -> Response:
|
|
11
|
+
assert isinstance(exc, RichHTTPException)
|
|
12
|
+
|
|
13
|
+
adapter = TypeAdapter(exc.__class__)
|
|
14
|
+
if exc._validate_arg_types:
|
|
15
|
+
adapter.validate_python(exc)
|
|
16
|
+
|
|
17
|
+
json_response = adapter.dump_python(exc)
|
|
18
|
+
return JSONResponse(content=json_response, status_code=exc.status_code)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def add_exc_handler(app: FastAPI):
|
|
22
|
+
app.add_exception_handler(RichHTTPException, handler=_handle_rich_http_exception)
|
|
@@ -12,6 +12,9 @@ from fastapi.openapi.utils import get_openapi as _get_openapi
|
|
|
12
12
|
from fastapi.routing import APIRoute
|
|
13
13
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
14
14
|
|
|
15
|
+
from richapi.exc_parser.compiler import (
|
|
16
|
+
ExceptionFinder,
|
|
17
|
+
)
|
|
15
18
|
from richapi.exc_parser.compiler import (
|
|
16
19
|
find_explicit_exceptions as _find_explicit_exceptions,
|
|
17
20
|
)
|
|
@@ -88,6 +91,8 @@ def compile_openapi_from_fastapi(
|
|
|
88
91
|
|
|
89
92
|
_fill_openapi_with_excpetions(openapi_schema, route, exceptions)
|
|
90
93
|
|
|
94
|
+
ExceptionFinder.clear_cache()
|
|
95
|
+
|
|
91
96
|
return openapi_schema
|
|
92
97
|
|
|
93
98
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import Any, Literal
|
|
3
|
+
from typing import Any, ClassVar, Literal
|
|
4
4
|
|
|
5
5
|
from fastapi import HTTPException
|
|
6
|
-
from pydantic import BaseModel, create_model
|
|
6
|
+
from pydantic import BaseModel, TypeAdapter, create_model
|
|
7
|
+
from pydantic.dataclasses import dataclass
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
class HTTPExceptionSchema(BaseModel):
|
|
@@ -27,10 +28,38 @@ class BaseHTTPException(HTTPException):
|
|
|
27
28
|
@classmethod
|
|
28
29
|
def get_json_schema(cls) -> HTTPExceptionSchema:
|
|
29
30
|
value = _generic_json_schema_builder(cls)
|
|
30
|
-
assert
|
|
31
|
+
assert (
|
|
32
|
+
value is not None
|
|
33
|
+
) # should never happen because status code is always set
|
|
31
34
|
return value
|
|
32
35
|
|
|
33
36
|
|
|
37
|
+
@dataclass
|
|
38
|
+
class RichHTTPException(HTTPException):
|
|
39
|
+
status_code: ClassVar[int] = 422
|
|
40
|
+
"""HTTP Status code to raise"""
|
|
41
|
+
|
|
42
|
+
_validate_arg_types: ClassVar[bool] = True
|
|
43
|
+
"""Whether to run validation on arguements"""
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def get_json_schema(cls) -> HTTPExceptionSchema:
|
|
47
|
+
adapter = TypeAdapter(cls)
|
|
48
|
+
schema = adapter.json_schema()
|
|
49
|
+
schema.pop("status_code", None)
|
|
50
|
+
|
|
51
|
+
schema_desc = cls.__doc__
|
|
52
|
+
if schema_desc == cls.__name__ + "()" or schema_desc is None:
|
|
53
|
+
schema_desc = "No description provided"
|
|
54
|
+
|
|
55
|
+
return HTTPExceptionSchema(
|
|
56
|
+
response_schema_json=schema,
|
|
57
|
+
schema_name=f"{cls.__name__}Schema",
|
|
58
|
+
status_code=cls.status_code,
|
|
59
|
+
schema_desc=schema_desc,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
34
63
|
def try_to_camel_case(string: str) -> str:
|
|
35
64
|
final_str = string
|
|
36
65
|
if " " in final_str: # with space
|
|
File without changes
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fastapi
|
|
2
|
+
import fastapi.security
|
|
3
|
+
from pydantic.dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from richapi.exc_parser.handler import add_exc_handler
|
|
6
|
+
from richapi.exc_parser.openapi import compile_openapi_from_fastapi, enrich_openapi
|
|
7
|
+
from richapi.exc_parser.protocol import RichHTTPException
|
|
8
|
+
|
|
9
|
+
app = fastapi.FastAPI()
|
|
10
|
+
app.openapi = enrich_openapi(app)
|
|
11
|
+
add_exc_handler(app)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Exception1(RichHTTPException):
|
|
16
|
+
status_code = 409
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Exception2(RichHTTPException):
|
|
21
|
+
status_code = 408
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Exception3(RichHTTPException):
|
|
26
|
+
status_code = 407
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def foo1():
|
|
30
|
+
raise Exception1()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def foo2():
|
|
34
|
+
raise Exception2()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def foo3():
|
|
38
|
+
raise Exception3()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PaymentService:
|
|
42
|
+
def foo(self):
|
|
43
|
+
foo1()
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def create(self):
|
|
47
|
+
foo2()
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
def update(self):
|
|
51
|
+
foo3()
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.post("/payment")
|
|
56
|
+
async def make_payment():
|
|
57
|
+
PaymentService().foo().create().update()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_class_is_detected():
|
|
61
|
+
openapi_json = compile_openapi_from_fastapi(
|
|
62
|
+
app, target_module="tests.test_builder_pattern"
|
|
63
|
+
)
|
|
64
|
+
home_responses = openapi_json["paths"]["/payment"]["post"]["responses"]
|
|
65
|
+
print(home_responses)
|
|
66
|
+
assert "408" in home_responses
|
|
67
|
+
assert "407" in home_responses
|
|
68
|
+
assert "409" in home_responses
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import fastapi
|
|
2
|
+
import fastapi.security
|
|
3
|
+
from pydantic.dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from richapi.exc_parser.handler import add_exc_handler
|
|
6
|
+
from richapi.exc_parser.openapi import compile_openapi_from_fastapi, enrich_openapi
|
|
7
|
+
from richapi.exc_parser.protocol import RichHTTPException
|
|
8
|
+
|
|
9
|
+
app = fastapi.FastAPI()
|
|
10
|
+
app.openapi = enrich_openapi(app)
|
|
11
|
+
add_exc_handler(app)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Exception1(RichHTTPException):
|
|
16
|
+
status_code = 409
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Exception2(RichHTTPException):
|
|
21
|
+
status_code = 408
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Exception3(RichHTTPException):
|
|
26
|
+
status_code = 407
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def foo1():
|
|
30
|
+
raise Exception1()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def foo2():
|
|
34
|
+
raise Exception2()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def foo3():
|
|
38
|
+
raise Exception3()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PaymentService:
|
|
42
|
+
def __init__(self):
|
|
43
|
+
foo1()
|
|
44
|
+
|
|
45
|
+
def create(self):
|
|
46
|
+
foo2()
|
|
47
|
+
|
|
48
|
+
def update(self):
|
|
49
|
+
foo3()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def make_payment_dep():
|
|
53
|
+
obj = PaymentService()
|
|
54
|
+
return obj
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.post("/payment")
|
|
58
|
+
async def make_payment(
|
|
59
|
+
obj2: PaymentService = fastapi.Depends(make_payment_dep),
|
|
60
|
+
):
|
|
61
|
+
obj = PaymentService()
|
|
62
|
+
obj.create()
|
|
63
|
+
obj2.update()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_class_is_detected():
|
|
67
|
+
openapi_json = compile_openapi_from_fastapi(app, target_module="tests.test_class")
|
|
68
|
+
home_responses = openapi_json["paths"]["/payment"]["post"]["responses"]
|
|
69
|
+
print(home_responses)
|
|
70
|
+
assert "408" in home_responses
|
|
71
|
+
assert "407" in home_responses
|
|
72
|
+
assert "409" in home_responses
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from richapi.exc_parser.compiler import _retrieve_from_annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def foo(value: Annotated[int, Annotated[str, str]]): ...
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_get_from_annotated():
|
|
10
|
+
result = _retrieve_from_annotations(foo, "value")
|
|
11
|
+
print(type(result))
|
|
12
|
+
assert result is int
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import fastapi
|
|
2
|
+
import fastapi.security
|
|
3
|
+
import pytest
|
|
4
|
+
from httpx import ASGITransport, AsyncClient
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
from pydantic.dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from richapi.exc_parser.handler import add_exc_handler
|
|
9
|
+
from richapi.exc_parser.openapi import compile_openapi_from_fastapi, enrich_openapi
|
|
10
|
+
from richapi.exc_parser.protocol import RichHTTPException
|
|
11
|
+
|
|
12
|
+
app = fastapi.FastAPI()
|
|
13
|
+
app.openapi = enrich_openapi(app)
|
|
14
|
+
add_exc_handler(app)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class NotEnoughBalance(RichHTTPException):
|
|
19
|
+
"""Custom exception for user balance"""
|
|
20
|
+
|
|
21
|
+
user_id: int
|
|
22
|
+
balance: float
|
|
23
|
+
status_code = 409
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SuccessResponse(BaseModel):
|
|
27
|
+
balance: float
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.post("/payment")
|
|
31
|
+
async def make_payment() -> SuccessResponse:
|
|
32
|
+
raise NotEnoughBalance(user_id=1, balance=0.5)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_custom_exc_detected():
|
|
36
|
+
openapi_json = compile_openapi_from_fastapi(
|
|
37
|
+
app, target_module="tests.test_custom_exc"
|
|
38
|
+
)
|
|
39
|
+
home_responses = openapi_json["paths"]["/payment"]["post"]["responses"]
|
|
40
|
+
assert "409" in home_responses
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.asyncio
|
|
44
|
+
async def test_make_payment():
|
|
45
|
+
async with AsyncClient(
|
|
46
|
+
transport=ASGITransport(app=app), base_url="http://test"
|
|
47
|
+
) as ac:
|
|
48
|
+
response = await ac.post("/payment")
|
|
49
|
+
|
|
50
|
+
assert response.status_code == 409
|
|
51
|
+
expected_response = {"user_id": 1, "balance": 0.5}
|
|
52
|
+
assert response.json() == expected_response
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import fastapi
|
|
2
|
+
import fastapi.security
|
|
3
|
+
|
|
4
|
+
from richapi.exc_parser.openapi import compile_openapi_from_fastapi, enrich_openapi
|
|
5
|
+
from tests.app import foo1 as FooService
|
|
6
|
+
|
|
7
|
+
app = fastapi.FastAPI()
|
|
8
|
+
app.openapi = enrich_openapi(app)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@app.post("/payment")
|
|
12
|
+
async def make_payment():
|
|
13
|
+
FooService.raise_exc()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_class_is_detected():
|
|
17
|
+
openapi_json = compile_openapi_from_fastapi(app, target_module="tests")
|
|
18
|
+
home_responses = openapi_json["paths"]["/payment"]["post"]["responses"]
|
|
19
|
+
print(home_responses)
|
|
20
|
+
assert "500" in home_responses
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
import fastapi
|
|
4
|
+
import fastapi.security
|
|
5
|
+
from pydantic.dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from richapi.exc_parser.openapi import compile_openapi_from_fastapi, enrich_openapi
|
|
8
|
+
from richapi.exc_parser.protocol import RichHTTPException
|
|
9
|
+
|
|
10
|
+
logging.getLogger().addHandler(logging.StreamHandler())
|
|
11
|
+
|
|
12
|
+
app = fastapi.FastAPI()
|
|
13
|
+
app.openapi = enrich_openapi(app, target_module="tests.test_instance_attr_call")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Exception1(RichHTTPException):
|
|
18
|
+
status_code = 409
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Exception2(RichHTTPException):
|
|
23
|
+
status_code = 408
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Exception3(RichHTTPException):
|
|
28
|
+
status_code = 407
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def foo1():
|
|
32
|
+
raise Exception1()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def foo2():
|
|
36
|
+
raise Exception2()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def foo3():
|
|
40
|
+
raise Exception3()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PaymentService:
|
|
44
|
+
def __init__(self):
|
|
45
|
+
foo1()
|
|
46
|
+
|
|
47
|
+
def create_inner(self):
|
|
48
|
+
foo2()
|
|
49
|
+
|
|
50
|
+
def update_inner(self):
|
|
51
|
+
foo3()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PaymentOrchestrator:
|
|
55
|
+
service: PaymentService
|
|
56
|
+
|
|
57
|
+
def __init__(self, payment_service: PaymentService):
|
|
58
|
+
self.service = payment_service
|
|
59
|
+
|
|
60
|
+
def create_outer(self):
|
|
61
|
+
self.service.create_inner()
|
|
62
|
+
self.update_outer()
|
|
63
|
+
|
|
64
|
+
def update_outer(self):
|
|
65
|
+
self.service.update_inner()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.post("/payment")
|
|
69
|
+
async def make_payment():
|
|
70
|
+
obj = PaymentOrchestrator(payment_service=PaymentService())
|
|
71
|
+
obj.create_outer()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_class_is_detected():
|
|
75
|
+
openapi_json = compile_openapi_from_fastapi(
|
|
76
|
+
app, target_module="tests.test_instance_attr_call"
|
|
77
|
+
)
|
|
78
|
+
home_responses = openapi_json["paths"]["/payment"]["post"]["responses"]
|
|
79
|
+
print(home_responses)
|
|
80
|
+
assert "408" in home_responses
|
|
81
|
+
assert "407" in home_responses
|
|
82
|
+
assert "409" in home_responses
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.1"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|