Compare commits

11 Commits

Author SHA1 Message Date
Georg K
26fd6fa19f fix: handle discriminated model with root definition 2023-02-09 12:25:28 +03:00
Georg K
be944ac98e fix: pass tests 2022-11-22 17:28:30 +03:00
Georg K
b896020a4f feat: test oas; feat: v1.120.7 2022-11-22 17:19:00 +03:00
Georg K
ba0530d6b1 feat: add support for operationId in docstring; feat: v1.120.6 2022-11-11 10:02:27 +03:00
Georg K
83739c7c8e feat: add opt field 2022-11-11 08:08:49 +03:00
Georg K
1dd98d2752 feat: add support for definition in query param 2022-08-04 23:01:04 +03:00
Georg K
207204fe53 feat: allow to specify custom jinja template 2022-07-28 03:25:37 +03:00
Georg K
7618066b7f feat: use swagger-4-ui-bundle (https://github.com/bartsanchez/swagger_ui_bundle) 2022-07-28 03:15:26 +03:00
Georg K
554e76ce51 fix: git tag match package version 2022-07-28 02:29:42 +03:00
Georg K
2c51e9d929 fix: sed doublequoted to env var subst 2022-07-28 02:26:05 +03:00
Georg K
ce341f8611 chore: test aiohttp_pydantic/__init__.py 2022-07-28 02:22:57 +03:00
10 changed files with 188 additions and 101 deletions

View File

@@ -5,7 +5,7 @@ publish-pypi:
stage: package stage: package
image: python:3.10 image: python:3.10
script: script:
- sed -i -e 's/1.12.1/${CI_COMMIT_TAG}/g' aiohttp_pydantic/__init__.py - sed -i -e "s/1.12.1/${CI_COMMIT_TAG:1}/g" aiohttp_pydantic/__init__.py
- pip install -U setuptools wheel pip; pip install invoke - pip install -U setuptools wheel pip; pip install invoke
- invoke upload --pypi-user ${PYPI_REPO_USER} --pypi-password ${PYPI_REPO_PASSWORD} --pypi-url ${PYPI_REPO_URL} - invoke upload --pypi-user ${PYPI_REPO_USER} --pypi-password ${PYPI_REPO_PASSWORD} --pypi-url ${PYPI_REPO_URL}
only: only:

View File

@@ -68,7 +68,10 @@ class BodyGetter(AbstractInjector):
def __init__(self, args_spec: dict, default_values: dict): def __init__(self, args_spec: dict, default_values: dict):
self.arg_name, self.model = next(iter(args_spec.items())) self.arg_name, self.model = next(iter(args_spec.items()))
self._expect_object = self.model.schema()["type"] == "object" schema = self.model.schema()
if "type" not in schema:
schema["type"] = "object"
self._expect_object = schema["type"] == "object"
async def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): async def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict):
try: try:

View File

@@ -14,12 +14,13 @@ def setup(
url_prefix: str = "/oas", url_prefix: str = "/oas",
enable: bool = True, enable: bool = True,
version_spec: Optional[str] = None, version_spec: Optional[str] = None,
title_spec: Optional[str] = None title_spec: Optional[str] = None,
custom_template: Optional[jinja2.Template] = None
): ):
if enable: if enable:
oas_app = web.Application() oas_app = web.Application()
oas_app["apps to expose"] = tuple(apps_to_expose) or (app,) oas_app["apps to expose"] = tuple(apps_to_expose) or (app,)
oas_app["index template"] = jinja2.Template( oas_app["index template"] = custom_template or jinja2.Template(
resources.read_text("aiohttp_pydantic.oas", "index.j2") resources.read_text("aiohttp_pydantic.oas", "index.j2")
) )
oas_app["version_spec"] = version_spec oas_app["version_spec"] = version_spec

View File

@@ -120,6 +120,19 @@ def tags(docstring: str) -> List[str]:
return [] return []
def operation_id(docstring: str) -> str | None:
"""
Extract the "OperationId:" block of the docstring.
"""
iterator = LinesIterator(docstring)
for line in iterator:
if re.fullmatch("operation_?id\\s*:.*", line, re.IGNORECASE):
iterator.rewind()
return line.split(":")[1].strip(' ')
return None
def operation(docstring: str) -> str: def operation(docstring: str) -> str:
""" """
Extract all docstring except the "Status Code:" block. Extract all docstring except the "Status Code:" block.
@@ -127,7 +140,7 @@ def operation(docstring: str) -> str:
lines = LinesIterator(docstring) lines = LinesIterator(docstring)
ret = [] ret = []
for line in lines: for line in lines:
if re.fullmatch("status\\s+codes?\\s*:|tags\\s*:.*", line, re.IGNORECASE): if re.fullmatch("status\\s+codes?\\s*:|tags\\s*:.*|operation_?id\\s*:.*", line, re.IGNORECASE):
lines.rewind() lines.rewind()
for _ in _i_extract_block(lines): for _ in _i_extract_block(lines):
pass pass

View File

@@ -1,45 +1,27 @@
{# This updated file is part of swagger_ui_bundle (https://github.com/dtkav/swagger_ui_bundle) #} {# This updated file is part of swagger_ui_bundle (https://github.com/bartsanchez/swagger_ui_bundle) #}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{{ title | default('Swagger UI') }}</title> <title>{{ title | default('Swagger UI') }}</title>
<link rel="stylesheet" type="text/css" href="{{ static_url | trim('/') }}/swagger-ui.css" > <link rel="stylesheet" type="text/css" href="{{ static_url | trim('/') }}/swagger-ui.css" />
<link rel="stylesheet" type="text/css" href="{{ static_url | trim('/') }}/index.css" />
<link rel="icon" type="image/png" href="{{ static_url | trim('/') }}/favicon-32x32.png" sizes="32x32" /> <link rel="icon" type="image/png" href="{{ static_url | trim('/') }}/favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="{{ static_url | trim('/') }}/favicon-16x16.png" sizes="16x16" /> <link rel="icon" type="image/png" href="{{ static_url | trim('/') }}/favicon-16x16.png" sizes="16x16" />
<style>
html
{
box-sizing: border-box;
overflow: -moz-scrollbars-vertical;
overflow-y: scroll;
}
*,
*:before,
*:after
{
box-sizing: inherit;
}
body
{
margin:0;
background: #fafafa;
}
</style>
</head> </head>
<body> <body>
<div id="swagger-ui"></div> <div id="swagger-ui"></div>
<script src="{{ static_url | trim('/') }}/swagger-ui-bundle.js" charset="UTF-8"> </script>
<script src="{{ static_url | trim('/') }}/swagger-ui-bundle.js"> </script> <script src="{{ static_url | trim('/') }}/swagger-ui-standalone-preset.js" charset="UTF-8"> </script>
<script src="{{ static_url | trim('/') }}/swagger-ui-standalone-preset.js"> </script>
<script> <script>
window.onload = function() { window.onload = function() {
// Begin Swagger UI call region // Begin Swagger UI call region
const ui = SwaggerUIBundle({ const ui = SwaggerUIBundle({
url: "{{ openapi_spec_url }}", url: "{{ openapi_spec_url }}",
{% if urls is defined %}
urls: {{ urls|tojson|safe }},
{% endif %}
validatorUrl: {{ validatorUrl | default('null') }}, validatorUrl: {{ validatorUrl | default('null') }},
{% if configUrl is defined %} {% if configUrl is defined %}
configUrl: "{{ configUrl }}", configUrl: "{{ configUrl }}",
@@ -54,16 +36,15 @@
SwaggerUIBundle.plugins.DownloadUrl SwaggerUIBundle.plugins.DownloadUrl
], ],
layout: "StandaloneLayout" layout: "StandaloneLayout"
}) });
{% if initOAuth is defined %} {% if initOAuth is defined %}
ui.initOAuth( ui.initOAuth(
{{ initOAuth|tojson|safe }} {{ initOAuth|tojson|safe }}
) )
{% endif %} {% endif %}
// End Swagger UI call region // End Swagger UI call region
window.ui = ui;
window.ui = ui };
}
</script> </script>
</body> </body>
</html> </html>

View File

@@ -207,6 +207,17 @@ class OperationObject:
else: else:
self._spec.pop("tags", None) self._spec.pop("tags", None)
@property
def operation_id(self) -> str | None:
return self._spec.get("operationId", None)
@operation_id.setter
def operation_id(self, operation_id: str | None) -> None:
if operation_id:
self._spec["operationId"] = operation_id
else:
self._spec.pop("operationId", None)
class PathItem: class PathItem:
def __init__(self, spec: dict): def __init__(self, spec: dict):

View File

@@ -34,7 +34,8 @@ class _OASResponseBuilder:
).copy() ).copy()
if def_sub_schemas := response_schema.pop("definitions", None): if def_sub_schemas := response_schema.pop("definitions", None):
self._oas.components.schemas.update(def_sub_schemas) self._oas.components.schemas.update(def_sub_schemas)
return response_schema self._oas.components.schemas.update({response_schema['title']: response_schema})
return {'$ref': f'#/components/schemas/{response_schema["title"]}'}
return {} return {}
def _handle_list(self, obj): def _handle_list(self, obj):
@@ -87,6 +88,7 @@ def _add_http_method_to_oas(
if description: if description:
oas_operation.description = docstring_parser.operation(description) oas_operation.description = docstring_parser.operation(description)
oas_operation.tags = docstring_parser.tags(description) oas_operation.tags = docstring_parser.tags(description)
oas_operation.operation_id = docstring_parser.operation_id(description)
status_code_descriptions = docstring_parser.status_code(description) status_code_descriptions = docstring_parser.status_code(description)
else: else:
status_code_descriptions = {} status_code_descriptions = {}
@@ -126,6 +128,10 @@ def _add_http_method_to_oas(
ref_template="#/components/schemas/{model}" ref_template="#/components/schemas/{model}"
) )
# move definitions
if def_sub_schemas := oas_operation.parameters[i].schema.pop("definitions", None):
oas.components.schemas.update(def_sub_schemas)
return_type = get_type_hints(handler).get("return") return_type = get_type_hints(handler).get("return")
if return_type is not None: if return_type is not None:
_OASResponseBuilder(oas, oas_operation, status_code_descriptions).build( _OASResponseBuilder(oas, oas_operation, status_code_descriptions).build(

View File

@@ -32,7 +32,7 @@ python_requires = >=3.8
install_requires = install_requires =
aiohttp aiohttp
pydantic>=1.7 pydantic>=1.7
swagger-ui-bundle swagger-4-ui-bundle
[options.extras_require] [options.extras_require]
test = test =

View File

@@ -1,16 +1,17 @@
from __future__ import annotations from __future__ import annotations
from enum import Enum from enum import Enum
from typing import List, Optional, Union, Literal from typing import List, Optional, Union, Literal, Annotated
from uuid import UUID from uuid import UUID
import pytest import pytest
from aiohttp import web from aiohttp import web
from pydantic import Field
from pydantic.main import BaseModel from pydantic.main import BaseModel
from aiohttp_pydantic import PydanticView, oas from aiohttp_pydantic import PydanticView, oas
from aiohttp_pydantic.injectors import Group from aiohttp_pydantic.injectors import Group
from aiohttp_pydantic.oas.typing import r200, r201, r204, r404 from aiohttp_pydantic.oas.typing import r200, r201, r204, r404, r400
from aiohttp_pydantic.oas.view import generate_oas from aiohttp_pydantic.oas.view import generate_oas
@@ -20,6 +21,11 @@ class Color(str, Enum):
PINK = "pink" PINK = "pink"
class Lang(str, Enum):
EN = 'en'
FR = 'fr'
class Toy(BaseModel): class Toy(BaseModel):
name: str name: str
color: Color color: Color
@@ -27,13 +33,32 @@ class Toy(BaseModel):
class Pet(BaseModel): class Pet(BaseModel):
id: int id: int
name: str name: Optional[str] = Field(None)
toys: List[Toy] toys: List[Toy]
class Error(BaseModel):
code: int
text: str
class Cat(BaseModel):
pet_type: Literal['cat']
meows: int
class Dog(BaseModel):
pet_type: Literal['dog']
barks: float
class Animal(BaseModel):
__root__: Annotated[Union[Cat, Dog], Field(discriminator='pet_type')]
class PetCollectionView(PydanticView): class PetCollectionView(PydanticView):
async def get( async def get(
self, format: str, name: Optional[str] = None, *, promo: Optional[UUID] = None self, format: str, lang: Lang = Lang.EN, name: Optional[str] = None, *, promo: Optional[UUID] = None
) -> r200[List[Pet]]: ) -> r200[List[Pet]]:
""" """
Get a list of pets Get a list of pets
@@ -41,6 +66,7 @@ class PetCollectionView(PydanticView):
Tags: pet Tags: pet
Status Codes: Status Codes:
200: Successful operation 200: Successful operation
OperationId: createPet
""" """
return web.json_response() return web.json_response()
@@ -51,12 +77,12 @@ class PetCollectionView(PydanticView):
class PetItemView(PydanticView): class PetItemView(PydanticView):
async def get( async def get(
self, self,
id: int, id: int,
/, /,
size: Union[int, Literal["x", "l", "s"]], size: Union[int, Literal["x", "l", "s"]],
day: Union[int, Literal["now"]] = "now", day: Union[int, Literal["now"]] = "now",
) -> Union[r200[Pet], r404]: ) -> Union[r200[Pet], r404[Error], r400[Error]]:
return web.json_response() return web.json_response()
async def put(self, id: int, /, pet: Pet): async def put(self, id: int, /, pet: Pet):
@@ -79,6 +105,11 @@ class ViewResponseReturnASimpleType(PydanticView):
return web.json_response() return web.json_response()
class DiscriminatedView(PydanticView):
async def post(self, /, request: Animal) -> r200[int]:
return web.json_response()
async def ensure_content_durability(client): async def ensure_content_durability(client):
""" """
Reload the page 2 times to ensure that content is always the same Reload the page 2 times to ensure that content is always the same
@@ -103,6 +134,7 @@ async def generated_oas(aiohttp_client, event_loop) -> web.Application:
app.router.add_view("/pets", PetCollectionView) app.router.add_view("/pets", PetCollectionView)
app.router.add_view("/pets/{id}", PetItemView) app.router.add_view("/pets/{id}", PetItemView)
app.router.add_view("/simple-type", ViewResponseReturnASimpleType) app.router.add_view("/simple-type", ViewResponseReturnASimpleType)
app.router.add_view("/animals", DiscriminatedView)
oas.setup(app) oas.setup(app)
return await ensure_content_durability(await aiohttp_client(app)) return await ensure_content_durability(await aiohttp_client(app))
@@ -110,12 +142,40 @@ async def generated_oas(aiohttp_client, event_loop) -> web.Application:
async def test_generated_oas_should_have_components_schemas(generated_oas): async def test_generated_oas_should_have_components_schemas(generated_oas):
assert generated_oas["components"]["schemas"] == { assert generated_oas["components"]["schemas"] == {
'Cat': {'properties': {'meows': {'title': 'Meows', 'type': 'integer'},
'pet_type': {'enum': ['cat'],
'title': 'Pet Type',
'type': 'string'}},
'required': ['pet_type', 'meows'],
'title': 'Cat',
'type': 'object'},
"Color": { "Color": {
"description": "An enumeration.", "description": "An enumeration.",
"enum": ["red", "green", "pink"], "enum": ["red", "green", "pink"],
"title": "Color", "title": "Color",
"type": "string", "type": "string",
}, },
'Dog': {'properties': {'barks': {'title': 'Barks', 'type': 'number'},
'pet_type': {'enum': ['dog'],
'title': 'Pet Type',
'type': 'string'}},
'required': ['pet_type', 'barks'],
'title': 'Dog',
'type': 'object'},
'Error': {
'properties': {
'code': {'title': 'Code', 'type': 'integer'},
'text': {'title': 'Text', 'type': 'string'}},
'required': ['code', 'text'],
'title': 'Error',
'type': 'object'
},
'Lang': {
'description': 'An enumeration.',
'enum': ['en', 'fr'],
'title': 'Lang',
'type': 'string'
},
"Toy": { "Toy": {
"properties": { "properties": {
"color": {"$ref": "#/components/schemas/Color"}, "color": {"$ref": "#/components/schemas/Color"},
@@ -125,6 +185,20 @@ async def test_generated_oas_should_have_components_schemas(generated_oas):
"title": "Toy", "title": "Toy",
"type": "object", "type": "object",
}, },
'Pet': {
'properties': {
'id': {'title': 'Id', 'type': 'integer'},
'name': {'title': 'Name', 'type': 'string'},
'toys': {
'items': {'$ref': '#/components/schemas/Toy'},
'title': 'Toys',
'type': 'array'
}
},
'required': ['id', 'toys'],
'title': 'Pet',
'type': 'object'
}
} }
@@ -135,6 +209,7 @@ async def test_generated_oas_should_have_pets_paths(generated_oas):
async def test_pets_route_should_have_get_method(generated_oas): async def test_pets_route_should_have_get_method(generated_oas):
assert generated_oas["paths"]["/pets"]["get"] == { assert generated_oas["paths"]["/pets"]["get"] == {
"description": "Get a list of pets", "description": "Get a list of pets",
"operationId": "createPet",
"tags": ["pet"], "tags": ["pet"],
"parameters": [ "parameters": [
{ {
@@ -143,6 +218,16 @@ async def test_pets_route_should_have_get_method(generated_oas):
"required": True, "required": True,
"schema": {"title": "format", "type": "string"}, "schema": {"title": "format", "type": "string"},
}, },
{
'in': 'query',
'name': 'lang',
'required': False,
'schema': {
'allOf': [{'$ref': '#/components/schemas/Lang'}],
'default': 'en',
'title': 'lang'
}
},
{ {
"in": "query", "in": "query",
"name": "name", "name": "name",
@@ -162,20 +247,7 @@ async def test_pets_route_should_have_get_method(generated_oas):
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"items": { "items": {'$ref': '#/components/schemas/Pet'},
"properties": {
"id": {"title": "Id", "type": "integer"},
"name": {"title": "Name", "type": "string"},
"toys": {
"items": {"$ref": "#/components/schemas/Toy"},
"title": "Toys",
"type": "array",
},
},
"required": ["id", "name", "toys"],
"title": "Pet",
"type": "object",
},
"type": "array", "type": "array",
} }
} }
@@ -201,7 +273,7 @@ async def test_pets_route_should_have_post_method(generated_oas):
"type": "array", "type": "array",
}, },
}, },
"required": ["id", "name", "toys"], "required": ["id", "toys"],
"title": "Pet", "title": "Pet",
"type": "object", "type": "object",
} }
@@ -213,20 +285,7 @@ async def test_pets_route_should_have_post_method(generated_oas):
"description": "", "description": "",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {'$ref': '#/components/schemas/Pet'}
"properties": {
"id": {"title": "Id", "type": "integer"},
"name": {"title": "Name", "type": "string"},
"toys": {
"items": {"$ref": "#/components/schemas/Toy"},
"title": "Toys",
"type": "array",
},
},
"required": ["id", "name", "toys"],
"title": "Pet",
"type": "object",
}
} }
}, },
} }
@@ -285,30 +344,14 @@ async def test_pets_id_route_should_have_get_method(generated_oas):
}, },
}, },
], ],
"responses": { 'responses': {
"200": { '200': {'content': {'application/json': {'schema': {'$ref': '#/components/schemas/Pet'}}},
"description": "", 'description': ''},
"content": { '400': {'content': {'application/json': {'schema': {'$ref': '#/components/schemas/Error'}}},
"application/json": { 'description': ''},
"schema": { '404': {'content': {'application/json': {'schema': {'$ref': '#/components/schemas/Error'}}},
"properties": { 'description': ''}
"id": {"title": "Id", "type": "integer"}, }
"name": {"title": "Name", "type": "string"},
"toys": {
"items": {"$ref": "#/components/schemas/Toy"},
"title": "Toys",
"type": "array",
},
},
"required": ["id", "name", "toys"],
"title": "Pet",
"type": "object",
}
}
},
},
"404": {"description": "", "content": {}},
},
} }
@@ -335,7 +378,7 @@ async def test_pets_id_route_should_have_put_method(generated_oas):
"type": "array", "type": "array",
}, },
}, },
"required": ["id", "name", "toys"], "required": ["id", "toys"],
"title": "Pet", "title": "Pet",
"type": "object", "type": "object",
} }

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from enum import Enum
from typing import Optional, List from typing import Optional, List
from pydantic import Field from pydantic import Field
from aiohttp import web from aiohttp import web
@@ -54,6 +55,20 @@ class ArticleViewWithPaginationGroup(PydanticView):
) )
class Lang(str, Enum):
EN = 'en'
FR = 'fr'
class ArticleViewWithEnumInQuery(PydanticView):
async def get(self, lang: Lang):
return web.json_response(
{
"lang": lang
}
)
async def test_get_article_without_required_qs_should_return_an_error_message( async def test_get_article_without_required_qs_should_return_an_error_message(
aiohttp_client, event_loop aiohttp_client, event_loop
): ):
@@ -236,6 +251,20 @@ async def test_get_article_with_page_and_page_size(aiohttp_client, event_loop):
assert resp.content_type == "application/json" assert resp.content_type == "application/json"
async def test_get_article_with_enum_in_query(aiohttp_client, event_loop):
app = web.Application()
app.router.add_view("/article", ArticleViewWithEnumInQuery)
client = await aiohttp_client(app)
resp = await client.get(
"/article", params={"lang": Lang.EN.value}
)
assert await resp.json() == {'lang': Lang.EN}
assert resp.status == 200
assert resp.content_type == "application/json"
async def test_get_article_with_page_and_wrong_page_size(aiohttp_client, event_loop): async def test_get_article_with_page_and_wrong_page_size(aiohttp_client, event_loop):
app = web.Application() app = web.Application()
app.router.add_view("/article", ArticleViewWithPaginationGroup) app.router.add_view("/article", ArticleViewWithPaginationGroup)