Compare commits

...

42 Commits
v1.5.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
39 changed files with 2618 additions and 205 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
...

4
.gitignore vendored
View File

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

View File

@ -1,22 +0,0 @@
language: python
python:
- '3.8'
script:
- pytest --cov-report=xml --cov=aiohttp_pydantic tests/
install:
- pip install -U setuptools wheel pip
- pip install -r requirements/test.txt
- pip install -r requirements/ci.txt
- pip install .
after_success:
- codecov
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,14 +1,15 @@
Aiohttp pydantic - Aiohttp View to validate and parse request Aiohttp pydantic - Aiohttp View to validate and parse request
============================================================= =============================================================
.. image:: https://travis-ci.org/Maillol/aiohttp-pydantic.svg?branch=main .. image:: https://cloud.drone.io/api/badges/Maillol/aiohttp-pydantic/status.svg
:target: https://travis-ci.org/Maillol/aiohttp-pydantic :target: https://cloud.drone.io/Maillol/aiohttp-pydantic
:alt: Build status for master branch
.. image:: https://img.shields.io/pypi/v/aiohttp-pydantic .. image:: https://img.shields.io/pypi/v/aiohttp-pydantic
:target: https://img.shields.io/pypi/v/aiohttp-pydantic :target: https://img.shields.io/pypi/v/aiohttp-pydantic
:alt: Latest PyPI package version :alt: Latest PyPI package version
.. image:: https://codecov.io/gh/Maillol/aiohttp-pydantic/branch/add_code_coverage/graph/badge.svg .. image:: https://codecov.io/gh/Maillol/aiohttp-pydantic/branch/main/graph/badge.svg
:target: https://codecov.io/gh/Maillol/aiohttp-pydantic :target: https://codecov.io/gh/Maillol/aiohttp-pydantic
:alt: codecov.io status for master branch :alt: codecov.io status for master branch
@ -54,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})
@ -68,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"
], ],
@ -82,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"
], ],
@ -99,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:
@ -116,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
@ -150,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
@ -205,6 +226,16 @@ on the same route, you must use *apps_to_expose* parameter.
oas.setup(app, apps_to_expose=[sub_app_1, sub_app_2]) 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 Add annotation to define response content
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -215,6 +246,9 @@ 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 the status code 200 and the response content is a List of Pet where Pet will be
defined using a pydantic.BaseModel defined using a pydantic.BaseModel
The docstring of methods will be parsed to fill the descriptions in the
Open Api Specification.
.. code-block:: python3 .. code-block:: python3
@ -233,20 +267,47 @@ defined using a pydantic.BaseModel
class PetCollectionView(PydanticView): class PetCollectionView(PydanticView):
async def get(self) -> r200[List[Pet]]: async def get(self) -> r200[List[Pet]]:
"""
Find all pets
Tags: pet
"""
pets = self.request.app["model"].list_pets() pets = self.request.app["model"].list_pets()
return web.json_response([pet.dict() for pet in pets]) return web.json_response([pet.dict() for pet in pets])
async def post(self, pet: Pet) -> r201[Pet]: 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) 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, /) -> Union[r200[Pet], r404[Error]]: 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) 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) -> r200[Pet]: 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) self.request.app["model"].update_pet(id, pet)
return web.json_response(pet.dict()) return web.json_response(pet.dict())
@ -254,6 +315,91 @@ defined using a pydantic.BaseModel
self.request.app["model"].remove_pet(id) self.request.app["model"].remove_pet(id)
return web.Response(status=204) return web.Response(status=204)
Group parameters
----------------
If your method has lot of parameters you can group them together inside one or several Groups.
.. code-block:: python3
from aiohttp_pydantic.injectors import Group
class Pagination(Group):
page_num: int = 1
page_size: int = 15
class ArticleView(PydanticView):
async def get(self, page: Pagination):
articles = Article.get(page.page_num, page.page_size)
...
The parameters page_num and page_size are expected in the query string, and
set inside a Pagination object passed as page parameter.
The code above is equivalent to:
.. code-block:: python3
class ArticleView(PydanticView):
async def get(self, page_num: int = 1, page_size: int = 15):
articles = Article.get(page_num, page_size)
...
You can add methods or properties to your Group.
.. code-block:: python3
class Pagination(Group):
page_num: int = 1
page_size: int = 15
@property
def num(self):
return self.page_num
@property
def size(self):
return self.page_size
def slice(self):
return slice(self.num, self.size)
class ArticleView(PydanticView):
async def get(self, page: Pagination):
articles = Article.get(page.num, page.size)
...
Custom Validation error
-----------------------
You can redefine the on_validation_error hook in your PydanticView
.. code-block:: python3
class PetView(PydanticView):
async def on_validation_error(self,
exception: ValidationError,
context: str):
errors = exception.errors()
for error in errors:
error["in"] = context # context is "body", "headers", "path" or "query string"
error["custom"] = "your custom field ..."
return json_response(data=errors, status=400)
Demo Demo
---- ----
@ -268,12 +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 file using the command: You can generate the OAS in a json or yaml file using the aiohttp_pydantic.oas command:
.. code-block:: bash .. code-block:: bash
python -m aiohttp_pydantic.oas demo.main 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 .. _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.5.0" __version__ = "1.12.1"
__all__ = ("PydanticView", "__version__") __all__ = ("PydanticView", "__version__")

View File

@ -1,13 +1,18 @@
import abc import abc
from inspect import signature import typing
from inspect import signature, getmro
from json.decoder import JSONDecodeError from json.decoder import JSONDecodeError
from typing import Callable, Tuple from types import SimpleNamespace
from typing import Callable, Tuple, Literal, Type, get_type_hints
from aiohttp.web_exceptions import HTTPBadRequest from aiohttp.web_exceptions import HTTPBadRequest
from aiohttp.web_request import BaseRequest from aiohttp.web_request import BaseRequest
from multidict import MultiDict
from pydantic import BaseModel from pydantic import BaseModel
from .utils import is_pydantic_base_model 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):
@ -15,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
""" """
@ -33,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())
@ -45,8 +64,11 @@ 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):
try: try:
@ -54,9 +76,18 @@ class BodyGetter(AbstractInjector):
except JSONDecodeError: except JSONDecodeError:
raise HTTPBadRequest( raise HTTPBadRequest(
text='{"error": "Malformed JSON"}', content_type="application/json" text='{"error": "Malformed JSON"}', content_type="application/json"
) ) from None
kwargs_view[self.arg_name] = self.model(**body) # 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):
@ -64,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):
@ -76,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 is_pydantic_base_model(param_spec.annotation): 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,5 +1,5 @@
from importlib import resources from importlib import resources
from typing import Iterable from typing import Iterable, Optional
import jinja2 import jinja2
from aiohttp import web from aiohttp import web
@ -13,6 +13,8 @@ def setup(
apps_to_expose: Iterable[web.Application] = (), apps_to_expose: Iterable[web.Application] = (),
url_prefix: str = "/oas", url_prefix: str = "/oas",
enable: bool = True, enable: bool = True,
version_spec: Optional[str] = None,
title_spec: Optional[str] = None,
): ):
if enable: if enable:
oas_app = web.Application() oas_app = web.Application()
@ -20,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

@ -1,10 +1,28 @@
import argparse import argparse
import importlib import importlib
import json import json
from typing import Dict, Protocol, Optional, Callable
import sys
from .view import generate_oas 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): def application_type(value):
""" """
Return aiohttp application defined in the value. Return aiohttp application defined in the value.
@ -26,6 +44,35 @@ def application_type(value):
raise argparse.ArgumentTypeError(error) from 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): def setup(parser: argparse.ArgumentParser):
parser.add_argument( parser.add_argument(
"apps", "apps",
@ -35,11 +82,52 @@ def setup(parser: argparse.ArgumentParser):
help="The name of the module containing the asyncio.web.Application." help="The name of the module containing the asyncio.web.Application."
" By default the variable named 'app' is loaded but you can define" " By default the variable named 'app' is loaded but you can define"
" an other variable name ending the name of module with : characters" " an other variable name ending the name of module with : characters"
" and the name of variable. Example: my_package.my_module:my_app", " 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) parser.set_defaults(func=show_oas)
def show_oas(args: argparse.Namespace): def show_oas(args: argparse.Namespace):
print(json.dumps(generate_oas(args.apps), sort_keys=True, indent=4)) """
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,4 +1,8 @@
from typing import Union """
Utility to write Open Api Specifications using the Python language.
"""
from typing import Union, List
class Info: class Info:
@ -7,7 +11,7 @@ class Info:
@property @property
def title(self): def title(self):
return self._spec["title"] return self._spec.get("title")
@title.setter @title.setter
def title(self, title): def title(self, title):
@ -15,7 +19,7 @@ class Info:
@property @property
def description(self): def description(self):
return self._spec["description"] return self._spec.get("description")
@description.setter @description.setter
def description(self, description): def description(self, description):
@ -23,12 +27,20 @@ class Info:
@property @property
def version(self): def version(self):
return self._spec["version"] return self._spec.get("version")
@version.setter @version.setter
def version(self, version): def version(self, version):
self._spec["version"] = version self._spec["version"] = version
@property
def terms_of_service(self):
return self._spec.get("termsOfService")
@terms_of_service.setter
def terms_of_service(self, terms_of_service):
self._spec["termsOfService"] = terms_of_service
class RequestBody: class RequestBody:
def __init__(self, spec: dict): def __init__(self, spec: dict):
@ -43,8 +55,8 @@ class RequestBody:
self._spec["description"] = description self._spec["description"] = description
@property @property
def required(self): def required(self) -> bool:
return self._spec["required"] return self._spec.get("required", False)
@required.setter @required.setter
def required(self, required: bool): def required(self, required: bool):
@ -121,6 +133,7 @@ class Parameters:
class Response: class Response:
def __init__(self, spec: dict): def __init__(self, spec: dict):
self._spec = spec self._spec = spec
self._spec.setdefault("description", "")
@property @property
def description(self) -> str: def description(self) -> str:
@ -144,7 +157,7 @@ class Responses:
self._spec = spec.setdefault("responses", {}) self._spec = spec.setdefault("responses", {})
def __getitem__(self, status_code: Union[int, str]) -> Response: def __getitem__(self, status_code: Union[int, str]) -> Response:
if not (100 <= int(status_code) < 600): if not 100 <= int(status_code) < 600:
raise ValueError("status_code must be between 100 and 599") raise ValueError("status_code must be between 100 and 599")
spec = self._spec.setdefault(str(status_code), {}) spec = self._spec.setdefault(str(status_code), {})
@ -183,6 +196,17 @@ class OperationObject:
def responses(self) -> Responses: def responses(self) -> Responses:
return Responses(self._spec) 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):
@ -220,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):
@ -244,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):
@ -265,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:
@ -281,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

@ -24,9 +24,10 @@ _status_code = frozenset(f"r{code}" for code in range(100, 600))
def _make_status_code_type(status_code): def _make_status_code_type(status_code):
if status_code in _status_code: if status_code in _status_code:
return new_class(status_code, (Protocol[RespContents],)) return new_class(status_code, (Protocol[RespContents],))
return None
def is_status_code_type(obj): def is_status_code_type(obj) -> bool:
""" """
Return True if obj is a status code type such as _200 or _404. Return True if obj is a status code type such as _200 or _404.
""" """

View File

@ -1,19 +1,20 @@
from inspect import getdoc
import typing import typing
from typing import List, Type 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 import Response, json_response
from aiohttp.web_app import Application from aiohttp.web_app import Application
from pydantic import BaseModel
from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem
from . import docstring_parser
from ..injectors import _parse_func_signature from ..injectors import _parse_func_signature
from ..utils import is_pydantic_base_model from ..utils import is_pydantic_base_model
from ..view import PydanticView, is_pydantic_view from ..view import PydanticView, is_pydantic_view
from .typing import is_status_code_type from .typing import is_status_code_type
JSON_SCHEMA_TYPES = {float: "number", str: "string", int: "integer"}
class _OASResponseBuilder: class _OASResponseBuilder:
""" """
@ -21,13 +22,19 @@ class _OASResponseBuilder:
generate the OAS operation response. generate the OAS operation response.
""" """
def __init__(self, oas_operation): def __init__(self, oas: OpenApiSpec3, oas_operation, status_code_descriptions):
self._oas_operation = oas_operation self._oas_operation = oas_operation
self._oas = oas
self._status_code_descriptions = status_code_descriptions
@staticmethod def _handle_pydantic_base_model(self, obj):
def _handle_pydantic_base_model(obj):
if is_pydantic_base_model(obj): if is_pydantic_base_model(obj):
return obj.schema() 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 {} return {}
def _handle_list(self, obj): def _handle_list(self, obj):
@ -46,10 +53,16 @@ class _OASResponseBuilder:
"schema": self._handle_list(typing.get_args(obj)[0]) "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): elif is_status_code_type(obj):
status_code = obj.__name__[1:] status_code = obj.__name__[1:]
self._oas_operation.responses[status_code].content = {} 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): def _handle_union(self, obj):
if typing.get_origin(obj) is typing.Union: if typing.get_origin(obj) is typing.Union:
@ -62,62 +75,94 @@ class _OASResponseBuilder:
def _add_http_method_to_oas( def _add_http_method_to_oas(
oas_path: PathItem, http_method: str, view: Type[PydanticView] oas: OpenApiSpec3, oas_path: PathItem, http_method: str, view: Type[PydanticView]
): ):
http_method = http_method.lower() http_method = http_method.lower()
oas_operation: OperationObject = getattr(oas_path, http_method) oas_operation: OperationObject = getattr(oas_path, http_method)
handler = getattr(view, http_method) handler = getattr(view, http_method)
path_args, body_args, qs_args, header_args = _parse_func_signature(handler) path_args, body_args, qs_args, header_args, defaults = _parse_func_signature(
handler, unpack_group=True
)
description = getdoc(handler) description = getdoc(handler)
if description: if description:
oas_operation.description = 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:
body_schema = (
next(iter(body_args.values()))
.schema(ref_template="#/components/schemas/{model}")
.copy()
)
if def_sub_schemas := body_schema.pop("definitions", None):
oas.components.schemas.update(def_sub_schemas)
oas_operation.request_body.content = { oas_operation.request_body.content = {
"application/json": {"schema": next(iter(body_args.values())).schema()} "application/json": {"schema": body_schema}
} }
i = 0 indexes = count()
for i, (name, type_) in enumerate(path_args.items()): for args_location, args in (
oas_operation.parameters[i].required = True ("path", path_args.items()),
oas_operation.parameters[i].in_ = "path" ("query", qs_args.items()),
oas_operation.parameters[i].name = name ("header", header_args.items()),
oas_operation.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_}}
oas_operation.parameters[i].required = False if name in defaults:
oas_operation.parameters[i].in_ = "query" attrs["__root__"] = defaults[name]
oas_operation.parameters[i].name = name oas_operation.parameters[i].required = False
oas_operation.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(
oas_operation.parameters[i].required = False ref_template="#/components/schemas/{model}"
oas_operation.parameters[i].in_ = "header" )
oas_operation.parameters[i].name = name
oas_operation.parameters[i].schema = {"type": JSON_SCHEMA_TYPES[type_]}
return_type = handler.__annotations__.get("return") return_type = get_type_hints(handler).get("return")
if return_type is not None: if return_type is not None:
_OASResponseBuilder(oas_operation).build(return_type) _OASResponseBuilder(oas, oas_operation, status_code_descriptions).build(
return_type
)
def generate_oas(apps: List[Application]) -> dict: 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. Generate and return Open Api Specification from PydanticView in application.
""" """
oas = OpenApiSpec3() 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 app in apps:
for resources in app.router.resources(): for resources in app.router.resources():
for resource_route in resources: for resource_route in resources:
if is_pydantic_view(resource_route.handler): if not is_pydantic_view(resource_route.handler):
view: Type[PydanticView] = resource_route.handler continue
info = resource_route.get_info()
path = oas.paths[info.get("path", info.get("formatter"))] view: Type[PydanticView] = resource_route.handler
if resource_route.method == "*": info = resource_route.get_info()
for method_name in view.allowed_methods: path = oas.paths[info.get("path", info.get("formatter"))]
_add_http_method_to_oas(path, method_name, view) if resource_route.method == "*":
else: for method_name in view.allowed_methods:
_add_http_method_to_oas(path, resource_route.method, view) _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 return oas.spec
@ -127,7 +172,9 @@ async def get_oas(request):
View to generate the 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"]
return json_response(generate_oas(apps)) version_spec = request.app["version_spec"]
title_spec = request.app["title_spec"]
return json_response(generate_oas(apps, version_spec, title_spec))
async def oas_ui(request): async def oas_ui(request):

View File

@ -5,7 +5,15 @@ def is_pydantic_base_model(obj):
""" """
Return true is obj is a pydantic.BaseModel subclass. 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: try:
return issubclass(obj, BaseModel) return issubclass(cls1, cls2)
except TypeError: except TypeError:
return False return False

View File

@ -1,6 +1,7 @@
from functools import update_wrapper from functools import update_wrapper
from inspect import iscoroutinefunction from inspect import iscoroutinefunction
from typing import Any, Callable, Generator, Iterable 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
@ -16,6 +17,8 @@ from .injectors import (
MatchInfoGetter, MatchInfoGetter,
QueryGetter, 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

@ -15,7 +15,7 @@ async def pet_not_found_to_404(request, handler):
app = Application(middlewares=[pet_not_found_to_404]) app = Application(middlewares=[pet_not_found_to_404])
oas.setup(app) oas.setup(app, version_spec="1.0.1", title_spec="My App")
app["model"] = Model() app["model"] = Model()
app.router.add_view("/pets", PetCollectionView) app.router.add_view("/pets", PetCollectionView)

View File

@ -1,10 +1,17 @@
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 age: int
friends: Friend
class Error(BaseModel): class Error(BaseModel):

View File

@ -10,25 +10,54 @@ from .model import Error, Pet
class PetCollectionView(PydanticView): class PetCollectionView(PydanticView):
async def get(self, age: Optional[int] = None) -> r200[List[Pet]]: async def get(self, age: Optional[int] = None) -> r200[List[Pet]]:
"""
List all pets
Status Codes:
200: Successful operation
"""
pets = self.request.app["model"].list_pets() pets = self.request.app["model"].list_pets()
return web.json_response( return web.json_response(
[pet.dict() for pet in pets if age is None or age == pet.age] [pet.dict() for pet in pets if age is None or age == pet.age]
) )
async def post(self, pet: Pet) -> r201[Pet]: async def post(self, pet: Pet) -> r201[Pet]:
"""
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, /) -> Union[r200[Pet], r404[Error]]: 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) -> r200[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, /) -> r204: 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.Response(status=204) return web.Response(status=204)

View File

@ -1,7 +1,42 @@
certifi==2020.11.8 async-timeout==3.0.1
chardet==3.0.4 attrs==21.2.0
codecov==2.1.10 bleach==4.0.0
coverage==5.3 certifi==2021.5.30
idna==2.10 cffi==1.14.6
requests==2.25.0 chardet==4.0.0
urllib3==1.26.2 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

View File

@ -1,13 +1,23 @@
attrs==20.3.0 async-timeout==3.0.1
coverage==5.3 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 iniconfig==1.1.1
packaging==20.4 multidict==5.1.0
packaging==21.0
pluggy==0.13.1 pluggy==0.13.1
py==1.9.0 py==1.10.0
Pygments==2.9.0
pyparsing==2.4.7 pyparsing==2.4.7
pytest==6.1.2 pytest==6.1.2
pytest-aiohttp==0.3.0 pytest-aiohttp==0.3.0
pytest-cov==2.10.1 pytest-cov==2.10.1
six==1.15.0 readme-renderer==29.0
six==1.16.0
toml==0.10.2 toml==0.10.2
typing-extensions==3.7.4.3 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==6.1.2; pytest-aiohttp==0.3.0; pytest-cov==2.10.1 test =
ci = pytest==6.1.2; pytest-aiohttp==0.3.0; pytest-cov==2.10.1; codecov==2.1.10 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}")

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

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

View File

@ -1,3 +1,5 @@
from __future__ import annotations
from aiohttp import web from aiohttp import web
from aiohttp_pydantic import PydanticView from aiohttp_pydantic import PydanticView

View File

@ -1,10 +1,15 @@
from __future__ import annotations
import argparse import argparse
from textwrap import dedent from textwrap import dedent
from io import StringIO
from pathlib import Path
import pytest import pytest
from aiohttp_pydantic.oas import cmd from aiohttp_pydantic.oas import cmd
PATH_TO_BASE_JSON_FILE = str(Path(__file__).parent / "oas_base.json")
@pytest.fixture @pytest.fixture
def cmd_line(): def cmd_line():
@ -13,13 +18,18 @@ def cmd_line():
return parser return parser
def test_show_oad_of_app(cmd_line, capfd): def test_show_oas_of_app(cmd_line):
args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample"]) args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample"])
args.output = StringIO()
args.func(args) args.func(args)
captured = capfd.readouterr()
expected = dedent( expected = dedent(
""" """
{ {
"info": {
"title": "Aiohttp pydantic application",
"version": "1.0.0"
},
"openapi": "3.0.0", "openapi": "3.0.0",
"paths": { "paths": {
"/route-1/{a}": { "/route-1/{a}": {
@ -30,6 +40,7 @@ def test_show_oad_of_app(cmd_line, capfd):
"name": "a", "name": "a",
"required": true, "required": true,
"schema": { "schema": {
"title": "a",
"type": "integer" "type": "integer"
} }
} }
@ -44,6 +55,7 @@ def test_show_oad_of_app(cmd_line, capfd):
"name": "b", "name": "b",
"required": true, "required": true,
"schema": { "schema": {
"title": "b",
"type": "integer" "type": "integer"
} }
} }
@ -55,16 +67,20 @@ def test_show_oad_of_app(cmd_line, capfd):
""" """
) )
assert captured.out.strip() == expected.strip() assert args.output.getvalue().strip() == expected.strip()
def test_show_oad_of_sub_app(cmd_line, capfd): def test_show_oas_of_sub_app(cmd_line):
args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample:sub_app"]) args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample:sub_app"])
args.output = StringIO()
args.func(args) args.func(args)
captured = capfd.readouterr()
expected = dedent( expected = dedent(
""" """
{ {
"info": {
"title": "Aiohttp pydantic application",
"version": "1.0.0"
},
"openapi": "3.0.0", "openapi": "3.0.0",
"paths": { "paths": {
"/sub-app/route-2/{b}": { "/sub-app/route-2/{b}": {
@ -75,6 +91,7 @@ def test_show_oad_of_sub_app(cmd_line, capfd):
"name": "b", "name": "b",
"required": true, "required": true,
"schema": { "schema": {
"title": "b",
"type": "integer" "type": "integer"
} }
} }
@ -86,16 +103,26 @@ def test_show_oad_of_sub_app(cmd_line, capfd):
""" """
) )
assert captured.out.strip() == expected.strip() assert args.output.getvalue().strip() == expected.strip()
def test_show_oad_of_a_callable(cmd_line, capfd): def test_show_oas_of_a_callable(cmd_line):
args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample:make_app()"]) 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) args.func(args)
captured = capfd.readouterr()
expected = dedent( expected = dedent(
""" """
{ {
"info": {
"title": "Aiohttp pydantic application",
"version": "1.0.0"
},
"openapi": "3.0.0", "openapi": "3.0.0",
"paths": { "paths": {
"/route-3/{a}": { "/route-3/{a}": {
@ -106,6 +133,7 @@ def test_show_oad_of_a_callable(cmd_line, capfd):
"name": "a", "name": "a",
"required": true, "required": true,
"schema": { "schema": {
"title": "a",
"type": "integer" "type": "integer"
} }
} }
@ -117,4 +145,4 @@ def test_show_oad_of_a_callable(cmd_line, capfd):
""" """
) )
assert captured.out.strip() == expected.strip() 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,22 +1,46 @@
from typing import List, Union from __future__ import annotations
from enum import Enum
from typing import List, Optional, Union, Literal
from uuid import UUID
import pytest import pytest
from aiohttp import web from aiohttp import web
from pydantic.main import BaseModel from pydantic.main import BaseModel
from aiohttp_pydantic import PydanticView, oas 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.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) -> r200[List[Pet]]: async def get(
self, format: str, name: Optional[str] = None, *, promo: Optional[UUID] = None
) -> r200[List[Pet]]:
""" """
Get a list of pets Get a list of pets
Tags: pet
Status Codes:
200: Successful operation
""" """
return web.json_response() return web.json_response()
@ -26,28 +50,82 @@ class PetCollectionView(PydanticView):
class PetItemView(PydanticView): class PetItemView(PydanticView):
async def get(self, id: int, /) -> Union[r200[Pet], r404]: 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, /) -> r204: 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):
@ -57,24 +135,51 @@ 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", "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": { "responses": {
"200": { "200": {
"description": "Successful operation",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"type": "array",
"items": { "items": {
"title": "Pet",
"type": "object",
"properties": { "properties": {
"id": {"title": "Id", "type": "integer"}, "id": {"title": "Id", "type": "integer"},
"name": {"title": "Name", "type": "string"}, "name": {"title": "Name", "type": "string"},
"toys": {
"items": {"$ref": "#/components/schemas/Toy"},
"title": "Toys",
"type": "array",
},
}, },
"required": ["id", "name"], "required": ["id", "name", "toys"],
"title": "Pet",
"type": "object",
}, },
"type": "array",
} }
} }
} },
} }
}, },
} }
@ -87,32 +192,43 @@ async def test_pets_route_should_have_post_method(generated_oas):
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"title": "Pet",
"type": "object",
"properties": { "properties": {
"id": {"title": "Id", "type": "integer"}, "id": {"title": "Id", "type": "integer"},
"name": {"title": "Name", "type": "string"}, "name": {"title": "Name", "type": "string"},
"toys": {
"items": {"$ref": "#/components/schemas/Toy"},
"title": "Toys",
"type": "array",
},
}, },
"required": ["id", "name"], "required": ["id", "name", "toys"],
"title": "Pet",
"type": "object",
} }
} }
} }
}, },
"responses": { "responses": {
"201": { "201": {
"description": "",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"title": "Pet",
"type": "object",
"properties": { "properties": {
"id": {"title": "Id", "type": "integer"}, "id": {"title": "Id", "type": "integer"},
"name": {"title": "Name", "type": "string"}, "name": {"title": "Name", "type": "string"},
"toys": {
"items": {"$ref": "#/components/schemas/Toy"},
"title": "Toys",
"type": "array",
},
}, },
"required": ["id", "name"], "required": ["id", "name", "toys"],
"title": "Pet",
"type": "object",
} }
} }
} },
} }
}, },
} }
@ -124,15 +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": [
{ {
"required": True,
"in": "path", "in": "path",
"name": "id", "name": "id",
"schema": {"type": "integer"}, "required": True,
"schema": {"title": "id", "type": "integer"},
} }
], ],
"responses": {"204": {"content": {}}}, "responses": {"204": {"content": {}, "description": "Empty but OK"}},
} }
@ -143,26 +260,54 @@ 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": { "responses": {
"200": { "200": {
"description": "",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"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",
} }
} }
} },
}, },
"404": {"content": {}}, "404": {"description": "", "content": {}},
}, },
} }
@ -174,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": {
@ -184,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",
} }
@ -193,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,3 +1,5 @@
from __future__ import annotations
from uuid import UUID from uuid import UUID
from pydantic import BaseModel from pydantic import BaseModel
@ -38,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,4 +1,6 @@
from typing import Optional from __future__ import annotations
from typing import Iterator, List, Optional
from aiohttp import web from aiohttp import web
from pydantic import BaseModel from pydantic import BaseModel
@ -11,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
@ -27,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",
}
] ]
@ -43,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",
@ -50,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,9 +1,13 @@
from __future__ import annotations
import json import json
from datetime import datetime from datetime import datetime
from enum import Enum
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 JSONEncoder(json.JSONEncoder): class JSONEncoder(json.JSONEncoder):
@ -21,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
): ):
@ -33,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",
@ -52,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",
@ -87,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,3 +1,5 @@
from __future__ import annotations
from aiohttp import web from aiohttp import web
from aiohttp_pydantic import PydanticView from aiohttp_pydantic import PydanticView
@ -8,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()
@ -19,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,13 +1,57 @@
from typing import Optional 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, age: Optional[int] = None): async def get(
return web.json_response({"with_comments": with_comments, "age": age}) 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(
@ -22,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",
@ -41,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",
@ -59,10 +105,15 @@ async def test_get_article_with_valid_qs_should_return_the_parsed_type(
resp = await client.get("/article", params={"with_comments": "yes", "age": 3}) 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, "age": 3} 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_none( async def test_get_article_with_valid_qs_and_omitted_optional_should_return_default_value(
aiohttp_client, loop aiohttp_client, loop
): ):
app = web.Application() app = web.Application()
@ -71,6 +122,136 @@ async def test_get_article_with_valid_qs_and_omitted_optional_should_return_none
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"})
assert await resp.json() == {
"with_comments": True,
"age": None,
"nb_items": 7,
"tags": [],
}
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, "age": None}
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"