Compare commits
6 Commits
v1.4.0
...
add_code_c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9519bb868 | ||
|
|
913f50298c | ||
|
|
03854cf939 | ||
|
|
2db23d3328 | ||
|
|
d866ce5358 | ||
|
|
13c19105d8 |
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__
|
||||||
|
|||||||
23
README.rst
23
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/add_code_coverage/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
|
||||||
--------------
|
--------------
|
||||||
|
|
||||||
@@ -254,3 +276,4 @@ You can generate the OAS in a json file using the command:
|
|||||||
|
|
||||||
|
|
||||||
.. _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.4.0"
|
__version__ = "1.4.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):
|
||||||
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)
|
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
|
||||||
|
|||||||
@@ -1,29 +1,20 @@
|
|||||||
|
from inspect import getdoc
|
||||||
import typing
|
import typing
|
||||||
from typing import List, Type
|
from typing import List, Type
|
||||||
|
|
||||||
from aiohttp.web import Response, json_response
|
from aiohttp.web import Response, json_response
|
||||||
from aiohttp.web_app import Application
|
from aiohttp.web_app import Application
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
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: "number", str: "string", int: "integer"}
|
||||||
|
|
||||||
|
|
||||||
def _is_pydantic_base_model(obj):
|
|
||||||
"""
|
|
||||||
Return true is obj is a pydantic.BaseModel subclass.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return issubclass(obj, BaseModel)
|
|
||||||
except TypeError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class _OASResponseBuilder:
|
class _OASResponseBuilder:
|
||||||
"""
|
"""
|
||||||
Parse the type annotated as returned by a function and
|
Parse the type annotated as returned by a function and
|
||||||
@@ -35,7 +26,7 @@ class _OASResponseBuilder:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _handle_pydantic_base_model(obj):
|
def _handle_pydantic_base_model(obj):
|
||||||
if _is_pydantic_base_model(obj):
|
if is_pydantic_base_model(obj):
|
||||||
return obj.schema()
|
return obj.schema()
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
@@ -77,6 +68,9 @@ def _add_http_method_to_oas(
|
|||||||
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 = {
|
||||||
|
|||||||
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
|
||||||
@@ -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
|
|
||||||
@@ -15,9 +15,13 @@ class Pet(BaseModel):
|
|||||||
|
|
||||||
class PetCollectionView(PydanticView):
|
class PetCollectionView(PydanticView):
|
||||||
async def get(self) -> r200[List[Pet]]:
|
async def get(self) -> 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,6 +56,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",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"content": {
|
"content": {
|
||||||
@@ -71,12 +76,13 @@ async def test_pets_route_should_have_get_method(generated_oas):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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