Compare commits

...

20 Commits

Author SHA1 Message Date
jar3b
6ba671532b feat: add raise_validation_errors parameter to setup()
To control how pydantic.ValidationError will be handled, own handler (return json) or raise exception to allow intercept in aiohttp middleware
2020-11-27 02:02:54 +03:00
jar3b
efbaaa5e6f fix: to push 2020-11-25 16:59:10 +03:00
jar3b
6211c71875 fix: detect x-forwarded-proto if deployed behind proxy
For static files handling, was set up in "oas_ui()"
2020-11-25 16:25:04 +03:00
jar3b
5567d73952 fix: return copy of schema without 'definitions' key
Instead of delete key 'definitions' from schema, bc schema was "cached" and if you try to load swagger twice, you got "no definitions" exception
2020-11-25 01:55:01 +03:00
jar3b
67a95ec9c9 fix: move response definitions to top level of oas 2020-11-25 01:23:10 +03:00
MAILLOL Vincent
93ec0f6c80 Update README.rst 2020-11-21 22:50:28 +01:00
Vincent Maillol
a6d96d711b Update version 1.5.1 2020-11-21 18:07:04 +01:00
MAILLOL Vincent
8aee135f95 Merge pull request #5 from Maillol/fix_bug_oas_generation
Fix bug query string appear as required in generated Open API specifi…
2020-11-21 17:57:36 +01:00
Vincent Maillol
462d8d8b98 Fix bug query string appear as required in generated Open API specification. 2020-11-21 17:23:01 +01:00
Vincent Maillol
0d3a33c964 Update version 1.5.0 2020-11-15 14:41:11 +01:00
MAILLOL Vincent
22979b7e59 Merge pull request #4 from Maillol/add_code_coverage
Add code coverage
2020-11-15 09:27:56 +01:00
Vincent Maillol
b9519bb868 Add code coverage 2020-11-15 09:21:39 +01:00
Vincent Maillol
913f50298c Use docstring of handler in the OAS description 2020-11-14 20:33:55 +01:00
Vincent Maillol
03854cf939 Update version 1.4.1 2020-11-03 13:14:16 +01:00
Vincent Maillol
2db23d3328 PydanticView returns 400 if the request payload is not JSON 2020-11-03 13:10:44 +01:00
Vincent Maillol
d866ce5358 fix bug we cannot use optional params 2020-11-03 12:54:28 +01:00
Vincent Maillol
13c19105d8 Update README.rst 2020-11-02 23:27:45 +01:00
Vincent Maillol
e4b23398b8 Update version 1.4.0 2020-11-01 19:45:05 +01:00
MAILLOL Vincent
57b50725ea Merge pull request #3 from Maillol/add-cmd-to-generate-oas
Add a command line tool to generate OAS in a file
2020-11-01 19:35:24 +01:00
Vincent Maillol
cda4fba4c2 Add a command line tool to generate OAS in a file 2020-11-01 14:35:41 +01:00
30 changed files with 724 additions and 102 deletions

3
.gitignore vendored
View File

@@ -1,6 +1,9 @@
.coverage
.idea/
.pytest_cache
__pycache__
aiohttp_pydantic.egg-info/
build/
coverage.xml
dist/

View File

@@ -2,11 +2,14 @@ language: python
python:
- '3.8'
script:
- pytest tests/
- pytest --cov-report=xml --cov=aiohttp_pydantic tests/
install:
- 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 .
after_success:
- codecov
deploy:
provider: pypi
username: __token__
@@ -16,4 +19,4 @@ deploy:
on:
tags: true
branch: main
python: '3.8'
python: '3.8'

View File

@@ -1,6 +1,28 @@
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
--------------
@@ -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/
.. _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
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')
If you want generate the Open Api Specification from several aiohttp sub-application.
on the same route, you must use *apps_to_expose* parameters
If you want generate the Open Api Specification from specific aiohttp sub-applications.
on the same route, you must use *apps_to_expose* parameter.
.. code-block:: python3
@@ -179,9 +201,9 @@ on the same route, you must use *apps_to_expose* parameters
app = 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
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -232,7 +254,6 @@ defined using a pydantic.BaseModel
self.request.app["model"].remove_pet(id)
return web.Response(status=204)
Demo
----
@@ -247,5 +268,12 @@ Have a look at `demo`_ for a complete example
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
.. _aiohttp view: https://docs.aiohttp.org/en/stable/web_quickstart.html#class-based-views

View File

@@ -1,5 +1,5 @@
from .view import PydanticView
__version__ = "1.3.0"
__version__ = "1.5.1"
__all__ = ("PydanticView", "__version__")

View File

@@ -1,10 +1,14 @@
import abc
from inspect import signature
from json.decoder import JSONDecodeError
from typing import Callable, Tuple
from aiohttp.web_exceptions import HTTPBadRequest
from aiohttp.web_request import BaseRequest
from pydantic import BaseModel
from .utils import is_pydantic_base_model
class AbstractInjector(metaclass=abc.ABCMeta):
"""
@@ -45,7 +49,13 @@ class BodyGetter(AbstractInjector):
self.arg_name, self.model = next(iter(args_spec.items()))
async def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict):
body = await request.json()
try:
body = await request.json()
except JSONDecodeError:
raise HTTPBadRequest(
text='{"error": "Malformed JSON"}', content_type="application/json"
)
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:
path_args[param_name] = param_spec.annotation
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
else:
qs_args[param_name] = param_spec.annotation

View File

@@ -13,10 +13,13 @@ def setup(
apps_to_expose: Iterable[web.Application] = (),
url_prefix: str = "/oas",
enable: bool = True,
raise_validation_errors: bool = False,
):
if enable:
oas_app = web.Application()
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(
resources.read_text("aiohttp_pydantic.oas", "index.j2")
)

View 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)

View 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))

View File

@@ -1,3 +1,7 @@
"""
Utility to write Open Api Specifications using the Python language.
"""
from typing import Union
@@ -7,7 +11,7 @@ class Info:
@property
def title(self):
return self._spec["title"]
return self._spec.get("title")
@title.setter
def title(self, title):
@@ -15,7 +19,7 @@ class Info:
@property
def description(self):
return self._spec["description"]
return self._spec.get("description")
@description.setter
def description(self, description):
@@ -23,12 +27,20 @@ class Info:
@property
def version(self):
return self._spec["version"]
return self._spec.get("version")
@version.setter
def version(self, 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:
def __init__(self, spec: dict):
@@ -43,8 +55,8 @@ class RequestBody:
self._spec["description"] = description
@property
def required(self):
return self._spec["required"]
def required(self) -> bool:
return self._spec.get("required", False)
@required.setter
def required(self, required: bool):
@@ -220,6 +232,22 @@ class PathItem:
def trace(self) -> OperationObject:
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:
def __init__(self, spec: dict):
@@ -244,7 +272,7 @@ class Server:
@property
def description(self) -> str:
return self._spec["url"]
return self._spec["description"]
@description.setter
def description(self, description: str):
@@ -284,3 +312,8 @@ class OpenApiSpec3:
@property
def spec(self):
return self._spec
@property
def definitions(self):
self._spec.setdefault('definitions', {})
return self._spec['definitions']

View File

@@ -13,7 +13,7 @@ Example:
from functools import lru_cache
from types import new_class
from typing import Protocol, TypeVar
from typing import Protocol, TypeVar, Optional, Type
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):
if status_code in _status_code:
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.
"""

View File

@@ -1,26 +1,44 @@
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 pydantic import BaseModel
from aiohttp.web_app import Application
from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem
from ..injectors import _parse_func_signature
from ..utils import is_pydantic_base_model
from ..view import PydanticView, is_pydantic_view
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:
return issubclass(obj, BaseModel)
except TypeError:
return False
if typing.get_origin(type_) is typing.Union:
args = typing.get_args(type_)
if len(args) == 2 and type(None) in args:
return next(iter(set(args) - {type(None)}))
return None
class _OASResponseBuilder:
@@ -29,13 +47,21 @@ class _OASResponseBuilder:
generate the OAS operation response.
"""
def __init__(self, oas_operation):
def __init__(self, oas_operation, definitions):
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 {}
def _handle_list(self, obj):
@@ -70,48 +96,50 @@ class _OASResponseBuilder:
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()
oas_operation: OperationObject = getattr(oas_path, http_method)
handler = getattr(view, http_method)
path_args, body_args, qs_args, header_args = _parse_func_signature(handler)
description = getdoc(handler)
if description:
oas_operation.description = description
if body_args:
oas_operation.request_body.content = {
"application/json": {"schema": next(iter(body_args.values())).schema()}
}
i = 0
for i, (name, type_) in enumerate(path_args.items()):
oas_operation.parameters[i].required = True
oas_operation.parameters[i].in_ = "path"
oas_operation.parameters[i].name = name
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].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_]}
indexes = count()
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
else:
oas_operation.parameters[i].schema = JSON_SCHEMA_TYPES[optional_type]
oas_operation.parameters[i].required = False
return_type = handler.__annotations__.get("return")
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()
for app in apps:
for resources in app.router.resources():
for resource_route in resources:
@@ -121,11 +149,19 @@ async def get_oas(request):
path = oas.paths[info.get("path", info.get("formatter"))]
if resource_route.method == "*":
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:
_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):
@@ -136,6 +172,9 @@ async def oas_ui(request):
static_url = request.app.router["static"].url_for(filename="")
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()
return Response(

11
aiohttp_pydantic/utils.py Normal file
View 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

View File

@@ -9,8 +9,14 @@ from aiohttp.web_exceptions import HTTPMethodNotAllowed
from aiohttp.web_response import StreamResponse
from pydantic import ValidationError
from .injectors import (AbstractInjector, BodyGetter, HeadersGetter,
MatchInfoGetter, QueryGetter, _parse_func_signature)
from .injectors import (
AbstractInjector,
BodyGetter,
HeadersGetter,
MatchInfoGetter,
QueryGetter,
_parse_func_signature,
)
class PydanticView(AbstractView):
@@ -77,7 +83,10 @@ def inject_params(
else:
injector.inject(self.request, args, kwargs)
except ValidationError as error:
return json_response(text=error.json(), status=400)
if self.request.app['raise_validation_errors']:
raise
else:
return json_response(text=error.json(), status=400)
return await handler(self, *args, **kwargs)

View File

@@ -1,25 +1,5 @@
from aiohttp import web
from aiohttp.web import 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 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)
from .main import app
web.run_app(app)

22
demo/main.py Normal file
View 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)

View File

@@ -4,6 +4,7 @@ from pydantic import BaseModel
class Pet(BaseModel):
id: int
name: str
age: int
class Error(BaseModel):

View File

@@ -1,4 +1,4 @@
from typing import List, Union
from typing import List, Optional, Union
from aiohttp import web
@@ -9,9 +9,11 @@ from .model import Error, Pet
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()
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]:
self.request.app["model"].add_pet(pet)

7
requirements/ci.txt Normal file
View 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
View 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

View File

@@ -35,8 +35,8 @@ install_requires =
swagger-ui-bundle
[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]
exclude =

View File

@@ -1,3 +0,0 @@
pytest==6.1.1
pytest-aiohttp==0.3.0
typing_extensions>=3.6.5

View File

View 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

View 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()

View File

View 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",
}

View 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"},
}
}
}

View 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()

View File

@@ -1,4 +1,5 @@
from typing import List, Union
from typing import List, Optional, Union
from uuid import UUID
import pytest
from aiohttp import web
@@ -14,10 +15,16 @@ class Pet(BaseModel):
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()
async def post(self, pet: Pet) -> r201[Pet]:
"""Create a Pet"""
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):
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": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"title": "Pet",
"type": "object",
"properties": {
"id": {"title": "Id", "type": "integer"},
"name": {"title": "Name", "type": "string"},
},
"required": ["id", "name"],
"title": "Pet",
"type": "object",
},
"type": "array",
}
}
}
}
}
},
}
async def test_pets_route_should_have_post_method(generated_oas):
assert generated_oas["paths"]["/pets"]["post"] == {
"description": "Create a Pet",
"requestBody": {
"content": {
"application/json": {

View File

@@ -1,11 +1,13 @@
from typing import Optional
from aiohttp import web
from aiohttp_pydantic import PydanticView
class ArticleView(PydanticView):
async def get(self, with_comments: bool):
return web.json_response({"with_comments": with_comments})
async def get(self, with_comments: bool, age: Optional[int] = None):
return web.json_response({"with_comments": with_comments, "age": age})
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)
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"})
assert resp.status == 200
assert resp.content_type == "application/json"
assert await resp.json() == {"with_comments": True}
assert await resp.json() == {"with_comments": True, "age": None}