Skip to content

Commit 8ceab26

Browse files
authored
Merge pull request #10 from taskiq-python/bugfix/swagger
2 parents 64092c4 + fead85c commit 8ceab26

File tree

7 files changed

+228
-33
lines changed

7 files changed

+228
-33
lines changed

README.md

Lines changed: 44 additions & 2 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

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: 71 additions & 24 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

66
import pydantic
77
from aiohttp import web
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"
@@ -105,9 +114,7 @@ def _add_route_def( # noqa: C901
105114
):
106115
input_schema = pydantic.TypeAdapter(
107116
dependency.signature.annotation,
108-
).json_schema(
109-
ref_template=REF_TEMPLATE,
110-
)
117+
).json_schema()
111118
openapi_schema["components"]["schemas"].update(
112119
input_schema.pop("definitions", {}),
113120
)
@@ -119,7 +126,7 @@ def _add_route_def( # noqa: C901
119126
"content": {content_type: {}},
120127
}
121128
elif isinstance(dependency.dependency, Query):
122-
route_info["parameters"].append(
129+
_insert_in_params(
123130
{
124131
"name": dependency.dependency.alias or dependency.param_name,
125132
"in": "query",
@@ -128,16 +135,17 @@ def _add_route_def( # noqa: C901
128135
},
129136
)
130137
elif isinstance(dependency.dependency, Header):
131-
route_info["parameters"].append(
138+
name = dependency.dependency.alias or dependency.param_name
139+
_insert_in_params(
132140
{
133-
"name": dependency.dependency.alias or dependency.param_name,
141+
"name": name.capitalize(),
134142
"in": "header",
135143
"description": dependency.dependency.description,
136144
"required": not _is_optional(dependency.signature),
137145
},
138146
)
139147
elif isinstance(dependency.dependency, Path):
140-
route_info["parameters"].append(
148+
_insert_in_params(
141149
{
142150
"name": dependency.dependency.alias or dependency.param_name,
143151
"in": "path",
@@ -147,8 +155,9 @@ def _add_route_def( # noqa: C901
147155
},
148156
)
149157

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

154163

@@ -265,7 +274,7 @@ async def event_handler(app: web.Application) -> None:
265274
return event_handler
266275

267276

268-
def extra_openapi(additional_schema: Dict[str, Any]) -> Callable[..., Any]:
277+
def extra_openapi(additional_schema: Dict[str, Any]) -> Callable[[_T], _T]:
269278
"""
270279
Add extra openapi schema.
271280
@@ -276,8 +285,46 @@ def extra_openapi(additional_schema: Dict[str, Any]) -> Callable[..., Any]:
276285
:return: same function with new attributes.
277286
"""
278287

279-
def decorator(func: Any) -> Any:
280-
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
281328
return func
282329

283330
return decorator

poetry.lock

Lines changed: 12 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ python = "^3.8.1"
2828
aiohttp = "^3"
2929
taskiq-dependencies = "^1"
3030
pydantic = "^2"
31+
deepmerge = "^1.1.0"
3132

3233
[tool.poetry.group.dev.dependencies]
3334
pytest = "^7.1.2"

tests/test_isoptional.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import inspect
2+
import sys
23
from typing import Optional, Union
34

45
import pytest
@@ -42,7 +43,7 @@ def tfunc(param: Union[int, str]):
4243
assert not _is_optional(param)
4344

4445

45-
@pytest.mark.skip("We doesn't support 3.10 annotation style yet.")
46+
@pytest.mark.skipif(sys.version_info < (3, 10), reason="Unsupported syntax")
4647
def test_new_type_style():
4748
def tfunc(param: "int | None"):
4849
"""Nothing."""
@@ -52,7 +53,7 @@ def tfunc(param: "int | None"):
5253
assert _is_optional(param)
5354

5455

55-
@pytest.mark.skip("We doesn't support string annotations yet.")
56+
@pytest.mark.skipif(sys.version_info < (3, 10), reason="Unsupported syntax")
5657
def test_string_annotation():
5758
def tfunc(param: "int | None"):
5859
"""Nothing."""

0 commit comments

Comments
 (0)