Compare commits

...

58 Commits
v1.0.0 ... main

Author SHA1 Message Date
Vincent Maillol
69fb553635 Fix - Does not work with from __future__ import annotations 2022-02-05 10:29:06 +01:00
Vincent Maillol
3648dde1ea Fix doc example 2021-10-01 08:51:50 +02:00
MAILLOL Vincent
f5f3a48ba4
Merge pull request #26 from Maillol/groups
Add group parameter feature
2021-10-01 08:28:27 +02:00
Vincent Maillol
799080bbd0 Add group parameter feature 2021-10-01 08:03:54 +02:00
Vincent Maillol
4a49d3b53d update version 2021-08-22 15:06:11 +02:00
MAILLOL Vincent
1b10ebbcfa
Merge pull request #25 from Maillol/user_can_add_tags_to_generated_oas
We can add custom tags to generated OPS
2021-08-22 15:03:04 +02:00
Vincent Maillol
fa7e8d914b We can add custom tags to generated OPS 2021-08-22 15:00:50 +02:00
MAILLOL Vincent
cb996860a9
Merge pull request #23 from Maillol/update-ci-add-tasks-file
Add pyinvoke task
2021-08-14 10:41:47 +02:00
Vincent Maillol
dbf1eb6ac4 Add pyinvoke task
Ensure git tag matches package versions before uploading
2021-08-14 10:21:00 +02:00
Vincent Maillol
adcf4ba902 version 1.10.1 2021-08-04 09:04:05 +02:00
MAILLOL Vincent
a624aba613
Merge pull request #22 from ffkirill/support_OptionalUnionT
Fix bug optional parameter is reported as required.
2021-08-04 08:35:56 +02:00
Vincent Maillol
c1a63e55b2 Add unit tests 2021-08-04 08:09:43 +02:00
Kirill A. Golubev
9a624437f4
add support of Optional[Union[T]] 2021-08-02 14:46:05 +03:00
Vincent Maillol
43d2789636 version 1.10.0 2021-07-26 09:04:35 +02:00
MAILLOL Vincent
258a5cddf6
Merge pull request #21 from Maillol/add-a-hook-on-the-errors
Add a hook to intercept ValidationError
2021-07-26 08:41:50 +02:00
Vincent Maillol
7ab2d84263 Add a hook to intercept ValidationError 2021-07-26 08:37:12 +02:00
MAILLOL Vincent
81138cc1c6
Merge pull request #20 from Maillol/update-ci
Enable deploy on pypi
2021-07-26 07:03:14 +02:00
Vincent Maillol
c9c8c6e205 Enable deploy on pypi 2021-07-26 06:58:02 +02:00
MAILLOL Vincent
17220f2840
Merge pull request #17 from Maillol/move-ci-to-drone
move ci
2021-07-13 15:56:05 +02:00
Vincent Maillol
ff32f68e89 move ci 2021-07-13 15:33:39 +02:00
MAILLOL Vincent
911bcbc2cd
Merge pull request #16 from Maillol/view-compatibility
Support subclassing PydanticViews
2021-07-11 07:37:25 +02:00
Vincent Maillol
89a22f2fcd code reformatting 2021-07-11 07:31:37 +02:00
Vincent Maillol
08ab4d2610 refactoring 2021-07-11 07:21:45 +02:00
Daan de Ruiter
c92437c624
Further specify allowed_methods type hint 2021-05-13 11:06:54 +02:00
Daan de Ruiter
5f86e1efda
Improve compatibility with web.View and support subclassing Views 2021-05-13 10:59:01 +02:00
Vincent Maillol
324c9b02f3 Update version 1.9.0 2021-04-04 14:02:37 +02:00
spinenkoia
beb638c0af
Added a wrapper for get_oas to throw spec info (#12) (#13)
* Added a wrapper for get_oas to throw spec info (#12)

* Added tests generate_oas

* Moved params to Application

Co-authored-by: Спиненко Иван ispinenko@ussc.ru <ispinenko@ussc.ru>
2021-04-04 13:22:05 +02:00
Vincent Maillol
7492af5acf Update version 1.8.1 2021-03-27 12:45:19 +01:00
MAILLOL Vincent
145d2fc0f2
query string accept multiple values for same parameter key (#11) 2021-03-27 11:56:19 +01:00
Daan de Ruiter
81d4e93a1d
Prevent internal server error when receiving a JSON request body with non-object top-level structure (#9)
Prevent internal server error when receiving a JSON request body with non-object top-level structure
2021-03-05 21:46:13 +01:00
Vincent Maillol
c6b979dcaf version=1.7.2 fix oas.spec schema is broken after reloading the page 2021-02-27 08:08:12 +01:00
Vincent Maillol
4ff9739293 version=1.7.1 fix README render, force twine check because travis does not mount error. 2020-12-20 17:06:30 +01:00
Vincent Maillol
071395e8bd Update version 1.7.0 2020-12-20 13:20:48 +01:00
MAILLOL Vincent
cd8422bde3
Merge pull request #6 from Maillol/increase_oas
Increase OAS description
2020-12-20 13:15:32 +01:00
Vincent Maillol
070d7e7259 Increase OAS description
Parce docstring of http handlers to increase OAS
Remove the not expected definitions key in the OAS
2020-12-20 13:08:51 +01:00
Vincent Maillol
25fcac18ec Fix wrong link in OAS components with nested pydantic.BaseModel 2020-11-28 19:46:36 +01:00
Vincent Maillol
f2b16a46b5 Update version 1.6.0 2020-11-28 12:42:06 +01:00
Vincent Maillol
c4c18ee4a1 increase pydantic integration with headers, query string and url path 2020-11-28 12:39:50 +01: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
Vincent Maillol
236374240e Update version to 1.3.0 2020-10-30 19:14:21 +01:00
MAILLOL Vincent
635f38e33a
Merge pull request #2 from Maillol/add-type-to-define-oas-responses
Add type to define OAS responses
2020-10-30 16:59:54 +01:00
Vincent Maillol
62d871fb5c Add type to define OAS responses 2020-10-30 16:05:04 +01:00
MAILLOL Vincent
77954cdd69
Merge pull request #1 from Maillol/add-oas-view
Add sub-app to generate open api spec
2020-10-25 20:42:55 +01:00
Vincent Maillol
d6b5fc26f3 Add sub-app to generate open api spec 2020-10-25 20:28:11 +01:00
48 changed files with 3999 additions and 199 deletions

73
.drone.jsonnet Normal file
View File

@ -0,0 +1,73 @@
/*
Code to generate the .drone.yaml. Use the command:
drone jsonnet --stream --format yaml
*/
local PYTHON_VERSIONS = ["3.8", "3.9"];
local BuildAndTestPipeline(name, image) = {
kind: "pipeline",
type: "docker",
name: name,
steps: [
{
name: "Install package and test",
image: image,
commands: [
"test \"$(md5sum tasks.py)\" = \"18f864b3ac76119938e3317e49b4ffa1 tasks.py\"",
"pip install -U setuptools wheel pip; pip install invoke",
"invoke prepare-upload"
]
},
{
name: "coverage",
image: "plugins/codecov",
settings: {
token: "9ea10e04-a71a-4eea-9dcc-8eaabe1479e2",
files: ["coverage.xml"]
}
}
],
trigger: {
event: ["pull_request", "push", "tag"]
}
};
[
BuildAndTestPipeline("python-" + std.strReplace(pythonVersion, '.', '-'),
"python:" + pythonVersion)
for pythonVersion in PYTHON_VERSIONS
] + [
{
kind: "pipeline",
type: "docker",
name: "Deploy on Pypi",
steps: [
{
name: "Install twine and deploy",
image: "python:3.8",
environment: {
pypi_username: {
from_secret: 'pypi_username'
},
pypi_password: {
from_secret: 'pypi_password'
}
},
commands: [
"test \"$(md5sum tasks.py)\" = \"18f864b3ac76119938e3317e49b4ffa1 tasks.py\"",
"pip install -U setuptools wheel pip; pip install invoke",
"invoke upload --pypi-user \"$pypi_username\" --pypi-password \"$pypi_password\""
]
},
],
trigger: {
event: ["tag"]
},
depends_on: ["python-" + std.strReplace(pythonVersion, '.', '-') for pythonVersion in PYTHON_VERSIONS]
}
]

95
.drone.yml Normal file
View File

@ -0,0 +1,95 @@
---
kind: pipeline
type: docker
name: python-3-8
platform:
os: linux
arch: amd64
steps:
- name: Install package and test
image: python:3.8
commands:
- test "$(md5sum tasks.py)" = "18f864b3ac76119938e3317e49b4ffa1 tasks.py"
- pip install -U setuptools wheel pip; pip install invoke
- invoke prepare-upload
- name: coverage
image: plugins/codecov
settings:
files:
- coverage.xml
token: 9ea10e04-a71a-4eea-9dcc-8eaabe1479e2
trigger:
event:
- pull_request
- push
- tag
---
kind: pipeline
type: docker
name: python-3-9
platform:
os: linux
arch: amd64
steps:
- name: Install package and test
image: python:3.9
commands:
- test "$(md5sum tasks.py)" = "18f864b3ac76119938e3317e49b4ffa1 tasks.py"
- pip install -U setuptools wheel pip; pip install invoke
- invoke prepare-upload
- name: coverage
image: plugins/codecov
settings:
files:
- coverage.xml
token: 9ea10e04-a71a-4eea-9dcc-8eaabe1479e2
trigger:
event:
- pull_request
- push
- tag
---
kind: pipeline
type: docker
name: Deploy on Pypi
platform:
os: linux
arch: amd64
steps:
- name: Install twine and deploy
image: python:3.8
commands:
- test "$(md5sum tasks.py)" = "18f864b3ac76119938e3317e49b4ffa1 tasks.py"
- pip install -U setuptools wheel pip; pip install invoke
- invoke upload --pypi-user "$pypi_username" --pypi-password "$pypi_password"
environment:
pypi_password:
from_secret: pypi_password
pypi_username:
from_secret: pypi_username
trigger:
event:
- tag
depends_on:
- python-3-8
- python-3-9
---
kind: signature
hmac: 9a24ccae6182723af71257495d7843fd40874006c5e867cdebf363f497ddb839
...

5
.gitignore vendored
View File

@ -1,6 +1,11 @@
.coverage
.idea/
.pypirc
.pytest_cache
__pycache__
aiohttp_pydantic.egg-info/
build/
coverage.xml
dist/
dist_venv/
venv/

View File

@ -1,17 +0,0 @@
language: python
python:
- '3.8'
script:
- pytest tests/
install:
- pip install -r test_requirements.txt
deploy:
provider: pypi
username: __token__
password:
secure: ki81Limjj8UgsX1GNpOF2+vYjc6GEPY1V9BbJkQl+5WVTynqKTDEi+jekx8Id0jYEGGQ8/PfTiXe7dY/MqfQ0oWQ5+UNmGZIQJwYCft4FJWrI5QoL1LE0tqKpXCzBX7rGr1BOdvToS9zwf3RDr1u7ib16V/xakX55raVpQ37ttE0cKEPzvq6MqZTfYvq0VnhPmTDbTDBd9krHHAAG5lVhm9oAbp9TkhKsWDuA+wGzgKt2tuPX6+Le4op/wiiBhAnhvcVzjDWaX8dxd3Ac0XlnPtl8EMe5lJJez/ahGedydwGDJC75TOl1b7WP9AqogvNISVN+2VYUVxkgoK9yC9zEjhCSWKHSz+t8ZddB+itYHvj9lMf04iObq8OSUcD71R4rASWMZ89YdksWb6qvD+md1oEl/M6JSyZAkv+aedFL5iyKS4oJpZT3fYYloUqhF3/aDVgC3mlnXVsxC2cCIdpvu2EVjpFqFJ+9qGpp3ZlhRfDkjbQA0IA6KXKaWkIadQouJ4Wr1WtXjN4w0QlAvGV/q3m4bQ3ZZGxYipS9MQwDnUoRYtrX6j7bsaXjBdfhPNlwzgHQDPbD//oX9ZI1Oe6+kT/WKQvBrtvftv+TUhQ49uePHn5o/eYAKh35IwYTBxLgk2t483k0ZI5cjVXd2zGRgAxPdB/XyGW84dJGPJNn8o=
distributions: "bdist_wheel"
on:
tags: true
branch: main
python: '3.8'

View File

@ -1,6 +1,29 @@
Aiohttp pydantic - Aiohttp View to validate and parse request
=============================================================
.. image:: https://cloud.drone.io/api/badges/Maillol/aiohttp-pydantic/status.svg
:target: https://cloud.drone.io/Maillol/aiohttp-pydantic
:alt: Build status for master branch
.. 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
--------------
@ -32,7 +55,7 @@ Example:
return web.json_response({'name': article.name,
'number_of_page': article.nb_page})
async def get(self, with_comments: Optional[bool]):
async def get(self, with_comments: bool=False):
return web.json_response({'with_comments': with_comments})
@ -46,6 +69,7 @@ Example:
$ curl -X GET http://127.0.0.1:8080/article?with_comments=a
[
{
"in": "query string",
"loc": [
"with_comments"
],
@ -60,6 +84,7 @@ Example:
$ curl -H "Content-Type: application/json" -X post http://127.0.0.1:8080/article --data '{}'
[
{
"in": "body",
"loc": [
"name"
],
@ -77,7 +102,7 @@ API:
Inject Path Parameters
~~~~~~~~~~~~~~~~~~~~~~
To declare a path parameters, you must declare your argument as a `positional-only parameters`_:
To declare a path parameter, you must declare your argument as a `positional-only parameters`_:
Example:
@ -94,22 +119,40 @@ Example:
Inject Query String Parameters
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
To declare a query parameters, you must declare your argument as simple argument:
To declare a query parameter, you must declare your argument as a simple argument:
.. code-block:: python3
class AccountView(PydanticView):
async def get(self, customer_id: str):
async def get(self, customer_id: Optional[str] = None):
...
app = web.Application()
app.router.add_get('/customers', AccountView)
A query string parameter is generally optional and we do not want to force the user to set it in the URL.
It's recommended to define a default value. It's possible to get a multiple value for the same parameter using
the List type
.. code-block:: python3
from typing import List
from pydantic import Field
class AccountView(PydanticView):
async def get(self, tags: List[str] = Field(default_factory=list)):
...
app = web.Application()
app.router.add_get('/customers', AccountView)
Inject Request Body
~~~~~~~~~~~~~~~~~~~
To declare a body parameters, you must declare your argument as a simple argument annotated with `pydantic Model`_.
To declare a body parameter, you must declare your argument as a simple argument annotated with `pydantic Model`_.
.. code-block:: python3
@ -128,7 +171,7 @@ To declare a body parameters, you must declare your argument as a simple argumen
Inject HTTP headers
~~~~~~~~~~~~~~~~~~~
To declare a HTTP headers parameters, you must declare your argument as a `keyword-only argument`_.
To declare a HTTP headers parameter, you must declare your argument as a `keyword-only argument`_.
.. code-block:: python3
@ -144,3 +187,262 @@ To declare a HTTP headers parameters, you must declare your argument as a `keywo
.. _positional-only parameters: https://www.python.org/dev/peps/pep-0570/
.. _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 (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
.. code-block:: python3
from aiohttp import web
from aiohttp_pydantic import oas
app = web.Application()
oas.setup(app)
By default, the route to display the Open Api Specification is /oas but you can change it using
*url_prefix* parameter
.. code-block:: python3
oas.setup(app, url_prefix='/spec-api')
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
from aiohttp import web
from aiohttp_pydantic import oas
app = web.Application()
sub_app_1 = web.Application()
sub_app_2 = web.Application()
oas.setup(app, apps_to_expose=[sub_app_1, sub_app_2])
You can change the title or the version of the generated open api specification using
*title_spec* and *version_spec* parameters:
.. code-block:: python3
oas.setup(app, title_spec="My application", version_spec="1.2.3")
Add annotation to define response content
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The module aiohttp_pydantic.oas.typing provides class to annotate a
response content.
For example *r200[List[Pet]]* means the server responses with
the status code 200 and the response content is a List of Pet where Pet will be
defined using a pydantic.BaseModel
The docstring of methods will be parsed to fill the descriptions in the
Open Api Specification.
.. code-block:: python3
from aiohttp_pydantic import PydanticView
from aiohttp_pydantic.oas.typing import r200, r201, r204, r404
class Pet(BaseModel):
id: int
name: str
class Error(BaseModel):
error: str
class PetCollectionView(PydanticView):
async def get(self) -> r200[List[Pet]]:
"""
Find all pets
Tags: pet
"""
pets = self.request.app["model"].list_pets()
return web.json_response([pet.dict() for pet in pets])
async def post(self, pet: Pet) -> r201[Pet]:
"""
Add a new pet to the store
Tags: pet
Status Codes:
201: The pet is created
"""
self.request.app["model"].add_pet(pet)
return web.json_response(pet.dict())
class PetItemView(PydanticView):
async def get(self, id: int, /) -> Union[r200[Pet], r404[Error]]:
"""
Find a pet by ID
Tags: pet
Status Codes:
200: Successful operation
404: Pet not found
"""
pet = self.request.app["model"].find_pet(id)
return web.json_response(pet.dict())
async def put(self, id: int, /, pet: Pet) -> r200[Pet]:
"""
Update an existing pet
Tags: pet
Status Codes:
200: successful operation
"""
self.request.app["model"].update_pet(id, pet)
return web.json_response(pet.dict())
async def delete(self, id: int, /) -> r204:
self.request.app["model"].remove_pet(id)
return web.Response(status=204)
Group parameters
----------------
If your method has lot of parameters you can group them together inside one or several Groups.
.. code-block:: python3
from aiohttp_pydantic.injectors import Group
class Pagination(Group):
page_num: int = 1
page_size: int = 15
class ArticleView(PydanticView):
async def get(self, page: Pagination):
articles = Article.get(page.page_num, page.page_size)
...
The parameters page_num and page_size are expected in the query string, and
set inside a Pagination object passed as page parameter.
The code above is equivalent to:
.. code-block:: python3
class ArticleView(PydanticView):
async def get(self, page_num: int = 1, page_size: int = 15):
articles = Article.get(page_num, page_size)
...
You can add methods or properties to your Group.
.. code-block:: python3
class Pagination(Group):
page_num: int = 1
page_size: int = 15
@property
def num(self):
return self.page_num
@property
def size(self):
return self.page_size
def slice(self):
return slice(self.num, self.size)
class ArticleView(PydanticView):
async def get(self, page: Pagination):
articles = Article.get(page.num, page.size)
...
Custom Validation error
-----------------------
You can redefine the on_validation_error hook in your PydanticView
.. code-block:: python3
class PetView(PydanticView):
async def on_validation_error(self,
exception: ValidationError,
context: str):
errors = exception.errors()
for error in errors:
error["in"] = context # context is "body", "headers", "path" or "query string"
error["custom"] = "your custom field ..."
return json_response(data=errors, status=400)
Demo
----
Have a look at `demo`_ for a complete example
.. code-block:: bash
git clone https://github.com/Maillol/aiohttp-pydantic.git
cd aiohttp-pydantic
pip install .
python -m demo
Go to http://127.0.0.1:8080/oas
You can generate the OAS in a json or yaml file using the aiohttp_pydantic.oas command:
.. code-block:: bash
python -m aiohttp_pydantic.oas demo.main
.. code-block:: bash
$ python3 -m aiohttp_pydantic.oas --help
usage: __main__.py [-h] [-b FILE] [-o FILE] [-f FORMAT] [APP [APP ...]]
Generate Open API Specification
positional arguments:
APP 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 If your
asyncio.web.Application is returned by a function, you can use the syntax:
my_package.my_module:my_app()
optional arguments:
-h, --help show this help message and exit
-b FILE, --base-oas-file FILE
A file that will be used as base to generate OAS
-o FILE, --output FILE
File to write the output
-f FORMAT, --format FORMAT
The output format, can be 'json' or 'yaml' (default is json)
.. _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,3 +1,5 @@
from .view import PydanticView
__all__ = ("PydanticView",)
__version__ = "1.12.1"
__all__ = ("PydanticView", "__version__")

View File

@ -1,11 +1,18 @@
from typing import Callable, Tuple
from aiohttp.web_request import BaseRequest
from pydantic import BaseModel
from inspect import signature
import abc
import typing
from inspect import signature, getmro
from json.decoder import JSONDecodeError
from types import SimpleNamespace
from typing import Callable, Tuple, Literal, Type, get_type_hints
from aiohttp.web_exceptions import HTTPBadRequest
from aiohttp.web_request import BaseRequest
from multidict import MultiDict
from pydantic import BaseModel
from .utils import is_pydantic_base_model, robuste_issubclass
CONTEXT = Literal["body", "headers", "path", "query string"]
class AbstractInjector(metaclass=abc.ABCMeta):
@ -13,8 +20,18 @@ class AbstractInjector(metaclass=abc.ABCMeta):
An injector parse HTTP request and inject params to the view.
"""
model: Type[BaseModel]
@property
@abc.abstractmethod
def __init__(self, args_spec: dict):
def context(self) -> CONTEXT:
"""
The name of part of parsed request
i.e "HTTP header", "URL path", ...
"""
@abc.abstractmethod
def __init__(self, args_spec: dict, default_values: dict):
"""
args_spec - ordered mapping: arg_name -> type
"""
@ -31,8 +48,12 @@ class MatchInfoGetter(AbstractInjector):
Validates and injects the part of URL path inside the view positional args.
"""
def __init__(self, args_spec: dict):
self.model = type("PathModel", (BaseModel,), {"__annotations__": args_spec})
context = "path"
def __init__(self, args_spec: dict, default_values: dict):
attrs = {"__annotations__": args_spec}
attrs.update(default_values)
self.model = type("PathModel", (BaseModel,), attrs)
def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict):
args_view.extend(self.model(**request.match_info).dict().values())
@ -43,12 +64,30 @@ class BodyGetter(AbstractInjector):
Validates and injects the content of request body inside the view kwargs.
"""
def __init__(self, args_spec: dict):
context = "body"
def __init__(self, args_spec: dict, default_values: dict):
self.arg_name, self.model = next(iter(args_spec.items()))
self._expect_object = self.model.schema()["type"] == "object"
async def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict):
try:
body = await request.json()
kwargs_view[self.arg_name] = self.model(**body)
except JSONDecodeError:
raise HTTPBadRequest(
text='{"error": "Malformed JSON"}', content_type="application/json"
) from None
# Pydantic tries to cast certain structures, such as a list of 2-tuples,
# to a dict. Prevent this by requiring the body to be a dict for object models.
if self._expect_object and not isinstance(body, dict):
raise HTTPBadRequest(
text='[{"in": "body", "loc": ["__root__"], "msg": "value is not a '
'valid dict", "type": "type_error.dict"}]',
content_type="application/json",
) from None
kwargs_view[self.arg_name] = self.model.parse_obj(body)
class QueryGetter(AbstractInjector):
@ -56,11 +95,49 @@ class QueryGetter(AbstractInjector):
Validates and injects the query string inside the view kwargs.
"""
def __init__(self, args_spec: dict):
self.model = type("QueryModel", (BaseModel,), {"__annotations__": args_spec})
context = "query string"
def __init__(self, args_spec: dict, default_values: dict):
args_spec = args_spec.copy()
self._groups = {}
for group_name, group in args_spec.items():
if robuste_issubclass(group, Group):
self._groups[group_name] = (group, _get_group_signature(group)[0])
_unpack_group_in_signature(args_spec, default_values)
attrs = {"__annotations__": args_spec}
attrs.update(default_values)
self.model = type("QueryModel", (BaseModel,), attrs)
self.args_spec = args_spec
self._is_multiple = frozenset(
name for name, spec in args_spec.items() if typing.get_origin(spec) is list
)
def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict):
kwargs_view.update(self.model(**request.query).dict())
data = self._query_to_dict(request.query)
cleaned = self.model(**data).dict()
for group_name, (group_cls, group_attrs) in self._groups.items():
group = group_cls()
for attr_name in group_attrs:
setattr(group, attr_name, cleaned.pop(attr_name))
cleaned[group_name] = group
kwargs_view.update(**cleaned)
def _query_to_dict(self, query: MultiDict):
"""
Return a dict with list as value from the MultiDict.
The value will be wrapped in a list if the args spec is define as a list or if
the multiple values are sent (i.e ?foo=1&foo=2)
"""
return {
key: values
if len(values := query.getall(key)) > 1 or key in self._is_multiple
else value
for key, value in query.items()
}
class HeadersGetter(AbstractInjector):
@ -68,42 +145,162 @@ class HeadersGetter(AbstractInjector):
Validates and injects the HTTP headers inside the view kwargs.
"""
def __init__(self, args_spec: dict):
self.model = type("HeaderModel", (BaseModel,), {"__annotations__": args_spec})
context = "headers"
def __init__(self, args_spec: dict, default_values: dict):
args_spec = args_spec.copy()
self._groups = {}
for group_name, group in args_spec.items():
if robuste_issubclass(group, Group):
self._groups[group_name] = (group, _get_group_signature(group)[0])
_unpack_group_in_signature(args_spec, default_values)
attrs = {"__annotations__": args_spec}
attrs.update(default_values)
self.model = type("HeaderModel", (BaseModel,), attrs)
def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict):
header = {k.lower().replace("-", "_"): v for k, v in request.headers.items()}
kwargs_view.update(self.model(**header).dict())
cleaned = self.model(**header).dict()
for group_name, (group_cls, group_attrs) in self._groups.items():
group = group_cls()
for attr_name in group_attrs:
setattr(group, attr_name, cleaned.pop(attr_name))
cleaned[group_name] = group
kwargs_view.update(cleaned)
def _parse_func_signature(func: Callable) -> Tuple[dict, dict, dict, dict]:
class Group(SimpleNamespace):
"""
Analyse function signature and returns 4-tuple:
Class to group header or query string parameters.
The parameter from query string or header will be set in the group
and the group will be passed as function parameter.
Example:
class Pagination(Group):
current_page: int = 1
page_size: int = 15
class PetView(PydanticView):
def get(self, page: Pagination):
...
"""
def _get_group_signature(cls) -> Tuple[dict, dict]:
"""
Analyse Group subclass annotations and return them with default values.
"""
sig = {}
defaults = {}
mro = getmro(cls)
for base in reversed(mro[: mro.index(Group)]):
attrs = vars(base)
# Use __annotations__ to know if an attribute is
# overwrite to remove the default value.
for attr_name, type_ in base.__annotations__.items():
if (default := attrs.get(attr_name)) is None:
defaults.pop(attr_name, None)
else:
defaults[attr_name] = default
# Use get_type_hints to have postponed annotations.
for attr_name, type_ in get_type_hints(base).items():
sig[attr_name] = type_
return sig, defaults
def _parse_func_signature(
func: Callable, unpack_group: bool = False
) -> Tuple[dict, dict, dict, dict, dict]:
"""
Analyse function signature and returns 5-tuple:
0 - arguments will be set from the url path
1 - argument will be set from the request body.
2 - argument will be set from the query string.
3 - argument will be set from the HTTP headers.
4 - Default value for each parameters
"""
path_args = {}
body_args = {}
qs_args = {}
header_args = {}
defaults = {}
annotations = get_type_hints(func)
for param_name, param_spec in signature(func).parameters.items():
if param_name == "self":
continue
if param_spec.annotation == param_spec.empty:
raise RuntimeError(f"The parameter {param_name} must have an annotation")
annotation = annotations[param_name]
if param_spec.default is not param_spec.empty:
defaults[param_name] = param_spec.default
if param_spec.kind is param_spec.POSITIONAL_ONLY:
path_args[param_name] = param_spec.annotation
path_args[param_name] = annotation
elif param_spec.kind is param_spec.POSITIONAL_OR_KEYWORD:
if issubclass(param_spec.annotation, BaseModel):
body_args[param_name] = param_spec.annotation
if is_pydantic_base_model(annotation):
body_args[param_name] = annotation
else:
qs_args[param_name] = param_spec.annotation
qs_args[param_name] = annotation
elif param_spec.kind is param_spec.KEYWORD_ONLY:
header_args[param_name] = param_spec.annotation
header_args[param_name] = annotation
else:
raise RuntimeError(f"You cannot use {param_spec.VAR_POSITIONAL} parameters")
return path_args, body_args, qs_args, header_args
if unpack_group:
try:
_unpack_group_in_signature(qs_args, defaults)
_unpack_group_in_signature(header_args, defaults)
except DuplicateNames as error:
raise TypeError(
f"Parameters conflict in function {func},"
f" the group {error.group} has an attribute named {error.attr_name}"
) from None
return path_args, body_args, qs_args, header_args, defaults
class DuplicateNames(Exception):
"""
Raised when a same parameter name is used in group and function signature.
"""
group: Type[Group]
attr_name: str
def __init__(self, group: Type[Group], attr_name: str):
self.group = group
self.attr_name = attr_name
super().__init__(
f"Conflict with {group}.{attr_name} and function parameter name"
)
def _unpack_group_in_signature(args: dict, defaults: dict) -> None:
"""
Unpack in place each Group found in args.
"""
for group_name, group in args.copy().items():
if robuste_issubclass(group, Group):
group_sig, group_default = _get_group_signature(group)
for attr_name in group_sig:
if attr_name in args and attr_name != group_name:
raise DuplicateNames(group, attr_name)
del args[group_name]
args.update(group_sig)
defaults.update(group_default)

View File

@ -0,0 +1,32 @@
from importlib import resources
from typing import Iterable, Optional
import jinja2
from aiohttp import web
from swagger_ui_bundle import swagger_ui_path
from .view import get_oas, oas_ui
def setup(
app: web.Application,
apps_to_expose: Iterable[web.Application] = (),
url_prefix: str = "/oas",
enable: bool = True,
version_spec: Optional[str] = None,
title_spec: Optional[str] = None,
):
if enable:
oas_app = web.Application()
oas_app["apps to expose"] = tuple(apps_to_expose) or (app,)
oas_app["index template"] = jinja2.Template(
resources.read_text("aiohttp_pydantic.oas", "index.j2")
)
oas_app["version_spec"] = version_spec
oas_app["title_spec"] = title_spec
oas_app.router.add_get("/spec", get_oas, name="spec")
oas_app.router.add_static("/static", swagger_ui_path, name="static")
oas_app.router.add_get("", oas_ui, name="index")
app.add_subapp(url_prefix, oas_app)

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)

133
aiohttp_pydantic/oas/cmd.py Normal file
View File

@ -0,0 +1,133 @@
import argparse
import importlib
import json
from typing import Dict, Protocol, Optional, Callable
import sys
from .view import generate_oas
class YamlModule(Protocol):
"""
Yaml Module type hint
"""
def dump(self, data) -> str:
pass
yaml: Optional[YamlModule]
try:
import yaml
except ImportError:
yaml = None
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 base_oas_file_type(value) -> Dict:
"""
Load base oas file
"""
try:
with open(value) as oas_file:
data = oas_file.read()
except OSError as error:
raise argparse.ArgumentTypeError(error) from error
return json.loads(data)
def format_type(value) -> Callable:
"""
Date Dumper one of (json, yaml)
"""
dumpers = {"json": lambda data: json.dumps(data, sort_keys=True, indent=4)}
if yaml is not None:
dumpers["yaml"] = yaml.dump
try:
return dumpers[value]
except KeyError:
raise argparse.ArgumentTypeError(
f"Wrong format value. (allowed values: {tuple(dumpers.keys())})"
) from None
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"
" If your asyncio.web.Application is returned by a function, you can"
" use the syntax: my_package.my_module:my_app()",
)
parser.add_argument(
"-b",
"--base-oas-file",
metavar="FILE",
dest="base",
type=base_oas_file_type,
help="A file that will be used as base to generate OAS",
default={},
)
parser.add_argument(
"-o",
"--output",
metavar="FILE",
type=argparse.FileType("w"),
help="File to write the output",
default=sys.stdout,
)
if yaml:
help_output_format = (
"The output format, can be 'json' or 'yaml' (default is json)"
)
else:
help_output_format = "The output format, only 'json' is available install pyyaml to have yaml output format"
parser.add_argument(
"-f",
"--format",
metavar="FORMAT",
dest="formatter",
type=format_type,
help=help_output_format,
default=format_type("json"),
)
parser.set_defaults(func=show_oas)
def show_oas(args: argparse.Namespace):
"""
Display Open API Specification on the stdout.
"""
spec = args.base
spec.update(generate_oas(args.apps))
print(args.formatter(spec), file=args.output)

View File

@ -0,0 +1,136 @@
"""
Utility to extract extra OAS description from docstring.
"""
import re
import textwrap
from typing import Dict, List
class LinesIterator:
def __init__(self, lines: str):
self._lines = lines.splitlines()
self._i = -1
def next_line(self) -> str:
if self._i == len(self._lines) - 1:
raise StopIteration from None
self._i += 1
return self._lines[self._i]
def rewind(self) -> str:
if self._i == -1:
raise StopIteration from None
self._i -= 1
return self._lines[self._i]
def __iter__(self):
return self
def __next__(self):
return self.next_line()
def _i_extract_block(lines: LinesIterator):
"""
Iter the line within an indented block and dedent them.
"""
# Go to the first not empty or not white space line.
try:
line = next(lines)
except StopIteration:
return # No block to extract.
while line.strip() == "":
try:
line = next(lines)
except StopIteration:
return
indent = re.fullmatch("( *).*", line).groups()[0]
indentation = len(indent)
start_of_other_block = re.compile(f" {{0,{indentation}}}[^ ].*")
yield line[indentation:]
# Yield lines until the indentation is the same or is greater than
# the first block line.
try:
line = next(lines)
except StopIteration:
return
while not start_of_other_block.fullmatch(line):
yield line[indentation:]
try:
line = next(lines)
except StopIteration:
return
lines.rewind()
def _dedent_under_first_line(text: str) -> str:
"""
Apply textwrap.dedent ignoring the first line.
"""
lines = text.splitlines()
other_lines = "\n".join(lines[1:])
if other_lines:
return f"{lines[0]}\n{textwrap.dedent(other_lines)}"
return text
def status_code(docstring: str) -> Dict[int, str]:
"""
Extract the "Status Code:" block of the docstring.
"""
iterator = LinesIterator(docstring)
for line in iterator:
if re.fullmatch("status\\s+codes?\\s*:", line, re.IGNORECASE):
iterator.rewind()
blocks = []
lines = []
i_block = _i_extract_block(iterator)
next(i_block)
for line_of_block in i_block:
if re.search("^\\s*\\d{3}\\s*:", line_of_block):
if lines:
blocks.append("\n".join(lines))
lines = []
lines.append(line_of_block)
if lines:
blocks.append("\n".join(lines))
return {
int(status.strip()): _dedent_under_first_line(desc.strip())
for status, desc in (block.split(":", 1) for block in blocks)
}
return {}
def tags(docstring: str) -> List[str]:
"""
Extract the "Tags:" block of the docstring.
"""
iterator = LinesIterator(docstring)
for line in iterator:
if re.fullmatch("tags\\s*:.*", line, re.IGNORECASE):
iterator.rewind()
lines = " ".join(_i_extract_block(iterator))
return [" ".join(e.split()) for e in re.split("[,;]", lines.split(":")[1])]
return []
def operation(docstring: str) -> str:
"""
Extract all docstring except the "Status Code:" block.
"""
lines = LinesIterator(docstring)
ret = []
for line in lines:
if re.fullmatch("status\\s+codes?\\s*:|tags\\s*:.*", line, re.IGNORECASE):
lines.rewind()
for _ in _i_extract_block(lines):
pass
else:
ret.append(line)
return ("\n".join(ret)).strip()

View File

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

View File

@ -0,0 +1,342 @@
"""
Utility to write Open Api Specifications using the Python language.
"""
from typing import Union, List
class Info:
def __init__(self, spec: dict):
self._spec = spec.setdefault("info", {})
@property
def title(self):
return self._spec.get("title")
@title.setter
def title(self, title):
self._spec["title"] = title
@property
def description(self):
return self._spec.get("description")
@description.setter
def description(self, description):
self._spec["description"] = description
@property
def version(self):
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):
self._spec = spec.setdefault("requestBody", {})
@property
def description(self):
return self._spec["description"]
@description.setter
def description(self, description: str):
self._spec["description"] = description
@property
def required(self) -> bool:
return self._spec.get("required", False)
@required.setter
def required(self, required: bool):
self._spec["required"] = required
@property
def content(self):
return self._spec["content"]
@content.setter
def content(self, content: dict):
self._spec["content"] = content
class Parameter:
def __init__(self, spec: dict):
self._spec = spec
@property
def name(self) -> str:
return self._spec["name"]
@name.setter
def name(self, name: str):
self._spec["name"] = name
@property
def in_(self) -> str:
return self._spec["in"]
@in_.setter
def in_(self, in_: str):
self._spec["in"] = in_
@property
def description(self) -> str:
return self._spec["description"]
@description.setter
def description(self, description: str):
self._spec["description"] = description
@property
def required(self) -> bool:
return self._spec["required"]
@required.setter
def required(self, required: bool):
self._spec["required"] = required
@property
def schema(self) -> dict:
return self._spec["schema"]
@schema.setter
def schema(self, schema: dict):
self._spec["schema"] = schema
class Parameters:
def __init__(self, spec):
self._spec = spec
self._spec.setdefault("parameters", [])
def __getitem__(self, item: int) -> Parameter:
if item == len(self._spec["parameters"]):
spec = {}
self._spec["parameters"].append(spec)
else:
spec = self._spec["parameters"][item]
return Parameter(spec)
class Response:
def __init__(self, spec: dict):
self._spec = spec
self._spec.setdefault("description", "")
@property
def description(self) -> str:
return self._spec["description"]
@description.setter
def description(self, description: str):
self._spec["description"] = description
@property
def content(self):
return self._spec["content"]
@content.setter
def content(self, content: dict):
self._spec["content"] = content
class Responses:
def __init__(self, spec: dict):
self._spec = spec.setdefault("responses", {})
def __getitem__(self, status_code: Union[int, str]) -> Response:
if not 100 <= int(status_code) < 600:
raise ValueError("status_code must be between 100 and 599")
spec = self._spec.setdefault(str(status_code), {})
return Response(spec)
class OperationObject:
def __init__(self, spec: dict):
self._spec = spec
@property
def summary(self) -> str:
return self._spec["summary"]
@summary.setter
def summary(self, summary: str):
self._spec["summary"] = summary
@property
def description(self) -> str:
return self._spec["description"]
@description.setter
def description(self, description: str):
self._spec["description"] = description
@property
def request_body(self) -> RequestBody:
return RequestBody(self._spec)
@property
def parameters(self) -> Parameters:
return Parameters(self._spec)
@property
def responses(self) -> Responses:
return Responses(self._spec)
@property
def tags(self) -> List[str]:
return self._spec.get("tags", [])[:]
@tags.setter
def tags(self, tags: List[str]):
if tags:
self._spec["tags"] = tags[:]
else:
self._spec.pop("tags", None)
class PathItem:
def __init__(self, spec: dict):
self._spec = spec
@property
def get(self) -> OperationObject:
return OperationObject(self._spec.setdefault("get", {}))
@property
def put(self) -> OperationObject:
return OperationObject(self._spec.setdefault("put", {}))
@property
def post(self) -> OperationObject:
return OperationObject(self._spec.setdefault("post", {}))
@property
def delete(self) -> OperationObject:
return OperationObject(self._spec.setdefault("delete", {}))
@property
def options(self) -> OperationObject:
return OperationObject(self._spec.setdefault("options", {}))
@property
def head(self) -> OperationObject:
return OperationObject(self._spec.setdefault("head", {}))
@property
def patch(self) -> OperationObject:
return OperationObject(self._spec.setdefault("patch", {}))
@property
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):
self._spec = spec.setdefault("paths", {})
def __getitem__(self, path: str) -> PathItem:
spec = self._spec.setdefault(path, {})
return PathItem(spec)
class Server:
def __init__(self, spec: dict):
self._spec = spec
@property
def url(self) -> str:
return self._spec["url"]
@url.setter
def url(self, url: str):
self._spec["url"] = url
@property
def description(self) -> str:
return self._spec["description"]
@description.setter
def description(self, description: str):
self._spec["description"] = description
class Servers:
def __init__(self, spec: dict):
self._spec = spec
self._spec.setdefault("servers", [])
def __getitem__(self, item: int) -> Server:
if item == len(self._spec["servers"]):
spec = {}
self._spec["servers"].append(spec)
else:
spec = self._spec["servers"][item]
return Server(spec)
class Components:
def __init__(self, spec: dict):
self._spec = spec.setdefault("components", {})
@property
def schemas(self) -> dict:
return self._spec.setdefault("schemas", {})
class OpenApiSpec3:
def __init__(self):
self._spec = {
"openapi": "3.0.0",
"info": {"version": "1.0.0", "title": "Aiohttp pydantic application"},
}
@property
def info(self) -> Info:
return Info(self._spec)
@property
def servers(self) -> Servers:
return Servers(self._spec)
@property
def paths(self) -> Paths:
return Paths(self._spec)
@property
def components(self) -> Components:
return Components(self._spec)
@property
def spec(self):
return self._spec

View File

@ -0,0 +1,48 @@
"""
This module provides type to annotate the content of web.Response returned by
the HTTP handlers.
The type are: r100, r101, ..., r599
Example:
class PetCollectionView(PydanticView):
async def get(self) -> Union[r200[List[Pet]], r404]:
...
"""
from functools import lru_cache
from types import new_class
from typing import Protocol, TypeVar
RespContents = TypeVar("RespContents", covariant=True)
_status_code = frozenset(f"r{code}" for code in range(100, 600))
@lru_cache(maxsize=len(_status_code))
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) -> bool:
"""
Return True if obj is a status code type such as _200 or _404.
"""
name = getattr(obj, "__name__", None)
if name not in _status_code:
return False
return obj is _make_status_code_type(name)
def __getattr__(name):
if (status_code_type := _make_status_code_type(name)) is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
return status_code_type
__all__ = list(_status_code)
__all__.append("is_status_code_type")

View File

@ -0,0 +1,199 @@
import typing
from inspect import getdoc
from itertools import count
from typing import List, Type, Optional, get_type_hints
from aiohttp.web import Response, json_response
from aiohttp.web_app import Application
from pydantic import BaseModel
from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem
from . import docstring_parser
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
class _OASResponseBuilder:
"""
Parse the type annotated as returned by a function and
generate the OAS operation response.
"""
def __init__(self, oas: OpenApiSpec3, oas_operation, status_code_descriptions):
self._oas_operation = oas_operation
self._oas = oas
self._status_code_descriptions = status_code_descriptions
def _handle_pydantic_base_model(self, obj):
if is_pydantic_base_model(obj):
response_schema = obj.schema(
ref_template="#/components/schemas/{model}"
).copy()
if def_sub_schemas := response_schema.pop("definitions", None):
self._oas.components.schemas.update(def_sub_schemas)
return response_schema
return {}
def _handle_list(self, obj):
if typing.get_origin(obj) is list:
return {
"type": "array",
"items": self._handle_pydantic_base_model(typing.get_args(obj)[0]),
}
return self._handle_pydantic_base_model(obj)
def _handle_status_code_type(self, obj):
if is_status_code_type(typing.get_origin(obj)):
status_code = typing.get_origin(obj).__name__[1:]
self._oas_operation.responses[status_code].content = {
"application/json": {
"schema": self._handle_list(typing.get_args(obj)[0])
}
}
desc = self._status_code_descriptions.get(int(status_code))
if desc:
self._oas_operation.responses[status_code].description = desc
elif is_status_code_type(obj):
status_code = obj.__name__[1:]
self._oas_operation.responses[status_code].content = {}
desc = self._status_code_descriptions.get(int(status_code))
if desc:
self._oas_operation.responses[status_code].description = desc
def _handle_union(self, obj):
if typing.get_origin(obj) is typing.Union:
for arg in typing.get_args(obj):
self._handle_status_code_type(arg)
self._handle_status_code_type(obj)
def build(self, obj):
self._handle_union(obj)
def _add_http_method_to_oas(
oas: OpenApiSpec3, oas_path: PathItem, http_method: str, view: Type[PydanticView]
):
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, defaults = _parse_func_signature(
handler, unpack_group=True
)
description = getdoc(handler)
if description:
oas_operation.description = docstring_parser.operation(description)
oas_operation.tags = docstring_parser.tags(description)
status_code_descriptions = docstring_parser.status_code(description)
else:
status_code_descriptions = {}
if body_args:
body_schema = (
next(iter(body_args.values()))
.schema(ref_template="#/components/schemas/{model}")
.copy()
)
if def_sub_schemas := body_schema.pop("definitions", None):
oas.components.schemas.update(def_sub_schemas)
oas_operation.request_body.content = {
"application/json": {"schema": body_schema}
}
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
attrs = {"__annotations__": {"__root__": type_}}
if name in defaults:
attrs["__root__"] = defaults[name]
oas_operation.parameters[i].required = False
else:
oas_operation.parameters[i].required = True
oas_operation.parameters[i].schema = type(name, (BaseModel,), attrs).schema(
ref_template="#/components/schemas/{model}"
)
return_type = get_type_hints(handler).get("return")
if return_type is not None:
_OASResponseBuilder(oas, oas_operation, status_code_descriptions).build(
return_type
)
def generate_oas(
apps: List[Application],
version_spec: Optional[str] = None,
title_spec: Optional[str] = None,
) -> dict:
"""
Generate and return Open Api Specification from PydanticView in application.
"""
oas = OpenApiSpec3()
if version_spec is not None:
oas.info.version = version_spec
if title_spec is not None:
oas.info.title = title_spec
for app in apps:
for resources in app.router.resources():
for resource_route in resources:
if not is_pydantic_view(resource_route.handler):
continue
view: Type[PydanticView] = resource_route.handler
info = resource_route.get_info()
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(oas, path, method_name, view)
else:
_add_http_method_to_oas(oas, path, resource_route.method, view)
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"]
version_spec = request.app["version_spec"]
title_spec = request.app["title_spec"]
return json_response(generate_oas(apps, version_spec, title_spec))
async def oas_ui(request):
"""
View to serve the swagger-ui to read open api specification of application.
"""
template = request.app["index template"]
static_url = request.app.router["static"].url_for(filename="")
spec_url = request.app.router["spec"].url_for()
host = request.url.origin()
return Response(
text=template.render(
{
"openapi_spec_url": host.with_path(str(spec_url)),
"static_url": host.with_path(str(static_url)),
}
),
content_type="text/html",
charset="utf-8",
)

19
aiohttp_pydantic/utils.py Normal file
View File

@ -0,0 +1,19 @@
from pydantic import BaseModel
def is_pydantic_base_model(obj):
"""
Return true is obj is a pydantic.BaseModel subclass.
"""
return robuste_issubclass(obj, BaseModel)
def robuste_issubclass(cls1, cls2):
"""
function likes issubclass but returns False instead of raise type error
if first parameter is not a class.
"""
try:
return issubclass(cls1, cls2)
except TypeError:
return False

View File

@ -1,22 +1,24 @@
from functools import update_wrapper
from inspect import iscoroutinefunction
from typing import Any, Callable, Generator, Iterable, Set, ClassVar
import warnings
from aiohttp.abc import AbstractView
from aiohttp.hdrs import METH_ALL
from aiohttp.web import json_response
from aiohttp.web_exceptions import HTTPMethodNotAllowed
from aiohttp.web_response import StreamResponse
from pydantic import ValidationError
from typing import Generator, Any, Callable, List, Iterable
from aiohttp.web import json_response
from functools import update_wrapper
from .injectors import (
MatchInfoGetter,
HeadersGetter,
QueryGetter,
BodyGetter,
AbstractInjector,
BodyGetter,
HeadersGetter,
MatchInfoGetter,
QueryGetter,
_parse_func_signature,
CONTEXT,
Group,
)
@ -25,44 +27,85 @@ class PydanticView(AbstractView):
An AIOHTTP View that validate request using function annotations.
"""
# Allowed HTTP methods; overridden when subclassed.
allowed_methods: ClassVar[Set[str]] = {}
async def _iter(self) -> StreamResponse:
method = getattr(self, self.request.method.lower(), None)
resp = await method()
return resp
if (method_name := self.request.method) not in self.allowed_methods:
self._raise_allowed_methods()
return await getattr(self, method_name.lower())()
def __await__(self) -> Generator[Any, None, StreamResponse]:
return self._iter().__await__()
def __init_subclass__(cls, **kwargs):
allowed_methods = {
def __init_subclass__(cls, **kwargs) -> None:
"""Define allowed methods and decorate handlers.
Handlers are decorated if and only if they directly bound on the PydanticView class or
PydanticView subclass. This prevents that methods are decorated multiple times and that method
defined in aiohttp.View parent class is decorated.
"""
cls.allowed_methods = {
meth_name for meth_name in METH_ALL if hasattr(cls, meth_name.lower())
}
async def raise_not_allowed(self):
raise HTTPMethodNotAllowed(self.request.method, allowed_methods)
for meth_name in METH_ALL:
if meth_name not in allowed_methods:
setattr(cls, meth_name.lower(), raise_not_allowed)
else:
if meth_name.lower() in vars(cls):
handler = getattr(cls, meth_name.lower())
decorated_handler = inject_params(handler, cls.parse_func_signature)
setattr(cls, meth_name.lower(), decorated_handler)
def _raise_allowed_methods(self) -> None:
raise HTTPMethodNotAllowed(self.request.method, self.allowed_methods)
def raise_not_allowed(self) -> None:
warnings.warn(
"PydanticView.raise_not_allowed is deprecated and renamed _raise_allowed_methods",
DeprecationWarning,
stacklevel=2,
)
self._raise_allowed_methods()
@staticmethod
def parse_func_signature(func: Callable) -> Iterable[AbstractInjector]:
path_args, body_args, qs_args, header_args = _parse_func_signature(func)
path_args, body_args, qs_args, header_args, defaults = _parse_func_signature(
func
)
injectors = []
def default_value(args: dict) -> dict:
"""
Returns the default values of args.
"""
return {name: defaults[name] for name in args if name in defaults}
if path_args:
injectors.append(MatchInfoGetter(path_args))
injectors.append(MatchInfoGetter(path_args, default_value(path_args)))
if body_args:
injectors.append(BodyGetter(body_args))
injectors.append(BodyGetter(body_args, default_value(body_args)))
if qs_args:
injectors.append(QueryGetter(qs_args))
injectors.append(QueryGetter(qs_args, default_value(qs_args)))
if header_args:
injectors.append(HeadersGetter(header_args))
injectors.append(HeadersGetter(header_args, default_value(header_args)))
return injectors
async def on_validation_error(
self, exception: ValidationError, context: CONTEXT
) -> StreamResponse:
"""
This method is a hook to intercept ValidationError.
This hook can be redefined to return a custom HTTP response error.
The exception is a pydantic.ValidationError and the context is "body",
"headers", "path" or "query string"
"""
errors = exception.errors()
for error in errors:
error["in"] = context
return json_response(data=errors, status=400)
def inject_params(
handler, parse_func_signature: Callable[[Callable], Iterable[AbstractInjector]]
@ -84,9 +127,30 @@ def inject_params(
else:
injector.inject(self.request, args, kwargs)
except ValidationError as error:
return json_response(text=error.json(), status=400)
return await self.on_validation_error(error, injector.context)
return await handler(self, *args, **kwargs)
update_wrapper(wrapped_handler, handler)
return wrapped_handler
def is_pydantic_view(obj) -> bool:
"""
Return True if obj is a PydanticView subclass else False.
"""
try:
return issubclass(obj, PydanticView)
except TypeError:
return False
__all__ = (
"AbstractInjector",
"BodyGetter",
"HeadersGetter",
"MatchInfoGetter",
"QueryGetter",
"CONTEXT",
"Group",
)

0
demo/__init__.py Normal file
View File

5
demo/__main__.py Normal file
View File

@ -0,0 +1,5 @@
from aiohttp import web
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, version_spec="1.0.1", title_spec="My App")
app["model"] = Model()
app.router.add_view("/pets", PetCollectionView)
app.router.add_view("/pets/{id}", PetItemView)

55
demo/model.py Normal file
View File

@ -0,0 +1,55 @@
from pydantic import BaseModel
from typing import List
class Friend(BaseModel):
name: str
age: str
class Pet(BaseModel):
id: int
name: str
age: int
friends: Friend
class Error(BaseModel):
error: str
class Model:
"""
To keep simple this demo, we use a simple dict as database to
store the models.
"""
class NotFound(KeyError):
"""
Raised when a pet is not found.
"""
def __init__(self):
self.storage = {}
def add_pet(self, pet: Pet):
self.storage[pet.id] = pet
def remove_pet(self, id: int):
try:
del self.storage[id]
except KeyError as error:
raise self.NotFound(str(error))
def update_pet(self, id: int, pet: Pet):
self.remove_pet(id)
self.add_pet(pet)
def find_pet(self, id: int):
try:
return self.storage[id]
except KeyError as error:
raise self.NotFound(str(error))
def list_pets(self):
return list(self.storage.values())

63
demo/view.py Normal file
View File

@ -0,0 +1,63 @@
from typing import List, Optional, Union
from aiohttp import web
from aiohttp_pydantic import PydanticView
from aiohttp_pydantic.oas.typing import r200, r201, r204, r404
from .model import Error, Pet
class PetCollectionView(PydanticView):
async def get(self, age: Optional[int] = None) -> r200[List[Pet]]:
"""
List all pets
Status Codes:
200: Successful operation
"""
pets = self.request.app["model"].list_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]:
"""
Add a new pet to the store
Status Codes:
201: Successful operation
"""
self.request.app["model"].add_pet(pet)
return web.json_response(pet.dict())
class PetItemView(PydanticView):
async def get(self, id: int, /) -> Union[r200[Pet], r404[Error]]:
"""
Find a pet by ID
Status Codes:
200: Successful operation
404: Pet not found
"""
pet = self.request.app["model"].find_pet(id)
return web.json_response(pet.dict())
async def put(self, id: int, /, pet: Pet) -> r200[Pet]:
"""
Update an existing object
Status Codes:
200: Successful operation
404: Pet not found
"""
self.request.app["model"].update_pet(id, pet)
return web.json_response(pet.dict())
async def delete(self, id: int, /) -> r204:
"""
Deletes a pet
"""
self.request.app["model"].remove_pet(id)
return web.Response(status=204)

6
pyproject.toml Normal file
View File

@ -0,0 +1,6 @@
[build-system]
requires = [
"setuptools >= 46.4.0",
"wheel",
]
build-backend = "setuptools.build_meta"

42
requirements/ci.txt Normal file
View File

@ -0,0 +1,42 @@
async-timeout==3.0.1
attrs==21.2.0
bleach==4.0.0
certifi==2021.5.30
cffi==1.14.6
chardet==4.0.0
charset-normalizer==2.0.4
codecov==2.1.11
colorama==0.4.4
coverage==5.5
cryptography==3.4.7
docutils==0.17.1
idna==3.2
importlib-metadata==4.6.3
iniconfig==1.1.1
jeepney==0.7.1
keyring==23.0.1
multidict==5.1.0
packaging==21.0
pkginfo==1.7.1
pluggy==0.13.1
py==1.10.0
pycparser==2.20
Pygments==2.9.0
pyparsing==2.4.7
pytest==6.1.2
pytest-aiohttp==0.3.0
pytest-cov==2.10.1
readme-renderer==29.0
requests==2.26.0
requests-toolbelt==0.9.1
rfc3986==1.5.0
SecretStorage==3.3.1
six==1.16.0
toml==0.10.2
tqdm==4.62.0
twine==3.4.2
typing-extensions==3.10.0.0
urllib3==1.26.6
webencodings==0.5.1
yarl==1.6.3
zipp==3.5.0

23
requirements/test.txt Normal file
View File

@ -0,0 +1,23 @@
async-timeout==3.0.1
attrs==21.2.0
bleach==4.0.0
chardet==4.0.0
coverage==5.5
docutils==0.17.1
idna==3.2
iniconfig==1.1.1
multidict==5.1.0
packaging==21.0
pluggy==0.13.1
py==1.10.0
Pygments==2.9.0
pyparsing==2.4.7
pytest==6.1.2
pytest-aiohttp==0.3.0
pytest-cov==2.10.1
readme-renderer==29.0
six==1.16.0
toml==0.10.2
typing-extensions==3.10.0.0
webencodings==0.5.1
yarl==1.6.3

54
setup.cfg Normal file
View File

@ -0,0 +1,54 @@
[metadata]
name = aiohttp_pydantic
version = attr: aiohttp_pydantic.__version__
url = https://github.com/Maillol/aiohttp-pydantic
author = Vincent Maillol
author_email = vincent.maillol@gmail.com
description = Aiohttp View using pydantic to validate request body and query sting regarding method annotations.
long_description = file: README.rst
keywords =
aiohttp
pydantic
annotations
validation
license = MIT
classifiers =
Intended Audience :: Developers
Intended Audience :: Information Technology
Programming Language :: Python
Programming Language :: Python :: 3
Programming Language :: Python :: 3 :: Only
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Topic :: Software Development :: Libraries :: Application Frameworks
Framework :: aiohttp
License :: OSI Approved :: MIT License
[options]
zip_safe = False
include_package_data = True
packages = find:
python_requires = >=3.8
install_requires =
aiohttp
pydantic>=1.7
swagger-ui-bundle
[options.extras_require]
test =
pytest==6.1.2
pytest-aiohttp==0.3.0
pytest-cov==2.10.1
readme-renderer==29.0
ci =
%(test)s
codecov==2.1.11
twine==3.4.2
[options.packages.find]
exclude =
tests*
demo*
[options.package_data]
aiohttp_pydantic.oas = index.j2

View File

@ -1,28 +1,3 @@
from setuptools import setup
setup(
name='aiohttp_pydantic',
version='1.0.0',
description='Aiohttp View using pydantic to validate request body and query sting regarding method annotation',
keywords='aiohttp pydantic annotation unpack inject validate',
author='Vincent Maillol',
author_email='vincent.maillol@gmail.com',
url='https://github.com/Maillol/aiohttp-pydantic',
license='MIT',
packages=['aiohttp_pydantic'],
classifiers=[
'Intended Audience :: Developers',
'Intended Audience :: Information Technology',
'Programming Language :: Python',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3 :: Only',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Topic :: Software Development :: Libraries :: Application Frameworks',
'Framework :: AsyncIO',
'License :: OSI Approved :: MIT License'
],
python_requires='>=3.6',
install_requires=['aiohttp', 'pydantic']
)
setup()

172
tasks.py Normal file
View File

@ -0,0 +1,172 @@
"""
To use this module, install invoke and type invoke -l
"""
from functools import partial
import os
from pathlib import Path
from setuptools.config import read_configuration
from invoke import task, Exit, Task as Task_, call
def activate_venv(c, venv: str):
"""
Activate a virtualenv
"""
virtual_env = Path().absolute() / venv
if original_path := os.environ.get("PATH"):
path = f'{virtual_env / "bin"}:{original_path}'
else:
path = str(virtual_env / "bin")
c.config.run.env["PATH"] = path
c.config.run.env["VIRTUAL_ENV"] = str(virtual_env)
os.environ.pop("PYTHONHOME", "")
def title(text, underline_char="#"):
"""
Display text as a title.
"""
template = f"{{:{underline_char}^80}}"
text = template.format(f" {text.strip()} ")
print(f"\033[1m{text}\033[0m")
class Task(Task_):
"""
This task add 'skip_if_recent' feature.
>>> @task(skip_if_recent=['./target', './dependency'])
>>> def my_tash(c):
>>> ...
target is file created by the task
dependency is file used by the task
The task is ran only if the dependency is more recent than the target file.
The target or the dependency can be a tuple of files.
"""
def __init__(self, *args, **kwargs):
self.skip_if_recent = kwargs.pop("skip_if_recent", None)
super().__init__(*args, **kwargs)
def __call__(self, *args, **kwargs):
title(self.__doc__ or self.name)
if self.skip_if_recent:
targets, dependencies = self.skip_if_recent
if isinstance(targets, str):
targets = (targets,)
if isinstance(dependencies, str):
dependencies = (dependencies,)
target_mtime = min(
((Path(file).exists() and Path(file).lstat().st_mtime) or 0)
for file in targets
)
dependency_mtime = max(Path(file).lstat().st_mtime for file in dependencies)
if dependency_mtime < target_mtime:
print(f"{self.name}, nothing to do")
return None
return super().__call__(*args, **kwargs)
task = partial(task, klass=Task)
@task()
def venv(c):
"""
Create a virtual environment for dev
"""
c.run("python -m venv --clear venv")
c.run("venv/bin/pip install -U setuptools wheel pip")
c.run("venv/bin/pip install -e .")
c.run("venv/bin/pip install -r requirements/test.txt")
@task()
def check_readme(c):
"""
Check the README.rst render
"""
c.run("python -m readme_renderer -o /dev/null README.rst")
@task()
def test(c, isolate=False):
"""
Launch tests
"""
opt = "I" if isolate else ""
c.run(f"python -{opt}m pytest --cov-report=xml --cov=aiohttp_pydantic tests/")
@task()
def tag_eq_version(c):
"""
Ensure that the last git tag matches the package version
"""
git_tag = c.run("git describe --tags HEAD", hide=True).stdout.strip()
package_version = read_configuration("./setup.cfg")["metadata"]["version"]
if git_tag != f"v{package_version}":
raise Exit(
f"ERROR: The git tag {git_tag!r} does not matches"
f" the package version {package_version!r}"
)
@task()
def prepare_ci_env(c):
"""
Prepare CI environment
"""
title("Creating virtual env", "=")
c.run("python -m venv --clear dist_venv")
activate_venv(c, "dist_venv")
c.run("dist_venv/bin/python -m pip install -U setuptools wheel pip")
title("Building wheel", "=")
c.run("dist_venv/bin/python setup.py build bdist_wheel")
title("Installing wheel", "=")
package_version = read_configuration("./setup.cfg")["metadata"]["version"]
dist = next(Path("dist").glob(f"aiohttp_pydantic-{package_version}-*.whl"))
c.run(f"dist_venv/bin/python -m pip install {dist}")
# We verify that aiohttp-pydantic module is importable before installing CI tools.
package_names = read_configuration("./setup.cfg")["options"]["packages"]
for package_name in package_names:
c.run(f"dist_venv/bin/python -I -c 'import {package_name}'")
title("Installing CI tools", "=")
c.run("dist_venv/bin/python -m pip install -r requirements/ci.txt")
@task(prepare_ci_env, check_readme, call(test, isolate=True), klass=Task_)
def prepare_upload(c):
"""
Launch all tests and verifications
"""
@task(tag_eq_version, prepare_upload)
def upload(c, pypi_user=None, pypi_password=None):
"""
Upload on pypi
"""
package_version = read_configuration("./setup.cfg")["metadata"]["version"]
dist = next(Path("dist").glob(f"aiohttp_pydantic-{package_version}-*.whl"))
if pypi_user is not None and pypi_password is not None:
c.run(
f"dist_venv/bin/twine upload --non-interactive"
f" -u {pypi_user} -p {pypi_password} {dist}",
hide=True,
)
else:
c.run(f"dist_venv/bin/twine upload --repository aiohttp-pydantic {dist}")

View File

@ -1,3 +0,0 @@
.
pytest==6.1.1
pytest-aiohttp==0.3.0

76
tests/test_group.py Normal file
View File

@ -0,0 +1,76 @@
from __future__ import annotations
import pytest
from aiohttp_pydantic.injectors import (
Group,
_get_group_signature,
_unpack_group_in_signature,
DuplicateNames,
)
def test_get_group_signature_with_a2b2():
class A(Group):
a: int = 1
class B(Group):
b: str = "b"
class B2(B):
b: str = "b2" # Overwrite default value
class A2(A):
a: int # Remove default value
class A2B2(A2, B2):
ab2: float
assert ({"ab2": float, "a": int, "b": str}, {"b": "b2"}) == _get_group_signature(
A2B2
)
def test_unpack_group_in_signature():
class PaginationGroup(Group):
page: int
page_size: int = 20
args = {"pagination": PaginationGroup, "name": str, "age": int}
default = {"age": 18}
_unpack_group_in_signature(args, default)
assert args == {"page": int, "page_size": int, "name": str, "age": int}
assert default == {"age": 18, "page_size": 20}
def test_unpack_group_in_signature_with_duplicate_error():
class PaginationGroup(Group):
page: int
page_size: int = 20
args = {"pagination": PaginationGroup, "page": int, "age": int}
with pytest.raises(DuplicateNames) as e_info:
_unpack_group_in_signature(args, {})
assert e_info.value.group is PaginationGroup
assert e_info.value.attr_name == "page"
def test_unpack_group_in_signature_with_parameters_overwrite():
class PaginationGroup(Group):
page: int = 0
page_size: int = 20
args = {"page": PaginationGroup, "age": int}
default = {}
_unpack_group_in_signature(args, default)
assert args == {"page": int, "page_size": int, "age": int}
assert default == {"page": 0, "page_size": 20}

View File

@ -0,0 +1,57 @@
from __future__ import annotations
from typing import Iterator, List, Optional
from aiohttp import web
from aiohttp.web_response import json_response
from pydantic import BaseModel
from aiohttp_pydantic import PydanticView
class ArticleModel(BaseModel):
name: str
nb_page: Optional[int]
class ArticleModels(BaseModel):
__root__: List[ArticleModel]
def __iter__(self) -> Iterator[ArticleModel]:
return iter(self.__root__)
class ArticleView(PydanticView):
async def post(self, article: ArticleModel):
return web.json_response(article.dict())
async def put(self, articles: ArticleModels):
return web.json_response([article.dict() for article in articles])
async def on_validation_error(self, exception, context):
errors = exception.errors()
for error in errors:
error["in"] = context
error["custom"] = "custom"
return json_response(data=errors, status=400)
async def test_post_an_article_with_wrong_type_field_should_return_an_error_message(
aiohttp_client, loop
):
app = web.Application()
app.router.add_view("/article", ArticleView)
client = await aiohttp_client(app)
resp = await client.post("/article", json={"name": "foo", "nb_page": "foo"})
assert resp.status == 400
assert resp.content_type == "application/json"
assert await resp.json() == [
{
"in": "body",
"loc": ["nb_page"],
"msg": "value is not a valid integer",
"custom": "custom",
"type": "type_error.integer",
}
]

73
tests/test_inheritance.py Normal file
View File

@ -0,0 +1,73 @@
from __future__ import annotations
from typing import Any
from aiohttp_pydantic import PydanticView
from aiohttp.web import View
def count_wrappers(obj: Any) -> int:
"""Count the number of times that an object is wrapped."""
i = 0
while i < 10:
try:
obj = obj.__wrapped__
except AttributeError:
return i
else:
i += 1
raise RuntimeError("Too many wrappers")
class AiohttpViewParent(View):
async def put(self):
pass
class PydanticViewParent(PydanticView):
async def get(self, id: int, /):
pass
def test_allowed_methods_get_decorated_exactly_once():
class ChildView(PydanticViewParent):
async def post(self, id: int, /):
pass
class SubChildView(ChildView):
async def get(self, id: int, /):
return super().get(id)
assert count_wrappers(ChildView.post) == 1
assert count_wrappers(ChildView.get) == 1
assert count_wrappers(SubChildView.post) == 1
assert count_wrappers(SubChildView.get) == 1
def test_methods_inherited_from_aiohttp_view_should_not_be_decorated():
class ChildView(AiohttpViewParent, PydanticView):
async def post(self, id: int, /):
pass
assert count_wrappers(ChildView.put) == 0
assert count_wrappers(ChildView.post) == 1
def test_allowed_methods_are_set_correctly():
class ChildView(AiohttpViewParent, PydanticView):
async def post(self, id: int, /):
pass
assert ChildView.allowed_methods == {"POST", "PUT"}
class ChildView(PydanticViewParent):
async def post(self, id: int, /):
pass
assert ChildView.allowed_methods == {"POST", "GET"}
class ChildView(AiohttpViewParent, PydanticViewParent):
async def post(self, id: int, /):
pass
assert ChildView.allowed_methods == {"POST", "PUT", "GET"}

View File

View File

View File

@ -0,0 +1 @@
{"info": {"title": "MyApp", "version": "1.0.0"}}

View File

@ -0,0 +1,29 @@
from __future__ import annotations
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,148 @@
from __future__ import annotations
import argparse
from textwrap import dedent
from io import StringIO
from pathlib import Path
import pytest
from aiohttp_pydantic.oas import cmd
PATH_TO_BASE_JSON_FILE = str(Path(__file__).parent / "oas_base.json")
@pytest.fixture
def cmd_line():
parser = argparse.ArgumentParser()
cmd.setup(parser)
return parser
def test_show_oas_of_app(cmd_line):
args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample"])
args.output = StringIO()
args.func(args)
expected = dedent(
"""
{
"info": {
"title": "Aiohttp pydantic application",
"version": "1.0.0"
},
"openapi": "3.0.0",
"paths": {
"/route-1/{a}": {
"get": {
"parameters": [
{
"in": "path",
"name": "a",
"required": true,
"schema": {
"title": "a",
"type": "integer"
}
}
]
}
},
"/sub-app/route-2/{b}": {
"post": {
"parameters": [
{
"in": "path",
"name": "b",
"required": true,
"schema": {
"title": "b",
"type": "integer"
}
}
]
}
}
}
}
"""
)
assert args.output.getvalue().strip() == expected.strip()
def test_show_oas_of_sub_app(cmd_line):
args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample:sub_app"])
args.output = StringIO()
args.func(args)
expected = dedent(
"""
{
"info": {
"title": "Aiohttp pydantic application",
"version": "1.0.0"
},
"openapi": "3.0.0",
"paths": {
"/sub-app/route-2/{b}": {
"post": {
"parameters": [
{
"in": "path",
"name": "b",
"required": true,
"schema": {
"title": "b",
"type": "integer"
}
}
]
}
}
}
}
"""
)
assert args.output.getvalue().strip() == expected.strip()
def test_show_oas_of_a_callable(cmd_line):
args = cmd_line.parse_args(
[
"tests.test_oas.test_cmd.sample:make_app()",
"--base-oas-file",
PATH_TO_BASE_JSON_FILE,
]
)
args.output = StringIO()
args.func(args)
expected = dedent(
"""
{
"info": {
"title": "Aiohttp pydantic application",
"version": "1.0.0"
},
"openapi": "3.0.0",
"paths": {
"/route-3/{a}": {
"get": {
"parameters": [
{
"in": "path",
"name": "a",
"required": true,
"schema": {
"title": "a",
"type": "integer"
}
}
]
}
}
}
}
"""
)
assert args.output.getvalue().strip() == expected.strip()

View File

@ -0,0 +1,157 @@
from __future__ import annotations
from textwrap import dedent
from aiohttp_pydantic.oas.docstring_parser import (
status_code,
tags,
operation,
_i_extract_block,
LinesIterator,
)
from inspect import getdoc
import pytest
def web_handler():
"""
bla bla bla
Tags: tag1, tag2
, tag3,
t a
g
4
Status Codes:
200: line 1
line 2:
- line 3
- line 4
line 5
300: line A 1
301: line B 1
line B 2
400: line C 1
line C 2
line C 3
bla bla
"""
def web_handler_2():
"""
bla bla bla
Tags: tag1
Status Codes:
200: line 1
bla bla
"""
def test_lines_iterator():
lines_iterator = LinesIterator("AAAA\nBBBB")
with pytest.raises(StopIteration):
lines_iterator.rewind()
assert lines_iterator.next_line() == "AAAA"
assert lines_iterator.rewind()
assert lines_iterator.next_line() == "AAAA"
assert lines_iterator.next_line() == "BBBB"
with pytest.raises(StopIteration):
lines_iterator.next_line()
def test_status_code():
expected = {
200: "line 1\n\nline 2:\n - line 3\n - line 4\n\nline 5",
300: "line A 1",
301: "line B 1\nline B 2",
400: "line C 1\n\nline C 2\n\n line C 3",
}
assert status_code(getdoc(web_handler)) == expected
def test_tags():
expected = ["tag1", "tag2", "tag3", "t a g 4"]
assert tags(getdoc(web_handler)) == expected
def test_operation():
expected = "bla bla bla\n\n\nbla bla"
assert operation(getdoc(web_handler)) == expected
assert operation(getdoc(web_handler_2)) == expected
def test_i_extract_block():
blocks = dedent(
"""
aaaa:
bbbb
cccc
dddd
"""
)
lines = LinesIterator(blocks)
text = "\n".join(_i_extract_block(lines))
assert text == """aaaa:\n\n bbbb\n\n cccc"""
blocks = dedent(
"""
aaaa:
bbbb
cccc
dddd
"""
)
lines = LinesIterator(blocks)
text = "\n".join(_i_extract_block(lines))
assert text == """aaaa:\n\n bbbb\n\n cccc\n"""
blocks = dedent(
"""
aaaa:
bbbb
cccc
"""
)
lines = LinesIterator(blocks)
text = "\n".join(_i_extract_block(lines))
assert text == """aaaa:\n\n bbbb\n\n cccc"""
lines = LinesIterator("")
text = "\n".join(_i_extract_block(lines))
assert text == ""
lines = LinesIterator("\n")
text = "\n".join(_i_extract_block(lines))
assert text == ""
lines = LinesIterator("aaaa:")
text = "\n".join(_i_extract_block(lines))
assert text == "aaaa:"

View File

View File

@ -0,0 +1,76 @@
from __future__ import annotations
import pytest
from aiohttp_pydantic.oas.struct import OpenApiSpec3
def test_info_title():
oas = OpenApiSpec3()
assert oas.info.title == "Aiohttp pydantic application"
oas.info.title = "Info Title"
assert oas.info.title == "Info Title"
assert oas.spec == {
"info": {
"title": "Info Title",
"version": "1.0.0",
},
"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",
"title": "Aiohttp pydantic application",
"version": "1.0.0",
},
"openapi": "3.0.0",
}
def test_info_version():
oas = OpenApiSpec3()
assert oas.info.version == "1.0.0"
oas.info.version = "3.14"
assert oas.info.version == "3.14"
assert oas.spec == {
"info": {"version": "3.14", "title": "Aiohttp pydantic application"},
"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": {
"title": "Aiohttp pydantic application",
"version": "1.0.0",
"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,147 @@
from __future__ import annotations
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",
"info": {"title": "Aiohttp pydantic application", "version": "1.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",
"info": {"title": "Aiohttp pydantic application", "version": "1.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",
"info": {"title": "Aiohttp pydantic application", "version": "1.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",
"info": {"title": "Aiohttp pydantic application", "version": "1.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",
"info": {"title": "Aiohttp pydantic application", "version": "1.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",
"info": {"title": "Aiohttp pydantic application", "version": "1.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_tags():
oas = OpenApiSpec3()
operation = oas.paths["/users/{petId}"].get
assert operation.tags == []
operation.tags = ["pets"]
assert oas.spec["paths"]["/users/{petId}"] == {"get": {"tags": ["pets"]}}
operation.tags = []
assert oas.spec["paths"]["/users/{petId}"] == {"get": {}}
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,40 @@
from __future__ import annotations
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",
"info": {"title": "Aiohttp pydantic application", "version": "1.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",
"info": {"title": "Aiohttp pydantic application", "version": "1.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()

414
tests/test_oas/test_view.py Normal file
View File

@ -0,0 +1,414 @@
from __future__ import annotations
from enum import Enum
from typing import List, Optional, Union, Literal
from uuid import UUID
import pytest
from aiohttp import web
from pydantic.main import BaseModel
from aiohttp_pydantic import PydanticView, oas
from aiohttp_pydantic.injectors import Group
from aiohttp_pydantic.oas.typing import r200, r201, r204, r404
from aiohttp_pydantic.oas.view import generate_oas
class Color(str, Enum):
RED = "red"
GREEN = "green"
PINK = "pink"
class Toy(BaseModel):
name: str
color: Color
class Pet(BaseModel):
id: int
name: str
toys: List[Toy]
class PetCollectionView(PydanticView):
async def get(
self, format: str, name: Optional[str] = None, *, promo: Optional[UUID] = None
) -> r200[List[Pet]]:
"""
Get a list of pets
Tags: pet
Status Codes:
200: Successful operation
"""
return web.json_response()
async def post(self, pet: Pet) -> r201[Pet]:
"""Create a Pet"""
return web.json_response()
class PetItemView(PydanticView):
async def get(
self,
id: int,
/,
size: Union[int, Literal["x", "l", "s"]],
day: Union[int, Literal["now"]] = "now",
) -> Union[r200[Pet], r404]:
return web.json_response()
async def put(self, id: int, /, pet: Pet):
return web.json_response()
async def delete(self, id: int, /) -> r204:
"""
Status Code:
204: Empty but OK
"""
return web.json_response()
class ViewResponseReturnASimpleType(PydanticView):
async def get(self) -> r200[int]:
"""
Status Codes:
200: The new number
"""
return web.json_response()
async def ensure_content_durability(client):
"""
Reload the page 2 times to ensure that content is always the same
note: pydantic can return a cached dict, if a view updates the dict the
output will be incoherent
"""
response_1 = await client.get("/oas/spec")
assert response_1.status == 200
assert response_1.content_type == "application/json"
content_1 = await response_1.json()
response_2 = await client.get("/oas/spec")
content_2 = await response_2.json()
assert content_1 == content_2
return content_2
@pytest.fixture
async def generated_oas(aiohttp_client, loop) -> web.Application:
app = web.Application()
app.router.add_view("/pets", PetCollectionView)
app.router.add_view("/pets/{id}", PetItemView)
app.router.add_view("/simple-type", ViewResponseReturnASimpleType)
oas.setup(app)
return await ensure_content_durability(await aiohttp_client(app))
async def test_generated_oas_should_have_components_schemas(generated_oas):
assert generated_oas["components"]["schemas"] == {
"Color": {
"description": "An enumeration.",
"enum": ["red", "green", "pink"],
"title": "Color",
"type": "string",
},
"Toy": {
"properties": {
"color": {"$ref": "#/components/schemas/Color"},
"name": {"title": "Name", "type": "string"},
},
"required": ["name", "color"],
"title": "Toy",
"type": "object",
},
}
async def test_generated_oas_should_have_pets_paths(generated_oas):
assert "/pets" in generated_oas["paths"]
async def test_pets_route_should_have_get_method(generated_oas):
assert generated_oas["paths"]["/pets"]["get"] == {
"description": "Get a list of pets",
"tags": ["pet"],
"parameters": [
{
"in": "query",
"name": "format",
"required": True,
"schema": {"title": "format", "type": "string"},
},
{
"in": "query",
"name": "name",
"required": False,
"schema": {"title": "name", "type": "string"},
},
{
"in": "header",
"name": "promo",
"required": False,
"schema": {"format": "uuid", "title": "promo", "type": "string"},
},
],
"responses": {
"200": {
"description": "Successful operation",
"content": {
"application/json": {
"schema": {
"items": {
"properties": {
"id": {"title": "Id", "type": "integer"},
"name": {"title": "Name", "type": "string"},
"toys": {
"items": {"$ref": "#/components/schemas/Toy"},
"title": "Toys",
"type": "array",
},
},
"required": ["id", "name", "toys"],
"title": "Pet",
"type": "object",
},
"type": "array",
}
}
},
}
},
}
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": {
"schema": {
"properties": {
"id": {"title": "Id", "type": "integer"},
"name": {"title": "Name", "type": "string"},
"toys": {
"items": {"$ref": "#/components/schemas/Toy"},
"title": "Toys",
"type": "array",
},
},
"required": ["id", "name", "toys"],
"title": "Pet",
"type": "object",
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"properties": {
"id": {"title": "Id", "type": "integer"},
"name": {"title": "Name", "type": "string"},
"toys": {
"items": {"$ref": "#/components/schemas/Toy"},
"title": "Toys",
"type": "array",
},
},
"required": ["id", "name", "toys"],
"title": "Pet",
"type": "object",
}
}
},
}
},
}
async def test_generated_oas_should_have_pets_id_paths(generated_oas):
assert "/pets/{id}" in generated_oas["paths"]
async def test_pets_id_route_should_have_delete_method(generated_oas):
assert generated_oas["paths"]["/pets/{id}"]["delete"] == {
"description": "",
"parameters": [
{
"in": "path",
"name": "id",
"required": True,
"schema": {"title": "id", "type": "integer"},
}
],
"responses": {"204": {"content": {}, "description": "Empty but OK"}},
}
async def test_pets_id_route_should_have_get_method(generated_oas):
assert generated_oas["paths"]["/pets/{id}"]["get"] == {
"parameters": [
{
"in": "path",
"name": "id",
"required": True,
"schema": {"title": "id", "type": "integer"},
},
{
"in": "query",
"name": "size",
"required": True,
"schema": {
"anyOf": [
{"type": "integer"},
{"enum": ["x", "l", "s"], "type": "string"},
],
"title": "size",
},
},
{
"in": "query",
"name": "day",
"required": False,
"schema": {
"anyOf": [{"type": "integer"}, {"enum": ["now"], "type": "string"}],
"default": "now",
"title": "day",
},
},
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"properties": {
"id": {"title": "Id", "type": "integer"},
"name": {"title": "Name", "type": "string"},
"toys": {
"items": {"$ref": "#/components/schemas/Toy"},
"title": "Toys",
"type": "array",
},
},
"required": ["id", "name", "toys"],
"title": "Pet",
"type": "object",
}
}
},
},
"404": {"description": "", "content": {}},
},
}
async def test_pets_id_route_should_have_put_method(generated_oas):
assert generated_oas["paths"]["/pets/{id}"]["put"] == {
"parameters": [
{
"in": "path",
"name": "id",
"required": True,
"schema": {"title": "id", "type": "integer"},
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"id": {"title": "Id", "type": "integer"},
"name": {"title": "Name", "type": "string"},
"toys": {
"items": {"$ref": "#/components/schemas/Toy"},
"title": "Toys",
"type": "array",
},
},
"required": ["id", "name", "toys"],
"title": "Pet",
"type": "object",
}
}
}
},
}
async def test_simple_type_route_should_have_get_method(generated_oas):
assert generated_oas["paths"]["/simple-type"]["get"] == {
"description": "",
"responses": {
"200": {
"content": {"application/json": {"schema": {}}},
"description": "The new number",
}
},
}
async def test_generated_view_info_default():
apps = (web.Application(),)
spec = generate_oas(apps)
assert spec == {
"info": {"title": "Aiohttp pydantic application", "version": "1.0.0"},
"openapi": "3.0.0",
}
async def test_generated_view_info_as_version():
apps = (web.Application(),)
spec = generate_oas(apps, version_spec="test version")
assert spec == {
"info": {"title": "Aiohttp pydantic application", "version": "test version"},
"openapi": "3.0.0",
}
async def test_generated_view_info_as_title():
apps = (web.Application(),)
spec = generate_oas(apps, title_spec="test title")
assert spec == {
"info": {"title": "test title", "version": "1.0.0"},
"openapi": "3.0.0",
}
class Pagination(Group):
page: int = 1
page_size: int = 20
async def test_use_parameters_group_should_not_impact_the_oas(aiohttp_client):
class PetCollectionView1(PydanticView):
async def get(self, page: int = 1, page_size: int = 20) -> r200[List[Pet]]:
return web.json_response()
class PetCollectionView2(PydanticView):
async def get(self, pagination: Pagination) -> r200[List[Pet]]:
return web.json_response()
app1 = web.Application()
app1.router.add_view("/pets", PetCollectionView1)
oas.setup(app1)
app2 = web.Application()
app2.router.add_view("/pets", PetCollectionView2)
oas.setup(app2)
assert await ensure_content_durability(
await aiohttp_client(app1)
) == await ensure_content_durability(await aiohttp_client(app2))

View File

@ -1,7 +1,11 @@
from aiohttp_pydantic.injectors import _parse_func_signature
from pydantic import BaseModel
from __future__ import annotations
from uuid import UUID
from pydantic import BaseModel
from aiohttp_pydantic.injectors import _parse_func_signature
class User(BaseModel):
firstname: str
@ -9,7 +13,6 @@ class User(BaseModel):
def test_parse_func_signature():
def body_only(self, user: User):
pass
@ -37,13 +40,42 @@ def test_parse_func_signature():
def path_body_qs_and_header(self, id: str, /, user: User, page: int, *, auth: UUID):
pass
assert _parse_func_signature(body_only) == ({}, {'user': User}, {}, {})
assert _parse_func_signature(path_only) == ({'id': str}, {}, {}, {})
assert _parse_func_signature(qs_only) == ({}, {}, {'page': int}, {})
assert _parse_func_signature(header_only) == ({}, {}, {}, {'auth': UUID})
assert _parse_func_signature(path_and_qs) == ({'id': str}, {}, {'page': int}, {})
assert _parse_func_signature(path_and_header) == ({'id': str}, {}, {}, {'auth': UUID})
assert _parse_func_signature(qs_and_header) == ({}, {}, {'page': int}, {'auth': UUID})
assert _parse_func_signature(path_qs_and_header) == ({'id': str}, {}, {'page': int}, {'auth': UUID})
assert _parse_func_signature(path_body_qs_and_header) == ({'id': str}, {'user': User}, {'page': int}, {'auth': UUID})
assert _parse_func_signature(body_only) == ({}, {"user": User}, {}, {}, {})
assert _parse_func_signature(path_only) == ({"id": str}, {}, {}, {}, {})
assert _parse_func_signature(qs_only) == ({}, {}, {"page": int}, {}, {})
assert _parse_func_signature(header_only) == ({}, {}, {}, {"auth": UUID}, {})
assert _parse_func_signature(path_and_qs) == (
{"id": str},
{},
{"page": int},
{},
{},
)
assert _parse_func_signature(path_and_header) == (
{"id": str},
{},
{},
{"auth": UUID},
{},
)
assert _parse_func_signature(qs_and_header) == (
{},
{},
{"page": int},
{"auth": UUID},
{},
)
assert _parse_func_signature(path_qs_and_header) == (
{"id": str},
{},
{"page": int},
{"auth": UUID},
{},
)
assert _parse_func_signature(path_body_qs_and_header) == (
{"id": str},
{"user": User},
{"page": int},
{"auth": UUID},
{},
)

View File

@ -1,6 +1,10 @@
from pydantic import BaseModel
from typing import Optional
from __future__ import annotations
from typing import Iterator, List, Optional
from aiohttp import web
from pydantic import BaseModel
from aiohttp_pydantic import PydanticView
@ -9,44 +13,119 @@ class ArticleModel(BaseModel):
nb_page: Optional[int]
class ArticleView(PydanticView):
class ArticleModels(BaseModel):
__root__: List[ArticleModel]
def __iter__(self) -> Iterator[ArticleModel]:
return iter(self.__root__)
class ArticleView(PydanticView):
async def post(self, article: ArticleModel):
return web.json_response(article.dict())
async def put(self, articles: ArticleModels):
return web.json_response([article.dict() for article in articles])
async def test_post_an_article_without_required_field_should_return_an_error_message(aiohttp_client, loop):
async def test_post_an_article_without_required_field_should_return_an_error_message(
aiohttp_client, loop
):
app = web.Application()
app.router.add_view('/article', ArticleView)
app.router.add_view("/article", ArticleView)
client = await aiohttp_client(app)
resp = await client.post('/article', json={})
resp = await client.post("/article", json={})
assert resp.status == 400
assert resp.content_type == 'application/json'
assert await resp.json() == [{'loc': ['name'],
'msg': 'field required',
'type': 'value_error.missing'}]
assert resp.content_type == "application/json"
assert await resp.json() == [
{
"in": "body",
"loc": ["name"],
"msg": "field required",
"type": "value_error.missing",
}
]
async def test_post_an_article_with_wrong_type_field_should_return_an_error_message(aiohttp_client, loop):
async def test_post_an_article_with_wrong_type_field_should_return_an_error_message(
aiohttp_client, loop
):
app = web.Application()
app.router.add_view('/article', ArticleView)
app.router.add_view("/article", ArticleView)
client = await aiohttp_client(app)
resp = await client.post('/article', json={'name': 'foo', 'nb_page': 'foo'})
resp = await client.post("/article", json={"name": "foo", "nb_page": "foo"})
assert resp.status == 400
assert resp.content_type == 'application/json'
assert await resp.json() == [{'loc': ['nb_page'],
'msg': 'value is not a valid integer',
'type': 'type_error.integer'}]
assert resp.content_type == "application/json"
assert await resp.json() == [
{
"in": "body",
"loc": ["nb_page"],
"msg": "value is not a valid integer",
"type": "type_error.integer",
}
]
async def test_post_an_array_json_is_supported(aiohttp_client, loop):
app = web.Application()
app.router.add_view("/article", ArticleView)
client = await aiohttp_client(app)
body = [{"name": "foo", "nb_page": 3}] * 2
resp = await client.put("/article", json=body)
assert resp.status == 200
assert resp.content_type == "application/json"
assert await resp.json() == body
async def test_post_an_array_json_to_an_object_model_should_return_an_error(
aiohttp_client, loop
):
app = web.Application()
app.router.add_view("/article", ArticleView)
client = await aiohttp_client(app)
resp = await client.post("/article", json=[{"name": "foo", "nb_page": 3}])
assert resp.status == 400
assert resp.content_type == "application/json"
assert await resp.json() == [
{
"in": "body",
"loc": ["__root__"],
"msg": "value is not a valid dict",
"type": "type_error.dict",
}
]
async def test_post_an_object_json_to_a_list_model_should_return_an_error(
aiohttp_client, loop
):
app = web.Application()
app.router.add_view("/article", ArticleView)
client = await aiohttp_client(app)
resp = await client.put("/article", json={"name": "foo", "nb_page": 3})
assert resp.status == 400
assert resp.content_type == "application/json"
assert await resp.json() == [
{
"in": "body",
"loc": ["__root__"],
"msg": "value is not a valid list",
"type": "type_error.list",
}
]
async def test_post_a_valid_article_should_return_the_parsed_type(aiohttp_client, loop):
app = web.Application()
app.router.add_view('/article', ArticleView)
app.router.add_view("/article", ArticleView)
client = await aiohttp_client(app)
resp = await client.post('/article', json={'name': 'foo', 'nb_page': 3})
resp = await client.post("/article", json={"name": "foo", "nb_page": 3})
assert resp.status == 200
assert resp.content_type == 'application/json'
assert await resp.json() == {'name': 'foo', 'nb_page': 3}
assert resp.content_type == "application/json"
assert await resp.json() == {"name": "foo", "nb_page": 3}

View File

@ -1,11 +1,16 @@
from aiohttp import web
from aiohttp_pydantic import PydanticView
from datetime import datetime
from __future__ import annotations
import json
from datetime import datetime
from enum import Enum
from aiohttp import web
from aiohttp_pydantic import PydanticView
from aiohttp_pydantic.injectors import Group
class JSONEncoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, datetime):
return o.isoformat()
@ -14,54 +19,164 @@ class JSONEncoder(json.JSONEncoder):
class ArticleView(PydanticView):
async def get(self, *, signature_expired: datetime):
return web.json_response({'signature': signature_expired}, dumps=JSONEncoder().encode)
return web.json_response(
{"signature": signature_expired}, dumps=JSONEncoder().encode
)
async def test_get_article_without_required_header_should_return_an_error_message(aiohttp_client, loop):
class FormatEnum(str, Enum):
UTM = "UMT"
MGRS = "MGRS"
class ViewWithEnumType(PydanticView):
async def get(self, *, format: FormatEnum):
return web.json_response({"format": format}, dumps=JSONEncoder().encode)
class Signature(Group):
signature_expired: datetime
signature_scope: str = "read"
@property
def expired(self) -> datetime:
return self.signature_expired
@property
def scope(self) -> str:
return self.signature_scope
class ArticleViewWithSignatureGroup(PydanticView):
async def get(
self,
*,
signature: Signature,
):
return web.json_response(
{"expired": signature.expired, "scope": signature.scope},
dumps=JSONEncoder().encode,
)
async def test_get_article_without_required_header_should_return_an_error_message(
aiohttp_client, loop
):
app = web.Application()
app.router.add_view('/article', ArticleView)
app.router.add_view("/article", ArticleView)
client = await aiohttp_client(app)
resp = await client.get('/article', headers={})
resp = await client.get("/article", headers={})
assert resp.status == 400
assert resp.content_type == 'application/json'
assert await resp.json() == [{'loc': ['signature_expired'],
'msg': 'field required',
'type': 'value_error.missing'}]
assert resp.content_type == "application/json"
assert await resp.json() == [
{
"in": "headers",
"loc": ["signature_expired"],
"msg": "field required",
"type": "value_error.missing",
}
]
async def test_get_article_with_wrong_header_type_should_return_an_error_message(aiohttp_client, loop):
async def test_get_article_with_wrong_header_type_should_return_an_error_message(
aiohttp_client, loop
):
app = web.Application()
app.router.add_view('/article', ArticleView)
app.router.add_view("/article", ArticleView)
client = await aiohttp_client(app)
resp = await client.get('/article', headers={'signature_expired': 'foo'})
resp = await client.get("/article", headers={"signature_expired": "foo"})
assert resp.status == 400
assert resp.content_type == 'application/json'
assert await resp.json() == [{'loc': ['signature_expired'],
'msg': 'invalid datetime format',
'type': 'value_error.datetime'}]
assert resp.content_type == "application/json"
assert await resp.json() == [
{
"in": "headers",
"loc": ["signature_expired"],
"msg": "invalid datetime format",
"type": "value_error.datetime",
}
]
async def test_get_article_with_valid_header_should_return_the_parsed_type(aiohttp_client, loop):
async def test_get_article_with_valid_header_should_return_the_parsed_type(
aiohttp_client, loop
):
app = web.Application()
app.router.add_view('/article', ArticleView)
app.router.add_view("/article", ArticleView)
client = await aiohttp_client(app)
resp = await client.get('/article', headers={'signature_expired': '2020-10-04T18:01:00'})
resp = await client.get(
"/article", headers={"signature_expired": "2020-10-04T18:01:00"}
)
assert resp.status == 200
assert resp.content_type == 'application/json'
assert await resp.json() == {'signature': '2020-10-04T18:01:00'}
assert resp.content_type == "application/json"
assert await resp.json() == {"signature": "2020-10-04T18:01:00"}
async def test_get_article_with_valid_header_containing_hyphen_should_be_returned(aiohttp_client, loop):
async def test_get_article_with_valid_header_containing_hyphen_should_be_returned(
aiohttp_client, loop
):
app = web.Application()
app.router.add_view('/article', ArticleView)
app.router.add_view("/article", ArticleView)
client = await aiohttp_client(app)
resp = await client.get('/article', headers={'Signature-Expired': '2020-10-04T18:01:00'})
resp = await client.get(
"/article", headers={"Signature-Expired": "2020-10-04T18:01:00"}
)
assert resp.status == 200
assert resp.content_type == 'application/json'
assert await resp.json() == {'signature': '2020-10-04T18:01:00'}
assert resp.content_type == "application/json"
assert await resp.json() == {"signature": "2020-10-04T18:01:00"}
async def test_wrong_value_to_header_defined_with_str_enum(aiohttp_client, loop):
app = web.Application()
app.router.add_view("/coord", ViewWithEnumType)
client = await aiohttp_client(app)
resp = await client.get("/coord", headers={"format": "WGS84"})
assert (
await resp.json()
== [
{
"ctx": {"enum_values": ["UMT", "MGRS"]},
"in": "headers",
"loc": ["format"],
"msg": "value is not a valid enumeration member; permitted: 'UMT', 'MGRS'",
"type": "type_error.enum",
}
]
!= {"signature": "2020-10-04T18:01:00"}
)
assert resp.status == 400
assert resp.content_type == "application/json"
async def test_correct_value_to_header_defined_with_str_enum(aiohttp_client, loop):
app = web.Application()
app.router.add_view("/coord", ViewWithEnumType)
client = await aiohttp_client(app)
resp = await client.get("/coord", headers={"format": "UMT"})
assert await resp.json() == {"format": "UMT"}
assert resp.status == 200
assert resp.content_type == "application/json"
async def test_with_signature_group(aiohttp_client, loop):
app = web.Application()
app.router.add_view("/article", ArticleViewWithSignatureGroup)
client = await aiohttp_client(app)
resp = await client.get(
"/article",
headers={
"signature_expired": "2020-10-04T18:01:00",
"signature.scope": "write",
},
)
assert await resp.json() == {"expired": "2020-10-04T18:01:00", "scope": "read"}
assert resp.status == 200
assert resp.content_type == "application/json"

View File

@ -1,20 +1,43 @@
from __future__ import annotations
from aiohttp import web
from aiohttp_pydantic import PydanticView
class ArticleView(PydanticView):
async def get(self, author_id: str, tag: str, date: int, /):
return web.json_response({'path': [author_id, tag, date]})
return web.json_response({"path": [author_id, tag, date]})
async def test_get_article_without_required_qs_should_return_an_error_message(aiohttp_client, loop):
async def test_get_article_with_correct_path_parameters_should_return_parameters_in_path(
aiohttp_client, loop
):
app = web.Application()
app.router.add_view('/article/{author_id}/tag/{tag}/before/{date}', ArticleView)
app.router.add_view("/article/{author_id}/tag/{tag}/before/{date}", ArticleView)
client = await aiohttp_client(app)
resp = await client.get('/article/1234/tag/music/before/1980')
resp = await client.get("/article/1234/tag/music/before/1980")
assert resp.status == 200
assert resp.content_type == 'application/json'
assert await resp.json() == {'path': ['1234', 'music', 1980]}
assert resp.content_type == "application/json"
assert await resp.json() == {"path": ["1234", "music", 1980]}
async def test_get_article_with_wrong_path_parameters_should_return_error(
aiohttp_client, loop
):
app = web.Application()
app.router.add_view("/article/{author_id}/tag/{tag}/before/{date}", ArticleView)
client = await aiohttp_client(app)
resp = await client.get("/article/1234/tag/music/before/now")
assert resp.status == 400
assert resp.content_type == "application/json"
assert await resp.json() == [
{
"in": "path",
"loc": ["date"],
"msg": "value is not a valid integer",
"type": "type_error.integer",
}
]

View File

@ -1,45 +1,257 @@
from __future__ import annotations
from typing import Optional, List
from pydantic import Field
from aiohttp import web
from aiohttp_pydantic import PydanticView
from aiohttp_pydantic.injectors import Group
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,
nb_items: int = 7,
tags: List[str] = Field(default_factory=list),
):
return web.json_response(
{
"with_comments": with_comments,
"age": age,
"nb_items": nb_items,
"tags": tags,
}
)
async def test_get_article_without_required_qs_should_return_an_error_message(aiohttp_client, loop):
class Pagination(Group):
page_num: int
page_size: int = 20
@property
def num(self) -> int:
return self.page_num
@property
def size(self) -> int:
return self.page_size
class ArticleViewWithPaginationGroup(PydanticView):
async def get(
self,
with_comments: bool,
page: Pagination,
):
return web.json_response(
{
"with_comments": with_comments,
"page_num": page.num,
"page_size": page.size,
}
)
async def test_get_article_without_required_qs_should_return_an_error_message(
aiohttp_client, loop
):
app = web.Application()
app.router.add_view('/article', ArticleView)
app.router.add_view("/article", ArticleView)
client = await aiohttp_client(app)
resp = await client.get('/article')
resp = await client.get("/article")
assert resp.status == 400
assert resp.content_type == 'application/json'
assert await resp.json() == [{'loc': ['with_comments'],
'msg': 'field required',
'type': 'value_error.missing'}]
assert resp.content_type == "application/json"
assert await resp.json() == [
{
"in": "query string",
"loc": ["with_comments"],
"msg": "field required",
"type": "value_error.missing",
}
]
async def test_get_article_with_wrong_qs_type_should_return_an_error_message(aiohttp_client, loop):
async def test_get_article_with_wrong_qs_type_should_return_an_error_message(
aiohttp_client, loop
):
app = web.Application()
app.router.add_view('/article', ArticleView)
app.router.add_view("/article", ArticleView)
client = await aiohttp_client(app)
resp = await client.get('/article', params={'with_comments': 'foo'})
resp = await client.get("/article", params={"with_comments": "foo"})
assert resp.status == 400
assert resp.content_type == 'application/json'
assert await resp.json() == [{'loc': ['with_comments'],
'msg': 'value could not be parsed to a boolean',
'type': 'type_error.bool'}]
assert resp.content_type == "application/json"
assert await resp.json() == [
{
"in": "query string",
"loc": ["with_comments"],
"msg": "value could not be parsed to a boolean",
"type": "type_error.bool",
}
]
async def test_get_article_with_valid_qs_should_return_the_parsed_type(aiohttp_client, loop):
async def test_get_article_with_valid_qs_should_return_the_parsed_type(
aiohttp_client, loop
):
app = web.Application()
app.router.add_view('/article', ArticleView)
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", "age": 3})
assert resp.status == 200
assert resp.content_type == 'application/json'
assert await resp.json() == {'with_comments': True}
assert resp.content_type == "application/json"
assert await resp.json() == {
"with_comments": True,
"age": 3,
"nb_items": 7,
"tags": [],
}
async def test_get_article_with_valid_qs_and_omitted_optional_should_return_default_value(
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 await resp.json() == {
"with_comments": True,
"age": None,
"nb_items": 7,
"tags": [],
}
assert resp.status == 200
assert resp.content_type == "application/json"
async def test_get_article_with_multiple_value_for_qs_age_must_failed(
aiohttp_client, loop
):
app = web.Application()
app.router.add_view("/article", ArticleView)
client = await aiohttp_client(app)
resp = await client.get("/article", params={"age": ["2", "3"], "with_comments": 1})
assert await resp.json() == [
{
"in": "query string",
"loc": ["age"],
"msg": "value is not a valid integer",
"type": "type_error.integer",
}
]
assert resp.status == 400
assert resp.content_type == "application/json"
async def test_get_article_with_multiple_value_of_tags(aiohttp_client, loop):
app = web.Application()
app.router.add_view("/article", ArticleView)
client = await aiohttp_client(app)
resp = await client.get(
"/article", params={"age": 2, "with_comments": 1, "tags": ["aa", "bb"]}
)
assert await resp.json() == {
"age": 2,
"nb_items": 7,
"tags": ["aa", "bb"],
"with_comments": True,
}
assert resp.status == 200
assert resp.content_type == "application/json"
async def test_get_article_with_one_value_of_tags_must_be_a_list(aiohttp_client, loop):
app = web.Application()
app.router.add_view("/article", ArticleView)
client = await aiohttp_client(app)
resp = await client.get(
"/article", params={"age": 2, "with_comments": 1, "tags": ["aa"]}
)
assert await resp.json() == {
"age": 2,
"nb_items": 7,
"tags": ["aa"],
"with_comments": True,
}
assert resp.status == 200
assert resp.content_type == "application/json"
async def test_get_article_without_required_field_page(aiohttp_client, loop):
app = web.Application()
app.router.add_view("/article", ArticleViewWithPaginationGroup)
client = await aiohttp_client(app)
resp = await client.get("/article", params={"with_comments": 1})
assert await resp.json() == [
{
"in": "query string",
"loc": ["page_num"],
"msg": "field required",
"type": "value_error.missing",
}
]
assert resp.status == 400
assert resp.content_type == "application/json"
async def test_get_article_with_page(aiohttp_client, loop):
app = web.Application()
app.router.add_view("/article", ArticleViewWithPaginationGroup)
client = await aiohttp_client(app)
resp = await client.get("/article", params={"with_comments": 1, "page_num": 2})
assert await resp.json() == {"page_num": 2, "page_size": 20, "with_comments": True}
assert resp.status == 200
assert resp.content_type == "application/json"
async def test_get_article_with_page_and_page_size(aiohttp_client, loop):
app = web.Application()
app.router.add_view("/article", ArticleViewWithPaginationGroup)
client = await aiohttp_client(app)
resp = await client.get(
"/article", params={"with_comments": 1, "page_num": 1, "page_size": 10}
)
assert await resp.json() == {"page_num": 1, "page_size": 10, "with_comments": True}
assert resp.status == 200
assert resp.content_type == "application/json"
async def test_get_article_with_page_and_wrong_page_size(aiohttp_client, loop):
app = web.Application()
app.router.add_view("/article", ArticleViewWithPaginationGroup)
client = await aiohttp_client(app)
resp = await client.get(
"/article", params={"with_comments": 1, "page_num": 1, "page_size": "large"}
)
assert await resp.json() == [
{
"in": "query string",
"loc": ["page_size"],
"msg": "value is not a valid integer",
"type": "type_error.integer",
}
]
assert resp.status == 400
assert resp.content_type == "application/json"