Compare commits
20 Commits
v1.3.0
...
fix-defini
Author | SHA1 | Date | |
---|---|---|---|
|
6ba671532b | ||
|
efbaaa5e6f | ||
|
6211c71875 | ||
|
5567d73952 | ||
|
67a95ec9c9 | ||
|
93ec0f6c80 | ||
|
a6d96d711b | ||
|
8aee135f95 | ||
|
462d8d8b98 | ||
|
0d3a33c964 | ||
|
22979b7e59 | ||
|
b9519bb868 | ||
|
913f50298c | ||
|
03854cf939 | ||
|
2db23d3328 | ||
|
d866ce5358 | ||
|
13c19105d8 | ||
|
e4b23398b8 | ||
|
57b50725ea | ||
|
cda4fba4c2 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,6 +1,9 @@
|
|||||||
|
.coverage
|
||||||
.idea/
|
.idea/
|
||||||
.pytest_cache
|
.pytest_cache
|
||||||
__pycache__
|
__pycache__
|
||||||
aiohttp_pydantic.egg-info/
|
aiohttp_pydantic.egg-info/
|
||||||
build/
|
build/
|
||||||
|
coverage.xml
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
@@ -2,11 +2,14 @@ language: python
|
|||||||
python:
|
python:
|
||||||
- '3.8'
|
- '3.8'
|
||||||
script:
|
script:
|
||||||
- pytest tests/
|
- pytest --cov-report=xml --cov=aiohttp_pydantic tests/
|
||||||
install:
|
install:
|
||||||
- pip install -U setuptools wheel pip
|
- pip install -U setuptools wheel pip
|
||||||
- pip install -r test_requirements.txt
|
- pip install -r requirements/test.txt
|
||||||
|
- pip install -r requirements/ci.txt
|
||||||
- pip install .
|
- pip install .
|
||||||
|
after_success:
|
||||||
|
- codecov
|
||||||
deploy:
|
deploy:
|
||||||
provider: pypi
|
provider: pypi
|
||||||
username: __token__
|
username: __token__
|
||||||
|
42
README.rst
42
README.rst
@@ -1,6 +1,28 @@
|
|||||||
Aiohttp pydantic - Aiohttp View to validate and parse request
|
Aiohttp pydantic - Aiohttp View to validate and parse request
|
||||||
=============================================================
|
=============================================================
|
||||||
|
|
||||||
|
.. image:: https://travis-ci.org/Maillol/aiohttp-pydantic.svg?branch=main
|
||||||
|
:target: https://travis-ci.org/Maillol/aiohttp-pydantic
|
||||||
|
|
||||||
|
.. image:: https://img.shields.io/pypi/v/aiohttp-pydantic
|
||||||
|
:target: https://img.shields.io/pypi/v/aiohttp-pydantic
|
||||||
|
:alt: Latest PyPI package version
|
||||||
|
|
||||||
|
.. image:: https://codecov.io/gh/Maillol/aiohttp-pydantic/branch/main/graph/badge.svg
|
||||||
|
:target: https://codecov.io/gh/Maillol/aiohttp-pydantic
|
||||||
|
:alt: codecov.io status for master branch
|
||||||
|
|
||||||
|
Aiohttp pydantic is an `aiohttp view`_ to easily parse and validate request.
|
||||||
|
You define using the function annotations what your methods for handling HTTP verbs expects and Aiohttp pydantic parses the HTTP request
|
||||||
|
for you, validates the data, and injects that you want as parameters.
|
||||||
|
|
||||||
|
|
||||||
|
Features:
|
||||||
|
|
||||||
|
- Query string, request body, URL path and HTTP headers validation.
|
||||||
|
- Open API Specification generation.
|
||||||
|
|
||||||
|
|
||||||
How to install
|
How to install
|
||||||
--------------
|
--------------
|
||||||
|
|
||||||
@@ -145,8 +167,8 @@ To declare a HTTP headers parameters, you must declare your argument as a `keywo
|
|||||||
.. _pydantic Model: https://pydantic-docs.helpmanual.io/usage/models/
|
.. _pydantic Model: https://pydantic-docs.helpmanual.io/usage/models/
|
||||||
.. _keyword-only argument: https://www.python.org/dev/peps/pep-3102/
|
.. _keyword-only argument: https://www.python.org/dev/peps/pep-3102/
|
||||||
|
|
||||||
Add route to generate Open Api Specification
|
Add route to generate Open Api Specification (OAS)
|
||||||
--------------------------------------------
|
--------------------------------------------------
|
||||||
|
|
||||||
aiohttp_pydantic provides a sub-application to serve a route to generate Open Api Specification
|
aiohttp_pydantic provides a sub-application to serve a route to generate Open Api Specification
|
||||||
reading annotation in your PydanticView. Use *aiohttp_pydantic.oas.setup()* to add the sub-application
|
reading annotation in your PydanticView. Use *aiohttp_pydantic.oas.setup()* to add the sub-application
|
||||||
@@ -168,8 +190,8 @@ By default, the route to display the Open Api Specification is /oas but you can
|
|||||||
|
|
||||||
oas.setup(app, url_prefix='/spec-api')
|
oas.setup(app, url_prefix='/spec-api')
|
||||||
|
|
||||||
If you want generate the Open Api Specification from several aiohttp sub-application.
|
If you want generate the Open Api Specification from specific aiohttp sub-applications.
|
||||||
on the same route, you must use *apps_to_expose* parameters
|
on the same route, you must use *apps_to_expose* parameter.
|
||||||
|
|
||||||
|
|
||||||
.. code-block:: python3
|
.. code-block:: python3
|
||||||
@@ -179,9 +201,9 @@ on the same route, you must use *apps_to_expose* parameters
|
|||||||
|
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
sub_app_1 = web.Application()
|
sub_app_1 = web.Application()
|
||||||
|
sub_app_2 = web.Application()
|
||||||
|
|
||||||
oas.setup(app, apps_to_expose=[app, sub_app_1])
|
oas.setup(app, apps_to_expose=[sub_app_1, sub_app_2])
|
||||||
|
|
||||||
|
|
||||||
Add annotation to define response content
|
Add annotation to define response content
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
@@ -232,7 +254,6 @@ defined using a pydantic.BaseModel
|
|||||||
self.request.app["model"].remove_pet(id)
|
self.request.app["model"].remove_pet(id)
|
||||||
return web.Response(status=204)
|
return web.Response(status=204)
|
||||||
|
|
||||||
|
|
||||||
Demo
|
Demo
|
||||||
----
|
----
|
||||||
|
|
||||||
@@ -247,5 +268,12 @@ Have a look at `demo`_ for a complete example
|
|||||||
|
|
||||||
Go to http://127.0.0.1:8080/oas
|
Go to http://127.0.0.1:8080/oas
|
||||||
|
|
||||||
|
You can generate the OAS in a json file using the command:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
python -m aiohttp_pydantic.oas demo.main
|
||||||
|
|
||||||
|
|
||||||
.. _demo: https://github.com/Maillol/aiohttp-pydantic/tree/main/demo
|
.. _demo: https://github.com/Maillol/aiohttp-pydantic/tree/main/demo
|
||||||
|
.. _aiohttp view: https://docs.aiohttp.org/en/stable/web_quickstart.html#class-based-views
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
from .view import PydanticView
|
from .view import PydanticView
|
||||||
|
|
||||||
__version__ = "1.3.0"
|
__version__ = "1.5.1"
|
||||||
|
|
||||||
__all__ = ("PydanticView", "__version__")
|
__all__ = ("PydanticView", "__version__")
|
||||||
|
@@ -1,10 +1,14 @@
|
|||||||
import abc
|
import abc
|
||||||
from inspect import signature
|
from inspect import signature
|
||||||
|
from json.decoder import JSONDecodeError
|
||||||
from typing import Callable, Tuple
|
from typing import Callable, Tuple
|
||||||
|
|
||||||
|
from aiohttp.web_exceptions import HTTPBadRequest
|
||||||
from aiohttp.web_request import BaseRequest
|
from aiohttp.web_request import BaseRequest
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .utils import is_pydantic_base_model
|
||||||
|
|
||||||
|
|
||||||
class AbstractInjector(metaclass=abc.ABCMeta):
|
class AbstractInjector(metaclass=abc.ABCMeta):
|
||||||
"""
|
"""
|
||||||
@@ -45,7 +49,13 @@ class BodyGetter(AbstractInjector):
|
|||||||
self.arg_name, self.model = next(iter(args_spec.items()))
|
self.arg_name, self.model = next(iter(args_spec.items()))
|
||||||
|
|
||||||
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:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
|
except JSONDecodeError:
|
||||||
|
raise HTTPBadRequest(
|
||||||
|
text='{"error": "Malformed JSON"}', content_type="application/json"
|
||||||
|
)
|
||||||
|
|
||||||
kwargs_view[self.arg_name] = self.model(**body)
|
kwargs_view[self.arg_name] = self.model(**body)
|
||||||
|
|
||||||
|
|
||||||
@@ -98,7 +108,7 @@ def _parse_func_signature(func: Callable) -> Tuple[dict, dict, dict, dict]:
|
|||||||
if param_spec.kind is param_spec.POSITIONAL_ONLY:
|
if param_spec.kind is param_spec.POSITIONAL_ONLY:
|
||||||
path_args[param_name] = param_spec.annotation
|
path_args[param_name] = param_spec.annotation
|
||||||
elif param_spec.kind is param_spec.POSITIONAL_OR_KEYWORD:
|
elif param_spec.kind is param_spec.POSITIONAL_OR_KEYWORD:
|
||||||
if issubclass(param_spec.annotation, BaseModel):
|
if is_pydantic_base_model(param_spec.annotation):
|
||||||
body_args[param_name] = param_spec.annotation
|
body_args[param_name] = param_spec.annotation
|
||||||
else:
|
else:
|
||||||
qs_args[param_name] = param_spec.annotation
|
qs_args[param_name] = param_spec.annotation
|
||||||
|
@@ -13,10 +13,13 @@ def setup(
|
|||||||
apps_to_expose: Iterable[web.Application] = (),
|
apps_to_expose: Iterable[web.Application] = (),
|
||||||
url_prefix: str = "/oas",
|
url_prefix: str = "/oas",
|
||||||
enable: bool = True,
|
enable: bool = True,
|
||||||
|
raise_validation_errors: bool = False,
|
||||||
):
|
):
|
||||||
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,)
|
||||||
|
for a in oas_app["apps to expose"]:
|
||||||
|
a['raise_validation_errors'] = raise_validation_errors
|
||||||
oas_app["index template"] = jinja2.Template(
|
oas_app["index template"] = jinja2.Template(
|
||||||
resources.read_text("aiohttp_pydantic.oas", "index.j2")
|
resources.read_text("aiohttp_pydantic.oas", "index.j2")
|
||||||
)
|
)
|
||||||
|
8
aiohttp_pydantic/oas/__main__.py
Normal file
8
aiohttp_pydantic/oas/__main__.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import argparse
|
||||||
|
|
||||||
|
from .cmd import setup
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Generate Open API Specification")
|
||||||
|
setup(parser)
|
||||||
|
args = parser.parse_args()
|
||||||
|
args.func(args)
|
45
aiohttp_pydantic/oas/cmd.py
Normal file
45
aiohttp_pydantic/oas/cmd.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import argparse
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
from .view import generate_oas
|
||||||
|
|
||||||
|
|
||||||
|
def application_type(value):
|
||||||
|
"""
|
||||||
|
Return aiohttp application defined in the value.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
module_name, app_name = value.split(":")
|
||||||
|
except ValueError:
|
||||||
|
module_name, app_name = value, "app"
|
||||||
|
|
||||||
|
module = importlib.import_module(module_name)
|
||||||
|
try:
|
||||||
|
if app_name.endswith("()"):
|
||||||
|
app_name = app_name.strip("()")
|
||||||
|
factory_app = getattr(module, app_name)
|
||||||
|
return factory_app()
|
||||||
|
return getattr(module, app_name)
|
||||||
|
|
||||||
|
except AttributeError as error:
|
||||||
|
raise argparse.ArgumentTypeError(error) from error
|
||||||
|
|
||||||
|
|
||||||
|
def setup(parser: argparse.ArgumentParser):
|
||||||
|
parser.add_argument(
|
||||||
|
"apps",
|
||||||
|
metavar="APP",
|
||||||
|
type=application_type,
|
||||||
|
nargs="*",
|
||||||
|
help="The name of the module containing the asyncio.web.Application."
|
||||||
|
" By default the variable named 'app' is loaded but you can define"
|
||||||
|
" an other variable name ending the name of module with : characters"
|
||||||
|
" and the name of variable. Example: my_package.my_module:my_app",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.set_defaults(func=show_oas)
|
||||||
|
|
||||||
|
|
||||||
|
def show_oas(args: argparse.Namespace):
|
||||||
|
print(json.dumps(generate_oas(args.apps), sort_keys=True, indent=4))
|
@@ -1,3 +1,7 @@
|
|||||||
|
"""
|
||||||
|
Utility to write Open Api Specifications using the Python language.
|
||||||
|
"""
|
||||||
|
|
||||||
from typing import Union
|
from typing import Union
|
||||||
|
|
||||||
|
|
||||||
@@ -7,7 +11,7 @@ class Info:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def title(self):
|
def title(self):
|
||||||
return self._spec["title"]
|
return self._spec.get("title")
|
||||||
|
|
||||||
@title.setter
|
@title.setter
|
||||||
def title(self, title):
|
def title(self, title):
|
||||||
@@ -15,7 +19,7 @@ class Info:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def description(self):
|
def description(self):
|
||||||
return self._spec["description"]
|
return self._spec.get("description")
|
||||||
|
|
||||||
@description.setter
|
@description.setter
|
||||||
def description(self, description):
|
def description(self, description):
|
||||||
@@ -23,12 +27,20 @@ class Info:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def version(self):
|
def version(self):
|
||||||
return self._spec["version"]
|
return self._spec.get("version")
|
||||||
|
|
||||||
@version.setter
|
@version.setter
|
||||||
def version(self, version):
|
def version(self, version):
|
||||||
self._spec["version"] = version
|
self._spec["version"] = version
|
||||||
|
|
||||||
|
@property
|
||||||
|
def terms_of_service(self):
|
||||||
|
return self._spec.get("termsOfService")
|
||||||
|
|
||||||
|
@terms_of_service.setter
|
||||||
|
def terms_of_service(self, terms_of_service):
|
||||||
|
self._spec["termsOfService"] = terms_of_service
|
||||||
|
|
||||||
|
|
||||||
class RequestBody:
|
class RequestBody:
|
||||||
def __init__(self, spec: dict):
|
def __init__(self, spec: dict):
|
||||||
@@ -43,8 +55,8 @@ class RequestBody:
|
|||||||
self._spec["description"] = description
|
self._spec["description"] = description
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def required(self):
|
def required(self) -> bool:
|
||||||
return self._spec["required"]
|
return self._spec.get("required", False)
|
||||||
|
|
||||||
@required.setter
|
@required.setter
|
||||||
def required(self, required: bool):
|
def required(self, required: bool):
|
||||||
@@ -220,6 +232,22 @@ class PathItem:
|
|||||||
def trace(self) -> OperationObject:
|
def trace(self) -> OperationObject:
|
||||||
return OperationObject(self._spec.setdefault("trace", {}))
|
return OperationObject(self._spec.setdefault("trace", {}))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
return self._spec["description"]
|
||||||
|
|
||||||
|
@description.setter
|
||||||
|
def description(self, description: str):
|
||||||
|
self._spec["description"] = description
|
||||||
|
|
||||||
|
@property
|
||||||
|
def summary(self) -> str:
|
||||||
|
return self._spec["summary"]
|
||||||
|
|
||||||
|
@summary.setter
|
||||||
|
def summary(self, summary: str):
|
||||||
|
self._spec["summary"] = summary
|
||||||
|
|
||||||
|
|
||||||
class Paths:
|
class Paths:
|
||||||
def __init__(self, spec: dict):
|
def __init__(self, spec: dict):
|
||||||
@@ -244,7 +272,7 @@ class Server:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return self._spec["url"]
|
return self._spec["description"]
|
||||||
|
|
||||||
@description.setter
|
@description.setter
|
||||||
def description(self, description: str):
|
def description(self, description: str):
|
||||||
@@ -284,3 +312,8 @@ class OpenApiSpec3:
|
|||||||
@property
|
@property
|
||||||
def spec(self):
|
def spec(self):
|
||||||
return self._spec
|
return self._spec
|
||||||
|
|
||||||
|
@property
|
||||||
|
def definitions(self):
|
||||||
|
self._spec.setdefault('definitions', {})
|
||||||
|
return self._spec['definitions']
|
@@ -13,7 +13,7 @@ Example:
|
|||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from types import new_class
|
from types import new_class
|
||||||
from typing import Protocol, TypeVar
|
from typing import Protocol, TypeVar, Optional, Type
|
||||||
|
|
||||||
RespContents = TypeVar("RespContents", covariant=True)
|
RespContents = TypeVar("RespContents", covariant=True)
|
||||||
|
|
||||||
@@ -24,9 +24,10 @@ _status_code = frozenset(f"r{code}" for code in range(100, 600))
|
|||||||
def _make_status_code_type(status_code):
|
def _make_status_code_type(status_code):
|
||||||
if status_code in _status_code:
|
if status_code in _status_code:
|
||||||
return new_class(status_code, (Protocol[RespContents],))
|
return new_class(status_code, (Protocol[RespContents],))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def is_status_code_type(obj):
|
def is_status_code_type(obj) -> bool:
|
||||||
"""
|
"""
|
||||||
Return True if obj is a status code type such as _200 or _404.
|
Return True if obj is a status code type such as _200 or _404.
|
||||||
"""
|
"""
|
||||||
|
@@ -1,26 +1,44 @@
|
|||||||
import typing
|
import typing
|
||||||
from typing import Type
|
from datetime import date, datetime
|
||||||
|
from inspect import getdoc
|
||||||
|
from itertools import count
|
||||||
|
from typing import List, Type
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from aiohttp.web import Response, json_response
|
from aiohttp.web import Response, json_response
|
||||||
from pydantic import BaseModel
|
from aiohttp.web_app import Application
|
||||||
|
|
||||||
from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem
|
from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem
|
||||||
|
|
||||||
from ..injectors import _parse_func_signature
|
from ..injectors import _parse_func_signature
|
||||||
|
from ..utils import is_pydantic_base_model
|
||||||
from ..view import PydanticView, is_pydantic_view
|
from ..view import PydanticView, is_pydantic_view
|
||||||
from .typing import is_status_code_type
|
from .typing import is_status_code_type
|
||||||
|
|
||||||
JSON_SCHEMA_TYPES = {float: "number", str: "string", int: "integer"}
|
JSON_SCHEMA_TYPES = {
|
||||||
|
float: {"type": "number"},
|
||||||
|
str: {"type": "string"},
|
||||||
|
int: {"type": "integer"},
|
||||||
|
UUID: {"type": "string", "format": "uuid"},
|
||||||
|
bool: {"type": "boolean"},
|
||||||
|
datetime: {"type": "string", "format": "date-time"},
|
||||||
|
date: {"type": "string", "format": "date"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _is_pydantic_base_model(obj):
|
def _handle_optional(type_):
|
||||||
"""
|
"""
|
||||||
Return true is obj is a pydantic.BaseModel subclass.
|
Returns the type wrapped in Optional or None.
|
||||||
|
|
||||||
|
>>> _handle_optional(int)
|
||||||
|
>>> _handle_optional(Optional[str])
|
||||||
|
<class 'str'>
|
||||||
"""
|
"""
|
||||||
try:
|
if typing.get_origin(type_) is typing.Union:
|
||||||
return issubclass(obj, BaseModel)
|
args = typing.get_args(type_)
|
||||||
except TypeError:
|
if len(args) == 2 and type(None) in args:
|
||||||
return False
|
return next(iter(set(args) - {type(None)}))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class _OASResponseBuilder:
|
class _OASResponseBuilder:
|
||||||
@@ -29,13 +47,21 @@ class _OASResponseBuilder:
|
|||||||
generate the OAS operation response.
|
generate the OAS operation response.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, oas_operation):
|
def __init__(self, oas_operation, definitions):
|
||||||
self._oas_operation = oas_operation
|
self._oas_operation = oas_operation
|
||||||
|
self._definitions = definitions
|
||||||
|
|
||||||
|
def _process_definitions(self, schema):
|
||||||
|
if 'definitions' in schema:
|
||||||
|
for k, v in schema['definitions'].items():
|
||||||
|
self._definitions[k] = v
|
||||||
|
|
||||||
|
return {i:schema[i] for i in schema if i!='definitions'}
|
||||||
|
|
||||||
|
def _handle_pydantic_base_model(self, obj):
|
||||||
|
if is_pydantic_base_model(obj):
|
||||||
|
return self._process_definitions(obj.schema())
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _handle_pydantic_base_model(obj):
|
|
||||||
if _is_pydantic_base_model(obj):
|
|
||||||
return obj.schema()
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def _handle_list(self, obj):
|
def _handle_list(self, obj):
|
||||||
@@ -70,48 +96,50 @@ class _OASResponseBuilder:
|
|||||||
|
|
||||||
|
|
||||||
def _add_http_method_to_oas(
|
def _add_http_method_to_oas(
|
||||||
oas_path: PathItem, http_method: str, view: Type[PydanticView]
|
oas_path: PathItem, http_method: str, view: Type[PydanticView], definitions: dict
|
||||||
):
|
):
|
||||||
http_method = http_method.lower()
|
http_method = http_method.lower()
|
||||||
oas_operation: OperationObject = getattr(oas_path, http_method)
|
oas_operation: OperationObject = getattr(oas_path, http_method)
|
||||||
handler = getattr(view, http_method)
|
handler = getattr(view, http_method)
|
||||||
path_args, body_args, qs_args, header_args = _parse_func_signature(handler)
|
path_args, body_args, qs_args, header_args = _parse_func_signature(handler)
|
||||||
|
description = getdoc(handler)
|
||||||
|
if description:
|
||||||
|
oas_operation.description = description
|
||||||
|
|
||||||
if body_args:
|
if body_args:
|
||||||
oas_operation.request_body.content = {
|
oas_operation.request_body.content = {
|
||||||
"application/json": {"schema": next(iter(body_args.values())).schema()}
|
"application/json": {"schema": next(iter(body_args.values())).schema()}
|
||||||
}
|
}
|
||||||
|
|
||||||
i = 0
|
indexes = count()
|
||||||
for i, (name, type_) in enumerate(path_args.items()):
|
for args_location, args in (
|
||||||
|
("path", path_args.items()),
|
||||||
|
("query", qs_args.items()),
|
||||||
|
("header", header_args.items()),
|
||||||
|
):
|
||||||
|
for name, type_ in args:
|
||||||
|
i = next(indexes)
|
||||||
|
oas_operation.parameters[i].in_ = args_location
|
||||||
|
oas_operation.parameters[i].name = name
|
||||||
|
optional_type = _handle_optional(type_)
|
||||||
|
if optional_type is None:
|
||||||
|
oas_operation.parameters[i].schema = JSON_SCHEMA_TYPES[type_]
|
||||||
oas_operation.parameters[i].required = True
|
oas_operation.parameters[i].required = True
|
||||||
oas_operation.parameters[i].in_ = "path"
|
else:
|
||||||
oas_operation.parameters[i].name = name
|
oas_operation.parameters[i].schema = JSON_SCHEMA_TYPES[optional_type]
|
||||||
oas_operation.parameters[i].schema = {"type": JSON_SCHEMA_TYPES[type_]}
|
|
||||||
|
|
||||||
for i, (name, type_) in enumerate(qs_args.items(), i + 1):
|
|
||||||
oas_operation.parameters[i].required = False
|
oas_operation.parameters[i].required = False
|
||||||
oas_operation.parameters[i].in_ = "query"
|
|
||||||
oas_operation.parameters[i].name = name
|
|
||||||
oas_operation.parameters[i].schema = {"type": JSON_SCHEMA_TYPES[type_]}
|
|
||||||
|
|
||||||
for i, (name, type_) in enumerate(header_args.items(), i + 1):
|
|
||||||
oas_operation.parameters[i].required = False
|
|
||||||
oas_operation.parameters[i].in_ = "header"
|
|
||||||
oas_operation.parameters[i].name = name
|
|
||||||
oas_operation.parameters[i].schema = {"type": JSON_SCHEMA_TYPES[type_]}
|
|
||||||
|
|
||||||
return_type = handler.__annotations__.get("return")
|
return_type = handler.__annotations__.get("return")
|
||||||
if return_type is not None:
|
if return_type is not None:
|
||||||
_OASResponseBuilder(oas_operation).build(return_type)
|
_OASResponseBuilder(oas_operation, definitions).build(return_type)
|
||||||
|
|
||||||
|
|
||||||
async def get_oas(request):
|
def generate_oas(apps: List[Application]) -> dict:
|
||||||
"""
|
"""
|
||||||
Generate Open Api Specification from PydanticView in application.
|
Generate and return Open Api Specification from PydanticView in application.
|
||||||
"""
|
"""
|
||||||
apps = request.app["apps to expose"]
|
|
||||||
oas = OpenApiSpec3()
|
oas = OpenApiSpec3()
|
||||||
|
|
||||||
for app in apps:
|
for app in apps:
|
||||||
for resources in app.router.resources():
|
for resources in app.router.resources():
|
||||||
for resource_route in resources:
|
for resource_route in resources:
|
||||||
@@ -121,11 +149,19 @@ async def get_oas(request):
|
|||||||
path = oas.paths[info.get("path", info.get("formatter"))]
|
path = oas.paths[info.get("path", info.get("formatter"))]
|
||||||
if resource_route.method == "*":
|
if resource_route.method == "*":
|
||||||
for method_name in view.allowed_methods:
|
for method_name in view.allowed_methods:
|
||||||
_add_http_method_to_oas(path, method_name, view)
|
_add_http_method_to_oas(path, method_name, view, oas.definitions)
|
||||||
else:
|
else:
|
||||||
_add_http_method_to_oas(path, resource_route.method, view)
|
_add_http_method_to_oas(path, resource_route.method, view, oas.definitions)
|
||||||
|
|
||||||
return json_response(oas.spec)
|
return oas.spec
|
||||||
|
|
||||||
|
|
||||||
|
async def get_oas(request):
|
||||||
|
"""
|
||||||
|
View to generate the Open Api Specification from PydanticView in application.
|
||||||
|
"""
|
||||||
|
apps = request.app["apps to expose"]
|
||||||
|
return json_response(generate_oas(apps))
|
||||||
|
|
||||||
|
|
||||||
async def oas_ui(request):
|
async def oas_ui(request):
|
||||||
@@ -136,6 +172,9 @@ async def oas_ui(request):
|
|||||||
|
|
||||||
static_url = request.app.router["static"].url_for(filename="")
|
static_url = request.app.router["static"].url_for(filename="")
|
||||||
spec_url = request.app.router["spec"].url_for()
|
spec_url = request.app.router["spec"].url_for()
|
||||||
|
|
||||||
|
if request.scheme != request.headers.get('x-forwarded-proto', request.scheme):
|
||||||
|
request = request.clone(scheme=request.headers['x-forwarded-proto'])
|
||||||
host = request.url.origin()
|
host = request.url.origin()
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
|
11
aiohttp_pydantic/utils.py
Normal file
11
aiohttp_pydantic/utils.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
def is_pydantic_base_model(obj):
|
||||||
|
"""
|
||||||
|
Return true is obj is a pydantic.BaseModel subclass.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return issubclass(obj, BaseModel)
|
||||||
|
except TypeError:
|
||||||
|
return False
|
@@ -9,8 +9,14 @@ from aiohttp.web_exceptions import HTTPMethodNotAllowed
|
|||||||
from aiohttp.web_response import StreamResponse
|
from aiohttp.web_response import StreamResponse
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from .injectors import (AbstractInjector, BodyGetter, HeadersGetter,
|
from .injectors import (
|
||||||
MatchInfoGetter, QueryGetter, _parse_func_signature)
|
AbstractInjector,
|
||||||
|
BodyGetter,
|
||||||
|
HeadersGetter,
|
||||||
|
MatchInfoGetter,
|
||||||
|
QueryGetter,
|
||||||
|
_parse_func_signature,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PydanticView(AbstractView):
|
class PydanticView(AbstractView):
|
||||||
@@ -77,6 +83,9 @@ def inject_params(
|
|||||||
else:
|
else:
|
||||||
injector.inject(self.request, args, kwargs)
|
injector.inject(self.request, args, kwargs)
|
||||||
except ValidationError as error:
|
except ValidationError as error:
|
||||||
|
if self.request.app['raise_validation_errors']:
|
||||||
|
raise
|
||||||
|
else:
|
||||||
return json_response(text=error.json(), status=400)
|
return json_response(text=error.json(), status=400)
|
||||||
|
|
||||||
return await handler(self, *args, **kwargs)
|
return await handler(self, *args, **kwargs)
|
||||||
|
@@ -1,25 +1,5 @@
|
|||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from aiohttp.web import middleware
|
|
||||||
|
|
||||||
from aiohttp_pydantic import oas
|
from .main import app
|
||||||
|
|
||||||
from .model import Model
|
|
||||||
from .view import PetCollectionView, PetItemView
|
|
||||||
|
|
||||||
|
|
||||||
@middleware
|
|
||||||
async def pet_not_found_to_404(request, handler):
|
|
||||||
try:
|
|
||||||
return await handler(request)
|
|
||||||
except Model.NotFound as key:
|
|
||||||
return web.json_response({"error": f"Pet {key} does not exist"}, status=404)
|
|
||||||
|
|
||||||
|
|
||||||
app = web.Application(middlewares=[pet_not_found_to_404])
|
|
||||||
oas.setup(app)
|
|
||||||
|
|
||||||
app["model"] = Model()
|
|
||||||
app.router.add_view("/pets", PetCollectionView)
|
|
||||||
app.router.add_view("/pets/{id}", PetItemView)
|
|
||||||
|
|
||||||
web.run_app(app)
|
web.run_app(app)
|
||||||
|
22
demo/main.py
Normal file
22
demo/main.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from aiohttp.web import Application, json_response, middleware
|
||||||
|
|
||||||
|
from aiohttp_pydantic import oas
|
||||||
|
|
||||||
|
from .model import Model
|
||||||
|
from .view import PetCollectionView, PetItemView
|
||||||
|
|
||||||
|
|
||||||
|
@middleware
|
||||||
|
async def pet_not_found_to_404(request, handler):
|
||||||
|
try:
|
||||||
|
return await handler(request)
|
||||||
|
except Model.NotFound as key:
|
||||||
|
return json_response({"error": f"Pet {key} does not exist"}, status=404)
|
||||||
|
|
||||||
|
|
||||||
|
app = Application(middlewares=[pet_not_found_to_404])
|
||||||
|
oas.setup(app)
|
||||||
|
|
||||||
|
app["model"] = Model()
|
||||||
|
app.router.add_view("/pets", PetCollectionView)
|
||||||
|
app.router.add_view("/pets/{id}", PetItemView)
|
@@ -4,6 +4,7 @@ from pydantic import BaseModel
|
|||||||
class Pet(BaseModel):
|
class Pet(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
|
age: int
|
||||||
|
|
||||||
|
|
||||||
class Error(BaseModel):
|
class Error(BaseModel):
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
from typing import List, Union
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
@@ -9,9 +9,11 @@ from .model import Error, Pet
|
|||||||
|
|
||||||
|
|
||||||
class PetCollectionView(PydanticView):
|
class PetCollectionView(PydanticView):
|
||||||
async def get(self) -> r200[List[Pet]]:
|
async def get(self, age: Optional[int] = None) -> r200[List[Pet]]:
|
||||||
pets = self.request.app["model"].list_pets()
|
pets = self.request.app["model"].list_pets()
|
||||||
return web.json_response([pet.dict() for pet in pets])
|
return web.json_response(
|
||||||
|
[pet.dict() for pet in pets if age is None or age == pet.age]
|
||||||
|
)
|
||||||
|
|
||||||
async def post(self, pet: Pet) -> r201[Pet]:
|
async def post(self, pet: Pet) -> r201[Pet]:
|
||||||
self.request.app["model"].add_pet(pet)
|
self.request.app["model"].add_pet(pet)
|
||||||
|
7
requirements/ci.txt
Normal file
7
requirements/ci.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
certifi==2020.11.8
|
||||||
|
chardet==3.0.4
|
||||||
|
codecov==2.1.10
|
||||||
|
coverage==5.3
|
||||||
|
idna==2.10
|
||||||
|
requests==2.25.0
|
||||||
|
urllib3==1.26.2
|
13
requirements/test.txt
Normal file
13
requirements/test.txt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
attrs==20.3.0
|
||||||
|
coverage==5.3
|
||||||
|
iniconfig==1.1.1
|
||||||
|
packaging==20.4
|
||||||
|
pluggy==0.13.1
|
||||||
|
py==1.9.0
|
||||||
|
pyparsing==2.4.7
|
||||||
|
pytest==6.1.2
|
||||||
|
pytest-aiohttp==0.3.0
|
||||||
|
pytest-cov==2.10.1
|
||||||
|
six==1.15.0
|
||||||
|
toml==0.10.2
|
||||||
|
typing-extensions==3.7.4.3
|
@@ -35,8 +35,8 @@ install_requires =
|
|||||||
swagger-ui-bundle
|
swagger-ui-bundle
|
||||||
|
|
||||||
[options.extras_require]
|
[options.extras_require]
|
||||||
test = pytest; pytest-aiohttp
|
test = pytest==6.1.2; pytest-aiohttp==0.3.0; pytest-cov==2.10.1
|
||||||
|
ci = pytest==6.1.2; pytest-aiohttp==0.3.0; pytest-cov==2.10.1; codecov==2.1.10
|
||||||
|
|
||||||
[options.packages.find]
|
[options.packages.find]
|
||||||
exclude =
|
exclude =
|
||||||
|
@@ -1,3 +0,0 @@
|
|||||||
pytest==6.1.1
|
|
||||||
pytest-aiohttp==0.3.0
|
|
||||||
typing_extensions>=3.6.5
|
|
0
tests/test_oas/test_cmd/__init__.py
Normal file
0
tests/test_oas/test_cmd/__init__.py
Normal file
27
tests/test_oas/test_cmd/sample.py
Normal file
27
tests/test_oas/test_cmd/sample.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from aiohttp_pydantic import PydanticView
|
||||||
|
|
||||||
|
|
||||||
|
class View1(PydanticView):
|
||||||
|
async def get(self, a: int, /):
|
||||||
|
return web.json_response()
|
||||||
|
|
||||||
|
|
||||||
|
class View2(PydanticView):
|
||||||
|
async def post(self, b: int, /):
|
||||||
|
return web.json_response()
|
||||||
|
|
||||||
|
|
||||||
|
sub_app = web.Application()
|
||||||
|
sub_app.router.add_view("/route-2/{b}", View2)
|
||||||
|
|
||||||
|
app = web.Application()
|
||||||
|
app.router.add_view("/route-1/{a}", View1)
|
||||||
|
app.add_subapp("/sub-app", sub_app)
|
||||||
|
|
||||||
|
|
||||||
|
def make_app():
|
||||||
|
app = web.Application()
|
||||||
|
app.router.add_view("/route-3/{a}", View1)
|
||||||
|
return app
|
120
tests/test_oas/test_cmd/test_cmd.py
Normal file
120
tests/test_oas/test_cmd/test_cmd.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import argparse
|
||||||
|
from textwrap import dedent
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from aiohttp_pydantic.oas import cmd
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cmd_line():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
cmd.setup(parser)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_oad_of_app(cmd_line, capfd):
|
||||||
|
args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample"])
|
||||||
|
args.func(args)
|
||||||
|
captured = capfd.readouterr()
|
||||||
|
expected = dedent(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"openapi": "3.0.0",
|
||||||
|
"paths": {
|
||||||
|
"/route-1/{a}": {
|
||||||
|
"get": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "a",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sub-app/route-2/{b}": {
|
||||||
|
"post": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "b",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured.out.strip() == expected.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_oad_of_sub_app(cmd_line, capfd):
|
||||||
|
args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample:sub_app"])
|
||||||
|
args.func(args)
|
||||||
|
captured = capfd.readouterr()
|
||||||
|
expected = dedent(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"openapi": "3.0.0",
|
||||||
|
"paths": {
|
||||||
|
"/sub-app/route-2/{b}": {
|
||||||
|
"post": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "b",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured.out.strip() == expected.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_oad_of_a_callable(cmd_line, capfd):
|
||||||
|
args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample:make_app()"])
|
||||||
|
args.func(args)
|
||||||
|
captured = capfd.readouterr()
|
||||||
|
expected = dedent(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"openapi": "3.0.0",
|
||||||
|
"paths": {
|
||||||
|
"/route-3/{a}": {
|
||||||
|
"get": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "a",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured.out.strip() == expected.strip()
|
0
tests/test_oas/test_struct/__init__.py
Normal file
0
tests/test_oas/test_struct/__init__.py
Normal file
54
tests/test_oas/test_struct/test_info.py
Normal file
54
tests/test_oas/test_struct/test_info.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from aiohttp_pydantic.oas.struct import OpenApiSpec3
|
||||||
|
|
||||||
|
|
||||||
|
def test_info_title():
|
||||||
|
oas = OpenApiSpec3()
|
||||||
|
assert oas.info.title is None
|
||||||
|
oas.info.title = "Info Title"
|
||||||
|
assert oas.info.title == "Info Title"
|
||||||
|
assert oas.spec == {"info": {"title": "Info Title"}, "openapi": "3.0.0"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_info_description():
|
||||||
|
oas = OpenApiSpec3()
|
||||||
|
assert oas.info.description is None
|
||||||
|
oas.info.description = "info description"
|
||||||
|
assert oas.info.description == "info description"
|
||||||
|
assert oas.spec == {"info": {"description": "info description"}, "openapi": "3.0.0"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_info_version():
|
||||||
|
oas = OpenApiSpec3()
|
||||||
|
assert oas.info.version is None
|
||||||
|
oas.info.version = "3.14"
|
||||||
|
assert oas.info.version == "3.14"
|
||||||
|
assert oas.spec == {"info": {"version": "3.14"}, "openapi": "3.0.0"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_info_terms_of_service():
|
||||||
|
oas = OpenApiSpec3()
|
||||||
|
assert oas.info.terms_of_service is None
|
||||||
|
oas.info.terms_of_service = "http://example.com/terms/"
|
||||||
|
assert oas.info.terms_of_service == "http://example.com/terms/"
|
||||||
|
assert oas.spec == {
|
||||||
|
"info": {"termsOfService": "http://example.com/terms/"},
|
||||||
|
"openapi": "3.0.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip("Not yet implemented")
|
||||||
|
def test_info_license():
|
||||||
|
oas = OpenApiSpec3()
|
||||||
|
oas.info.license.name = "Apache 2.0"
|
||||||
|
oas.info.license.url = "https://www.apache.org/licenses/LICENSE-2.0.html"
|
||||||
|
assert oas.spec == {
|
||||||
|
"info": {
|
||||||
|
"license": {
|
||||||
|
"name": "Apache 2.0",
|
||||||
|
"url": "https://www.apache.org/licenses/LICENSE-2.0.html",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"openapi": "3.0.0",
|
||||||
|
}
|
124
tests/test_oas/test_struct/test_paths.py
Normal file
124
tests/test_oas/test_struct/test_paths.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
from aiohttp_pydantic.oas.struct import OpenApiSpec3
|
||||||
|
|
||||||
|
|
||||||
|
def test_paths_description():
|
||||||
|
oas = OpenApiSpec3()
|
||||||
|
oas.paths["/users/{id}"].description = "This route ..."
|
||||||
|
assert oas.spec == {
|
||||||
|
"openapi": "3.0.0",
|
||||||
|
"paths": {"/users/{id}": {"description": "This route ..."}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_paths_get():
|
||||||
|
oas = OpenApiSpec3()
|
||||||
|
oas.paths["/users/{id}"].get
|
||||||
|
assert oas.spec == {"openapi": "3.0.0", "paths": {"/users/{id}": {"get": {}}}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_paths_operation_description():
|
||||||
|
oas = OpenApiSpec3()
|
||||||
|
operation = oas.paths["/users/{id}"].get
|
||||||
|
operation.description = "Long descriptions ..."
|
||||||
|
assert oas.spec == {
|
||||||
|
"openapi": "3.0.0",
|
||||||
|
"paths": {"/users/{id}": {"get": {"description": "Long descriptions ..."}}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_paths_operation_summary():
|
||||||
|
oas = OpenApiSpec3()
|
||||||
|
operation = oas.paths["/users/{id}"].get
|
||||||
|
operation.summary = "Updates a pet in the store with form data"
|
||||||
|
assert oas.spec == {
|
||||||
|
"openapi": "3.0.0",
|
||||||
|
"paths": {
|
||||||
|
"/users/{id}": {
|
||||||
|
"get": {"summary": "Updates a pet in the store with form data"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_paths_operation_parameters():
|
||||||
|
oas = OpenApiSpec3()
|
||||||
|
operation = oas.paths["/users/{petId}"].get
|
||||||
|
parameter = operation.parameters[0]
|
||||||
|
parameter.name = "petId"
|
||||||
|
parameter.description = "ID of pet that needs to be updated"
|
||||||
|
parameter.in_ = "path"
|
||||||
|
parameter.required = True
|
||||||
|
|
||||||
|
assert oas.spec == {
|
||||||
|
"openapi": "3.0.0",
|
||||||
|
"paths": {
|
||||||
|
"/users/{petId}": {
|
||||||
|
"get": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "ID of pet that needs to be updated",
|
||||||
|
"in": "path",
|
||||||
|
"name": "petId",
|
||||||
|
"required": True,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_paths_operation_requestBody():
|
||||||
|
oas = OpenApiSpec3()
|
||||||
|
request_body = oas.paths["/users/{petId}"].get.request_body
|
||||||
|
request_body.description = "user to add to the system"
|
||||||
|
request_body.content = {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {"$ref": "#/components/schemas/User"},
|
||||||
|
"examples": {
|
||||||
|
"user": {
|
||||||
|
"summary": "User Example",
|
||||||
|
"externalValue": "http://foo.bar/examples/user-example.json",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request_body.required = True
|
||||||
|
assert oas.spec == {
|
||||||
|
"openapi": "3.0.0",
|
||||||
|
"paths": {
|
||||||
|
"/users/{petId}": {
|
||||||
|
"get": {
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"examples": {
|
||||||
|
"user": {
|
||||||
|
"externalValue": "http://foo.bar/examples/user-example.json",
|
||||||
|
"summary": "User Example",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schema": {"$ref": "#/components/schemas/User"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "user to add to the system",
|
||||||
|
"required": True,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_paths_operation_responses():
|
||||||
|
oas = OpenApiSpec3()
|
||||||
|
response = oas.paths["/users/{petId}"].get.responses[200]
|
||||||
|
response.description = "A complex object array response"
|
||||||
|
response.content = {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"$ref": "#/components/schemas/VeryComplexType"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
36
tests/test_oas/test_struct/test_servers.py
Normal file
36
tests/test_oas/test_struct/test_servers.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from aiohttp_pydantic.oas.struct import OpenApiSpec3
|
||||||
|
|
||||||
|
|
||||||
|
def test_sever_url():
|
||||||
|
oas = OpenApiSpec3()
|
||||||
|
oas.servers[0].url = "https://development.gigantic-server.com/v1"
|
||||||
|
oas.servers[1].url = "https://development.gigantic-server.com/v2"
|
||||||
|
assert oas.spec == {
|
||||||
|
"openapi": "3.0.0",
|
||||||
|
"servers": [
|
||||||
|
{"url": "https://development.gigantic-server.com/v1"},
|
||||||
|
{"url": "https://development.gigantic-server.com/v2"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sever_description():
|
||||||
|
oas = OpenApiSpec3()
|
||||||
|
oas.servers[0].url = "https://development.gigantic-server.com/v1"
|
||||||
|
oas.servers[0].description = "Development server"
|
||||||
|
assert oas.spec == {
|
||||||
|
"openapi": "3.0.0",
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"url": "https://development.gigantic-server.com/v1",
|
||||||
|
"description": "Development server",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip("Not yet implemented")
|
||||||
|
def test_sever_variables():
|
||||||
|
oas = OpenApiSpec3()
|
@@ -1,4 +1,5 @@
|
|||||||
from typing import List, Union
|
from typing import List, Optional, Union
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
@@ -14,10 +15,16 @@ class Pet(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class PetCollectionView(PydanticView):
|
class PetCollectionView(PydanticView):
|
||||||
async def get(self) -> r200[List[Pet]]:
|
async def get(
|
||||||
|
self, format: str, name: Optional[str] = None, *, promo: Optional[UUID] = None
|
||||||
|
) -> r200[List[Pet]]:
|
||||||
|
"""
|
||||||
|
Get a list of pets
|
||||||
|
"""
|
||||||
return web.json_response()
|
return web.json_response()
|
||||||
|
|
||||||
async def post(self, pet: Pet) -> r201[Pet]:
|
async def post(self, pet: Pet) -> r201[Pet]:
|
||||||
|
"""Create a Pet"""
|
||||||
return web.json_response()
|
return web.json_response()
|
||||||
|
|
||||||
|
|
||||||
@@ -52,31 +59,53 @@ 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",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "format",
|
||||||
|
"required": True,
|
||||||
|
"schema": {"type": "string"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "name",
|
||||||
|
"required": False,
|
||||||
|
"schema": {"type": "string"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "header",
|
||||||
|
"name": "promo",
|
||||||
|
"required": False,
|
||||||
|
"schema": {"format": "uuid", "type": "string"},
|
||||||
|
},
|
||||||
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "array",
|
|
||||||
"items": {
|
"items": {
|
||||||
"title": "Pet",
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {"title": "Id", "type": "integer"},
|
"id": {"title": "Id", "type": "integer"},
|
||||||
"name": {"title": "Name", "type": "string"},
|
"name": {"title": "Name", "type": "string"},
|
||||||
},
|
},
|
||||||
"required": ["id", "name"],
|
"required": ["id", "name"],
|
||||||
|
"title": "Pet",
|
||||||
|
"type": "object",
|
||||||
},
|
},
|
||||||
|
"type": "array",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def test_pets_route_should_have_post_method(generated_oas):
|
async def test_pets_route_should_have_post_method(generated_oas):
|
||||||
assert generated_oas["paths"]["/pets"]["post"] == {
|
assert generated_oas["paths"]["/pets"]["post"] == {
|
||||||
|
"description": "Create a Pet",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
|
@@ -1,11 +1,13 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from aiohttp_pydantic import PydanticView
|
from aiohttp_pydantic import PydanticView
|
||||||
|
|
||||||
|
|
||||||
class ArticleView(PydanticView):
|
class ArticleView(PydanticView):
|
||||||
async def get(self, with_comments: bool):
|
async def get(self, with_comments: bool, age: Optional[int] = None):
|
||||||
return web.json_response({"with_comments": with_comments})
|
return web.json_response({"with_comments": with_comments, "age": age})
|
||||||
|
|
||||||
|
|
||||||
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(
|
||||||
@@ -53,7 +55,22 @@ async def test_get_article_with_valid_qs_should_return_the_parsed_type(
|
|||||||
app.router.add_view("/article", ArticleView)
|
app.router.add_view("/article", ArticleView)
|
||||||
|
|
||||||
client = await aiohttp_client(app)
|
client = await aiohttp_client(app)
|
||||||
|
|
||||||
|
resp = await client.get("/article", params={"with_comments": "yes", "age": 3})
|
||||||
|
assert resp.status == 200
|
||||||
|
assert resp.content_type == "application/json"
|
||||||
|
assert await resp.json() == {"with_comments": True, "age": 3}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_article_with_valid_qs_and_omitted_optional_should_return_none(
|
||||||
|
aiohttp_client, loop
|
||||||
|
):
|
||||||
|
app = web.Application()
|
||||||
|
app.router.add_view("/article", ArticleView)
|
||||||
|
|
||||||
|
client = await aiohttp_client(app)
|
||||||
|
|
||||||
resp = await client.get("/article", params={"with_comments": "yes"})
|
resp = await client.get("/article", params={"with_comments": "yes"})
|
||||||
assert resp.status == 200
|
assert resp.status == 200
|
||||||
assert resp.content_type == "application/json"
|
assert resp.content_type == "application/json"
|
||||||
assert await resp.json() == {"with_comments": True}
|
assert await resp.json() == {"with_comments": True, "age": None}
|
||||||
|
Reference in New Issue
Block a user