Compare commits

8 Commits

Author SHA1 Message Date
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
9 changed files with 120 additions and 51 deletions

View File

@@ -5,8 +5,7 @@ publish-pypi:
stage: package
image: python:3.10
script:
- sed -i -e 's/1.12.1/${CI_COMMIT_TAG}/g' aiohttp_pydantic/__init__.py
- cat 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
- invoke upload --pypi-user ${PYPI_REPO_USER} --pypi-password ${PYPI_REPO_PASSWORD} --pypi-url ${PYPI_REPO_URL}
only:

View File

@@ -14,12 +14,13 @@ def setup(
url_prefix: str = "/oas",
enable: bool = True,
version_spec: Optional[str] = None,
title_spec: Optional[str] = None
title_spec: Optional[str] = None,
custom_template: Optional[jinja2.Template] = None
):
if enable:
oas_app = web.Application()
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")
)
oas_app["version_spec"] = version_spec

View File

@@ -120,6 +120,19 @@ def tags(docstring: str) -> List[str]:
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:
"""
Extract all docstring except the "Status Code:" block.
@@ -127,7 +140,7 @@ def operation(docstring: str) -> str:
lines = LinesIterator(docstring)
ret = []
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()
for _ in _i_extract_block(lines):
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>
<html lang="en">
<head>
<meta charset="UTF-8">
<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-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>
<body>
<div id="swagger-ui"></div>
<script src="{{ static_url | trim('/') }}/swagger-ui-bundle.js"> </script>
<script src="{{ static_url | trim('/') }}/swagger-ui-standalone-preset.js"> </script>
<script src="{{ static_url | trim('/') }}/swagger-ui-bundle.js" charset="UTF-8"> </script>
<script src="{{ static_url | trim('/') }}/swagger-ui-standalone-preset.js" charset="UTF-8"> </script>
<script>
window.onload = function() {
// Begin Swagger UI call region
const ui = SwaggerUIBundle({
url: "{{ openapi_spec_url }}",
{% if urls is defined %}
urls: {{ urls|tojson|safe }},
{% endif %}
validatorUrl: {{ validatorUrl | default('null') }},
{% if configUrl is defined %}
configUrl: "{{ configUrl }}",
@@ -54,16 +36,15 @@
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout"
})
});
{% if initOAuth is defined %}
ui.initOAuth(
{{ initOAuth|tojson|safe }}
)
{% endif %}
// End Swagger UI call region
window.ui = ui
}
window.ui = ui;
};
</script>
</body>
</html>

View File

@@ -207,6 +207,17 @@ class OperationObject:
else:
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:
def __init__(self, spec: dict):

View File

@@ -34,7 +34,8 @@ class _OASResponseBuilder:
).copy()
if def_sub_schemas := response_schema.pop("definitions", None):
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 {}
def _handle_list(self, obj):
@@ -87,6 +88,7 @@ def _add_http_method_to_oas(
if description:
oas_operation.description = docstring_parser.operation(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)
else:
status_code_descriptions = {}
@@ -126,6 +128,10 @@ def _add_http_method_to_oas(
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")
if return_type is not None:
_OASResponseBuilder(oas, oas_operation, status_code_descriptions).build(

View File

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

View File

@@ -6,11 +6,12 @@ from uuid import UUID
import pytest
from aiohttp import web
from pydantic import Field
from pydantic.main import BaseModel
from aiohttp_pydantic import PydanticView, oas
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
@@ -20,6 +21,11 @@ class Color(str, Enum):
PINK = "pink"
class Lang(str, Enum):
EN = 'en'
FR = 'fr'
class Toy(BaseModel):
name: str
color: Color
@@ -27,13 +33,18 @@ class Toy(BaseModel):
class Pet(BaseModel):
id: int
name: str
name: Optional[str] = Field(None)
toys: List[Toy]
class Error(BaseModel):
code: int
text: str
class PetCollectionView(PydanticView):
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]]:
"""
Get a list of pets
@@ -41,6 +52,7 @@ class PetCollectionView(PydanticView):
Tags: pet
Status Codes:
200: Successful operation
OperationId: createPet
"""
return web.json_response()
@@ -51,12 +63,12 @@ class PetCollectionView(PydanticView):
class PetItemView(PydanticView):
async def get(
self,
id: int,
/,
size: Union[int, Literal["x", "l", "s"]],
day: Union[int, Literal["now"]] = "now",
) -> Union[r200[Pet], r404]:
self,
id: int,
/,
size: Union[int, Literal["x", "l", "s"]],
day: Union[int, Literal["now"]] = "now",
) -> Union[r200[Pet], r404[Error], r400[Error]]:
return web.json_response()
async def put(self, id: int, /, pet: Pet):
@@ -116,6 +128,12 @@ async def test_generated_oas_should_have_components_schemas(generated_oas):
"title": "Color",
"type": "string",
},
'Lang': {
'description': 'An enumeration.',
'enum': ['en', 'fr'],
'title': 'Lang',
'type': 'string'
},
"Toy": {
"properties": {
"color": {"$ref": "#/components/schemas/Color"},
@@ -135,6 +153,7 @@ async def test_generated_oas_should_have_pets_paths(generated_oas):
async def test_pets_route_should_have_get_method(generated_oas):
assert generated_oas["paths"]["/pets"]["get"] == {
"description": "Get a list of pets",
"operationId": "createPet",
"tags": ["pet"],
"parameters": [
{
@@ -143,6 +162,16 @@ async def test_pets_route_should_have_get_method(generated_oas):
"required": True,
"schema": {"title": "format", "type": "string"},
},
{
'in': 'query',
'name': 'lang',
'required': False,
'schema': {
'allOf': [{'$ref': '#/components/schemas/Lang'}],
'default': 'en',
'title': 'lang'
}
},
{
"in": "query",
"name": "name",
@@ -172,7 +201,7 @@ async def test_pets_route_should_have_get_method(generated_oas):
"type": "array",
},
},
"required": ["id", "name", "toys"],
"required": ["id", "toys"],
"title": "Pet",
"type": "object",
},
@@ -201,7 +230,7 @@ async def test_pets_route_should_have_post_method(generated_oas):
"type": "array",
},
},
"required": ["id", "name", "toys"],
"required": ["id", "toys"],
"title": "Pet",
"type": "object",
}
@@ -223,7 +252,7 @@ async def test_pets_route_should_have_post_method(generated_oas):
"type": "array",
},
},
"required": ["id", "name", "toys"],
"required": ["id", "toys"],
"title": "Pet",
"type": "object",
}
@@ -300,7 +329,7 @@ async def test_pets_id_route_should_have_get_method(generated_oas):
"type": "array",
},
},
"required": ["id", "name", "toys"],
"required": ["id", "toys"],
"title": "Pet",
"type": "object",
}
@@ -335,7 +364,7 @@ async def test_pets_id_route_should_have_put_method(generated_oas):
"type": "array",
},
},
"required": ["id", "name", "toys"],
"required": ["id", "toys"],
"title": "Pet",
"type": "object",
}

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from enum import Enum
from typing import Optional, List
from pydantic import Field
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(
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"
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):
app = web.Application()
app.router.add_view("/article", ArticleViewWithPaginationGroup)