richapi 0.2.0__tar.gz → 0.3.0__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.0 → richapi-0.3.0}/PKG-INFO +1 -1
- {richapi-0.2.0 → richapi-0.3.0}/docs/advance.md +1 -4
- {richapi-0.2.0 → richapi-0.3.0}/docs/conventions.md +1 -1
- richapi-0.3.0/docs/images/custom-exception.png +0 -0
- {richapi-0.2.0 → richapi-0.3.0}/docs/index.md +51 -4
- {richapi-0.2.0 → richapi-0.3.0}/pyproject.toml +1 -1
- richapi-0.3.0/richapi/__init__.py +11 -0
- richapi-0.3.0/richapi/__version__.py +1 -0
- {richapi-0.2.0 → richapi-0.3.0}/richapi/exc_parser/compiler.py +8 -3
- richapi-0.3.0/richapi/exc_parser/handler.py +22 -0
- {richapi-0.2.0 → richapi-0.3.0}/richapi/exc_parser/openapi.py +8 -0
- {richapi-0.2.0 → richapi-0.3.0}/richapi/exc_parser/protocol.py +41 -8
- richapi-0.3.0/tests/test_custom_exc.py +52 -0
- richapi-0.2.0/richapi/__init__.py +0 -4
- richapi-0.2.0/richapi/__version__.py +0 -1
- {richapi-0.2.0 → richapi-0.3.0}/.envrc +0 -0
- {richapi-0.2.0 → richapi-0.3.0}/.github/workflows/deploy-docs-on-demand.yml +0 -0
- {richapi-0.2.0 → richapi-0.3.0}/.github/workflows/main.yml +0 -0
- {richapi-0.2.0 → richapi-0.3.0}/.github/workflows/on-release-main.yml +0 -0
- {richapi-0.2.0 → richapi-0.3.0}/.gitignore +0 -0
- {richapi-0.2.0 → richapi-0.3.0}/README.md +0 -0
- {richapi-0.2.0 → richapi-0.3.0}/deploy_docs.py +0 -0
- {richapi-0.2.0 → richapi-0.3.0}/docs/images/with-richapi.png +0 -0
- {richapi-0.2.0 → richapi-0.3.0}/docs/images/without-richapi.png +0 -0
- {richapi-0.2.0 → richapi-0.3.0}/docs/implementation.md +0 -0
- {richapi-0.2.0 → richapi-0.3.0}/makefile +0 -0
- {richapi-0.2.0 → richapi-0.3.0}/mkdocs.yml +0 -0
- {richapi-0.2.0 → richapi-0.3.0}/richapi/cli.py +0 -0
- {richapi-0.2.0 → richapi-0.3.0}/richapi/exc_parser/__init__.py +0 -0
- {richapi-0.2.0 → richapi-0.3.0}/richapi/exceptions.py +0 -0
- {richapi-0.2.0 → richapi-0.3.0}/tests/__init__.py +0 -0
- {richapi-0.2.0 → richapi-0.3.0}/tests/test_exc_raising_cases.py +0 -0
- {richapi-0.2.0 → richapi-0.3.0}/tests/test_exception_edge_cases.py +0 -0
- {richapi-0.2.0 → richapi-0.3.0}/tests/test_fastapi_auth_functions.py +0 -0
- {richapi-0.2.0 → richapi-0.3.0}/tox.ini +0 -0
- {richapi-0.2.0 → richapi-0.3.0}/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.0
|
|
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.
|
|
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.0"
|
|
@@ -114,15 +114,14 @@ def _find_all_class_exceptions(
|
|
|
114
114
|
)
|
|
115
115
|
)
|
|
116
116
|
|
|
117
|
-
ExceptionFinder.visited = set() # clean up memory
|
|
118
117
|
return result
|
|
119
118
|
|
|
120
119
|
|
|
121
120
|
def is_absolutely_callable_object(obj: Callable) -> bool:
|
|
122
121
|
return (
|
|
123
122
|
inspect.isclass(type(obj))
|
|
124
|
-
and not inspect.isfunction(obj)
|
|
125
|
-
and not inspect.ismethod(obj)
|
|
123
|
+
and (not inspect.isfunction(obj))
|
|
124
|
+
and (not inspect.ismethod(obj))
|
|
126
125
|
)
|
|
127
126
|
|
|
128
127
|
|
|
@@ -142,6 +141,12 @@ def _find_explicit_expection_recursively(
|
|
|
142
141
|
return []
|
|
143
142
|
|
|
144
143
|
if is_absolutely_callable_object(func_obj):
|
|
144
|
+
cls_module = inspect.getmodule(type(func_obj))
|
|
145
|
+
if cls_module and is_stdlib(cls_module.__name__):
|
|
146
|
+
return []
|
|
147
|
+
if cls_module and to_filter_predicate(cls_module.__name__) is False:
|
|
148
|
+
return []
|
|
149
|
+
|
|
145
150
|
return _find_all_class_exceptions(type(func_obj), to_filter_predicate, tree)
|
|
146
151
|
|
|
147
152
|
ExceptionFinder.visited.add(func_obj)
|
|
@@ -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
|
)
|
|
@@ -44,12 +47,15 @@ def enrich_openapi(
|
|
|
44
47
|
) -> Callable:
|
|
45
48
|
if target_module is None:
|
|
46
49
|
target_module = _find_module_name_where_app_defined_in(app)
|
|
50
|
+
|
|
47
51
|
if target_module is None or target_module == "__main__":
|
|
48
52
|
raise BaseRichAPIException(
|
|
49
53
|
"Could not determine the module where the FastAPI instance was created.\n"
|
|
50
54
|
"Please provide the module name as a string or list of strings."
|
|
51
55
|
)
|
|
52
56
|
|
|
57
|
+
target_module = target_module.split(".")[0] # get the top-level module
|
|
58
|
+
|
|
53
59
|
def _custom_openapi() -> dict:
|
|
54
60
|
if app.openapi_schema: # pragma: no cover
|
|
55
61
|
return app.openapi_schema
|
|
@@ -85,6 +91,8 @@ def compile_openapi_from_fastapi(
|
|
|
85
91
|
|
|
86
92
|
_fill_openapi_with_excpetions(openapi_schema, route, exceptions)
|
|
87
93
|
|
|
94
|
+
ExceptionFinder.visited.clear()
|
|
95
|
+
|
|
88
96
|
return openapi_schema
|
|
89
97
|
|
|
90
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):
|
|
@@ -33,15 +34,47 @@ class BaseHTTPException(HTTPException):
|
|
|
33
34
|
return value
|
|
34
35
|
|
|
35
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
|
+
|
|
36
63
|
def try_to_camel_case(string: str) -> str:
|
|
37
|
-
|
|
64
|
+
final_str = string
|
|
65
|
+
if " " in final_str: # with space
|
|
38
66
|
components = string.split(" ")
|
|
39
|
-
|
|
40
|
-
|
|
67
|
+
final_str = components[0] + "".join(x.title() for x in components[1:])
|
|
68
|
+
|
|
69
|
+
if "_" in final_str: # snake case
|
|
41
70
|
components = string.split("_")
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
71
|
+
final_str = components[0] + "".join(x.title() for x in components[1:])
|
|
72
|
+
|
|
73
|
+
if "-" in final_str: # kebab case
|
|
74
|
+
components = string.split("-")
|
|
75
|
+
final_str = components[0] + "".join(x.title() for x in components[1:])
|
|
76
|
+
|
|
77
|
+
return final_str
|
|
45
78
|
|
|
46
79
|
|
|
47
80
|
def _generic_json_schema_builder(
|
|
@@ -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
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|