Skip to content

Commit 220850f

Browse files
committed
Merge branch 'release/0.3.0'
2 parents e86bc76 + 71a83ac commit 220850f

File tree

10 files changed

+842
-536
lines changed

10 files changed

+842
-536
lines changed

README.md

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,10 @@ main_router.add_routes(memes_router, prefix="/memes")
6969

7070
If you use dependencies in you handlers, we can easily generate swagger for you.
7171
We have some limitations:
72-
1. We don't support string type annotation for detecting required parameters in openapi. Like `a: "Optional[int]"`.
73-
2. We don't have support for 3.10 style Option annotations. E.G. `int | None`
72+
1. We don't support resolving type aliases if hint is a string.
73+
If you define variable like this: `myvar = int | None` and then in handler
74+
you'd create annotation like this: `param: "str | myvar"` it will fail.
75+
You need to unquote type hint in order to get it work.
7476

7577
We will try to fix these limitations later.
7678

@@ -84,6 +86,46 @@ app = web.Application()
8486
app.on_startup.extend([init, setup_swagger()])
8587
```
8688

89+
### Responses
90+
91+
You can define schema for responses using dataclasses or
92+
pydantic models. This would not affect handlers in any way,
93+
it's only for documentation purposes, if you want to actually
94+
validate values your handler returns, please write your own wrapper.
95+
96+
```python
97+
from dataclasses import dataclass
98+
99+
from aiohttp import web
100+
from pydantic import BaseModel
101+
102+
from aiohttp_deps import Router, openapi_response
103+
104+
router = Router()
105+
106+
107+
@dataclass
108+
class Success:
109+
data: str
110+
111+
112+
class Unauthorized(BaseModel):
113+
why: str
114+
115+
116+
@router.get("/")
117+
@openapi_response(200, Success, content_type="application/xml")
118+
@openapi_response(200, Success)
119+
@openapi_response(401, Unauthorized, description="When token is not correct")
120+
async def handler() -> web.Response:
121+
...
122+
```
123+
124+
This example illustrates how much you can do with this decorator. You
125+
can have multiple content-types for a single status, or you can have different
126+
possble statuses. This function is pretty simple and if you want to make
127+
your own decorator for your responses, it won't be hard.
128+
87129

88130
## Default dependencies
89131

@@ -154,7 +196,7 @@ class UserInfo(BaseModel):
154196

155197
@router.post("/users")
156198
async def new_data(user: UserInfo = Depends(Json())):
157-
return web.json_response({"user": user.dict()})
199+
return web.json_response({"user": user.model_dump()})
158200

159201
```
160202

@@ -168,7 +210,7 @@ If you want to make this data optional, just mark it as optional.
168210
async def new_data(user: Optional[UserInfo] = Depends(Json())):
169211
if user is None:
170212
return web.json_response({"user": None})
171-
return web.json_response({"user": user.dict()})
213+
return web.json_response({"user": user.model_dump()})
172214

173215
```
174216

@@ -275,19 +317,18 @@ To make the magic happen, please add `arbitrary_types_allowed` to the config of
275317

276318

277319
```python
278-
from pydantic import BaseModel
320+
import pydantic
279321
from aiohttp_deps import Router, Depends, Form
280322
from aiohttp import web
281323

282324
router = Router()
283325

284326

285-
class MyForm(BaseModel):
327+
class MyForm(pydantic.BaseModel):
286328
id: int
287329
file: web.FileField
288330

289-
class Config:
290-
arbitrary_types_allowed = True
331+
model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
291332

292333

293334
@router.post("/")

aiohttp_deps/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from aiohttp_deps.initializer import init
55
from aiohttp_deps.router import Router
6-
from aiohttp_deps.swagger import extra_openapi, setup_swagger
6+
from aiohttp_deps.swagger import extra_openapi, openapi_response, setup_swagger
77
from aiohttp_deps.utils import Form, Header, Json, Path, Query
88
from aiohttp_deps.view import View
99

@@ -19,4 +19,5 @@
1919
"Query",
2020
"Form",
2121
"Path",
22+
"openapi_response",
2223
]

aiohttp_deps/swagger.py

Lines changed: 73 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
import inspect
22
from collections import defaultdict
33
from logging import getLogger
4-
from typing import Any, Awaitable, Callable, Dict, Optional, Union
4+
from typing import Any, Awaitable, Callable, Dict, Optional, TypeVar, get_type_hints
55

6+
import pydantic
67
from aiohttp import web
7-
from pydantic import schema_of
8-
from pydantic.utils import deep_update
8+
from deepmerge import always_merger
99
from taskiq_dependencies import DependencyGraph
1010

1111
from aiohttp_deps.initializer import InjectableFuncHandler, InjectableViewHandler
1212
from aiohttp_deps.utils import Form, Header, Json, Path, Query
1313

14-
REF_TEMPLATE = "#/components/schemas/{model}"
14+
_T = TypeVar("_T") # noqa: WPS111
15+
1516
SCHEMA_KEY = "openapi_schema"
1617
SWAGGER_HTML_TEMPALTE = """
1718
<html lang="en">
@@ -67,19 +68,14 @@ def _is_optional(annotation: Optional[inspect.Parameter]) -> bool:
6768
if annotation is None or annotation.annotation == annotation.empty:
6869
return True
6970

70-
origin = getattr(annotation.annotation, "__origin__", None)
71-
if origin is None:
72-
return False
71+
def dummy(_var: annotation.annotation) -> None: # type: ignore
72+
"""Dummy function to use for type resolution."""
7373

74-
if origin == Union:
75-
args = getattr(annotation.annotation, "__args__", ())
76-
for arg in args:
77-
if arg is type(None): # noqa: E721, WPS516
78-
return True
79-
return False
74+
var = get_type_hints(dummy).get("_var")
75+
return var == Optional[var]
8076

8177

82-
def _add_route_def( # noqa: C901
78+
def _add_route_def( # noqa: C901, WPS210
8379
openapi_schema: Dict[str, Any],
8480
route: web.ResourceRoute,
8581
method: str,
@@ -94,6 +90,19 @@ def _add_route_def( # noqa: C901
9490
if route.resource is None: # pragma: no cover
9591
return
9692

93+
params: Dict[tuple[str, str], Any] = {}
94+
95+
def _insert_in_params(data: Dict[str, Any]) -> None:
96+
element = params.get((data["name"], data["in"]))
97+
if element is None:
98+
params[(data["name"], data["in"])] = data
99+
return
100+
element["required"] = element.get("required") or data.get("required")
101+
element["allowEmptyValue"] = bool(element.get("allowEmptyValue")) and bool(
102+
data.get("allowEmptyValue"),
103+
)
104+
params[(data["name"], data["in"])] = element
105+
97106
for dependency in graph.ordered_deps:
98107
if isinstance(dependency.dependency, (Json, Form)):
99108
content_type = "application/json"
@@ -103,10 +112,9 @@ def _add_route_def( # noqa: C901
103112
dependency.signature
104113
and dependency.signature.annotation != inspect.Parameter.empty
105114
):
106-
input_schema = schema_of(
115+
input_schema = pydantic.TypeAdapter(
107116
dependency.signature.annotation,
108-
ref_template=REF_TEMPLATE,
109-
)
117+
).json_schema()
110118
openapi_schema["components"]["schemas"].update(
111119
input_schema.pop("definitions", {}),
112120
)
@@ -118,7 +126,7 @@ def _add_route_def( # noqa: C901
118126
"content": {content_type: {}},
119127
}
120128
elif isinstance(dependency.dependency, Query):
121-
route_info["parameters"].append(
129+
_insert_in_params(
122130
{
123131
"name": dependency.dependency.alias or dependency.param_name,
124132
"in": "query",
@@ -127,16 +135,17 @@ def _add_route_def( # noqa: C901
127135
},
128136
)
129137
elif isinstance(dependency.dependency, Header):
130-
route_info["parameters"].append(
138+
name = dependency.dependency.alias or dependency.param_name
139+
_insert_in_params(
131140
{
132-
"name": dependency.dependency.alias or dependency.param_name,
141+
"name": name.capitalize(),
133142
"in": "header",
134143
"description": dependency.dependency.description,
135144
"required": not _is_optional(dependency.signature),
136145
},
137146
)
138147
elif isinstance(dependency.dependency, Path):
139-
route_info["parameters"].append(
148+
_insert_in_params(
140149
{
141150
"name": dependency.dependency.alias or dependency.param_name,
142151
"in": "path",
@@ -146,8 +155,9 @@ def _add_route_def( # noqa: C901
146155
},
147156
)
148157

158+
route_info["parameters"] = list(params.values())
149159
openapi_schema["paths"][route.resource.canonical].update(
150-
{method.lower(): deep_update(route_info, extra_openapi)},
160+
{method.lower(): always_merger.merge(route_info, extra_openapi)},
151161
)
152162

153163

@@ -264,7 +274,7 @@ async def event_handler(app: web.Application) -> None:
264274
return event_handler
265275

266276

267-
def extra_openapi(additional_schema: Dict[str, Any]) -> Callable[..., Any]:
277+
def extra_openapi(additional_schema: Dict[str, Any]) -> Callable[[_T], _T]:
268278
"""
269279
Add extra openapi schema.
270280
@@ -275,8 +285,46 @@ def extra_openapi(additional_schema: Dict[str, Any]) -> Callable[..., Any]:
275285
:return: same function with new attributes.
276286
"""
277287

278-
def decorator(func: Any) -> Any:
279-
func.__extra_openapi__ = additional_schema
288+
def decorator(func: _T) -> _T:
289+
func.__extra_openapi__ = additional_schema # type: ignore
290+
return func
291+
292+
return decorator
293+
294+
295+
def openapi_response(
296+
status: int,
297+
model: Any,
298+
*,
299+
content_type: str = "application/json",
300+
description: Optional[str] = None,
301+
) -> Callable[[_T], _T]:
302+
"""
303+
Add response schema to the endpoint.
304+
305+
This function takes a status and model,
306+
which is going to represent the response.
307+
308+
:param status: Status of a response.
309+
:param model: Response model.
310+
:param content_type: Content-type of a response.
311+
:param description: Response's description.
312+
313+
:returns: decorator that modifies your function.
314+
"""
315+
316+
def decorator(func: _T) -> _T:
317+
openapi = getattr(func, "__extra_openapi__", {})
318+
adapter: "pydantic.TypeAdapter[Any]" = pydantic.TypeAdapter(model)
319+
responses = openapi.get("responses", {})
320+
status_response = responses.get(status, {})
321+
if not status_response:
322+
status_response["description"] = description
323+
status_response["content"] = status_response.get("content", {})
324+
status_response["content"][content_type] = {"schema": adapter.json_schema()}
325+
responses[status] = status_response
326+
openapi["responses"] = responses
327+
func.__extra_openapi__ = openapi # type: ignore
280328
return func
281329

282330
return decorator

0 commit comments

Comments
 (0)