Compare commits
5 Commits
main
...
fix-defini
Author | SHA1 | Date | |
---|---|---|---|
|
6ba671532b | ||
|
efbaaa5e6f | ||
|
6211c71875 | ||
|
5567d73952 | ||
|
67a95ec9c9 |
@ -1,73 +0,0 @@
|
||||
/*
|
||||
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
95
.drone.yml
@ -1,95 +0,0 @@
|
||||
---
|
||||
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
4
.gitignore
vendored
@ -1,11 +1,9 @@
|
||||
.coverage
|
||||
.idea/
|
||||
.pypirc
|
||||
.pytest_cache
|
||||
__pycache__
|
||||
aiohttp_pydantic.egg-info/
|
||||
build/
|
||||
coverage.xml
|
||||
dist/
|
||||
dist_venv/
|
||||
venv/
|
||||
|
||||
|
22
.travis.yml
Normal file
22
.travis.yml
Normal file
@ -0,0 +1,22 @@
|
||||
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'
|
187
README.rst
187
README.rst
@ -1,9 +1,8 @@
|
||||
Aiohttp pydantic - Aiohttp View to validate and parse request
|
||||
=============================================================
|
||||
|
||||
.. image:: https://cloud.drone.io/api/badges/Maillol/aiohttp-pydantic/status.svg
|
||||
:target: https://cloud.drone.io/Maillol/aiohttp-pydantic
|
||||
:alt: Build status for master branch
|
||||
.. image:: https://travis-ci.org/Maillol/aiohttp-pydantic.svg?branch=main
|
||||
:target: https://travis-ci.org/Maillol/aiohttp-pydantic
|
||||
|
||||
.. image:: https://img.shields.io/pypi/v/aiohttp-pydantic
|
||||
:target: https://img.shields.io/pypi/v/aiohttp-pydantic
|
||||
@ -55,7 +54,7 @@ Example:
|
||||
return web.json_response({'name': article.name,
|
||||
'number_of_page': article.nb_page})
|
||||
|
||||
async def get(self, with_comments: bool=False):
|
||||
async def get(self, with_comments: Optional[bool]):
|
||||
return web.json_response({'with_comments': with_comments})
|
||||
|
||||
|
||||
@ -69,7 +68,6 @@ Example:
|
||||
$ curl -X GET http://127.0.0.1:8080/article?with_comments=a
|
||||
[
|
||||
{
|
||||
"in": "query string",
|
||||
"loc": [
|
||||
"with_comments"
|
||||
],
|
||||
@ -84,7 +82,6 @@ Example:
|
||||
$ curl -H "Content-Type: application/json" -X post http://127.0.0.1:8080/article --data '{}'
|
||||
[
|
||||
{
|
||||
"in": "body",
|
||||
"loc": [
|
||||
"name"
|
||||
],
|
||||
@ -102,7 +99,7 @@ API:
|
||||
Inject Path Parameters
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
To declare a path parameter, you must declare your argument as a `positional-only parameters`_:
|
||||
To declare a path parameters, you must declare your argument as a `positional-only parameters`_:
|
||||
|
||||
|
||||
Example:
|
||||
@ -119,40 +116,22 @@ Example:
|
||||
Inject Query String Parameters
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
To declare a query parameter, you must declare your argument as a simple argument:
|
||||
To declare a query parameters, you must declare your argument as simple argument:
|
||||
|
||||
|
||||
.. code-block:: python3
|
||||
|
||||
class AccountView(PydanticView):
|
||||
async def get(self, customer_id: Optional[str] = None):
|
||||
async def get(self, customer_id: str):
|
||||
...
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_get('/customers', AccountView)
|
||||
|
||||
|
||||
A query string parameter is generally optional and we do not want to force the user to set it in the URL.
|
||||
It's recommended to define a default value. It's possible to get a multiple value for the same parameter using
|
||||
the List type
|
||||
|
||||
.. code-block:: python3
|
||||
|
||||
from typing import List
|
||||
from pydantic import Field
|
||||
|
||||
class AccountView(PydanticView):
|
||||
async def get(self, tags: List[str] = Field(default_factory=list)):
|
||||
...
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_get('/customers', AccountView)
|
||||
|
||||
|
||||
Inject Request Body
|
||||
~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
To declare a body parameter, you must declare your argument as a simple argument annotated with `pydantic Model`_.
|
||||
To declare a body parameters, you must declare your argument as a simple argument annotated with `pydantic Model`_.
|
||||
|
||||
|
||||
.. code-block:: python3
|
||||
@ -171,7 +150,7 @@ To declare a body parameter, you must declare your argument as a simple argument
|
||||
Inject HTTP headers
|
||||
~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
To declare a HTTP headers parameter, you must declare your argument as a `keyword-only argument`_.
|
||||
To declare a HTTP headers parameters, you must declare your argument as a `keyword-only argument`_.
|
||||
|
||||
|
||||
.. code-block:: python3
|
||||
@ -226,16 +205,6 @@ on the same route, you must use *apps_to_expose* parameter.
|
||||
|
||||
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
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
@ -246,9 +215,6 @@ For example *r200[List[Pet]]* means the server responses with
|
||||
the status code 200 and the response content is a List of Pet where Pet will be
|
||||
defined using a pydantic.BaseModel
|
||||
|
||||
The docstring of methods will be parsed to fill the descriptions in the
|
||||
Open Api Specification.
|
||||
|
||||
|
||||
.. code-block:: python3
|
||||
|
||||
@ -267,47 +233,20 @@ Open Api Specification.
|
||||
|
||||
class PetCollectionView(PydanticView):
|
||||
async def get(self) -> r200[List[Pet]]:
|
||||
"""
|
||||
Find all pets
|
||||
|
||||
Tags: pet
|
||||
"""
|
||||
pets = self.request.app["model"].list_pets()
|
||||
return web.json_response([pet.dict() for pet in pets])
|
||||
|
||||
async def post(self, pet: Pet) -> r201[Pet]:
|
||||
"""
|
||||
Add a new pet to the store
|
||||
|
||||
Tags: pet
|
||||
Status Codes:
|
||||
201: The pet is created
|
||||
"""
|
||||
self.request.app["model"].add_pet(pet)
|
||||
return web.json_response(pet.dict())
|
||||
|
||||
|
||||
class PetItemView(PydanticView):
|
||||
async def get(self, id: int, /) -> Union[r200[Pet], r404[Error]]:
|
||||
"""
|
||||
Find a pet by ID
|
||||
|
||||
Tags: pet
|
||||
Status Codes:
|
||||
200: Successful operation
|
||||
404: Pet not found
|
||||
"""
|
||||
pet = self.request.app["model"].find_pet(id)
|
||||
return web.json_response(pet.dict())
|
||||
|
||||
async def put(self, id: int, /, pet: Pet) -> r200[Pet]:
|
||||
"""
|
||||
Update an existing pet
|
||||
|
||||
Tags: pet
|
||||
Status Codes:
|
||||
200: successful operation
|
||||
"""
|
||||
self.request.app["model"].update_pet(id, pet)
|
||||
return web.json_response(pet.dict())
|
||||
|
||||
@ -315,91 +254,6 @@ Open Api Specification.
|
||||
self.request.app["model"].remove_pet(id)
|
||||
return web.Response(status=204)
|
||||
|
||||
|
||||
Group parameters
|
||||
----------------
|
||||
|
||||
If your method has lot of parameters you can group them together inside one or several Groups.
|
||||
|
||||
|
||||
.. code-block:: python3
|
||||
|
||||
from aiohttp_pydantic.injectors import Group
|
||||
|
||||
class Pagination(Group):
|
||||
page_num: int = 1
|
||||
page_size: int = 15
|
||||
|
||||
|
||||
class ArticleView(PydanticView):
|
||||
|
||||
async def get(self, page: Pagination):
|
||||
articles = Article.get(page.page_num, page.page_size)
|
||||
...
|
||||
|
||||
|
||||
The parameters page_num and page_size are expected in the query string, and
|
||||
set inside a Pagination object passed as page parameter.
|
||||
|
||||
The code above is equivalent to:
|
||||
|
||||
|
||||
.. code-block:: python3
|
||||
|
||||
class ArticleView(PydanticView):
|
||||
|
||||
async def get(self, page_num: int = 1, page_size: int = 15):
|
||||
articles = Article.get(page_num, page_size)
|
||||
...
|
||||
|
||||
|
||||
You can add methods or properties to your Group.
|
||||
|
||||
|
||||
.. code-block:: python3
|
||||
|
||||
class Pagination(Group):
|
||||
page_num: int = 1
|
||||
page_size: int = 15
|
||||
|
||||
@property
|
||||
def num(self):
|
||||
return self.page_num
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
return self.page_size
|
||||
|
||||
def slice(self):
|
||||
return slice(self.num, self.size)
|
||||
|
||||
|
||||
class ArticleView(PydanticView):
|
||||
|
||||
async def get(self, page: Pagination):
|
||||
articles = Article.get(page.num, page.size)
|
||||
...
|
||||
|
||||
|
||||
Custom Validation error
|
||||
-----------------------
|
||||
|
||||
You can redefine the on_validation_error hook in your PydanticView
|
||||
|
||||
.. code-block:: python3
|
||||
|
||||
class PetView(PydanticView):
|
||||
|
||||
async def on_validation_error(self,
|
||||
exception: ValidationError,
|
||||
context: str):
|
||||
errors = exception.errors()
|
||||
for error in errors:
|
||||
error["in"] = context # context is "body", "headers", "path" or "query string"
|
||||
error["custom"] = "your custom field ..."
|
||||
return json_response(data=errors, status=400)
|
||||
|
||||
|
||||
Demo
|
||||
----
|
||||
|
||||
@ -414,35 +268,12 @@ Have a look at `demo`_ for a complete example
|
||||
|
||||
Go to http://127.0.0.1:8080/oas
|
||||
|
||||
You can generate the OAS in a json or yaml file using the aiohttp_pydantic.oas command:
|
||||
You can generate the OAS in a json file using the command:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
python -m aiohttp_pydantic.oas demo.main
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ python3 -m aiohttp_pydantic.oas --help
|
||||
usage: __main__.py [-h] [-b FILE] [-o FILE] [-f FORMAT] [APP [APP ...]]
|
||||
|
||||
Generate Open API Specification
|
||||
|
||||
positional arguments:
|
||||
APP The name of the module containing the asyncio.web.Application. By default the variable named
|
||||
'app' is loaded but you can define an other variable name ending the name of module with :
|
||||
characters and the name of variable. Example: my_package.my_module:my_app If your
|
||||
asyncio.web.Application is returned by a function, you can use the syntax:
|
||||
my_package.my_module:my_app()
|
||||
|
||||
optional arguments:
|
||||
-h, --help show this help message and exit
|
||||
-b FILE, --base-oas-file FILE
|
||||
A file that will be used as base to generate OAS
|
||||
-o FILE, --output FILE
|
||||
File to write the output
|
||||
-f FORMAT, --format FORMAT
|
||||
The output format, can be 'json' or 'yaml' (default is json)
|
||||
|
||||
|
||||
.. _demo: https://github.com/Maillol/aiohttp-pydantic/tree/main/demo
|
||||
.. _aiohttp view: https://docs.aiohttp.org/en/stable/web_quickstart.html#class-based-views
|
||||
|
@ -1,5 +1,5 @@
|
||||
from .view import PydanticView
|
||||
|
||||
__version__ = "1.12.1"
|
||||
__version__ = "1.5.1"
|
||||
|
||||
__all__ = ("PydanticView", "__version__")
|
||||
|
@ -1,18 +1,13 @@
|
||||
import abc
|
||||
import typing
|
||||
from inspect import signature, getmro
|
||||
from inspect import signature
|
||||
from json.decoder import JSONDecodeError
|
||||
from types import SimpleNamespace
|
||||
from typing import Callable, Tuple, Literal, Type, get_type_hints
|
||||
from typing import Callable, Tuple
|
||||
|
||||
from aiohttp.web_exceptions import HTTPBadRequest
|
||||
from aiohttp.web_request import BaseRequest
|
||||
from multidict import MultiDict
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .utils import is_pydantic_base_model, robuste_issubclass
|
||||
|
||||
CONTEXT = Literal["body", "headers", "path", "query string"]
|
||||
from .utils import is_pydantic_base_model
|
||||
|
||||
|
||||
class AbstractInjector(metaclass=abc.ABCMeta):
|
||||
@ -20,18 +15,8 @@ class AbstractInjector(metaclass=abc.ABCMeta):
|
||||
An injector parse HTTP request and inject params to the view.
|
||||
"""
|
||||
|
||||
model: Type[BaseModel]
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
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):
|
||||
def __init__(self, args_spec: dict):
|
||||
"""
|
||||
args_spec - ordered mapping: arg_name -> type
|
||||
"""
|
||||
@ -48,12 +33,8 @@ class MatchInfoGetter(AbstractInjector):
|
||||
Validates and injects the part of URL path inside the view positional args.
|
||||
"""
|
||||
|
||||
context = "path"
|
||||
|
||||
def __init__(self, args_spec: dict, default_values: dict):
|
||||
attrs = {"__annotations__": args_spec}
|
||||
attrs.update(default_values)
|
||||
self.model = type("PathModel", (BaseModel,), attrs)
|
||||
def __init__(self, args_spec: dict):
|
||||
self.model = type("PathModel", (BaseModel,), {"__annotations__": args_spec})
|
||||
|
||||
def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict):
|
||||
args_view.extend(self.model(**request.match_info).dict().values())
|
||||
@ -64,11 +45,8 @@ class BodyGetter(AbstractInjector):
|
||||
Validates and injects the content of request body inside the view kwargs.
|
||||
"""
|
||||
|
||||
context = "body"
|
||||
|
||||
def __init__(self, args_spec: dict, default_values: dict):
|
||||
def __init__(self, args_spec: dict):
|
||||
self.arg_name, self.model = next(iter(args_spec.items()))
|
||||
self._expect_object = self.model.schema()["type"] == "object"
|
||||
|
||||
async def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict):
|
||||
try:
|
||||
@ -76,18 +54,9 @@ class BodyGetter(AbstractInjector):
|
||||
except JSONDecodeError:
|
||||
raise HTTPBadRequest(
|
||||
text='{"error": "Malformed JSON"}', content_type="application/json"
|
||||
) from None
|
||||
)
|
||||
|
||||
# Pydantic tries to cast certain structures, such as a list of 2-tuples,
|
||||
# to a dict. Prevent this by requiring the body to be a dict for object models.
|
||||
if self._expect_object and not isinstance(body, dict):
|
||||
raise HTTPBadRequest(
|
||||
text='[{"in": "body", "loc": ["__root__"], "msg": "value is not a '
|
||||
'valid dict", "type": "type_error.dict"}]',
|
||||
content_type="application/json",
|
||||
) from None
|
||||
|
||||
kwargs_view[self.arg_name] = self.model.parse_obj(body)
|
||||
kwargs_view[self.arg_name] = self.model(**body)
|
||||
|
||||
|
||||
class QueryGetter(AbstractInjector):
|
||||
@ -95,49 +64,11 @@ class QueryGetter(AbstractInjector):
|
||||
Validates and injects the query string inside the view kwargs.
|
||||
"""
|
||||
|
||||
context = "query string"
|
||||
|
||||
def __init__(self, args_spec: dict, default_values: dict):
|
||||
args_spec = args_spec.copy()
|
||||
|
||||
self._groups = {}
|
||||
for group_name, group in args_spec.items():
|
||||
if robuste_issubclass(group, Group):
|
||||
self._groups[group_name] = (group, _get_group_signature(group)[0])
|
||||
|
||||
_unpack_group_in_signature(args_spec, default_values)
|
||||
attrs = {"__annotations__": args_spec}
|
||||
attrs.update(default_values)
|
||||
|
||||
self.model = type("QueryModel", (BaseModel,), attrs)
|
||||
self.args_spec = args_spec
|
||||
self._is_multiple = frozenset(
|
||||
name for name, spec in args_spec.items() if typing.get_origin(spec) is list
|
||||
)
|
||||
def __init__(self, args_spec: dict):
|
||||
self.model = type("QueryModel", (BaseModel,), {"__annotations__": args_spec})
|
||||
|
||||
def inject(self, request: BaseRequest, args_view: list, kwargs_view: 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()
|
||||
}
|
||||
kwargs_view.update(self.model(**request.query).dict())
|
||||
|
||||
|
||||
class HeadersGetter(AbstractInjector):
|
||||
@ -145,162 +76,45 @@ class HeadersGetter(AbstractInjector):
|
||||
Validates and injects the HTTP headers inside the view kwargs.
|
||||
"""
|
||||
|
||||
context = "headers"
|
||||
|
||||
def __init__(self, args_spec: dict, default_values: dict):
|
||||
args_spec = args_spec.copy()
|
||||
|
||||
self._groups = {}
|
||||
for group_name, group in args_spec.items():
|
||||
if robuste_issubclass(group, Group):
|
||||
self._groups[group_name] = (group, _get_group_signature(group)[0])
|
||||
|
||||
_unpack_group_in_signature(args_spec, default_values)
|
||||
|
||||
attrs = {"__annotations__": args_spec}
|
||||
attrs.update(default_values)
|
||||
self.model = type("HeaderModel", (BaseModel,), attrs)
|
||||
def __init__(self, args_spec: dict):
|
||||
self.model = type("HeaderModel", (BaseModel,), {"__annotations__": args_spec})
|
||||
|
||||
def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict):
|
||||
header = {k.lower().replace("-", "_"): v for k, v in request.headers.items()}
|
||||
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)
|
||||
kwargs_view.update(self.model(**header).dict())
|
||||
|
||||
|
||||
class Group(SimpleNamespace):
|
||||
def _parse_func_signature(func: Callable) -> Tuple[dict, dict, dict, dict]:
|
||||
"""
|
||||
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:
|
||||
Analyse function signature and returns 4-tuple:
|
||||
0 - arguments will be set from the url path
|
||||
1 - argument will be set from the request body.
|
||||
2 - argument will be set from the query string.
|
||||
3 - argument will be set from the HTTP headers.
|
||||
4 - Default value for each parameters
|
||||
"""
|
||||
|
||||
path_args = {}
|
||||
body_args = {}
|
||||
qs_args = {}
|
||||
header_args = {}
|
||||
defaults = {}
|
||||
|
||||
annotations = get_type_hints(func)
|
||||
for param_name, param_spec in signature(func).parameters.items():
|
||||
|
||||
if param_name == "self":
|
||||
continue
|
||||
|
||||
if param_spec.annotation == param_spec.empty:
|
||||
raise RuntimeError(f"The parameter {param_name} must have an annotation")
|
||||
|
||||
annotation = annotations[param_name]
|
||||
if param_spec.default is not param_spec.empty:
|
||||
defaults[param_name] = param_spec.default
|
||||
|
||||
if param_spec.kind is param_spec.POSITIONAL_ONLY:
|
||||
path_args[param_name] = annotation
|
||||
|
||||
path_args[param_name] = param_spec.annotation
|
||||
elif param_spec.kind is param_spec.POSITIONAL_OR_KEYWORD:
|
||||
if is_pydantic_base_model(annotation):
|
||||
body_args[param_name] = annotation
|
||||
if is_pydantic_base_model(param_spec.annotation):
|
||||
body_args[param_name] = param_spec.annotation
|
||||
else:
|
||||
qs_args[param_name] = annotation
|
||||
qs_args[param_name] = param_spec.annotation
|
||||
elif param_spec.kind is param_spec.KEYWORD_ONLY:
|
||||
header_args[param_name] = annotation
|
||||
header_args[param_name] = param_spec.annotation
|
||||
else:
|
||||
raise RuntimeError(f"You cannot use {param_spec.VAR_POSITIONAL} parameters")
|
||||
|
||||
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)
|
||||
return path_args, body_args, qs_args, header_args
|
||||
|
@ -1,5 +1,5 @@
|
||||
from importlib import resources
|
||||
from typing import Iterable, Optional
|
||||
from typing import Iterable
|
||||
|
||||
import jinja2
|
||||
from aiohttp import web
|
||||
@ -13,18 +13,16 @@ def setup(
|
||||
apps_to_expose: Iterable[web.Application] = (),
|
||||
url_prefix: str = "/oas",
|
||||
enable: bool = True,
|
||||
version_spec: Optional[str] = None,
|
||||
title_spec: Optional[str] = None,
|
||||
raise_validation_errors: bool = False,
|
||||
):
|
||||
if enable:
|
||||
oas_app = web.Application()
|
||||
oas_app["apps to expose"] = tuple(apps_to_expose) or (app,)
|
||||
for a in oas_app["apps to expose"]:
|
||||
a['raise_validation_errors'] = raise_validation_errors
|
||||
oas_app["index template"] = jinja2.Template(
|
||||
resources.read_text("aiohttp_pydantic.oas", "index.j2")
|
||||
)
|
||||
oas_app["version_spec"] = version_spec
|
||||
oas_app["title_spec"] = title_spec
|
||||
|
||||
oas_app.router.add_get("/spec", get_oas, name="spec")
|
||||
oas_app.router.add_static("/static", swagger_ui_path, name="static")
|
||||
oas_app.router.add_get("", oas_ui, name="index")
|
||||
|
@ -1,28 +1,10 @@
|
||||
import argparse
|
||||
import importlib
|
||||
import json
|
||||
from typing import Dict, Protocol, Optional, Callable
|
||||
import sys
|
||||
|
||||
from .view import generate_oas
|
||||
|
||||
|
||||
class YamlModule(Protocol):
|
||||
"""
|
||||
Yaml Module type hint
|
||||
"""
|
||||
|
||||
def dump(self, data) -> str:
|
||||
pass
|
||||
|
||||
|
||||
yaml: Optional[YamlModule]
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
yaml = None
|
||||
|
||||
|
||||
def application_type(value):
|
||||
"""
|
||||
Return aiohttp application defined in the value.
|
||||
@ -44,35 +26,6 @@ def application_type(value):
|
||||
raise argparse.ArgumentTypeError(error) from error
|
||||
|
||||
|
||||
def base_oas_file_type(value) -> Dict:
|
||||
"""
|
||||
Load base oas file
|
||||
"""
|
||||
try:
|
||||
with open(value) as oas_file:
|
||||
data = oas_file.read()
|
||||
except OSError as error:
|
||||
raise argparse.ArgumentTypeError(error) from error
|
||||
|
||||
return json.loads(data)
|
||||
|
||||
|
||||
def format_type(value) -> Callable:
|
||||
"""
|
||||
Date Dumper one of (json, yaml)
|
||||
"""
|
||||
dumpers = {"json": lambda data: json.dumps(data, sort_keys=True, indent=4)}
|
||||
if yaml is not None:
|
||||
dumpers["yaml"] = yaml.dump
|
||||
|
||||
try:
|
||||
return dumpers[value]
|
||||
except KeyError:
|
||||
raise argparse.ArgumentTypeError(
|
||||
f"Wrong format value. (allowed values: {tuple(dumpers.keys())})"
|
||||
) from None
|
||||
|
||||
|
||||
def setup(parser: argparse.ArgumentParser):
|
||||
parser.add_argument(
|
||||
"apps",
|
||||
@ -82,52 +35,11 @@ def setup(parser: argparse.ArgumentParser):
|
||||
help="The name of the module containing the asyncio.web.Application."
|
||||
" By default the variable named 'app' is loaded but you can define"
|
||||
" an other variable name ending the name of module with : characters"
|
||||
" and the name of variable. Example: my_package.my_module:my_app"
|
||||
" If your asyncio.web.Application is returned by a function, you can"
|
||||
" use the syntax: my_package.my_module:my_app()",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-b",
|
||||
"--base-oas-file",
|
||||
metavar="FILE",
|
||||
dest="base",
|
||||
type=base_oas_file_type,
|
||||
help="A file that will be used as base to generate OAS",
|
||||
default={},
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
metavar="FILE",
|
||||
type=argparse.FileType("w"),
|
||||
help="File to write the output",
|
||||
default=sys.stdout,
|
||||
)
|
||||
|
||||
if yaml:
|
||||
help_output_format = (
|
||||
"The output format, can be 'json' or 'yaml' (default is json)"
|
||||
)
|
||||
else:
|
||||
help_output_format = "The output format, only 'json' is available install pyyaml to have yaml output format"
|
||||
|
||||
parser.add_argument(
|
||||
"-f",
|
||||
"--format",
|
||||
metavar="FORMAT",
|
||||
dest="formatter",
|
||||
type=format_type,
|
||||
help=help_output_format,
|
||||
default=format_type("json"),
|
||||
" and the name of variable. Example: my_package.my_module:my_app",
|
||||
)
|
||||
|
||||
parser.set_defaults(func=show_oas)
|
||||
|
||||
|
||||
def show_oas(args: argparse.Namespace):
|
||||
"""
|
||||
Display Open API Specification on the stdout.
|
||||
"""
|
||||
spec = args.base
|
||||
spec.update(generate_oas(args.apps))
|
||||
print(args.formatter(spec), file=args.output)
|
||||
print(json.dumps(generate_oas(args.apps), sort_keys=True, indent=4))
|
||||
|
@ -1,136 +0,0 @@
|
||||
"""
|
||||
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()
|
@ -2,7 +2,7 @@
|
||||
Utility to write Open Api Specifications using the Python language.
|
||||
"""
|
||||
|
||||
from typing import Union, List
|
||||
from typing import Union
|
||||
|
||||
|
||||
class Info:
|
||||
@ -133,7 +133,6 @@ class Parameters:
|
||||
class Response:
|
||||
def __init__(self, spec: dict):
|
||||
self._spec = spec
|
||||
self._spec.setdefault("description", "")
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
@ -157,7 +156,7 @@ class Responses:
|
||||
self._spec = spec.setdefault("responses", {})
|
||||
|
||||
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")
|
||||
|
||||
spec = self._spec.setdefault(str(status_code), {})
|
||||
@ -196,17 +195,6 @@ class OperationObject:
|
||||
def responses(self) -> Responses:
|
||||
return Responses(self._spec)
|
||||
|
||||
@property
|
||||
def tags(self) -> List[str]:
|
||||
return self._spec.get("tags", [])[:]
|
||||
|
||||
@tags.setter
|
||||
def tags(self, tags: List[str]):
|
||||
if tags:
|
||||
self._spec["tags"] = tags[:]
|
||||
else:
|
||||
self._spec.pop("tags", None)
|
||||
|
||||
|
||||
class PathItem:
|
||||
def __init__(self, spec: dict):
|
||||
@ -305,21 +293,9 @@ class Servers:
|
||||
return Server(spec)
|
||||
|
||||
|
||||
class Components:
|
||||
def __init__(self, spec: dict):
|
||||
self._spec = spec.setdefault("components", {})
|
||||
|
||||
@property
|
||||
def schemas(self) -> dict:
|
||||
return self._spec.setdefault("schemas", {})
|
||||
|
||||
|
||||
class OpenApiSpec3:
|
||||
def __init__(self):
|
||||
self._spec = {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"version": "1.0.0", "title": "Aiohttp pydantic application"},
|
||||
}
|
||||
self._spec = {"openapi": "3.0.0"}
|
||||
|
||||
@property
|
||||
def info(self) -> Info:
|
||||
@ -333,10 +309,11 @@ class OpenApiSpec3:
|
||||
def paths(self) -> Paths:
|
||||
return Paths(self._spec)
|
||||
|
||||
@property
|
||||
def components(self) -> Components:
|
||||
return Components(self._spec)
|
||||
|
||||
@property
|
||||
def spec(self):
|
||||
return self._spec
|
||||
|
||||
@property
|
||||
def definitions(self):
|
||||
self._spec.setdefault('definitions', {})
|
||||
return self._spec['definitions']
|
@ -13,7 +13,7 @@ Example:
|
||||
|
||||
from functools import lru_cache
|
||||
from types import new_class
|
||||
from typing import Protocol, TypeVar
|
||||
from typing import Protocol, TypeVar, Optional, Type
|
||||
|
||||
RespContents = TypeVar("RespContents", covariant=True)
|
||||
|
||||
|
@ -1,20 +1,45 @@
|
||||
import typing
|
||||
from datetime import date, datetime
|
||||
from inspect import getdoc
|
||||
from itertools import count
|
||||
from typing import List, Type, Optional, get_type_hints
|
||||
from typing import List, Type
|
||||
from uuid import UUID
|
||||
|
||||
from aiohttp.web import Response, json_response
|
||||
from aiohttp.web_app import Application
|
||||
from pydantic import BaseModel
|
||||
|
||||
from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem
|
||||
from . import docstring_parser
|
||||
|
||||
from ..injectors import _parse_func_signature
|
||||
from ..utils import is_pydantic_base_model
|
||||
from ..view import PydanticView, is_pydantic_view
|
||||
from .typing import is_status_code_type
|
||||
|
||||
JSON_SCHEMA_TYPES = {
|
||||
float: {"type": "number"},
|
||||
str: {"type": "string"},
|
||||
int: {"type": "integer"},
|
||||
UUID: {"type": "string", "format": "uuid"},
|
||||
bool: {"type": "boolean"},
|
||||
datetime: {"type": "string", "format": "date-time"},
|
||||
date: {"type": "string", "format": "date"},
|
||||
}
|
||||
|
||||
|
||||
def _handle_optional(type_):
|
||||
"""
|
||||
Returns the type wrapped in Optional or None.
|
||||
|
||||
>>> _handle_optional(int)
|
||||
>>> _handle_optional(Optional[str])
|
||||
<class 'str'>
|
||||
"""
|
||||
if typing.get_origin(type_) is typing.Union:
|
||||
args = typing.get_args(type_)
|
||||
if len(args) == 2 and type(None) in args:
|
||||
return next(iter(set(args) - {type(None)}))
|
||||
return None
|
||||
|
||||
|
||||
class _OASResponseBuilder:
|
||||
"""
|
||||
@ -22,19 +47,21 @@ class _OASResponseBuilder:
|
||||
generate the OAS operation response.
|
||||
"""
|
||||
|
||||
def __init__(self, oas: OpenApiSpec3, oas_operation, status_code_descriptions):
|
||||
def __init__(self, oas_operation, definitions):
|
||||
self._oas_operation = oas_operation
|
||||
self._oas = oas
|
||||
self._status_code_descriptions = status_code_descriptions
|
||||
self._definitions = definitions
|
||||
|
||||
def _process_definitions(self, schema):
|
||||
if 'definitions' in schema:
|
||||
for k, v in schema['definitions'].items():
|
||||
self._definitions[k] = v
|
||||
|
||||
return {i:schema[i] for i in schema if i!='definitions'}
|
||||
|
||||
def _handle_pydantic_base_model(self, obj):
|
||||
if is_pydantic_base_model(obj):
|
||||
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 self._process_definitions(obj.schema())
|
||||
|
||||
return {}
|
||||
|
||||
def _handle_list(self, obj):
|
||||
@ -53,16 +80,10 @@ class _OASResponseBuilder:
|
||||
"schema": self._handle_list(typing.get_args(obj)[0])
|
||||
}
|
||||
}
|
||||
desc = self._status_code_descriptions.get(int(status_code))
|
||||
if desc:
|
||||
self._oas_operation.responses[status_code].description = desc
|
||||
|
||||
elif is_status_code_type(obj):
|
||||
status_code = obj.__name__[1:]
|
||||
self._oas_operation.responses[status_code].content = {}
|
||||
desc = self._status_code_descriptions.get(int(status_code))
|
||||
if desc:
|
||||
self._oas_operation.responses[status_code].description = desc
|
||||
|
||||
def _handle_union(self, obj):
|
||||
if typing.get_origin(obj) is typing.Union:
|
||||
@ -75,33 +96,19 @@ class _OASResponseBuilder:
|
||||
|
||||
|
||||
def _add_http_method_to_oas(
|
||||
oas: OpenApiSpec3, oas_path: PathItem, http_method: str, view: Type[PydanticView]
|
||||
oas_path: PathItem, http_method: str, view: Type[PydanticView], definitions: dict
|
||||
):
|
||||
http_method = http_method.lower()
|
||||
oas_operation: OperationObject = getattr(oas_path, http_method)
|
||||
handler = getattr(view, http_method)
|
||||
path_args, body_args, qs_args, header_args, defaults = _parse_func_signature(
|
||||
handler, unpack_group=True
|
||||
)
|
||||
path_args, body_args, qs_args, header_args = _parse_func_signature(handler)
|
||||
description = getdoc(handler)
|
||||
if description:
|
||||
oas_operation.description = docstring_parser.operation(description)
|
||||
oas_operation.tags = docstring_parser.tags(description)
|
||||
status_code_descriptions = docstring_parser.status_code(description)
|
||||
else:
|
||||
status_code_descriptions = {}
|
||||
oas_operation.description = description
|
||||
|
||||
if body_args:
|
||||
body_schema = (
|
||||
next(iter(body_args.values()))
|
||||
.schema(ref_template="#/components/schemas/{model}")
|
||||
.copy()
|
||||
)
|
||||
if def_sub_schemas := body_schema.pop("definitions", None):
|
||||
oas.components.schemas.update(def_sub_schemas)
|
||||
|
||||
oas_operation.request_body.content = {
|
||||
"application/json": {"schema": body_schema}
|
||||
"application/json": {"schema": next(iter(body_args.values())).schema()}
|
||||
}
|
||||
|
||||
indexes = count()
|
||||
@ -114,55 +121,37 @@ def _add_http_method_to_oas(
|
||||
i = next(indexes)
|
||||
oas_operation.parameters[i].in_ = args_location
|
||||
oas_operation.parameters[i].name = name
|
||||
|
||||
attrs = {"__annotations__": {"__root__": type_}}
|
||||
if name in defaults:
|
||||
attrs["__root__"] = defaults[name]
|
||||
oas_operation.parameters[i].required = False
|
||||
else:
|
||||
optional_type = _handle_optional(type_)
|
||||
if optional_type is None:
|
||||
oas_operation.parameters[i].schema = JSON_SCHEMA_TYPES[type_]
|
||||
oas_operation.parameters[i].required = True
|
||||
else:
|
||||
oas_operation.parameters[i].schema = JSON_SCHEMA_TYPES[optional_type]
|
||||
oas_operation.parameters[i].required = False
|
||||
|
||||
oas_operation.parameters[i].schema = type(name, (BaseModel,), attrs).schema(
|
||||
ref_template="#/components/schemas/{model}"
|
||||
)
|
||||
|
||||
return_type = get_type_hints(handler).get("return")
|
||||
return_type = handler.__annotations__.get("return")
|
||||
if return_type is not None:
|
||||
_OASResponseBuilder(oas, oas_operation, status_code_descriptions).build(
|
||||
return_type
|
||||
)
|
||||
_OASResponseBuilder(oas_operation, definitions).build(return_type)
|
||||
|
||||
|
||||
def generate_oas(
|
||||
apps: List[Application],
|
||||
version_spec: Optional[str] = None,
|
||||
title_spec: Optional[str] = None,
|
||||
) -> dict:
|
||||
def generate_oas(apps: List[Application]) -> dict:
|
||||
"""
|
||||
Generate and return Open Api Specification from PydanticView in application.
|
||||
"""
|
||||
oas = OpenApiSpec3()
|
||||
|
||||
if version_spec is not None:
|
||||
oas.info.version = version_spec
|
||||
|
||||
if title_spec is not None:
|
||||
oas.info.title = title_spec
|
||||
|
||||
for app in apps:
|
||||
for resources in app.router.resources():
|
||||
for resource_route in resources:
|
||||
if not is_pydantic_view(resource_route.handler):
|
||||
continue
|
||||
|
||||
view: Type[PydanticView] = resource_route.handler
|
||||
info = resource_route.get_info()
|
||||
path = oas.paths[info.get("path", info.get("formatter"))]
|
||||
if resource_route.method == "*":
|
||||
for method_name in view.allowed_methods:
|
||||
_add_http_method_to_oas(oas, path, method_name, view)
|
||||
else:
|
||||
_add_http_method_to_oas(oas, path, resource_route.method, view)
|
||||
if is_pydantic_view(resource_route.handler):
|
||||
view: Type[PydanticView] = resource_route.handler
|
||||
info = resource_route.get_info()
|
||||
path = oas.paths[info.get("path", info.get("formatter"))]
|
||||
if resource_route.method == "*":
|
||||
for method_name in view.allowed_methods:
|
||||
_add_http_method_to_oas(path, method_name, view, oas.definitions)
|
||||
else:
|
||||
_add_http_method_to_oas(path, resource_route.method, view, oas.definitions)
|
||||
|
||||
return oas.spec
|
||||
|
||||
@ -172,9 +161,7 @@ async def get_oas(request):
|
||||
View to generate the Open Api Specification from PydanticView in application.
|
||||
"""
|
||||
apps = request.app["apps to expose"]
|
||||
version_spec = request.app["version_spec"]
|
||||
title_spec = request.app["title_spec"]
|
||||
return json_response(generate_oas(apps, version_spec, title_spec))
|
||||
return json_response(generate_oas(apps))
|
||||
|
||||
|
||||
async def oas_ui(request):
|
||||
@ -185,6 +172,9 @@ async def oas_ui(request):
|
||||
|
||||
static_url = request.app.router["static"].url_for(filename="")
|
||||
spec_url = request.app.router["spec"].url_for()
|
||||
|
||||
if request.scheme != request.headers.get('x-forwarded-proto', request.scheme):
|
||||
request = request.clone(scheme=request.headers['x-forwarded-proto'])
|
||||
host = request.url.origin()
|
||||
|
||||
return Response(
|
||||
|
@ -5,15 +5,7 @@ def is_pydantic_base_model(obj):
|
||||
"""
|
||||
Return true is obj is a pydantic.BaseModel subclass.
|
||||
"""
|
||||
return robuste_issubclass(obj, BaseModel)
|
||||
|
||||
|
||||
def robuste_issubclass(cls1, cls2):
|
||||
"""
|
||||
function likes issubclass but returns False instead of raise type error
|
||||
if first parameter is not a class.
|
||||
"""
|
||||
try:
|
||||
return issubclass(cls1, cls2)
|
||||
return issubclass(obj, BaseModel)
|
||||
except TypeError:
|
||||
return False
|
||||
|
@ -1,7 +1,6 @@
|
||||
from functools import update_wrapper
|
||||
from inspect import iscoroutinefunction
|
||||
from typing import Any, Callable, Generator, Iterable, Set, ClassVar
|
||||
import warnings
|
||||
from typing import Any, Callable, Generator, Iterable
|
||||
|
||||
from aiohttp.abc import AbstractView
|
||||
from aiohttp.hdrs import METH_ALL
|
||||
@ -17,8 +16,6 @@ from .injectors import (
|
||||
MatchInfoGetter,
|
||||
QueryGetter,
|
||||
_parse_func_signature,
|
||||
CONTEXT,
|
||||
Group,
|
||||
)
|
||||
|
||||
|
||||
@ -27,85 +24,44 @@ class PydanticView(AbstractView):
|
||||
An AIOHTTP View that validate request using function annotations.
|
||||
"""
|
||||
|
||||
# Allowed HTTP methods; overridden when subclassed.
|
||||
allowed_methods: ClassVar[Set[str]] = {}
|
||||
|
||||
async def _iter(self) -> StreamResponse:
|
||||
if (method_name := self.request.method) not in self.allowed_methods:
|
||||
self._raise_allowed_methods()
|
||||
return await getattr(self, method_name.lower())()
|
||||
method = getattr(self, self.request.method.lower(), None)
|
||||
resp = await method()
|
||||
return resp
|
||||
|
||||
def __await__(self) -> Generator[Any, None, StreamResponse]:
|
||||
return self._iter().__await__()
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
cls.allowed_methods = {
|
||||
meth_name for meth_name in METH_ALL if hasattr(cls, meth_name.lower())
|
||||
}
|
||||
|
||||
for meth_name in METH_ALL:
|
||||
if meth_name.lower() in vars(cls):
|
||||
if meth_name not in cls.allowed_methods:
|
||||
setattr(cls, meth_name.lower(), cls.raise_not_allowed)
|
||||
else:
|
||||
handler = getattr(cls, meth_name.lower())
|
||||
decorated_handler = inject_params(handler, cls.parse_func_signature)
|
||||
setattr(cls, meth_name.lower(), decorated_handler)
|
||||
|
||||
def _raise_allowed_methods(self) -> None:
|
||||
async def raise_not_allowed(self):
|
||||
raise HTTPMethodNotAllowed(self.request.method, self.allowed_methods)
|
||||
|
||||
def raise_not_allowed(self) -> None:
|
||||
warnings.warn(
|
||||
"PydanticView.raise_not_allowed is deprecated and renamed _raise_allowed_methods",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
self._raise_allowed_methods()
|
||||
|
||||
@staticmethod
|
||||
def parse_func_signature(func: Callable) -> Iterable[AbstractInjector]:
|
||||
path_args, body_args, qs_args, header_args, defaults = _parse_func_signature(
|
||||
func
|
||||
)
|
||||
path_args, body_args, qs_args, header_args = _parse_func_signature(func)
|
||||
injectors = []
|
||||
|
||||
def default_value(args: dict) -> dict:
|
||||
"""
|
||||
Returns the default values of args.
|
||||
"""
|
||||
return {name: defaults[name] for name in args if name in defaults}
|
||||
|
||||
if path_args:
|
||||
injectors.append(MatchInfoGetter(path_args, default_value(path_args)))
|
||||
injectors.append(MatchInfoGetter(path_args))
|
||||
if body_args:
|
||||
injectors.append(BodyGetter(body_args, default_value(body_args)))
|
||||
injectors.append(BodyGetter(body_args))
|
||||
if qs_args:
|
||||
injectors.append(QueryGetter(qs_args, default_value(qs_args)))
|
||||
injectors.append(QueryGetter(qs_args))
|
||||
if header_args:
|
||||
injectors.append(HeadersGetter(header_args, default_value(header_args)))
|
||||
injectors.append(HeadersGetter(header_args))
|
||||
return injectors
|
||||
|
||||
async def on_validation_error(
|
||||
self, exception: ValidationError, context: CONTEXT
|
||||
) -> StreamResponse:
|
||||
"""
|
||||
This method is a hook to intercept ValidationError.
|
||||
|
||||
This hook can be redefined to return a custom HTTP response error.
|
||||
The exception is a pydantic.ValidationError and the context is "body",
|
||||
"headers", "path" or "query string"
|
||||
"""
|
||||
errors = exception.errors()
|
||||
for error in errors:
|
||||
error["in"] = context
|
||||
|
||||
return json_response(data=errors, status=400)
|
||||
|
||||
|
||||
def inject_params(
|
||||
handler, parse_func_signature: Callable[[Callable], Iterable[AbstractInjector]]
|
||||
@ -127,7 +83,10 @@ def inject_params(
|
||||
else:
|
||||
injector.inject(self.request, args, kwargs)
|
||||
except ValidationError as error:
|
||||
return await self.on_validation_error(error, injector.context)
|
||||
if self.request.app['raise_validation_errors']:
|
||||
raise
|
||||
else:
|
||||
return json_response(text=error.json(), status=400)
|
||||
|
||||
return await handler(self, *args, **kwargs)
|
||||
|
||||
@ -143,14 +102,3 @@ def is_pydantic_view(obj) -> bool:
|
||||
return issubclass(obj, PydanticView)
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
|
||||
__all__ = (
|
||||
"AbstractInjector",
|
||||
"BodyGetter",
|
||||
"HeadersGetter",
|
||||
"MatchInfoGetter",
|
||||
"QueryGetter",
|
||||
"CONTEXT",
|
||||
"Group",
|
||||
)
|
||||
|
@ -15,7 +15,7 @@ async def pet_not_found_to_404(request, handler):
|
||||
|
||||
|
||||
app = Application(middlewares=[pet_not_found_to_404])
|
||||
oas.setup(app, version_spec="1.0.1", title_spec="My App")
|
||||
oas.setup(app)
|
||||
|
||||
app["model"] = Model()
|
||||
app.router.add_view("/pets", PetCollectionView)
|
||||
|
@ -1,17 +1,10 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import List
|
||||
|
||||
|
||||
class Friend(BaseModel):
|
||||
name: str
|
||||
age: str
|
||||
|
||||
|
||||
class Pet(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
age: int
|
||||
friends: Friend
|
||||
|
||||
|
||||
class Error(BaseModel):
|
||||
|
29
demo/view.py
29
demo/view.py
@ -10,54 +10,25 @@ from .model import Error, Pet
|
||||
|
||||
class PetCollectionView(PydanticView):
|
||||
async def get(self, age: Optional[int] = None) -> r200[List[Pet]]:
|
||||
"""
|
||||
List all pets
|
||||
|
||||
Status Codes:
|
||||
200: Successful operation
|
||||
"""
|
||||
pets = self.request.app["model"].list_pets()
|
||||
return web.json_response(
|
||||
[pet.dict() for pet in pets if age is None or age == pet.age]
|
||||
)
|
||||
|
||||
async def post(self, pet: Pet) -> r201[Pet]:
|
||||
"""
|
||||
Add a new pet to the store
|
||||
|
||||
Status Codes:
|
||||
201: Successful operation
|
||||
"""
|
||||
self.request.app["model"].add_pet(pet)
|
||||
return web.json_response(pet.dict())
|
||||
|
||||
|
||||
class PetItemView(PydanticView):
|
||||
async def get(self, id: int, /) -> Union[r200[Pet], r404[Error]]:
|
||||
"""
|
||||
Find a pet by ID
|
||||
|
||||
Status Codes:
|
||||
200: Successful operation
|
||||
404: Pet not found
|
||||
"""
|
||||
pet = self.request.app["model"].find_pet(id)
|
||||
return web.json_response(pet.dict())
|
||||
|
||||
async def put(self, id: int, /, pet: Pet) -> r200[Pet]:
|
||||
"""
|
||||
Update an existing object
|
||||
|
||||
Status Codes:
|
||||
200: Successful operation
|
||||
404: Pet not found
|
||||
"""
|
||||
self.request.app["model"].update_pet(id, pet)
|
||||
return web.json_response(pet.dict())
|
||||
|
||||
async def delete(self, id: int, /) -> r204:
|
||||
"""
|
||||
Deletes a pet
|
||||
"""
|
||||
self.request.app["model"].remove_pet(id)
|
||||
return web.Response(status=204)
|
||||
|
@ -1,42 +1,7 @@
|
||||
async-timeout==3.0.1
|
||||
attrs==21.2.0
|
||||
bleach==4.0.0
|
||||
certifi==2021.5.30
|
||||
cffi==1.14.6
|
||||
chardet==4.0.0
|
||||
charset-normalizer==2.0.4
|
||||
codecov==2.1.11
|
||||
colorama==0.4.4
|
||||
coverage==5.5
|
||||
cryptography==3.4.7
|
||||
docutils==0.17.1
|
||||
idna==3.2
|
||||
importlib-metadata==4.6.3
|
||||
iniconfig==1.1.1
|
||||
jeepney==0.7.1
|
||||
keyring==23.0.1
|
||||
multidict==5.1.0
|
||||
packaging==21.0
|
||||
pkginfo==1.7.1
|
||||
pluggy==0.13.1
|
||||
py==1.10.0
|
||||
pycparser==2.20
|
||||
Pygments==2.9.0
|
||||
pyparsing==2.4.7
|
||||
pytest==6.1.2
|
||||
pytest-aiohttp==0.3.0
|
||||
pytest-cov==2.10.1
|
||||
readme-renderer==29.0
|
||||
requests==2.26.0
|
||||
requests-toolbelt==0.9.1
|
||||
rfc3986==1.5.0
|
||||
SecretStorage==3.3.1
|
||||
six==1.16.0
|
||||
toml==0.10.2
|
||||
tqdm==4.62.0
|
||||
twine==3.4.2
|
||||
typing-extensions==3.10.0.0
|
||||
urllib3==1.26.6
|
||||
webencodings==0.5.1
|
||||
yarl==1.6.3
|
||||
zipp==3.5.0
|
||||
certifi==2020.11.8
|
||||
chardet==3.0.4
|
||||
codecov==2.1.10
|
||||
coverage==5.3
|
||||
idna==2.10
|
||||
requests==2.25.0
|
||||
urllib3==1.26.2
|
||||
|
@ -1,23 +1,13 @@
|
||||
async-timeout==3.0.1
|
||||
attrs==21.2.0
|
||||
bleach==4.0.0
|
||||
chardet==4.0.0
|
||||
coverage==5.5
|
||||
docutils==0.17.1
|
||||
idna==3.2
|
||||
attrs==20.3.0
|
||||
coverage==5.3
|
||||
iniconfig==1.1.1
|
||||
multidict==5.1.0
|
||||
packaging==21.0
|
||||
packaging==20.4
|
||||
pluggy==0.13.1
|
||||
py==1.10.0
|
||||
Pygments==2.9.0
|
||||
py==1.9.0
|
||||
pyparsing==2.4.7
|
||||
pytest==6.1.2
|
||||
pytest-aiohttp==0.3.0
|
||||
pytest-cov==2.10.1
|
||||
readme-renderer==29.0
|
||||
six==1.16.0
|
||||
six==1.15.0
|
||||
toml==0.10.2
|
||||
typing-extensions==3.10.0.0
|
||||
webencodings==0.5.1
|
||||
yarl==1.6.3
|
||||
typing-extensions==3.7.4.3
|
||||
|
19
setup.cfg
19
setup.cfg
@ -21,7 +21,7 @@ classifiers =
|
||||
Programming Language :: Python :: 3.8
|
||||
Programming Language :: Python :: 3.9
|
||||
Topic :: Software Development :: Libraries :: Application Frameworks
|
||||
Framework :: aiohttp
|
||||
Framework :: AsyncIO
|
||||
License :: OSI Approved :: MIT License
|
||||
|
||||
[options]
|
||||
@ -31,24 +31,17 @@ packages = find:
|
||||
python_requires = >=3.8
|
||||
install_requires =
|
||||
aiohttp
|
||||
pydantic>=1.7
|
||||
pydantic
|
||||
swagger-ui-bundle
|
||||
|
||||
[options.extras_require]
|
||||
test =
|
||||
pytest==6.1.2
|
||||
pytest-aiohttp==0.3.0
|
||||
pytest-cov==2.10.1
|
||||
readme-renderer==29.0
|
||||
ci =
|
||||
%(test)s
|
||||
codecov==2.1.11
|
||||
twine==3.4.2
|
||||
test = pytest==6.1.2; pytest-aiohttp==0.3.0; pytest-cov==2.10.1
|
||||
ci = pytest==6.1.2; pytest-aiohttp==0.3.0; pytest-cov==2.10.1; codecov==2.1.10
|
||||
|
||||
[options.packages.find]
|
||||
exclude =
|
||||
tests*
|
||||
demo*
|
||||
tests
|
||||
demo
|
||||
|
||||
[options.package_data]
|
||||
aiohttp_pydantic.oas = index.j2
|
||||
|
172
tasks.py
172
tasks.py
@ -1,172 +0,0 @@
|
||||
"""
|
||||
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}")
|
@ -1,76 +0,0 @@
|
||||
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}
|
@ -1,57 +0,0 @@
|
||||
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",
|
||||
}
|
||||
]
|
@ -1,73 +0,0 @@
|
||||
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"}
|
@ -1 +0,0 @@
|
||||
{"info": {"title": "MyApp", "version": "1.0.0"}}
|
@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from aiohttp_pydantic import PydanticView
|
||||
|
@ -1,15 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from textwrap import dedent
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from aiohttp_pydantic.oas import cmd
|
||||
|
||||
PATH_TO_BASE_JSON_FILE = str(Path(__file__).parent / "oas_base.json")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cmd_line():
|
||||
@ -18,18 +13,13 @@ def cmd_line():
|
||||
return parser
|
||||
|
||||
|
||||
def test_show_oas_of_app(cmd_line):
|
||||
def test_show_oad_of_app(cmd_line, capfd):
|
||||
args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample"])
|
||||
args.output = StringIO()
|
||||
args.func(args)
|
||||
|
||||
captured = capfd.readouterr()
|
||||
expected = dedent(
|
||||
"""
|
||||
{
|
||||
"info": {
|
||||
"title": "Aiohttp pydantic application",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
{
|
||||
"openapi": "3.0.0",
|
||||
"paths": {
|
||||
"/route-1/{a}": {
|
||||
@ -40,7 +30,6 @@ def test_show_oas_of_app(cmd_line):
|
||||
"name": "a",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "a",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
@ -55,7 +44,6 @@ def test_show_oas_of_app(cmd_line):
|
||||
"name": "b",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "b",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
@ -67,20 +55,16 @@ def test_show_oas_of_app(cmd_line):
|
||||
"""
|
||||
)
|
||||
|
||||
assert args.output.getvalue().strip() == expected.strip()
|
||||
assert captured.out.strip() == expected.strip()
|
||||
|
||||
|
||||
def test_show_oas_of_sub_app(cmd_line):
|
||||
def test_show_oad_of_sub_app(cmd_line, capfd):
|
||||
args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample:sub_app"])
|
||||
args.output = StringIO()
|
||||
args.func(args)
|
||||
captured = capfd.readouterr()
|
||||
expected = dedent(
|
||||
"""
|
||||
{
|
||||
"info": {
|
||||
"title": "Aiohttp pydantic application",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
{
|
||||
"openapi": "3.0.0",
|
||||
"paths": {
|
||||
"/sub-app/route-2/{b}": {
|
||||
@ -91,7 +75,6 @@ def test_show_oas_of_sub_app(cmd_line):
|
||||
"name": "b",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "b",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
@ -103,26 +86,16 @@ def test_show_oas_of_sub_app(cmd_line):
|
||||
"""
|
||||
)
|
||||
|
||||
assert args.output.getvalue().strip() == expected.strip()
|
||||
assert captured.out.strip() == expected.strip()
|
||||
|
||||
|
||||
def test_show_oas_of_a_callable(cmd_line):
|
||||
args = cmd_line.parse_args(
|
||||
[
|
||||
"tests.test_oas.test_cmd.sample:make_app()",
|
||||
"--base-oas-file",
|
||||
PATH_TO_BASE_JSON_FILE,
|
||||
]
|
||||
)
|
||||
args.output = StringIO()
|
||||
def test_show_oad_of_a_callable(cmd_line, capfd):
|
||||
args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample:make_app()"])
|
||||
args.func(args)
|
||||
captured = capfd.readouterr()
|
||||
expected = dedent(
|
||||
"""
|
||||
{
|
||||
"info": {
|
||||
"title": "Aiohttp pydantic application",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"openapi": "3.0.0",
|
||||
"paths": {
|
||||
"/route-3/{a}": {
|
||||
@ -133,7 +106,6 @@ def test_show_oas_of_a_callable(cmd_line):
|
||||
"name": "a",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "a",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
@ -145,4 +117,4 @@ def test_show_oas_of_a_callable(cmd_line):
|
||||
"""
|
||||
)
|
||||
|
||||
assert args.output.getvalue().strip() == expected.strip()
|
||||
assert captured.out.strip() == expected.strip()
|
||||
|
@ -1,157 +0,0 @@
|
||||
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:"
|
@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from aiohttp_pydantic.oas.struct import OpenApiSpec3
|
||||
@ -7,16 +5,10 @@ from aiohttp_pydantic.oas.struct import OpenApiSpec3
|
||||
|
||||
def test_info_title():
|
||||
oas = OpenApiSpec3()
|
||||
assert oas.info.title == "Aiohttp pydantic application"
|
||||
assert oas.info.title is None
|
||||
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",
|
||||
}
|
||||
assert oas.spec == {"info": {"title": "Info Title"}, "openapi": "3.0.0"}
|
||||
|
||||
|
||||
def test_info_description():
|
||||
@ -24,25 +16,15 @@ def test_info_description():
|
||||
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",
|
||||
}
|
||||
assert oas.spec == {"info": {"description": "info description"}, "openapi": "3.0.0"}
|
||||
|
||||
|
||||
def test_info_version():
|
||||
oas = OpenApiSpec3()
|
||||
assert oas.info.version == "1.0.0"
|
||||
assert oas.info.version is None
|
||||
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",
|
||||
}
|
||||
assert oas.spec == {"info": {"version": "3.14"}, "openapi": "3.0.0"}
|
||||
|
||||
|
||||
def test_info_terms_of_service():
|
||||
@ -51,11 +33,7 @@ def test_info_terms_of_service():
|
||||
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/",
|
||||
},
|
||||
"info": {"termsOfService": "http://example.com/terms/"},
|
||||
"openapi": "3.0.0",
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from aiohttp_pydantic.oas.struct import OpenApiSpec3
|
||||
|
||||
|
||||
@ -8,7 +6,6 @@ def test_paths_description():
|
||||
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 ..."}},
|
||||
}
|
||||
|
||||
@ -16,11 +13,7 @@ def test_paths_description():
|
||||
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": {}}},
|
||||
}
|
||||
assert oas.spec == {"openapi": "3.0.0", "paths": {"/users/{id}": {"get": {}}}}
|
||||
|
||||
|
||||
def test_paths_operation_description():
|
||||
@ -29,7 +22,6 @@ def test_paths_operation_description():
|
||||
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 ..."}}},
|
||||
}
|
||||
|
||||
@ -40,7 +32,6 @@ def test_paths_operation_summary():
|
||||
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"}
|
||||
@ -60,7 +51,6 @@ def test_paths_operation_parameters():
|
||||
|
||||
assert oas.spec == {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "Aiohttp pydantic application", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/users/{petId}": {
|
||||
"get": {
|
||||
@ -96,7 +86,6 @@ def test_paths_operation_requestBody():
|
||||
request_body.required = True
|
||||
assert oas.spec == {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "Aiohttp pydantic application", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/users/{petId}": {
|
||||
"get": {
|
||||
@ -121,18 +110,6 @@ def test_paths_operation_requestBody():
|
||||
}
|
||||
|
||||
|
||||
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]
|
||||
|
@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from aiohttp_pydantic.oas.struct import OpenApiSpec3
|
||||
@ -11,7 +9,6 @@ def test_sever_url():
|
||||
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"},
|
||||
@ -25,7 +22,6 @@ def test_sever_description():
|
||||
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",
|
||||
|
@ -1,7 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Union, Literal
|
||||
from typing import List, Optional, Union
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
@ -9,26 +6,12 @@ from aiohttp import web
|
||||
from pydantic.main import BaseModel
|
||||
|
||||
from aiohttp_pydantic import PydanticView, oas
|
||||
from aiohttp_pydantic.injectors import Group
|
||||
from aiohttp_pydantic.oas.typing import r200, r201, r204, r404
|
||||
from aiohttp_pydantic.oas.view import generate_oas
|
||||
|
||||
|
||||
class Color(str, Enum):
|
||||
RED = "red"
|
||||
GREEN = "green"
|
||||
PINK = "pink"
|
||||
|
||||
|
||||
class Toy(BaseModel):
|
||||
name: str
|
||||
color: Color
|
||||
|
||||
|
||||
class Pet(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
toys: List[Toy]
|
||||
|
||||
|
||||
class PetCollectionView(PydanticView):
|
||||
@ -37,10 +20,6 @@ class PetCollectionView(PydanticView):
|
||||
) -> r200[List[Pet]]:
|
||||
"""
|
||||
Get a list of pets
|
||||
|
||||
Tags: pet
|
||||
Status Codes:
|
||||
200: Successful operation
|
||||
"""
|
||||
return web.json_response()
|
||||
|
||||
@ -50,82 +29,28 @@ class PetCollectionView(PydanticView):
|
||||
|
||||
|
||||
class PetItemView(PydanticView):
|
||||
async def get(
|
||||
self,
|
||||
id: int,
|
||||
/,
|
||||
size: Union[int, Literal["x", "l", "s"]],
|
||||
day: Union[int, Literal["now"]] = "now",
|
||||
) -> Union[r200[Pet], r404]:
|
||||
async def get(self, id: int, /) -> Union[r200[Pet], r404]:
|
||||
return web.json_response()
|
||||
|
||||
async def put(self, id: int, /, pet: Pet):
|
||||
return web.json_response()
|
||||
|
||||
async def delete(self, id: int, /) -> r204:
|
||||
"""
|
||||
Status Code:
|
||||
204: Empty but OK
|
||||
"""
|
||||
return web.json_response()
|
||||
|
||||
|
||||
class ViewResponseReturnASimpleType(PydanticView):
|
||||
async def get(self) -> r200[int]:
|
||||
"""
|
||||
Status Codes:
|
||||
200: The new number
|
||||
"""
|
||||
return web.json_response()
|
||||
|
||||
|
||||
async def ensure_content_durability(client):
|
||||
"""
|
||||
Reload the page 2 times to ensure that content is always the same
|
||||
note: pydantic can return a cached dict, if a view updates the dict the
|
||||
output will be incoherent
|
||||
"""
|
||||
response_1 = await client.get("/oas/spec")
|
||||
assert response_1.status == 200
|
||||
assert response_1.content_type == "application/json"
|
||||
content_1 = await response_1.json()
|
||||
|
||||
response_2 = await client.get("/oas/spec")
|
||||
content_2 = await response_2.json()
|
||||
assert content_1 == content_2
|
||||
|
||||
return content_2
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def generated_oas(aiohttp_client, loop) -> web.Application:
|
||||
app = web.Application()
|
||||
app.router.add_view("/pets", PetCollectionView)
|
||||
app.router.add_view("/pets/{id}", PetItemView)
|
||||
app.router.add_view("/simple-type", ViewResponseReturnASimpleType)
|
||||
oas.setup(app)
|
||||
|
||||
return await ensure_content_durability(await aiohttp_client(app))
|
||||
|
||||
|
||||
async def test_generated_oas_should_have_components_schemas(generated_oas):
|
||||
assert generated_oas["components"]["schemas"] == {
|
||||
"Color": {
|
||||
"description": "An enumeration.",
|
||||
"enum": ["red", "green", "pink"],
|
||||
"title": "Color",
|
||||
"type": "string",
|
||||
},
|
||||
"Toy": {
|
||||
"properties": {
|
||||
"color": {"$ref": "#/components/schemas/Color"},
|
||||
"name": {"title": "Name", "type": "string"},
|
||||
},
|
||||
"required": ["name", "color"],
|
||||
"title": "Toy",
|
||||
"type": "object",
|
||||
},
|
||||
}
|
||||
client = await aiohttp_client(app)
|
||||
response = await client.get("/oas/spec")
|
||||
assert response.status == 200
|
||||
assert response.content_type == "application/json"
|
||||
return await response.json()
|
||||
|
||||
|
||||
async def test_generated_oas_should_have_pets_paths(generated_oas):
|
||||
@ -135,30 +60,28 @@ async def test_generated_oas_should_have_pets_paths(generated_oas):
|
||||
async def test_pets_route_should_have_get_method(generated_oas):
|
||||
assert generated_oas["paths"]["/pets"]["get"] == {
|
||||
"description": "Get a list of pets",
|
||||
"tags": ["pet"],
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "format",
|
||||
"required": True,
|
||||
"schema": {"title": "format", "type": "string"},
|
||||
"schema": {"type": "string"},
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "name",
|
||||
"required": False,
|
||||
"schema": {"title": "name", "type": "string"},
|
||||
"schema": {"type": "string"},
|
||||
},
|
||||
{
|
||||
"in": "header",
|
||||
"name": "promo",
|
||||
"required": False,
|
||||
"schema": {"format": "uuid", "title": "promo", "type": "string"},
|
||||
"schema": {"format": "uuid", "type": "string"},
|
||||
},
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful operation",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@ -166,20 +89,15 @@ async def test_pets_route_should_have_get_method(generated_oas):
|
||||
"properties": {
|
||||
"id": {"title": "Id", "type": "integer"},
|
||||
"name": {"title": "Name", "type": "string"},
|
||||
"toys": {
|
||||
"items": {"$ref": "#/components/schemas/Toy"},
|
||||
"title": "Toys",
|
||||
"type": "array",
|
||||
},
|
||||
},
|
||||
"required": ["id", "name", "toys"],
|
||||
"required": ["id", "name"],
|
||||
"title": "Pet",
|
||||
"type": "object",
|
||||
},
|
||||
"type": "array",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
@ -192,43 +110,32 @@ async def test_pets_route_should_have_post_method(generated_oas):
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"title": "Pet",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"title": "Id", "type": "integer"},
|
||||
"name": {"title": "Name", "type": "string"},
|
||||
"toys": {
|
||||
"items": {"$ref": "#/components/schemas/Toy"},
|
||||
"title": "Toys",
|
||||
"type": "array",
|
||||
},
|
||||
},
|
||||
"required": ["id", "name", "toys"],
|
||||
"title": "Pet",
|
||||
"type": "object",
|
||||
"required": ["id", "name"],
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"title": "Pet",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"title": "Id", "type": "integer"},
|
||||
"name": {"title": "Name", "type": "string"},
|
||||
"toys": {
|
||||
"items": {"$ref": "#/components/schemas/Toy"},
|
||||
"title": "Toys",
|
||||
"type": "array",
|
||||
},
|
||||
},
|
||||
"required": ["id", "name", "toys"],
|
||||
"title": "Pet",
|
||||
"type": "object",
|
||||
"required": ["id", "name"],
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
@ -240,16 +147,15 @@ async def test_generated_oas_should_have_pets_id_paths(generated_oas):
|
||||
|
||||
async def test_pets_id_route_should_have_delete_method(generated_oas):
|
||||
assert generated_oas["paths"]["/pets/{id}"]["delete"] == {
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
"required": True,
|
||||
"in": "path",
|
||||
"name": "id",
|
||||
"required": True,
|
||||
"schema": {"title": "id", "type": "integer"},
|
||||
"schema": {"type": "integer"},
|
||||
}
|
||||
],
|
||||
"responses": {"204": {"content": {}, "description": "Empty but OK"}},
|
||||
"responses": {"204": {"content": {}}},
|
||||
}
|
||||
|
||||
|
||||
@ -260,54 +166,26 @@ async def test_pets_id_route_should_have_get_method(generated_oas):
|
||||
"in": "path",
|
||||
"name": "id",
|
||||
"required": True,
|
||||
"schema": {"title": "id", "type": "integer"},
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "size",
|
||||
"required": True,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{"type": "integer"},
|
||||
{"enum": ["x", "l", "s"], "type": "string"},
|
||||
],
|
||||
"title": "size",
|
||||
},
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "day",
|
||||
"required": False,
|
||||
"schema": {
|
||||
"anyOf": [{"type": "integer"}, {"enum": ["now"], "type": "string"}],
|
||||
"default": "now",
|
||||
"title": "day",
|
||||
},
|
||||
},
|
||||
"schema": {"type": "integer"},
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"id": {"title": "Id", "type": "integer"},
|
||||
"name": {"title": "Name", "type": "string"},
|
||||
"toys": {
|
||||
"items": {"$ref": "#/components/schemas/Toy"},
|
||||
"title": "Toys",
|
||||
"type": "array",
|
||||
},
|
||||
},
|
||||
"required": ["id", "name", "toys"],
|
||||
"required": ["id", "name"],
|
||||
"title": "Pet",
|
||||
"type": "object",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"404": {"description": "", "content": {}},
|
||||
"404": {"content": {}},
|
||||
},
|
||||
}
|
||||
|
||||
@ -319,7 +197,7 @@ async def test_pets_id_route_should_have_put_method(generated_oas):
|
||||
"in": "path",
|
||||
"name": "id",
|
||||
"required": True,
|
||||
"schema": {"title": "id", "type": "integer"},
|
||||
"schema": {"type": "integer"},
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
@ -329,13 +207,8 @@ async def test_pets_id_route_should_have_put_method(generated_oas):
|
||||
"properties": {
|
||||
"id": {"title": "Id", "type": "integer"},
|
||||
"name": {"title": "Name", "type": "string"},
|
||||
"toys": {
|
||||
"items": {"$ref": "#/components/schemas/Toy"},
|
||||
"title": "Toys",
|
||||
"type": "array",
|
||||
},
|
||||
},
|
||||
"required": ["id", "name", "toys"],
|
||||
"required": ["id", "name"],
|
||||
"title": "Pet",
|
||||
"type": "object",
|
||||
}
|
||||
@ -343,72 +216,3 @@ 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))
|
||||
|
@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
@ -40,42 +38,32 @@ def test_parse_func_signature():
|
||||
def path_body_qs_and_header(self, id: str, /, user: User, page: int, *, auth: UUID):
|
||||
pass
|
||||
|
||||
assert _parse_func_signature(body_only) == ({}, {"user": User}, {}, {}, {})
|
||||
assert _parse_func_signature(path_only) == ({"id": str}, {}, {}, {}, {})
|
||||
assert _parse_func_signature(qs_only) == ({}, {}, {"page": int}, {}, {})
|
||||
assert _parse_func_signature(header_only) == ({}, {}, {}, {"auth": UUID}, {})
|
||||
assert _parse_func_signature(path_and_qs) == (
|
||||
{"id": str},
|
||||
{},
|
||||
{"page": int},
|
||||
{},
|
||||
{},
|
||||
)
|
||||
assert _parse_func_signature(body_only) == ({}, {"user": User}, {}, {})
|
||||
assert _parse_func_signature(path_only) == ({"id": str}, {}, {}, {})
|
||||
assert _parse_func_signature(qs_only) == ({}, {}, {"page": int}, {})
|
||||
assert _parse_func_signature(header_only) == ({}, {}, {}, {"auth": UUID})
|
||||
assert _parse_func_signature(path_and_qs) == ({"id": str}, {}, {"page": int}, {})
|
||||
assert _parse_func_signature(path_and_header) == (
|
||||
{"id": str},
|
||||
{},
|
||||
{},
|
||||
{"auth": UUID},
|
||||
{},
|
||||
)
|
||||
assert _parse_func_signature(qs_and_header) == (
|
||||
{},
|
||||
{},
|
||||
{"page": int},
|
||||
{"auth": UUID},
|
||||
{},
|
||||
)
|
||||
assert _parse_func_signature(path_qs_and_header) == (
|
||||
{"id": str},
|
||||
{},
|
||||
{"page": int},
|
||||
{"auth": UUID},
|
||||
{},
|
||||
)
|
||||
assert _parse_func_signature(path_body_qs_and_header) == (
|
||||
{"id": str},
|
||||
{"user": User},
|
||||
{"page": int},
|
||||
{"auth": UUID},
|
||||
{},
|
||||
)
|
||||
|
@ -1,6 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterator, List, Optional
|
||||
from typing import Optional
|
||||
|
||||
from aiohttp import web
|
||||
from pydantic import BaseModel
|
||||
@ -13,20 +11,10 @@ class ArticleModel(BaseModel):
|
||||
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 test_post_an_article_without_required_field_should_return_an_error_message(
|
||||
aiohttp_client, loop
|
||||
@ -39,12 +27,7 @@ async def test_post_an_article_without_required_field_should_return_an_error_mes
|
||||
assert resp.status == 400
|
||||
assert resp.content_type == "application/json"
|
||||
assert await resp.json() == [
|
||||
{
|
||||
"in": "body",
|
||||
"loc": ["name"],
|
||||
"msg": "field required",
|
||||
"type": "value_error.missing",
|
||||
}
|
||||
{"loc": ["name"], "msg": "field required", "type": "value_error.missing"}
|
||||
]
|
||||
|
||||
|
||||
@ -60,7 +43,6 @@ async def test_post_an_article_with_wrong_type_field_should_return_an_error_mess
|
||||
assert resp.content_type == "application/json"
|
||||
assert await resp.json() == [
|
||||
{
|
||||
"in": "body",
|
||||
"loc": ["nb_page"],
|
||||
"msg": "value is not a valid integer",
|
||||
"type": "type_error.integer",
|
||||
@ -68,58 +50,6 @@ 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):
|
||||
app = web.Application()
|
||||
app.router.add_view("/article", ArticleView)
|
||||
|
@ -1,13 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from aiohttp_pydantic import PydanticView
|
||||
from aiohttp_pydantic.injectors import Group
|
||||
|
||||
|
||||
class JSONEncoder(json.JSONEncoder):
|
||||
@ -25,41 +21,6 @@ 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(
|
||||
aiohttp_client, loop
|
||||
):
|
||||
@ -72,7 +33,6 @@ async def test_get_article_without_required_header_should_return_an_error_messag
|
||||
assert resp.content_type == "application/json"
|
||||
assert await resp.json() == [
|
||||
{
|
||||
"in": "headers",
|
||||
"loc": ["signature_expired"],
|
||||
"msg": "field required",
|
||||
"type": "value_error.missing",
|
||||
@ -92,7 +52,6 @@ async def test_get_article_with_wrong_header_type_should_return_an_error_message
|
||||
assert resp.content_type == "application/json"
|
||||
assert await resp.json() == [
|
||||
{
|
||||
"in": "headers",
|
||||
"loc": ["signature_expired"],
|
||||
"msg": "invalid datetime format",
|
||||
"type": "value_error.datetime",
|
||||
@ -128,55 +87,3 @@ async def test_get_article_with_valid_header_containing_hyphen_should_be_returne
|
||||
assert resp.status == 200
|
||||
assert resp.content_type == "application/json"
|
||||
assert await resp.json() == {"signature": "2020-10-04T18:01:00"}
|
||||
|
||||
|
||||
async def test_wrong_value_to_header_defined_with_str_enum(aiohttp_client, loop):
|
||||
app = web.Application()
|
||||
app.router.add_view("/coord", ViewWithEnumType)
|
||||
|
||||
client = await aiohttp_client(app)
|
||||
resp = await client.get("/coord", headers={"format": "WGS84"})
|
||||
assert (
|
||||
await resp.json()
|
||||
== [
|
||||
{
|
||||
"ctx": {"enum_values": ["UMT", "MGRS"]},
|
||||
"in": "headers",
|
||||
"loc": ["format"],
|
||||
"msg": "value is not a valid enumeration member; permitted: 'UMT', 'MGRS'",
|
||||
"type": "type_error.enum",
|
||||
}
|
||||
]
|
||||
!= {"signature": "2020-10-04T18:01:00"}
|
||||
)
|
||||
assert resp.status == 400
|
||||
assert resp.content_type == "application/json"
|
||||
|
||||
|
||||
async def test_correct_value_to_header_defined_with_str_enum(aiohttp_client, loop):
|
||||
app = web.Application()
|
||||
app.router.add_view("/coord", ViewWithEnumType)
|
||||
|
||||
client = await aiohttp_client(app)
|
||||
resp = await client.get("/coord", headers={"format": "UMT"})
|
||||
assert await resp.json() == {"format": "UMT"}
|
||||
assert resp.status == 200
|
||||
assert resp.content_type == "application/json"
|
||||
|
||||
|
||||
async def test_with_signature_group(aiohttp_client, loop):
|
||||
app = web.Application()
|
||||
app.router.add_view("/article", ArticleViewWithSignatureGroup)
|
||||
|
||||
client = await aiohttp_client(app)
|
||||
resp = await client.get(
|
||||
"/article",
|
||||
headers={
|
||||
"signature_expired": "2020-10-04T18:01:00",
|
||||
"signature.scope": "write",
|
||||
},
|
||||
)
|
||||
|
||||
assert await resp.json() == {"expired": "2020-10-04T18:01:00", "scope": "read"}
|
||||
assert resp.status == 200
|
||||
assert resp.content_type == "application/json"
|
||||
|
@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from aiohttp_pydantic import PydanticView
|
||||
@ -10,7 +8,7 @@ class ArticleView(PydanticView):
|
||||
return web.json_response({"path": [author_id, tag, date]})
|
||||
|
||||
|
||||
async def test_get_article_with_correct_path_parameters_should_return_parameters_in_path(
|
||||
async def test_get_article_without_required_qs_should_return_an_error_message(
|
||||
aiohttp_client, loop
|
||||
):
|
||||
app = web.Application()
|
||||
@ -21,23 +19,3 @@ async def test_get_article_with_correct_path_parameters_should_return_parameters
|
||||
assert resp.status == 200
|
||||
assert resp.content_type == "application/json"
|
||||
assert await resp.json() == {"path": ["1234", "music", 1980]}
|
||||
|
||||
|
||||
async def test_get_article_with_wrong_path_parameters_should_return_error(
|
||||
aiohttp_client, loop
|
||||
):
|
||||
app = web.Application()
|
||||
app.router.add_view("/article/{author_id}/tag/{tag}/before/{date}", ArticleView)
|
||||
|
||||
client = await aiohttp_client(app)
|
||||
resp = await client.get("/article/1234/tag/music/before/now")
|
||||
assert resp.status == 400
|
||||
assert resp.content_type == "application/json"
|
||||
assert await resp.json() == [
|
||||
{
|
||||
"in": "path",
|
||||
"loc": ["date"],
|
||||
"msg": "value is not a valid integer",
|
||||
"type": "type_error.integer",
|
||||
}
|
||||
]
|
||||
|
@ -1,57 +1,13 @@
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
|
||||
from typing import Optional, List
|
||||
from pydantic import Field
|
||||
from aiohttp import web
|
||||
|
||||
from aiohttp_pydantic import PydanticView
|
||||
from aiohttp_pydantic.injectors import Group
|
||||
|
||||
|
||||
class ArticleView(PydanticView):
|
||||
async def get(
|
||||
self,
|
||||
with_comments: bool,
|
||||
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 get(self, with_comments: bool, age: Optional[int] = None):
|
||||
return web.json_response({"with_comments": with_comments, "age": age})
|
||||
|
||||
|
||||
async def test_get_article_without_required_qs_should_return_an_error_message(
|
||||
@ -66,7 +22,6 @@ async def test_get_article_without_required_qs_should_return_an_error_message(
|
||||
assert resp.content_type == "application/json"
|
||||
assert await resp.json() == [
|
||||
{
|
||||
"in": "query string",
|
||||
"loc": ["with_comments"],
|
||||
"msg": "field required",
|
||||
"type": "value_error.missing",
|
||||
@ -86,7 +41,6 @@ async def test_get_article_with_wrong_qs_type_should_return_an_error_message(
|
||||
assert resp.content_type == "application/json"
|
||||
assert await resp.json() == [
|
||||
{
|
||||
"in": "query string",
|
||||
"loc": ["with_comments"],
|
||||
"msg": "value could not be parsed to a boolean",
|
||||
"type": "type_error.bool",
|
||||
@ -105,15 +59,10 @@ async def test_get_article_with_valid_qs_should_return_the_parsed_type(
|
||||
resp = await client.get("/article", params={"with_comments": "yes", "age": 3})
|
||||
assert resp.status == 200
|
||||
assert resp.content_type == "application/json"
|
||||
assert await resp.json() == {
|
||||
"with_comments": True,
|
||||
"age": 3,
|
||||
"nb_items": 7,
|
||||
"tags": [],
|
||||
}
|
||||
assert await resp.json() == {"with_comments": True, "age": 3}
|
||||
|
||||
|
||||
async def test_get_article_with_valid_qs_and_omitted_optional_should_return_default_value(
|
||||
async def test_get_article_with_valid_qs_and_omitted_optional_should_return_none(
|
||||
aiohttp_client, loop
|
||||
):
|
||||
app = web.Application()
|
||||
@ -122,136 +71,6 @@ async def test_get_article_with_valid_qs_and_omitted_optional_should_return_defa
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
resp = await client.get("/article", params={"with_comments": "yes"})
|
||||
assert await resp.json() == {
|
||||
"with_comments": True,
|
||||
"age": None,
|
||||
"nb_items": 7,
|
||||
"tags": [],
|
||||
}
|
||||
assert resp.status == 200
|
||||
assert resp.content_type == "application/json"
|
||||
|
||||
|
||||
async def test_get_article_with_multiple_value_for_qs_age_must_failed(
|
||||
aiohttp_client, loop
|
||||
):
|
||||
app = web.Application()
|
||||
app.router.add_view("/article", ArticleView)
|
||||
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
resp = await client.get("/article", params={"age": ["2", "3"], "with_comments": 1})
|
||||
assert await resp.json() == [
|
||||
{
|
||||
"in": "query string",
|
||||
"loc": ["age"],
|
||||
"msg": "value is not a valid integer",
|
||||
"type": "type_error.integer",
|
||||
}
|
||||
]
|
||||
assert resp.status == 400
|
||||
assert resp.content_type == "application/json"
|
||||
|
||||
|
||||
async def test_get_article_with_multiple_value_of_tags(aiohttp_client, loop):
|
||||
app = web.Application()
|
||||
app.router.add_view("/article", ArticleView)
|
||||
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
resp = await client.get(
|
||||
"/article", params={"age": 2, "with_comments": 1, "tags": ["aa", "bb"]}
|
||||
)
|
||||
assert await resp.json() == {
|
||||
"age": 2,
|
||||
"nb_items": 7,
|
||||
"tags": ["aa", "bb"],
|
||||
"with_comments": True,
|
||||
}
|
||||
assert resp.status == 200
|
||||
assert resp.content_type == "application/json"
|
||||
|
||||
|
||||
async def test_get_article_with_one_value_of_tags_must_be_a_list(aiohttp_client, loop):
|
||||
app = web.Application()
|
||||
app.router.add_view("/article", ArticleView)
|
||||
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
resp = await client.get(
|
||||
"/article", params={"age": 2, "with_comments": 1, "tags": ["aa"]}
|
||||
)
|
||||
assert await resp.json() == {
|
||||
"age": 2,
|
||||
"nb_items": 7,
|
||||
"tags": ["aa"],
|
||||
"with_comments": True,
|
||||
}
|
||||
assert resp.status == 200
|
||||
assert resp.content_type == "application/json"
|
||||
|
||||
|
||||
async def test_get_article_without_required_field_page(aiohttp_client, loop):
|
||||
app = web.Application()
|
||||
app.router.add_view("/article", ArticleViewWithPaginationGroup)
|
||||
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
resp = await client.get("/article", params={"with_comments": 1})
|
||||
assert await resp.json() == [
|
||||
{
|
||||
"in": "query string",
|
||||
"loc": ["page_num"],
|
||||
"msg": "field required",
|
||||
"type": "value_error.missing",
|
||||
}
|
||||
]
|
||||
assert resp.status == 400
|
||||
assert resp.content_type == "application/json"
|
||||
|
||||
|
||||
async def test_get_article_with_page(aiohttp_client, loop):
|
||||
app = web.Application()
|
||||
app.router.add_view("/article", ArticleViewWithPaginationGroup)
|
||||
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
resp = await client.get("/article", params={"with_comments": 1, "page_num": 2})
|
||||
assert await resp.json() == {"page_num": 2, "page_size": 20, "with_comments": True}
|
||||
assert resp.status == 200
|
||||
assert resp.content_type == "application/json"
|
||||
|
||||
|
||||
async def test_get_article_with_page_and_page_size(aiohttp_client, loop):
|
||||
app = web.Application()
|
||||
app.router.add_view("/article", ArticleViewWithPaginationGroup)
|
||||
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
resp = await client.get(
|
||||
"/article", params={"with_comments": 1, "page_num": 1, "page_size": 10}
|
||||
)
|
||||
assert await resp.json() == {"page_num": 1, "page_size": 10, "with_comments": True}
|
||||
assert resp.status == 200
|
||||
assert resp.content_type == "application/json"
|
||||
|
||||
|
||||
async def test_get_article_with_page_and_wrong_page_size(aiohttp_client, loop):
|
||||
app = web.Application()
|
||||
app.router.add_view("/article", ArticleViewWithPaginationGroup)
|
||||
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
resp = await client.get(
|
||||
"/article", params={"with_comments": 1, "page_num": 1, "page_size": "large"}
|
||||
)
|
||||
assert await resp.json() == [
|
||||
{
|
||||
"in": "query string",
|
||||
"loc": ["page_size"],
|
||||
"msg": "value is not a valid integer",
|
||||
"type": "type_error.integer",
|
||||
}
|
||||
]
|
||||
assert resp.status == 400
|
||||
assert resp.content_type == "application/json"
|
||||
assert await resp.json() == {"with_comments": True, "age": None}
|
||||
|
Loading…
x
Reference in New Issue
Block a user