Compare commits

...

57 Commits
v1.1.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
43 changed files with 3204 additions and 212 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/ .idea/
.pypirc
.pytest_cache .pytest_cache
__pycache__ __pycache__
aiohttp_pydantic.egg-info/ aiohttp_pydantic.egg-info/
build/ build/
coverage.xml
dist/ dist/
dist_venv/
venv/

View File

@ -1,19 +0,0 @@
language: python
python:
- '3.8'
script:
- pytest tests/
install:
- pip install -U setuptools wheel pip
- pip install -r test_requirements.txt
- pip install .
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 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 How to install
-------------- --------------
@ -32,7 +55,7 @@ Example:
return web.json_response({'name': article.name, return web.json_response({'name': article.name,
'number_of_page': article.nb_page}) '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}) 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 $ curl -X GET http://127.0.0.1:8080/article?with_comments=a
[ [
{ {
"in": "query string",
"loc": [ "loc": [
"with_comments" "with_comments"
], ],
@ -60,6 +84,7 @@ Example:
$ curl -H "Content-Type: application/json" -X post http://127.0.0.1:8080/article --data '{}' $ curl -H "Content-Type: application/json" -X post http://127.0.0.1:8080/article --data '{}'
[ [
{ {
"in": "body",
"loc": [ "loc": [
"name" "name"
], ],
@ -77,7 +102,7 @@ API:
Inject Path Parameters 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: Example:
@ -94,22 +119,40 @@ Example:
Inject Query String Parameters 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 .. code-block:: python3
class AccountView(PydanticView): class AccountView(PydanticView):
async def get(self, customer_id: str): async def get(self, customer_id: Optional[str] = None):
... ...
app = web.Application() app = web.Application()
app.router.add_get('/customers', AccountView) 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 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 .. code-block:: python3
@ -128,7 +171,7 @@ To declare a body parameters, you must declare your argument as a simple argumen
Inject HTTP headers 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 .. code-block:: python3
@ -145,8 +188,8 @@ To declare a HTTP headers parameters, you must declare your argument as a `keywo
.. _pydantic Model: https://pydantic-docs.helpmanual.io/usage/models/ .. _pydantic Model: https://pydantic-docs.helpmanual.io/usage/models/
.. _keyword-only argument: https://www.python.org/dev/peps/pep-3102/ .. _keyword-only argument: https://www.python.org/dev/peps/pep-3102/
Add route to generate Open Api Specification Add route to generate Open Api Specification (OAS)
-------------------------------------------- --------------------------------------------------
aiohttp_pydantic provides a sub-application to serve a route to generate Open Api Specification aiohttp_pydantic provides a sub-application to serve a route to generate Open Api Specification
reading annotation in your PydanticView. Use *aiohttp_pydantic.oas.setup()* to add the sub-application reading annotation in your PydanticView. Use *aiohttp_pydantic.oas.setup()* to add the sub-application
@ -168,8 +211,8 @@ By default, the route to display the Open Api Specification is /oas but you can
oas.setup(app, url_prefix='/spec-api') oas.setup(app, url_prefix='/spec-api')
If you want generate the Open Api Specification from several aiohttp sub-application. If you want generate the Open Api Specification from specific aiohttp sub-applications.
on the same route, you must use *apps_to_expose* parameters on the same route, you must use *apps_to_expose* parameter.
.. code-block:: python3 .. code-block:: python3
@ -179,11 +222,186 @@ on the same route, you must use *apps_to_expose* parameters
app = web.Application() app = web.Application()
sub_app_1 = web.Application() sub_app_1 = web.Application()
sub_app_2 = web.Application()
oas.setup(app, apps_to_expose=[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)
oas.setup(app, apps_to_expose=[app, sub_app_1])
Demo Demo
==== ----
Have a look at `demo`_ for a complete example Have a look at `demo`_ for a complete example
@ -196,6 +414,35 @@ Have a look at `demo`_ for a complete example
Go to http://127.0.0.1:8080/oas Go to http://127.0.0.1:8080/oas
You can generate the OAS in a json 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 .. _demo: https://github.com/Maillol/aiohttp-pydantic/tree/main/demo
.. _aiohttp view: https://docs.aiohttp.org/en/stable/web_quickstart.html#class-based-views

View File

@ -1,5 +1,5 @@
from .view import PydanticView from .view import PydanticView
__version__ = "1.0.0" __version__ = "1.12.1"
__all__ = ("PydanticView", "__version__") __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 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): 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. An injector parse HTTP request and inject params to the view.
""" """
model: Type[BaseModel]
@property
@abc.abstractmethod @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 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. Validates and injects the part of URL path inside the view positional args.
""" """
def __init__(self, args_spec: dict): context = "path"
self.model = type("PathModel", (BaseModel,), {"__annotations__": args_spec})
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): def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict):
args_view.extend(self.model(**request.match_info).dict().values()) 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. 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.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): async def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict):
body = await request.json() try:
kwargs_view[self.arg_name] = self.model(**body) body = await request.json()
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): class QueryGetter(AbstractInjector):
@ -56,11 +95,49 @@ class QueryGetter(AbstractInjector):
Validates and injects the query string inside the view kwargs. Validates and injects the query string inside the view kwargs.
""" """
def __init__(self, args_spec: dict): context = "query string"
self.model = type("QueryModel", (BaseModel,), {"__annotations__": args_spec})
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): 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): class HeadersGetter(AbstractInjector):
@ -68,45 +145,162 @@ class HeadersGetter(AbstractInjector):
Validates and injects the HTTP headers inside the view kwargs. Validates and injects the HTTP headers inside the view kwargs.
""" """
def __init__(self, args_spec: dict): context = "headers"
self.model = type("HeaderModel", (BaseModel,), {"__annotations__": args_spec})
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): def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict):
header = {k.lower().replace("-", "_"): v for k, v in request.headers.items()} 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 0 - arguments will be set from the url path
1 - argument will be set from the request body. 1 - argument will be set from the request body.
2 - argument will be set from the query string. 2 - argument will be set from the query string.
3 - argument will be set from the HTTP headers. 3 - argument will be set from the HTTP headers.
4 - Default value for each parameters
""" """
path_args = {} path_args = {}
body_args = {} body_args = {}
qs_args = {} qs_args = {}
header_args = {} header_args = {}
defaults = {}
annotations = get_type_hints(func)
for param_name, param_spec in signature(func).parameters.items(): for param_name, param_spec in signature(func).parameters.items():
if param_name == "self": if param_name == "self":
continue continue
if param_spec.annotation == param_spec.empty: if param_spec.annotation == param_spec.empty:
raise RuntimeError(f"The parameter {param_name} must have an annotation") 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: 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: elif param_spec.kind is param_spec.POSITIONAL_OR_KEYWORD:
if issubclass(param_spec.annotation, BaseModel): if is_pydantic_base_model(annotation):
body_args[param_name] = param_spec.annotation body_args[param_name] = annotation
else: else:
qs_args[param_name] = param_spec.annotation qs_args[param_name] = annotation
elif param_spec.kind is param_spec.KEYWORD_ONLY: elif param_spec.kind is param_spec.KEYWORD_ONLY:
header_args[param_name] = param_spec.annotation header_args[param_name] = annotation
else: else:
raise RuntimeError(f"You cannot use {param_spec.VAR_POSITIONAL} parameters") 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

@ -1,17 +1,20 @@
from typing import Iterable
from importlib import resources from importlib import resources
from typing import Iterable, Optional
import jinja2 import jinja2
from aiohttp import web from aiohttp import web
from .view import get_oas, oas_ui
from swagger_ui_bundle import swagger_ui_path from swagger_ui_bundle import swagger_ui_path
from .view import get_oas, oas_ui
def setup( def setup(
app: web.Application, app: web.Application,
apps_to_expose: Iterable[web.Application] = (), apps_to_expose: Iterable[web.Application] = (),
url_prefix: str = "/oas", url_prefix: str = "/oas",
enable: bool = True, enable: bool = True,
version_spec: Optional[str] = None,
title_spec: Optional[str] = None,
): ):
if enable: if enable:
oas_app = web.Application() oas_app = web.Application()
@ -19,6 +22,9 @@ def setup(
oas_app["index template"] = jinja2.Template( oas_app["index template"] = jinja2.Template(
resources.read_text("aiohttp_pydantic.oas", "index.j2") resources.read_text("aiohttp_pydantic.oas", "index.j2")
) )
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_get("/spec", get_oas, name="spec")
oas_app.router.add_static("/static", swagger_ui_path, name="static") oas_app.router.add_static("/static", swagger_ui_path, name="static")
oas_app.router.add_get("", oas_ui, name="index") oas_app.router.add_get("", oas_ui, name="index")

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

@ -1,10 +1,17 @@
"""
Utility to write Open Api Specifications using the Python language.
"""
from typing import Union, List
class Info: class Info:
def __init__(self, spec: dict): def __init__(self, spec: dict):
self._spec = spec.setdefault("info", {}) self._spec = spec.setdefault("info", {})
@property @property
def title(self): def title(self):
return self._spec["title"] return self._spec.get("title")
@title.setter @title.setter
def title(self, title): def title(self, title):
@ -12,7 +19,7 @@ class Info:
@property @property
def description(self): def description(self):
return self._spec["description"] return self._spec.get("description")
@description.setter @description.setter
def description(self, description): def description(self, description):
@ -20,12 +27,20 @@ class Info:
@property @property
def version(self): def version(self):
return self._spec["version"] return self._spec.get("version")
@version.setter @version.setter
def version(self, version): def version(self, version):
self._spec["version"] = version self._spec["version"] = version
@property
def terms_of_service(self):
return self._spec.get("termsOfService")
@terms_of_service.setter
def terms_of_service(self, terms_of_service):
self._spec["termsOfService"] = terms_of_service
class RequestBody: class RequestBody:
def __init__(self, spec: dict): def __init__(self, spec: dict):
@ -40,8 +55,8 @@ class RequestBody:
self._spec["description"] = description self._spec["description"] = description
@property @property
def required(self): def required(self) -> bool:
return self._spec["required"] return self._spec.get("required", False)
@required.setter @required.setter
def required(self, required: bool): def required(self, required: bool):
@ -115,6 +130,40 @@ class Parameters:
return Parameter(spec) 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: class OperationObject:
def __init__(self, spec: dict): def __init__(self, spec: dict):
self._spec = spec self._spec = spec
@ -143,6 +192,21 @@ class OperationObject:
def parameters(self) -> Parameters: def parameters(self) -> Parameters:
return Parameters(self._spec) 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: class PathItem:
def __init__(self, spec: dict): def __init__(self, spec: dict):
@ -180,6 +244,22 @@ class PathItem:
def trace(self) -> OperationObject: def trace(self) -> OperationObject:
return OperationObject(self._spec.setdefault("trace", {})) return OperationObject(self._spec.setdefault("trace", {}))
@property
def description(self) -> str:
return self._spec["description"]
@description.setter
def description(self, description: str):
self._spec["description"] = description
@property
def summary(self) -> str:
return self._spec["summary"]
@summary.setter
def summary(self, summary: str):
self._spec["summary"] = summary
class Paths: class Paths:
def __init__(self, spec: dict): def __init__(self, spec: dict):
@ -204,7 +284,7 @@ class Server:
@property @property
def description(self) -> str: def description(self) -> str:
return self._spec["url"] return self._spec["description"]
@description.setter @description.setter
def description(self, description: str): def description(self, description: str):
@ -225,9 +305,21 @@ class Servers:
return Server(spec) 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: class OpenApiSpec3:
def __init__(self): def __init__(self):
self._spec = {"openapi": "3.0.0"} self._spec = {
"openapi": "3.0.0",
"info": {"version": "1.0.0", "title": "Aiohttp pydantic application"},
}
@property @property
def info(self) -> Info: def info(self) -> Info:
@ -241,6 +333,10 @@ class OpenApiSpec3:
def paths(self) -> Paths: def paths(self) -> Paths:
return Paths(self._spec) return Paths(self._spec)
@property
def components(self) -> Components:
return Components(self._spec)
@property @property
def spec(self): def spec(self):
return self._spec 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

@ -1,66 +1,180 @@
from aiohttp.web import json_response, Response 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 aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem
from typing import Type from . import docstring_parser
from ..injectors import _parse_func_signature from ..injectors import _parse_func_signature
from ..utils import is_pydantic_base_model
from ..view import PydanticView, is_pydantic_view from ..view import PydanticView, is_pydantic_view
from .typing import is_status_code_type
JSON_SCHEMA_TYPES = {float: "number", str: "string", int: "integer"} 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_path: PathItem, method: str, view: Type[PydanticView]): def _add_http_method_to_oas(
method = method.lower() oas: OpenApiSpec3, oas_path: PathItem, http_method: str, view: Type[PydanticView]
mtd: OperationObject = getattr(oas_path, method) ):
handler = getattr(view, method) http_method = http_method.lower()
path_args, body_args, qs_args, header_args = _parse_func_signature(handler) 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: if body_args:
mtd.request_body.content = { body_schema = (
"application/json": {"schema": next(iter(body_args.values())).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}
} }
i = 0 indexes = count()
for i, (name, type_) in enumerate(path_args.items()): for args_location, args in (
mtd.parameters[i].required = True ("path", path_args.items()),
mtd.parameters[i].in_ = "path" ("query", qs_args.items()),
mtd.parameters[i].name = name ("header", header_args.items()),
mtd.parameters[i].schema = {"type": JSON_SCHEMA_TYPES[type_]} ):
for name, type_ in args:
i = next(indexes)
oas_operation.parameters[i].in_ = args_location
oas_operation.parameters[i].name = name
for i, (name, type_) in enumerate(qs_args.items(), i + 1): attrs = {"__annotations__": {"__root__": type_}}
mtd.parameters[i].required = False if name in defaults:
mtd.parameters[i].in_ = "query" attrs["__root__"] = defaults[name]
mtd.parameters[i].name = name oas_operation.parameters[i].required = False
mtd.parameters[i].schema = {"type": JSON_SCHEMA_TYPES[type_]} else:
oas_operation.parameters[i].required = True
for i, (name, type_) in enumerate(header_args.items(), i + 1): oas_operation.parameters[i].schema = type(name, (BaseModel,), attrs).schema(
mtd.parameters[i].required = False ref_template="#/components/schemas/{model}"
mtd.parameters[i].in_ = "header" )
mtd.parameters[i].name = name
mtd.parameters[i].schema = {"type": JSON_SCHEMA_TYPES[type_]} 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): async def get_oas(request):
""" """
Generate Open Api Specification from PydanticView in application. View to generate the Open Api Specification from PydanticView in application.
""" """
apps = request.app["apps to expose"] apps = request.app["apps to expose"]
oas = OpenApiSpec3() version_spec = request.app["version_spec"]
for app in apps: title_spec = request.app["title_spec"]
for resources in app.router.resources(): return json_response(generate_oas(apps, version_spec, title_spec))
for resource_route in resources:
if is_pydantic_view(resource_route.handler):
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(path, method_name, view)
else:
_add_http_method_to_oas(path, resource_route.method, view)
return json_response(oas.spec)
async def oas_ui(request): async def oas_ui(request):

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,21 +1,24 @@
from functools import update_wrapper
from inspect import iscoroutinefunction from inspect import iscoroutinefunction
from typing import Any, Callable, Generator, Iterable, Set, ClassVar
import warnings
from aiohttp.abc import AbstractView from aiohttp.abc import AbstractView
from aiohttp.hdrs import METH_ALL from aiohttp.hdrs import METH_ALL
from aiohttp.web import json_response
from aiohttp.web_exceptions import HTTPMethodNotAllowed from aiohttp.web_exceptions import HTTPMethodNotAllowed
from aiohttp.web_response import StreamResponse from aiohttp.web_response import StreamResponse
from pydantic import ValidationError from pydantic import ValidationError
from typing import Generator, Any, Callable, Type, Iterable
from aiohttp.web import json_response
from functools import update_wrapper
from .injectors import ( from .injectors import (
MatchInfoGetter,
HeadersGetter,
QueryGetter,
BodyGetter,
AbstractInjector, AbstractInjector,
BodyGetter,
HeadersGetter,
MatchInfoGetter,
QueryGetter,
_parse_func_signature, _parse_func_signature,
CONTEXT,
Group,
) )
@ -24,44 +27,85 @@ class PydanticView(AbstractView):
An AIOHTTP View that validate request using function annotations. 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: async def _iter(self) -> StreamResponse:
method = getattr(self, self.request.method.lower(), None) if (method_name := self.request.method) not in self.allowed_methods:
resp = await method() self._raise_allowed_methods()
return resp return await getattr(self, method_name.lower())()
def __await__(self) -> Generator[Any, None, StreamResponse]: def __await__(self) -> Generator[Any, None, StreamResponse]:
return self._iter().__await__() return self._iter().__await__()
def __init_subclass__(cls, **kwargs): 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 = { cls.allowed_methods = {
meth_name for meth_name in METH_ALL if hasattr(cls, meth_name.lower()) meth_name for meth_name in METH_ALL if hasattr(cls, meth_name.lower())
} }
for meth_name in METH_ALL: for meth_name in METH_ALL:
if meth_name not in cls.allowed_methods: if meth_name.lower() in vars(cls):
setattr(cls, meth_name.lower(), cls.raise_not_allowed)
else:
handler = getattr(cls, meth_name.lower()) handler = getattr(cls, meth_name.lower())
decorated_handler = inject_params(handler, cls.parse_func_signature) decorated_handler = inject_params(handler, cls.parse_func_signature)
setattr(cls, meth_name.lower(), decorated_handler) setattr(cls, meth_name.lower(), decorated_handler)
async def raise_not_allowed(self): def _raise_allowed_methods(self) -> None:
raise HTTPMethodNotAllowed(self.request.method, self.allowed_methods) 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 @staticmethod
def parse_func_signature(func: Callable) -> Iterable[AbstractInjector]: 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 = [] 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: if path_args:
injectors.append(MatchInfoGetter(path_args)) injectors.append(MatchInfoGetter(path_args, default_value(path_args)))
if body_args: if body_args:
injectors.append(BodyGetter(body_args)) injectors.append(BodyGetter(body_args, default_value(body_args)))
if qs_args: if qs_args:
injectors.append(QueryGetter(qs_args)) injectors.append(QueryGetter(qs_args, default_value(qs_args)))
if header_args: if header_args:
injectors.append(HeadersGetter(header_args)) injectors.append(HeadersGetter(header_args, default_value(header_args)))
return injectors 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( def inject_params(
handler, parse_func_signature: Callable[[Callable], Iterable[AbstractInjector]] handler, parse_func_signature: Callable[[Callable], Iterable[AbstractInjector]]
@ -83,7 +127,7 @@ def inject_params(
else: else:
injector.inject(self.request, args, kwargs) injector.inject(self.request, args, kwargs)
except ValidationError as error: except ValidationError as error:
return json_response(text=error.json(), status=400) return await self.on_validation_error(error, injector.context)
return await handler(self, *args, **kwargs) return await handler(self, *args, **kwargs)
@ -99,3 +143,14 @@ def is_pydantic_view(obj) -> bool:
return issubclass(obj, PydanticView) return issubclass(obj, PydanticView)
except TypeError: except TypeError:
return False return False
__all__ = (
"AbstractInjector",
"BodyGetter",
"HeadersGetter",
"MatchInfoGetter",
"QueryGetter",
"CONTEXT",
"Group",
)

View File

@ -1,25 +1,5 @@
from aiohttp import web from aiohttp import web
from aiohttp_pydantic import oas from .main import app
from aiohttp.web import middleware
from .view import PetItemView, PetCollectionView
from .model import Model
@middleware
async def pet_not_found_to_404(request, handler):
try:
return await handler(request)
except Model.NotFound as key:
return web.json_response({"error": f"Pet {key} does not exist"}, status=404)
app = web.Application(middlewares=[pet_not_found_to_404])
oas.setup(app)
app["model"] = Model()
app.router.add_view("/pets", PetCollectionView)
app.router.add_view("/pets/{id}", PetItemView)
web.run_app(app) web.run_app(app)

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

View File

@ -1,9 +1,21 @@
from pydantic import BaseModel from pydantic import BaseModel
from typing import List
class Friend(BaseModel):
name: str
age: str
class Pet(BaseModel): class Pet(BaseModel):
id: int id: int
name: str name: str
age: int
friends: Friend
class Error(BaseModel):
error: str
class Model: class Model:

View File

@ -1,28 +1,63 @@
from aiohttp_pydantic import PydanticView from typing import List, Optional, Union
from aiohttp import web from aiohttp import web
from .model import Pet from aiohttp_pydantic import PydanticView
from aiohttp_pydantic.oas.typing import r200, r201, r204, r404
from .model import Error, Pet
class PetCollectionView(PydanticView): class PetCollectionView(PydanticView):
async def get(self): async def get(self, age: Optional[int] = None) -> r200[List[Pet]]:
pets = self.request.app["model"].list_pets() """
return web.json_response([pet.dict() for pet in pets]) List all pets
async def post(self, pet: Pet): 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) self.request.app["model"].add_pet(pet)
return web.json_response(pet.dict()) return web.json_response(pet.dict())
class PetItemView(PydanticView): class PetItemView(PydanticView):
async def get(self, id: int, /): 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) pet = self.request.app["model"].find_pet(id)
return web.json_response(pet.dict()) return web.json_response(pet.dict())
async def put(self, id: int, /, pet: Pet): 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) self.request.app["model"].update_pet(id, pet)
return web.json_response(pet.dict()) return web.json_response(pet.dict())
async def delete(self, id: int, /): async def delete(self, id: int, /) -> r204:
"""
Deletes a pet
"""
self.request.app["model"].remove_pet(id) self.request.app["model"].remove_pet(id)
return web.json_response(id) return web.Response(status=204)

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

View File

@ -21,7 +21,7 @@ classifiers =
Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.9
Topic :: Software Development :: Libraries :: Application Frameworks Topic :: Software Development :: Libraries :: Application Frameworks
Framework :: AsyncIO Framework :: aiohttp
License :: OSI Approved :: MIT License License :: OSI Approved :: MIT License
[options] [options]
@ -31,17 +31,24 @@ packages = find:
python_requires = >=3.8 python_requires = >=3.8
install_requires = install_requires =
aiohttp aiohttp
pydantic pydantic>=1.7
swagger-ui-bundle swagger-ui-bundle
[options.extras_require] [options.extras_require]
test = pytest; pytest-aiohttp test =
pytest==6.1.2
pytest-aiohttp==0.3.0
pytest-cov==2.10.1
readme-renderer==29.0
ci =
%(test)s
codecov==2.1.11
twine==3.4.2
[options.packages.find] [options.packages.find]
exclude = exclude =
tests tests*
demo demo*
[options.package_data] [options.package_data]
aiohttp_pydantic.oas = index.j2 aiohttp_pydantic.oas = index.j2

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
typing_extensions>=3.6.5

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

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

View File

@ -1,46 +1,131 @@
from pydantic.main import BaseModel from __future__ import annotations
from aiohttp_pydantic import PydanticView, oas
from aiohttp import web from enum import Enum
from typing import List, Optional, Union, Literal
from uuid import UUID
import pytest 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): class Pet(BaseModel):
id: int id: int
name: str name: str
toys: List[Toy]
class PetCollectionView(PydanticView): class PetCollectionView(PydanticView):
async def get(self): 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() return web.json_response()
async def post(self, pet: Pet): async def post(self, pet: Pet) -> r201[Pet]:
"""Create a Pet"""
return web.json_response() return web.json_response()
class PetItemView(PydanticView): class PetItemView(PydanticView):
async def get(self, id: int, /): 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() return web.json_response()
async def put(self, id: int, /, pet: Pet): async def put(self, id: int, /, pet: Pet):
return web.json_response() return web.json_response()
async def delete(self, id: int, /): async def delete(self, id: int, /) -> r204:
"""
Status Code:
204: Empty but OK
"""
return web.json_response() 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 @pytest.fixture
async def generated_oas(aiohttp_client, loop) -> web.Application: async def generated_oas(aiohttp_client, loop) -> web.Application:
app = web.Application() app = web.Application()
app.router.add_view("/pets", PetCollectionView) app.router.add_view("/pets", PetCollectionView)
app.router.add_view("/pets/{id}", PetItemView) app.router.add_view("/pets/{id}", PetItemView)
app.router.add_view("/simple-type", ViewResponseReturnASimpleType)
oas.setup(app) oas.setup(app)
client = await aiohttp_client(app) return await ensure_content_durability(await aiohttp_client(app))
response = await client.get("/oas/spec")
assert response.status == 200
assert response.content_type == "application/json" async def test_generated_oas_should_have_components_schemas(generated_oas):
return await response.json() 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): async def test_generated_oas_should_have_pets_paths(generated_oas):
@ -48,11 +133,61 @@ async def test_generated_oas_should_have_pets_paths(generated_oas):
async def test_pets_route_should_have_get_method(generated_oas): async def test_pets_route_should_have_get_method(generated_oas):
assert generated_oas["paths"]["/pets"]["get"] == {} assert generated_oas["paths"]["/pets"]["get"] == {
"description": "Get a list of pets",
"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): async def test_pets_route_should_have_post_method(generated_oas):
assert generated_oas["paths"]["/pets"]["post"] == { assert generated_oas["paths"]["/pets"]["post"] == {
"description": "Create a Pet",
"requestBody": { "requestBody": {
"content": { "content": {
"application/json": { "application/json": {
@ -60,14 +195,42 @@ async def test_pets_route_should_have_post_method(generated_oas):
"properties": { "properties": {
"id": {"title": "Id", "type": "integer"}, "id": {"title": "Id", "type": "integer"},
"name": {"title": "Name", "type": "string"}, "name": {"title": "Name", "type": "string"},
"toys": {
"items": {"$ref": "#/components/schemas/Toy"},
"title": "Toys",
"type": "array",
},
}, },
"required": ["id", "name"], "required": ["id", "name", "toys"],
"title": "Pet", "title": "Pet",
"type": "object", "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",
}
}
},
}
},
} }
@ -77,14 +240,16 @@ async def test_generated_oas_should_have_pets_id_paths(generated_oas):
async def test_pets_id_route_should_have_delete_method(generated_oas): async def test_pets_id_route_should_have_delete_method(generated_oas):
assert generated_oas["paths"]["/pets/{id}"]["delete"] == { assert generated_oas["paths"]["/pets/{id}"]["delete"] == {
"description": "",
"parameters": [ "parameters": [
{ {
"in": "path", "in": "path",
"name": "id", "name": "id",
"required": True, "required": True,
"schema": {"type": "integer"}, "schema": {"title": "id", "type": "integer"},
} }
] ],
"responses": {"204": {"content": {}, "description": "Empty but OK"}},
} }
@ -95,9 +260,55 @@ async def test_pets_id_route_should_have_get_method(generated_oas):
"in": "path", "in": "path",
"name": "id", "name": "id",
"required": True, "required": True,
"schema": {"type": "integer"}, "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": {}},
},
} }
@ -108,7 +319,7 @@ async def test_pets_id_route_should_have_put_method(generated_oas):
"in": "path", "in": "path",
"name": "id", "name": "id",
"required": True, "required": True,
"schema": {"type": "integer"}, "schema": {"title": "id", "type": "integer"},
} }
], ],
"requestBody": { "requestBody": {
@ -118,8 +329,13 @@ async def test_pets_id_route_should_have_put_method(generated_oas):
"properties": { "properties": {
"id": {"title": "Id", "type": "integer"}, "id": {"title": "Id", "type": "integer"},
"name": {"title": "Name", "type": "string"}, "name": {"title": "Name", "type": "string"},
"toys": {
"items": {"$ref": "#/components/schemas/Toy"},
"title": "Toys",
"type": "array",
},
}, },
"required": ["id", "name"], "required": ["id", "name", "toys"],
"title": "Pet", "title": "Pet",
"type": "object", "type": "object",
} }
@ -127,3 +343,72 @@ async def test_pets_id_route_should_have_put_method(generated_oas):
} }
}, },
} }
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 __future__ import annotations
from pydantic import BaseModel
from uuid import UUID from uuid import UUID
from pydantic import BaseModel
from aiohttp_pydantic.injectors import _parse_func_signature
class User(BaseModel): class User(BaseModel):
firstname: str firstname: str
@ -36,32 +40,42 @@ def test_parse_func_signature():
def path_body_qs_and_header(self, id: str, /, user: User, page: int, *, auth: UUID): def path_body_qs_and_header(self, id: str, /, user: User, page: int, *, auth: UUID):
pass pass
assert _parse_func_signature(body_only) == ({}, {"user": User}, {}, {}) assert _parse_func_signature(body_only) == ({}, {"user": User}, {}, {}, {})
assert _parse_func_signature(path_only) == ({"id": str}, {}, {}, {}) assert _parse_func_signature(path_only) == ({"id": str}, {}, {}, {}, {})
assert _parse_func_signature(qs_only) == ({}, {}, {"page": int}, {}) assert _parse_func_signature(qs_only) == ({}, {}, {"page": int}, {}, {})
assert _parse_func_signature(header_only) == ({}, {}, {}, {"auth": UUID}) 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_qs) == (
{"id": str},
{},
{"page": int},
{},
{},
)
assert _parse_func_signature(path_and_header) == ( assert _parse_func_signature(path_and_header) == (
{"id": str}, {"id": str},
{}, {},
{}, {},
{"auth": UUID}, {"auth": UUID},
{},
) )
assert _parse_func_signature(qs_and_header) == ( assert _parse_func_signature(qs_and_header) == (
{}, {},
{}, {},
{"page": int}, {"page": int},
{"auth": UUID}, {"auth": UUID},
{},
) )
assert _parse_func_signature(path_qs_and_header) == ( assert _parse_func_signature(path_qs_and_header) == (
{"id": str}, {"id": str},
{}, {},
{"page": int}, {"page": int},
{"auth": UUID}, {"auth": UUID},
{},
) )
assert _parse_func_signature(path_body_qs_and_header) == ( assert _parse_func_signature(path_body_qs_and_header) == (
{"id": str}, {"id": str},
{"user": User}, {"user": User},
{"page": int}, {"page": int},
{"auth": UUID}, {"auth": UUID},
{},
) )

View File

@ -1,6 +1,10 @@
from pydantic import BaseModel from __future__ import annotations
from typing import Optional
from typing import Iterator, List, Optional
from aiohttp import web from aiohttp import web
from pydantic import BaseModel
from aiohttp_pydantic import PydanticView from aiohttp_pydantic import PydanticView
@ -9,10 +13,20 @@ class ArticleModel(BaseModel):
nb_page: Optional[int] nb_page: Optional[int]
class ArticleModels(BaseModel):
__root__: List[ArticleModel]
def __iter__(self) -> Iterator[ArticleModel]:
return iter(self.__root__)
class ArticleView(PydanticView): class ArticleView(PydanticView):
async def post(self, article: ArticleModel): async def post(self, article: ArticleModel):
return web.json_response(article.dict()) 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( async def test_post_an_article_without_required_field_should_return_an_error_message(
aiohttp_client, loop aiohttp_client, loop
@ -25,7 +39,12 @@ async def test_post_an_article_without_required_field_should_return_an_error_mes
assert resp.status == 400 assert resp.status == 400
assert resp.content_type == "application/json" assert resp.content_type == "application/json"
assert await resp.json() == [ assert await resp.json() == [
{"loc": ["name"], "msg": "field required", "type": "value_error.missing"} {
"in": "body",
"loc": ["name"],
"msg": "field required",
"type": "value_error.missing",
}
] ]
@ -41,6 +60,7 @@ async def test_post_an_article_with_wrong_type_field_should_return_an_error_mess
assert resp.content_type == "application/json" assert resp.content_type == "application/json"
assert await resp.json() == [ assert await resp.json() == [
{ {
"in": "body",
"loc": ["nb_page"], "loc": ["nb_page"],
"msg": "value is not a valid integer", "msg": "value is not a valid integer",
"type": "type_error.integer", "type": "type_error.integer",
@ -48,6 +68,58 @@ async def test_post_an_article_with_wrong_type_field_should_return_an_error_mess
] ]
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): async def test_post_a_valid_article_should_return_the_parsed_type(aiohttp_client, loop):
app = web.Application() app = web.Application()
app.router.add_view("/article", ArticleView) app.router.add_view("/article", ArticleView)

View File

@ -1,7 +1,13 @@
from aiohttp import web from __future__ import annotations
from aiohttp_pydantic import PydanticView
from datetime import datetime
import json 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): class JSONEncoder(json.JSONEncoder):
@ -19,6 +25,41 @@ class ArticleView(PydanticView):
) )
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( async def test_get_article_without_required_header_should_return_an_error_message(
aiohttp_client, loop aiohttp_client, loop
): ):
@ -31,6 +72,7 @@ async def test_get_article_without_required_header_should_return_an_error_messag
assert resp.content_type == "application/json" assert resp.content_type == "application/json"
assert await resp.json() == [ assert await resp.json() == [
{ {
"in": "headers",
"loc": ["signature_expired"], "loc": ["signature_expired"],
"msg": "field required", "msg": "field required",
"type": "value_error.missing", "type": "value_error.missing",
@ -50,6 +92,7 @@ async def test_get_article_with_wrong_header_type_should_return_an_error_message
assert resp.content_type == "application/json" assert resp.content_type == "application/json"
assert await resp.json() == [ assert await resp.json() == [
{ {
"in": "headers",
"loc": ["signature_expired"], "loc": ["signature_expired"],
"msg": "invalid datetime format", "msg": "invalid datetime format",
"type": "value_error.datetime", "type": "value_error.datetime",
@ -85,3 +128,55 @@ async def test_get_article_with_valid_header_containing_hyphen_should_be_returne
assert resp.status == 200 assert resp.status == 200
assert resp.content_type == "application/json" assert resp.content_type == "application/json"
assert await resp.json() == {"signature": "2020-10-04T18:01:00"} 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,4 +1,7 @@
from __future__ import annotations
from aiohttp import web from aiohttp import web
from aiohttp_pydantic import PydanticView from aiohttp_pydantic import PydanticView
@ -7,7 +10,7 @@ class ArticleView(PydanticView):
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( async def test_get_article_with_correct_path_parameters_should_return_parameters_in_path(
aiohttp_client, loop aiohttp_client, loop
): ):
app = web.Application() app = web.Application()
@ -18,3 +21,23 @@ async def test_get_article_without_required_qs_should_return_an_error_message(
assert resp.status == 200 assert resp.status == 200
assert resp.content_type == "application/json" assert resp.content_type == "application/json"
assert await resp.json() == {"path": ["1234", "music", 1980]} 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,10 +1,57 @@
from __future__ import annotations
from typing import Optional, List
from pydantic import Field
from aiohttp import web from aiohttp import web
from aiohttp_pydantic import PydanticView from aiohttp_pydantic import PydanticView
from aiohttp_pydantic.injectors import Group
class ArticleView(PydanticView): class ArticleView(PydanticView):
async def get(self, with_comments: bool): async def get(
return web.json_response({"with_comments": with_comments}) 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,
}
)
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( async def test_get_article_without_required_qs_should_return_an_error_message(
@ -19,6 +66,7 @@ async def test_get_article_without_required_qs_should_return_an_error_message(
assert resp.content_type == "application/json" assert resp.content_type == "application/json"
assert await resp.json() == [ assert await resp.json() == [
{ {
"in": "query string",
"loc": ["with_comments"], "loc": ["with_comments"],
"msg": "field required", "msg": "field required",
"type": "value_error.missing", "type": "value_error.missing",
@ -38,6 +86,7 @@ async def test_get_article_with_wrong_qs_type_should_return_an_error_message(
assert resp.content_type == "application/json" assert resp.content_type == "application/json"
assert await resp.json() == [ assert await resp.json() == [
{ {
"in": "query string",
"loc": ["with_comments"], "loc": ["with_comments"],
"msg": "value could not be parsed to a boolean", "msg": "value could not be parsed to a boolean",
"type": "type_error.bool", "type": "type_error.bool",
@ -52,7 +101,157 @@ async def test_get_article_with_valid_qs_should_return_the_parsed_type(
app.router.add_view("/article", ArticleView) app.router.add_view("/article", ArticleView)
client = await aiohttp_client(app) client = await aiohttp_client(app)
resp = await client.get("/article", params={"with_comments": "yes"})
resp = await client.get("/article", params={"with_comments": "yes", "age": 3})
assert resp.status == 200 assert resp.status == 200
assert resp.content_type == "application/json" assert resp.content_type == "application/json"
assert await resp.json() == {"with_comments": True} assert await resp.json() == {
"with_comments": True,
"age": 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"