diff --git a/.drone.jsonnet b/.drone.jsonnet new file mode 100644 index 0000000..3cef70b --- /dev/null +++ b/.drone.jsonnet @@ -0,0 +1,73 @@ +/* +Code to generate the .drone.yaml. Use the command: + +drone jsonnet --stream --format yaml +*/ + + +local PYTHON_VERSIONS = ["3.8", "3.9"]; + + +local BuildAndTestPipeline(name, image) = { + kind: "pipeline", + type: "docker", + name: name, + steps: [ + { + name: "Install package and test", + image: image, + commands: [ + "test \"$(md5sum tasks.py)\" = \"18f864b3ac76119938e3317e49b4ffa1 tasks.py\"", + "pip install -U setuptools wheel pip; pip install invoke", + "invoke prepare-upload" + ] + }, + { + name: "coverage", + image: "plugins/codecov", + settings: { + token: "9ea10e04-a71a-4eea-9dcc-8eaabe1479e2", + files: ["coverage.xml"] + } + } + ], + trigger: { + event: ["pull_request", "push", "tag"] + } +}; + + +[ + BuildAndTestPipeline("python-" + std.strReplace(pythonVersion, '.', '-'), + "python:" + pythonVersion) + for pythonVersion in PYTHON_VERSIONS +] + [ + { + kind: "pipeline", + type: "docker", + name: "Deploy on Pypi", + steps: [ + { + name: "Install twine and deploy", + image: "python:3.8", + environment: { + pypi_username: { + from_secret: 'pypi_username' + }, + pypi_password: { + from_secret: 'pypi_password' + } + }, + commands: [ + "test \"$(md5sum tasks.py)\" = \"18f864b3ac76119938e3317e49b4ffa1 tasks.py\"", + "pip install -U setuptools wheel pip; pip install invoke", + "invoke upload --pypi-user \"$pypi_username\" --pypi-password \"$pypi_password\"" + ] + }, + ], + trigger: { + event: ["tag"] + }, + depends_on: ["python-" + std.strReplace(pythonVersion, '.', '-') for pythonVersion in PYTHON_VERSIONS] + } +] diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..c623f97 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,95 @@ +--- +kind: pipeline +type: docker +name: python-3-8 + +platform: + os: linux + arch: amd64 + +steps: +- name: Install package and test + image: python:3.8 + commands: + - test "$(md5sum tasks.py)" = "18f864b3ac76119938e3317e49b4ffa1 tasks.py" + - pip install -U setuptools wheel pip; pip install invoke + - invoke prepare-upload + +- name: coverage + image: plugins/codecov + settings: + files: + - coverage.xml + token: 9ea10e04-a71a-4eea-9dcc-8eaabe1479e2 + +trigger: + event: + - pull_request + - push + - tag + +--- +kind: pipeline +type: docker +name: python-3-9 + +platform: + os: linux + arch: amd64 + +steps: +- name: Install package and test + image: python:3.9 + commands: + - test "$(md5sum tasks.py)" = "18f864b3ac76119938e3317e49b4ffa1 tasks.py" + - pip install -U setuptools wheel pip; pip install invoke + - invoke prepare-upload + +- name: coverage + image: plugins/codecov + settings: + files: + - coverage.xml + token: 9ea10e04-a71a-4eea-9dcc-8eaabe1479e2 + +trigger: + event: + - pull_request + - push + - tag + +--- +kind: pipeline +type: docker +name: Deploy on Pypi + +platform: + os: linux + arch: amd64 + +steps: +- name: Install twine and deploy + image: python:3.8 + commands: + - test "$(md5sum tasks.py)" = "18f864b3ac76119938e3317e49b4ffa1 tasks.py" + - pip install -U setuptools wheel pip; pip install invoke + - invoke upload --pypi-user "$pypi_username" --pypi-password "$pypi_password" + environment: + pypi_password: + from_secret: pypi_password + pypi_username: + from_secret: pypi_username + +trigger: + event: + - tag + +depends_on: +- python-3-8 +- python-3-9 + +--- +kind: signature +hmac: 9a24ccae6182723af71257495d7843fd40874006c5e867cdebf363f497ddb839 + +... diff --git a/.gitignore b/.gitignore index c2d8c31..f3f07b7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,11 @@ .coverage .idea/ +.pypirc .pytest_cache __pycache__ aiohttp_pydantic.egg-info/ build/ coverage.xml dist/ - +dist_venv/ +venv/ \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 3aa319e..0000000 --- a/.travis.yml +++ /dev/null @@ -1,22 +0,0 @@ -language: python -python: -- '3.8' -script: -- pytest --cov-report=xml --cov=aiohttp_pydantic tests/ -install: -- pip install -U setuptools wheel pip -- pip install -r requirements/test.txt -- pip install -r requirements/ci.txt -- pip install . -after_success: - - codecov -deploy: - provider: pypi - username: __token__ - password: - secure: ki81Limjj8UgsX1GNpOF2+vYjc6GEPY1V9BbJkQl+5WVTynqKTDEi+jekx8Id0jYEGGQ8/PfTiXe7dY/MqfQ0oWQ5+UNmGZIQJwYCft4FJWrI5QoL1LE0tqKpXCzBX7rGr1BOdvToS9zwf3RDr1u7ib16V/xakX55raVpQ37ttE0cKEPzvq6MqZTfYvq0VnhPmTDbTDBd9krHHAAG5lVhm9oAbp9TkhKsWDuA+wGzgKt2tuPX6+Le4op/wiiBhAnhvcVzjDWaX8dxd3Ac0XlnPtl8EMe5lJJez/ahGedydwGDJC75TOl1b7WP9AqogvNISVN+2VYUVxkgoK9yC9zEjhCSWKHSz+t8ZddB+itYHvj9lMf04iObq8OSUcD71R4rASWMZ89YdksWb6qvD+md1oEl/M6JSyZAkv+aedFL5iyKS4oJpZT3fYYloUqhF3/aDVgC3mlnXVsxC2cCIdpvu2EVjpFqFJ+9qGpp3ZlhRfDkjbQA0IA6KXKaWkIadQouJ4Wr1WtXjN4w0QlAvGV/q3m4bQ3ZZGxYipS9MQwDnUoRYtrX6j7bsaXjBdfhPNlwzgHQDPbD//oX9ZI1Oe6+kT/WKQvBrtvftv+TUhQ49uePHn5o/eYAKh35IwYTBxLgk2t483k0ZI5cjVXd2zGRgAxPdB/XyGW84dJGPJNn8o= - distributions: "bdist_wheel" - on: - tags: true - branch: main - python: '3.8' diff --git a/README.rst b/README.rst index c56fb34..b8bc2d7 100644 --- a/README.rst +++ b/README.rst @@ -1,8 +1,9 @@ Aiohttp pydantic - Aiohttp View to validate and parse request ============================================================= -.. image:: https://travis-ci.org/Maillol/aiohttp-pydantic.svg?branch=main - :target: https://travis-ci.org/Maillol/aiohttp-pydantic +.. image:: https://cloud.drone.io/api/badges/Maillol/aiohttp-pydantic/status.svg + :target: https://cloud.drone.io/Maillol/aiohttp-pydantic + :alt: Build status for master branch .. image:: https://img.shields.io/pypi/v/aiohttp-pydantic :target: https://img.shields.io/pypi/v/aiohttp-pydantic @@ -54,7 +55,7 @@ Example: return web.json_response({'name': article.name, 'number_of_page': article.nb_page}) - async def get(self, with_comments: Optional[bool]): + async def get(self, with_comments: bool=False): return web.json_response({'with_comments': with_comments}) @@ -101,7 +102,7 @@ API: Inject Path Parameters ~~~~~~~~~~~~~~~~~~~~~~ -To declare a path parameters, you must declare your argument as a `positional-only parameters`_: +To declare a path parameter, you must declare your argument as a `positional-only parameters`_: Example: @@ -118,18 +119,36 @@ Example: Inject Query String Parameters ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -To declare a query parameters, you must declare your argument as a simple argument: +To declare a query parameter, you must declare your argument as a simple argument: .. code-block:: python3 class AccountView(PydanticView): - async def get(self, customer_id: str): + async def get(self, customer_id: Optional[str] = None): ... app = web.Application() app.router.add_get('/customers', AccountView) + +A query string parameter is generally optional and we do not want to force the user to set it in the URL. +It's recommended to define a default value. It's possible to get a multiple value for the same parameter using +the List type + +.. code-block:: python3 + + from typing import List + from pydantic import Field + + class AccountView(PydanticView): + async def get(self, tags: List[str] = Field(default_factory=list)): + ... + + app = web.Application() + app.router.add_get('/customers', AccountView) + + Inject Request Body ~~~~~~~~~~~~~~~~~~~ @@ -152,7 +171,7 @@ To declare a body parameter, you must declare your argument as a simple argument Inject HTTP headers ~~~~~~~~~~~~~~~~~~~ -To declare a HTTP headers parameters, you must declare your argument as a `keyword-only argument`_. +To declare a HTTP headers parameter, you must declare your argument as a `keyword-only argument`_. .. code-block:: python3 @@ -207,6 +226,16 @@ on the same route, you must use *apps_to_expose* parameter. oas.setup(app, apps_to_expose=[sub_app_1, sub_app_2]) + +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 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -217,6 +246,9 @@ For example *r200[List[Pet]]* means the server responses with the status code 200 and the response content is a List of Pet where Pet will be defined using a pydantic.BaseModel +The docstring of methods will be parsed to fill the descriptions in the +Open Api Specification. + .. code-block:: python3 @@ -235,20 +267,47 @@ defined using a pydantic.BaseModel 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()) @@ -256,6 +315,91 @@ defined using a pydantic.BaseModel 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 ---- @@ -270,12 +414,35 @@ 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 file using the command: +You can generate the OAS in a json or yaml file using the aiohttp_pydantic.oas command: .. code-block:: bash python -m aiohttp_pydantic.oas demo.main +.. code-block:: bash + + $ python3 -m aiohttp_pydantic.oas --help + usage: __main__.py [-h] [-b FILE] [-o FILE] [-f FORMAT] [APP [APP ...]] + + Generate Open API Specification + + positional arguments: + APP The name of the module containing the asyncio.web.Application. By default the variable named + 'app' is loaded but you can define an other variable name ending the name of module with : + characters and the name of variable. Example: my_package.my_module:my_app If your + asyncio.web.Application is returned by a function, you can use the syntax: + my_package.my_module:my_app() + + optional arguments: + -h, --help show this help message and exit + -b FILE, --base-oas-file FILE + A file that will be used as base to generate OAS + -o FILE, --output FILE + File to write the output + -f FORMAT, --format FORMAT + The output format, can be 'json' or 'yaml' (default is json) + .. _demo: https://github.com/Maillol/aiohttp-pydantic/tree/main/demo .. _aiohttp view: https://docs.aiohttp.org/en/stable/web_quickstart.html#class-based-views diff --git a/aiohttp_pydantic/__init__.py b/aiohttp_pydantic/__init__.py index dabdbee..da5ba94 100644 --- a/aiohttp_pydantic/__init__.py +++ b/aiohttp_pydantic/__init__.py @@ -1,5 +1,5 @@ from .view import PydanticView -__version__ = "1.6.1" +__version__ = "1.12.1" __all__ = ("PydanticView", "__version__") diff --git a/aiohttp_pydantic/injectors.py b/aiohttp_pydantic/injectors.py index 75284ae..335adae 100644 --- a/aiohttp_pydantic/injectors.py +++ b/aiohttp_pydantic/injectors.py @@ -1,13 +1,18 @@ import abc -from inspect import signature +import typing +from inspect import signature, getmro from json.decoder import JSONDecodeError -from typing import Callable, Tuple +from types import SimpleNamespace +from typing import Callable, Tuple, Literal, Type, get_type_hints from aiohttp.web_exceptions import HTTPBadRequest from aiohttp.web_request import BaseRequest +from multidict import MultiDict from pydantic import BaseModel -from .utils import is_pydantic_base_model +from .utils import is_pydantic_base_model, robuste_issubclass + +CONTEXT = Literal["body", "headers", "path", "query string"] class AbstractInjector(metaclass=abc.ABCMeta): @@ -15,9 +20,11 @@ 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) -> str: + def context(self) -> CONTEXT: """ The name of part of parsed request i.e "HTTP header", "URL path", ... @@ -61,6 +68,7 @@ class BodyGetter(AbstractInjector): def __init__(self, args_spec: dict, default_values: dict): self.arg_name, self.model = next(iter(args_spec.items())) + self._expect_object = self.model.schema()["type"] == "object" async def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): try: @@ -70,7 +78,16 @@ class BodyGetter(AbstractInjector): text='{"error": "Malformed JSON"}', content_type="application/json" ) from None - kwargs_view[self.arg_name] = self.model(**body) + # Pydantic tries to cast certain structures, such as a list of 2-tuples, + # to a dict. Prevent this by requiring the body to be a dict for object models. + if self._expect_object and not isinstance(body, dict): + raise HTTPBadRequest( + text='[{"in": "body", "loc": ["__root__"], "msg": "value is not a ' + 'valid dict", "type": "type_error.dict"}]', + content_type="application/json", + ) from None + + kwargs_view[self.arg_name] = self.model.parse_obj(body) class QueryGetter(AbstractInjector): @@ -81,12 +98,46 @@ class QueryGetter(AbstractInjector): context = "query string" def __init__(self, args_spec: dict, default_values: dict): + args_spec = args_spec.copy() + + self._groups = {} + for group_name, group in args_spec.items(): + if robuste_issubclass(group, Group): + self._groups[group_name] = (group, _get_group_signature(group)[0]) + + _unpack_group_in_signature(args_spec, default_values) attrs = {"__annotations__": args_spec} attrs.update(default_values) + self.model = type("QueryModel", (BaseModel,), attrs) + self.args_spec = args_spec + self._is_multiple = frozenset( + name for name, spec in args_spec.items() if typing.get_origin(spec) is list + ) def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): - kwargs_view.update(self.model(**request.query).dict()) + data = self._query_to_dict(request.query) + cleaned = self.model(**data).dict() + for group_name, (group_cls, group_attrs) in self._groups.items(): + group = group_cls() + for attr_name in group_attrs: + setattr(group, attr_name, cleaned.pop(attr_name)) + cleaned[group_name] = group + kwargs_view.update(**cleaned) + + def _query_to_dict(self, query: MultiDict): + """ + Return a dict with list as value from the MultiDict. + + The value will be wrapped in a list if the args spec is define as a list or if + the multiple values are sent (i.e ?foo=1&foo=2) + """ + return { + key: values + if len(values := query.getall(key)) > 1 or key in self._is_multiple + else value + for key, value in query.items() + } class HeadersGetter(AbstractInjector): @@ -97,18 +148,80 @@ class HeadersGetter(AbstractInjector): context = "headers" def __init__(self, args_spec: dict, default_values: dict): + args_spec = args_spec.copy() + + self._groups = {} + for group_name, group in args_spec.items(): + if robuste_issubclass(group, Group): + self._groups[group_name] = (group, _get_group_signature(group)[0]) + + _unpack_group_in_signature(args_spec, default_values) + attrs = {"__annotations__": args_spec} attrs.update(default_values) self.model = type("HeaderModel", (BaseModel,), attrs) def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): header = {k.lower().replace("-", "_"): v for k, v in request.headers.items()} - kwargs_view.update(self.model(**header).dict()) + cleaned = self.model(**header).dict() + for group_name, (group_cls, group_attrs) in self._groups.items(): + group = group_cls() + for attr_name in group_attrs: + setattr(group, attr_name, cleaned.pop(attr_name)) + cleaned[group_name] = group + kwargs_view.update(cleaned) -def _parse_func_signature(func: Callable) -> Tuple[dict, dict, dict, dict, dict]: +class Group(SimpleNamespace): """ - Analyse function signature and returns 4-tuple: + Class to group header or query string parameters. + + The parameter from query string or header will be set in the group + and the group will be passed as function parameter. + + Example: + + class Pagination(Group): + current_page: int = 1 + page_size: int = 15 + + class PetView(PydanticView): + def get(self, page: Pagination): + ... + """ + + +def _get_group_signature(cls) -> Tuple[dict, dict]: + """ + Analyse Group subclass annotations and return them with default values. + """ + + sig = {} + defaults = {} + mro = getmro(cls) + for base in reversed(mro[: mro.index(Group)]): + attrs = vars(base) + + # Use __annotations__ to know if an attribute is + # overwrite to remove the default value. + for attr_name, type_ in base.__annotations__.items(): + if (default := attrs.get(attr_name)) is None: + defaults.pop(attr_name, None) + else: + defaults[attr_name] = default + + # Use get_type_hints to have postponed annotations. + for attr_name, type_ in get_type_hints(base).items(): + sig[attr_name] = type_ + + return sig, defaults + + +def _parse_func_signature( + func: Callable, unpack_group: bool = False +) -> Tuple[dict, dict, dict, dict, dict]: + """ + Analyse function signature and returns 5-tuple: 0 - arguments will be set from the url path 1 - argument will be set from the request body. 2 - argument will be set from the query string. @@ -122,27 +235,72 @@ def _parse_func_signature(func: Callable) -> Tuple[dict, dict, dict, dict, dict] header_args = {} defaults = {} + annotations = get_type_hints(func) for param_name, param_spec in signature(func).parameters.items(): + if param_name == "self": continue if param_spec.annotation == param_spec.empty: raise RuntimeError(f"The parameter {param_name} must have an annotation") + annotation = annotations[param_name] if param_spec.default is not param_spec.empty: defaults[param_name] = param_spec.default if param_spec.kind is param_spec.POSITIONAL_ONLY: - path_args[param_name] = param_spec.annotation + path_args[param_name] = annotation elif param_spec.kind is param_spec.POSITIONAL_OR_KEYWORD: - if is_pydantic_base_model(param_spec.annotation): - body_args[param_name] = param_spec.annotation + if is_pydantic_base_model(annotation): + body_args[param_name] = annotation else: - qs_args[param_name] = param_spec.annotation + qs_args[param_name] = annotation elif param_spec.kind is param_spec.KEYWORD_ONLY: - header_args[param_name] = param_spec.annotation + header_args[param_name] = annotation else: raise RuntimeError(f"You cannot use {param_spec.VAR_POSITIONAL} parameters") + 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) diff --git a/aiohttp_pydantic/oas/__init__.py b/aiohttp_pydantic/oas/__init__.py index 71f69ad..bf4d3ad 100644 --- a/aiohttp_pydantic/oas/__init__.py +++ b/aiohttp_pydantic/oas/__init__.py @@ -1,5 +1,5 @@ from importlib import resources -from typing import Iterable +from typing import Iterable, Optional import jinja2 from aiohttp import web @@ -13,6 +13,8 @@ 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: @@ -23,6 +25,9 @@ def setup( 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") diff --git a/aiohttp_pydantic/oas/cmd.py b/aiohttp_pydantic/oas/cmd.py index b0ee01b..8bea0de 100644 --- a/aiohttp_pydantic/oas/cmd.py +++ b/aiohttp_pydantic/oas/cmd.py @@ -1,10 +1,28 @@ 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. @@ -26,6 +44,35 @@ 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", @@ -35,11 +82,52 @@ 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", + " and the name of variable. Example: my_package.my_module:my_app" + " If your asyncio.web.Application is returned by a function, you can" + " use the syntax: my_package.my_module:my_app()", + ) + parser.add_argument( + "-b", + "--base-oas-file", + metavar="FILE", + dest="base", + type=base_oas_file_type, + help="A file that will be used as base to generate OAS", + default={}, + ) + parser.add_argument( + "-o", + "--output", + metavar="FILE", + type=argparse.FileType("w"), + help="File to write the output", + default=sys.stdout, + ) + + if yaml: + help_output_format = ( + "The output format, can be 'json' or 'yaml' (default is json)" + ) + else: + help_output_format = "The output format, only 'json' is available install pyyaml to have yaml output format" + + parser.add_argument( + "-f", + "--format", + metavar="FORMAT", + dest="formatter", + type=format_type, + help=help_output_format, + default=format_type("json"), ) parser.set_defaults(func=show_oas) def show_oas(args: argparse.Namespace): - print(json.dumps(generate_oas(args.apps), sort_keys=True, indent=4)) + """ + Display Open API Specification on the stdout. + """ + spec = args.base + spec.update(generate_oas(args.apps)) + print(args.formatter(spec), file=args.output) diff --git a/aiohttp_pydantic/oas/docstring_parser.py b/aiohttp_pydantic/oas/docstring_parser.py new file mode 100644 index 0000000..eda47ef --- /dev/null +++ b/aiohttp_pydantic/oas/docstring_parser.py @@ -0,0 +1,136 @@ +""" +Utility to extract extra OAS description from docstring. +""" + +import re +import textwrap +from typing import Dict, List + + +class LinesIterator: + def __init__(self, lines: str): + self._lines = lines.splitlines() + self._i = -1 + + def next_line(self) -> str: + if self._i == len(self._lines) - 1: + raise StopIteration from None + self._i += 1 + return self._lines[self._i] + + def rewind(self) -> str: + if self._i == -1: + raise StopIteration from None + self._i -= 1 + return self._lines[self._i] + + def __iter__(self): + return self + + def __next__(self): + return self.next_line() + + +def _i_extract_block(lines: LinesIterator): + """ + Iter the line within an indented block and dedent them. + """ + + # Go to the first not empty or not white space line. + try: + line = next(lines) + except StopIteration: + return # No block to extract. + while line.strip() == "": + try: + line = next(lines) + except StopIteration: + return + + indent = re.fullmatch("( *).*", line).groups()[0] + indentation = len(indent) + start_of_other_block = re.compile(f" {{0,{indentation}}}[^ ].*") + yield line[indentation:] + + # Yield lines until the indentation is the same or is greater than + # the first block line. + try: + line = next(lines) + except StopIteration: + return + while not start_of_other_block.fullmatch(line): + yield line[indentation:] + try: + line = next(lines) + except StopIteration: + return + + lines.rewind() + + +def _dedent_under_first_line(text: str) -> str: + """ + Apply textwrap.dedent ignoring the first line. + """ + lines = text.splitlines() + other_lines = "\n".join(lines[1:]) + if other_lines: + return f"{lines[0]}\n{textwrap.dedent(other_lines)}" + return text + + +def status_code(docstring: str) -> Dict[int, str]: + """ + Extract the "Status Code:" block of the docstring. + """ + iterator = LinesIterator(docstring) + for line in iterator: + if re.fullmatch("status\\s+codes?\\s*:", line, re.IGNORECASE): + iterator.rewind() + blocks = [] + lines = [] + i_block = _i_extract_block(iterator) + next(i_block) + for line_of_block in i_block: + if re.search("^\\s*\\d{3}\\s*:", line_of_block): + if lines: + blocks.append("\n".join(lines)) + lines = [] + lines.append(line_of_block) + if lines: + blocks.append("\n".join(lines)) + + return { + int(status.strip()): _dedent_under_first_line(desc.strip()) + for status, desc in (block.split(":", 1) for block in blocks) + } + return {} + + +def tags(docstring: str) -> List[str]: + """ + Extract the "Tags:" block of the docstring. + """ + iterator = LinesIterator(docstring) + for line in iterator: + if re.fullmatch("tags\\s*:.*", line, re.IGNORECASE): + iterator.rewind() + lines = " ".join(_i_extract_block(iterator)) + return [" ".join(e.split()) for e in re.split("[,;]", lines.split(":")[1])] + return [] + + +def operation(docstring: str) -> str: + """ + Extract all docstring except the "Status Code:" block. + """ + lines = LinesIterator(docstring) + ret = [] + for line in lines: + if re.fullmatch("status\\s+codes?\\s*:|tags\\s*:.*", line, re.IGNORECASE): + lines.rewind() + for _ in _i_extract_block(lines): + pass + else: + ret.append(line) + return ("\n".join(ret)).strip() diff --git a/aiohttp_pydantic/oas/struct.py b/aiohttp_pydantic/oas/struct.py index faa8358..e03a62d 100644 --- a/aiohttp_pydantic/oas/struct.py +++ b/aiohttp_pydantic/oas/struct.py @@ -2,7 +2,7 @@ Utility to write Open Api Specifications using the Python language. """ -from typing import Union +from typing import Union, List class Info: @@ -133,6 +133,7 @@ class Parameters: class Response: def __init__(self, spec: dict): self._spec = spec + self._spec.setdefault("description", "") @property def description(self) -> str: @@ -156,7 +157,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), {}) @@ -195,6 +196,17 @@ 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): @@ -304,7 +316,10 @@ class Components: class OpenApiSpec3: def __init__(self): - self._spec = {"openapi": "3.0.0"} + self._spec = { + "openapi": "3.0.0", + "info": {"version": "1.0.0", "title": "Aiohttp pydantic application"}, + } @property def info(self) -> Info: diff --git a/aiohttp_pydantic/oas/view.py b/aiohttp_pydantic/oas/view.py index 09a3d0a..37bd3ff 100644 --- a/aiohttp_pydantic/oas/view.py +++ b/aiohttp_pydantic/oas/view.py @@ -1,13 +1,14 @@ import typing from inspect import getdoc from itertools import count -from typing import List, Type +from typing import List, Type, Optional, get_type_hints from aiohttp.web import Response, json_response from aiohttp.web_app import Application from pydantic import BaseModel from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem +from . import docstring_parser from ..injectors import _parse_func_signature from ..utils import is_pydantic_base_model @@ -15,35 +16,23 @@ from ..view import PydanticView, is_pydantic_view from .typing import is_status_code_type -def _handle_optional(type_): - """ - Returns the type wrapped in Optional or None. - - >>> _handle_optional(int) - >>> _handle_optional(Optional[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: """ Parse the type annotated as returned by a function and generate the OAS operation response. """ - def __init__(self, oas: OpenApiSpec3, oas_operation): + def __init__(self, oas: OpenApiSpec3, oas_operation, status_code_descriptions): self._oas_operation = oas_operation self._oas = oas + self._status_code_descriptions = status_code_descriptions def _handle_pydantic_base_model(self, obj): if is_pydantic_base_model(obj): - response_schema = obj.schema(ref_template="#/components/schemas/{model}") - if def_sub_schemas := response_schema.get("definitions", None): + 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 {} @@ -64,10 +53,16 @@ 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: @@ -86,17 +81,23 @@ def _add_http_method_to_oas( 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 + handler, unpack_group=True ) description = getdoc(handler) if description: - oas_operation.description = description + oas_operation.description = docstring_parser.operation(description) + oas_operation.tags = docstring_parser.tags(description) + status_code_descriptions = docstring_parser.status_code(description) + else: + status_code_descriptions = {} if body_args: - body_schema = next(iter(body_args.values())).schema( - ref_template="#/components/schemas/{model}" + body_schema = ( + next(iter(body_args.values())) + .schema(ref_template="#/components/schemas/{model}") + .copy() ) - if def_sub_schemas := body_schema.get("definitions", None): + if def_sub_schemas := body_schema.pop("definitions", None): oas.components.schemas.update(def_sub_schemas) oas_operation.request_body.content = { @@ -113,28 +114,41 @@ def _add_http_method_to_oas( i = next(indexes) oas_operation.parameters[i].in_ = args_location oas_operation.parameters[i].name = name - optional_type = _handle_optional(type_) attrs = {"__annotations__": {"__root__": type_}} if name in defaults: attrs["__root__"] = defaults[name] + oas_operation.parameters[i].required = False + else: + oas_operation.parameters[i].required = True oas_operation.parameters[i].schema = type(name, (BaseModel,), attrs).schema( ref_template="#/components/schemas/{model}" ) - oas_operation.parameters[i].required = optional_type is None - - return_type = handler.__annotations__.get("return") + return_type = get_type_hints(handler).get("return") if return_type is not None: - _OASResponseBuilder(oas, oas_operation).build(return_type) + _OASResponseBuilder(oas, oas_operation, status_code_descriptions).build( + return_type + ) -def generate_oas(apps: List[Application]) -> dict: +def generate_oas( + apps: List[Application], + version_spec: Optional[str] = None, + title_spec: Optional[str] = None, +) -> dict: """ Generate and return Open Api Specification from PydanticView in application. """ 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: @@ -158,7 +172,9 @@ async def get_oas(request): View to generate the Open Api Specification from PydanticView in application. """ apps = request.app["apps to expose"] - return json_response(generate_oas(apps)) + version_spec = request.app["version_spec"] + title_spec = request.app["title_spec"] + return json_response(generate_oas(apps, version_spec, title_spec)) async def oas_ui(request): diff --git a/aiohttp_pydantic/utils.py b/aiohttp_pydantic/utils.py index efee749..92644a1 100644 --- a/aiohttp_pydantic/utils.py +++ b/aiohttp_pydantic/utils.py @@ -5,7 +5,15 @@ 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(obj, BaseModel) + return issubclass(cls1, cls2) except TypeError: return False diff --git a/aiohttp_pydantic/view.py b/aiohttp_pydantic/view.py index 3a41430..dbf2833 100644 --- a/aiohttp_pydantic/view.py +++ b/aiohttp_pydantic/view.py @@ -1,6 +1,7 @@ from functools import update_wrapper from inspect import iscoroutinefunction -from typing import Any, Callable, Generator, Iterable +from typing import Any, Callable, Generator, Iterable, Set, ClassVar +import warnings from aiohttp.abc import AbstractView from aiohttp.hdrs import METH_ALL @@ -9,8 +10,16 @@ from aiohttp.web_exceptions import HTTPMethodNotAllowed from aiohttp.web_response import StreamResponse from pydantic import ValidationError -from .injectors import (AbstractInjector, BodyGetter, HeadersGetter, - MatchInfoGetter, QueryGetter, _parse_func_signature) +from .injectors import ( + AbstractInjector, + BodyGetter, + HeadersGetter, + MatchInfoGetter, + QueryGetter, + _parse_func_signature, + CONTEXT, + Group, +) class PydanticView(AbstractView): @@ -18,30 +27,46 @@ class PydanticView(AbstractView): An AIOHTTP View that validate request using function annotations. """ + # Allowed HTTP methods; overridden when subclassed. + allowed_methods: ClassVar[Set[str]] = {} + async def _iter(self) -> StreamResponse: - method = getattr(self, self.request.method.lower(), None) - resp = await method() - return resp + if (method_name := self.request.method) not in self.allowed_methods: + self._raise_allowed_methods() + return await getattr(self, method_name.lower())() def __await__(self) -> Generator[Any, None, StreamResponse]: return self._iter().__await__() - def __init_subclass__(cls, **kwargs): + def __init_subclass__(cls, **kwargs) -> None: + """Define allowed methods and decorate handlers. + + Handlers are decorated if and only if they directly bound on the PydanticView class or + PydanticView subclass. This prevents that methods are decorated multiple times and that method + defined in aiohttp.View parent class is decorated. + """ + cls.allowed_methods = { meth_name for meth_name in METH_ALL if hasattr(cls, meth_name.lower()) } for meth_name in METH_ALL: - if meth_name not in cls.allowed_methods: - setattr(cls, meth_name.lower(), cls.raise_not_allowed) - else: + if meth_name.lower() in vars(cls): handler = getattr(cls, meth_name.lower()) decorated_handler = inject_params(handler, cls.parse_func_signature) setattr(cls, meth_name.lower(), decorated_handler) - async def raise_not_allowed(self): + def _raise_allowed_methods(self) -> None: raise HTTPMethodNotAllowed(self.request.method, self.allowed_methods) + def raise_not_allowed(self) -> None: + warnings.warn( + "PydanticView.raise_not_allowed is deprecated and renamed _raise_allowed_methods", + DeprecationWarning, + stacklevel=2, + ) + self._raise_allowed_methods() + @staticmethod def parse_func_signature(func: Callable) -> Iterable[AbstractInjector]: path_args, body_args, qs_args, header_args, defaults = _parse_func_signature( @@ -65,6 +90,22 @@ class PydanticView(AbstractView): injectors.append(HeadersGetter(header_args, default_value(header_args))) return injectors + async def on_validation_error( + self, exception: ValidationError, context: CONTEXT + ) -> StreamResponse: + """ + This method is a hook to intercept ValidationError. + + This hook can be redefined to return a custom HTTP response error. + The exception is a pydantic.ValidationError and the context is "body", + "headers", "path" or "query string" + """ + errors = exception.errors() + for error in errors: + error["in"] = context + + return json_response(data=errors, status=400) + def inject_params( handler, parse_func_signature: Callable[[Callable], Iterable[AbstractInjector]] @@ -89,11 +130,7 @@ def inject_params( if self.request.app['raise_validation_errors']: raise else: - errors = error.errors() - for error in errors: - error["in"] = injector.context - - return json_response(data=errors, status=400) + return await self.on_validation_error(error, injector.context) return await handler(self, *args, **kwargs) @@ -109,3 +146,14 @@ def is_pydantic_view(obj) -> bool: return issubclass(obj, PydanticView) except TypeError: return False + + +__all__ = ( + "AbstractInjector", + "BodyGetter", + "HeadersGetter", + "MatchInfoGetter", + "QueryGetter", + "CONTEXT", + "Group", +) diff --git a/demo/main.py b/demo/main.py index ee4902a..0ba8414 100644 --- a/demo/main.py +++ b/demo/main.py @@ -15,7 +15,7 @@ async def pet_not_found_to_404(request, handler): app = Application(middlewares=[pet_not_found_to_404]) -oas.setup(app) +oas.setup(app, version_spec="1.0.1", title_spec="My App") app["model"] = Model() app.router.add_view("/pets", PetCollectionView) diff --git a/demo/view.py b/demo/view.py index bed6260..aa9b48d 100644 --- a/demo/view.py +++ b/demo/view.py @@ -10,25 +10,54 @@ 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) diff --git a/requirements/ci.txt b/requirements/ci.txt index ce4b7a7..1bb68d6 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -1,7 +1,42 @@ -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 +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 diff --git a/requirements/test.txt b/requirements/test.txt index 4edcdc9..5228fdc 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,13 +1,23 @@ -attrs==20.3.0 -coverage==5.3 +async-timeout==3.0.1 +attrs==21.2.0 +bleach==4.0.0 +chardet==4.0.0 +coverage==5.5 +docutils==0.17.1 +idna==3.2 iniconfig==1.1.1 -packaging==20.4 +multidict==5.1.0 +packaging==21.0 pluggy==0.13.1 -py==1.9.0 +py==1.10.0 +Pygments==2.9.0 pyparsing==2.4.7 pytest==6.1.2 pytest-aiohttp==0.3.0 pytest-cov==2.10.1 -six==1.15.0 +readme-renderer==29.0 +six==1.16.0 toml==0.10.2 -typing-extensions==3.7.4.3 +typing-extensions==3.10.0.0 +webencodings==0.5.1 +yarl==1.6.3 diff --git a/setup.cfg b/setup.cfg index 32dc85e..bd084a2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -21,7 +21,7 @@ classifiers = Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Topic :: Software Development :: Libraries :: Application Frameworks - Framework :: AsyncIO + Framework :: aiohttp License :: OSI Approved :: MIT License [options] @@ -35,13 +35,20 @@ install_requires = swagger-ui-bundle [options.extras_require] -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 +test = + pytest==6.1.2 + pytest-aiohttp==0.3.0 + pytest-cov==2.10.1 + readme-renderer==29.0 +ci = + %(test)s + codecov==2.1.11 + twine==3.4.2 [options.packages.find] exclude = - tests - demo + tests* + demo* [options.package_data] aiohttp_pydantic.oas = index.j2 diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..96bf572 --- /dev/null +++ b/tasks.py @@ -0,0 +1,172 @@ +""" +To use this module, install invoke and type invoke -l +""" + +from functools import partial +import os +from pathlib import Path +from setuptools.config import read_configuration + +from invoke import task, Exit, Task as Task_, call + + +def activate_venv(c, venv: str): + """ + Activate a virtualenv + """ + virtual_env = Path().absolute() / venv + if original_path := os.environ.get("PATH"): + path = f'{virtual_env / "bin"}:{original_path}' + else: + path = str(virtual_env / "bin") + c.config.run.env["PATH"] = path + c.config.run.env["VIRTUAL_ENV"] = str(virtual_env) + os.environ.pop("PYTHONHOME", "") + + +def title(text, underline_char="#"): + """ + Display text as a title. + """ + template = f"{{:{underline_char}^80}}" + text = template.format(f" {text.strip()} ") + print(f"\033[1m{text}\033[0m") + + +class Task(Task_): + """ + This task add 'skip_if_recent' feature. + + >>> @task(skip_if_recent=['./target', './dependency']) + >>> def my_tash(c): + >>> ... + + target is file created by the task + dependency is file used by the task + + The task is ran only if the dependency is more recent than the target file. + The target or the dependency can be a tuple of files. + """ + + def __init__(self, *args, **kwargs): + self.skip_if_recent = kwargs.pop("skip_if_recent", None) + super().__init__(*args, **kwargs) + + def __call__(self, *args, **kwargs): + title(self.__doc__ or self.name) + + if self.skip_if_recent: + targets, dependencies = self.skip_if_recent + if isinstance(targets, str): + targets = (targets,) + if isinstance(dependencies, str): + dependencies = (dependencies,) + + target_mtime = min( + ((Path(file).exists() and Path(file).lstat().st_mtime) or 0) + for file in targets + ) + dependency_mtime = max(Path(file).lstat().st_mtime for file in dependencies) + + if dependency_mtime < target_mtime: + print(f"{self.name}, nothing to do") + return None + + return super().__call__(*args, **kwargs) + + +task = partial(task, klass=Task) + + +@task() +def venv(c): + """ + Create a virtual environment for dev + """ + c.run("python -m venv --clear venv") + c.run("venv/bin/pip install -U setuptools wheel pip") + c.run("venv/bin/pip install -e .") + c.run("venv/bin/pip install -r requirements/test.txt") + + +@task() +def check_readme(c): + """ + Check the README.rst render + """ + c.run("python -m readme_renderer -o /dev/null README.rst") + + +@task() +def test(c, isolate=False): + """ + Launch tests + """ + opt = "I" if isolate else "" + c.run(f"python -{opt}m pytest --cov-report=xml --cov=aiohttp_pydantic tests/") + + +@task() +def tag_eq_version(c): + """ + Ensure that the last git tag matches the package version + """ + git_tag = c.run("git describe --tags HEAD", hide=True).stdout.strip() + package_version = read_configuration("./setup.cfg")["metadata"]["version"] + if git_tag != f"v{package_version}": + raise Exit( + f"ERROR: The git tag {git_tag!r} does not matches" + f" the package version {package_version!r}" + ) + + +@task() +def prepare_ci_env(c): + """ + Prepare CI environment + """ + title("Creating virtual env", "=") + c.run("python -m venv --clear dist_venv") + activate_venv(c, "dist_venv") + + c.run("dist_venv/bin/python -m pip install -U setuptools wheel pip") + + title("Building wheel", "=") + c.run("dist_venv/bin/python setup.py build bdist_wheel") + + title("Installing wheel", "=") + package_version = read_configuration("./setup.cfg")["metadata"]["version"] + dist = next(Path("dist").glob(f"aiohttp_pydantic-{package_version}-*.whl")) + c.run(f"dist_venv/bin/python -m pip install {dist}") + + # We verify that aiohttp-pydantic module is importable before installing CI tools. + package_names = read_configuration("./setup.cfg")["options"]["packages"] + for package_name in package_names: + c.run(f"dist_venv/bin/python -I -c 'import {package_name}'") + + title("Installing CI tools", "=") + c.run("dist_venv/bin/python -m pip install -r requirements/ci.txt") + + +@task(prepare_ci_env, check_readme, call(test, isolate=True), klass=Task_) +def prepare_upload(c): + """ + Launch all tests and verifications + """ + + +@task(tag_eq_version, prepare_upload) +def upload(c, pypi_user=None, pypi_password=None): + """ + Upload on pypi + """ + package_version = read_configuration("./setup.cfg")["metadata"]["version"] + dist = next(Path("dist").glob(f"aiohttp_pydantic-{package_version}-*.whl")) + if pypi_user is not None and pypi_password is not None: + c.run( + f"dist_venv/bin/twine upload --non-interactive" + f" -u {pypi_user} -p {pypi_password} {dist}", + hide=True, + ) + else: + c.run(f"dist_venv/bin/twine upload --repository aiohttp-pydantic {dist}") diff --git a/tests/test_group.py b/tests/test_group.py new file mode 100644 index 0000000..c734109 --- /dev/null +++ b/tests/test_group.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import pytest + +from aiohttp_pydantic.injectors import ( + Group, + _get_group_signature, + _unpack_group_in_signature, + DuplicateNames, +) + + +def test_get_group_signature_with_a2b2(): + class A(Group): + a: int = 1 + + class B(Group): + b: str = "b" + + class B2(B): + b: str = "b2" # Overwrite default value + + class A2(A): + a: int # Remove default value + + class A2B2(A2, B2): + ab2: float + + assert ({"ab2": float, "a": int, "b": str}, {"b": "b2"}) == _get_group_signature( + A2B2 + ) + + +def test_unpack_group_in_signature(): + class PaginationGroup(Group): + page: int + page_size: int = 20 + + args = {"pagination": PaginationGroup, "name": str, "age": int} + + default = {"age": 18} + + _unpack_group_in_signature(args, default) + + assert args == {"page": int, "page_size": int, "name": str, "age": int} + + assert default == {"age": 18, "page_size": 20} + + +def test_unpack_group_in_signature_with_duplicate_error(): + class PaginationGroup(Group): + page: int + page_size: int = 20 + + args = {"pagination": PaginationGroup, "page": int, "age": int} + + with pytest.raises(DuplicateNames) as e_info: + _unpack_group_in_signature(args, {}) + + assert e_info.value.group is PaginationGroup + assert e_info.value.attr_name == "page" + + +def test_unpack_group_in_signature_with_parameters_overwrite(): + class PaginationGroup(Group): + page: int = 0 + page_size: int = 20 + + args = {"page": PaginationGroup, "age": int} + + default = {} + _unpack_group_in_signature(args, default) + + assert args == {"page": int, "page_size": int, "age": int} + + assert default == {"page": 0, "page_size": 20} diff --git a/tests/test_hook_to_custom_response.py b/tests/test_hook_to_custom_response.py new file mode 100644 index 0000000..dd3b5b5 --- /dev/null +++ b/tests/test_hook_to_custom_response.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from typing import Iterator, List, Optional + +from aiohttp import web +from aiohttp.web_response import json_response +from pydantic import BaseModel + +from aiohttp_pydantic import PydanticView + + +class ArticleModel(BaseModel): + name: str + nb_page: Optional[int] + + +class ArticleModels(BaseModel): + __root__: List[ArticleModel] + + def __iter__(self) -> Iterator[ArticleModel]: + return iter(self.__root__) + + +class ArticleView(PydanticView): + async def post(self, article: ArticleModel): + return web.json_response(article.dict()) + + async def put(self, articles: ArticleModels): + return web.json_response([article.dict() for article in articles]) + + async def on_validation_error(self, exception, context): + errors = exception.errors() + for error in errors: + error["in"] = context + error["custom"] = "custom" + return json_response(data=errors, status=400) + + +async def test_post_an_article_with_wrong_type_field_should_return_an_error_message( + aiohttp_client, loop +): + app = web.Application() + app.router.add_view("/article", ArticleView) + + client = await aiohttp_client(app) + resp = await client.post("/article", json={"name": "foo", "nb_page": "foo"}) + assert resp.status == 400 + assert resp.content_type == "application/json" + assert await resp.json() == [ + { + "in": "body", + "loc": ["nb_page"], + "msg": "value is not a valid integer", + "custom": "custom", + "type": "type_error.integer", + } + ] diff --git a/tests/test_inheritance.py b/tests/test_inheritance.py new file mode 100644 index 0000000..baa69ac --- /dev/null +++ b/tests/test_inheritance.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from typing import Any + +from aiohttp_pydantic import PydanticView +from aiohttp.web import View + + +def count_wrappers(obj: Any) -> int: + """Count the number of times that an object is wrapped.""" + i = 0 + while i < 10: + try: + obj = obj.__wrapped__ + except AttributeError: + return i + else: + i += 1 + raise RuntimeError("Too many wrappers") + + +class AiohttpViewParent(View): + async def put(self): + pass + + +class PydanticViewParent(PydanticView): + async def get(self, id: int, /): + pass + + +def test_allowed_methods_get_decorated_exactly_once(): + class ChildView(PydanticViewParent): + async def post(self, id: int, /): + pass + + class SubChildView(ChildView): + async def get(self, id: int, /): + return super().get(id) + + assert count_wrappers(ChildView.post) == 1 + assert count_wrappers(ChildView.get) == 1 + assert count_wrappers(SubChildView.post) == 1 + assert count_wrappers(SubChildView.get) == 1 + + +def test_methods_inherited_from_aiohttp_view_should_not_be_decorated(): + class ChildView(AiohttpViewParent, PydanticView): + async def post(self, id: int, /): + pass + + assert count_wrappers(ChildView.put) == 0 + assert count_wrappers(ChildView.post) == 1 + + +def test_allowed_methods_are_set_correctly(): + class ChildView(AiohttpViewParent, PydanticView): + async def post(self, id: int, /): + pass + + assert ChildView.allowed_methods == {"POST", "PUT"} + + class ChildView(PydanticViewParent): + async def post(self, id: int, /): + pass + + assert ChildView.allowed_methods == {"POST", "GET"} + + class ChildView(AiohttpViewParent, PydanticViewParent): + async def post(self, id: int, /): + pass + + assert ChildView.allowed_methods == {"POST", "PUT", "GET"} diff --git a/tests/test_oas/test_cmd/oas_base.json b/tests/test_oas/test_cmd/oas_base.json new file mode 100644 index 0000000..75eb624 --- /dev/null +++ b/tests/test_oas/test_cmd/oas_base.json @@ -0,0 +1 @@ +{"info": {"title": "MyApp", "version": "1.0.0"}} \ No newline at end of file diff --git a/tests/test_oas/test_cmd/sample.py b/tests/test_oas/test_cmd/sample.py index 2c24c7e..7662041 100644 --- a/tests/test_oas/test_cmd/sample.py +++ b/tests/test_oas/test_cmd/sample.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from aiohttp import web from aiohttp_pydantic import PydanticView diff --git a/tests/test_oas/test_cmd/test_cmd.py b/tests/test_oas/test_cmd/test_cmd.py index 189e46b..1dd43e8 100644 --- a/tests/test_oas/test_cmd/test_cmd.py +++ b/tests/test_oas/test_cmd/test_cmd.py @@ -1,10 +1,15 @@ +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(): @@ -13,13 +18,18 @@ def cmd_line(): return parser -def test_show_oad_of_app(cmd_line, capfd): +def test_show_oas_of_app(cmd_line): args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample"]) + args.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}": { @@ -57,16 +67,20 @@ def test_show_oad_of_app(cmd_line, capfd): """ ) - assert captured.out.strip() == expected.strip() + assert args.output.getvalue().strip() == expected.strip() -def test_show_oad_of_sub_app(cmd_line, capfd): +def test_show_oas_of_sub_app(cmd_line): args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample:sub_app"]) + args.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}": { @@ -89,16 +103,26 @@ def test_show_oad_of_sub_app(cmd_line, capfd): """ ) - assert captured.out.strip() == expected.strip() + assert args.output.getvalue().strip() == expected.strip() -def test_show_oad_of_a_callable(cmd_line, capfd): - args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample:make_app()"]) +def test_show_oas_of_a_callable(cmd_line): + args = cmd_line.parse_args( + [ + "tests.test_oas.test_cmd.sample:make_app()", + "--base-oas-file", + PATH_TO_BASE_JSON_FILE, + ] + ) + args.output = StringIO() args.func(args) - captured = capfd.readouterr() expected = dedent( """ { + "info": { + "title": "Aiohttp pydantic application", + "version": "1.0.0" + }, "openapi": "3.0.0", "paths": { "/route-3/{a}": { @@ -121,4 +145,4 @@ def test_show_oad_of_a_callable(cmd_line, capfd): """ ) - assert captured.out.strip() == expected.strip() + assert args.output.getvalue().strip() == expected.strip() diff --git a/tests/test_oas/test_docstring_parser.py b/tests/test_oas/test_docstring_parser.py new file mode 100644 index 0000000..77c69c7 --- /dev/null +++ b/tests/test_oas/test_docstring_parser.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from textwrap import dedent + +from aiohttp_pydantic.oas.docstring_parser import ( + status_code, + tags, + operation, + _i_extract_block, + LinesIterator, +) +from inspect import getdoc +import pytest + + +def web_handler(): + """ + bla bla bla + + + Tags: tag1, tag2 + , tag3, + + t a + g + 4 + + Status Codes: + 200: line 1 + + line 2: + - line 3 + - line 4 + + line 5 + + 300: line A 1 + + 301: line B 1 + line B 2 + 400: line C 1 + + line C 2 + + line C 3 + + bla bla + """ + + +def web_handler_2(): + """ + bla bla bla + + + Tags: tag1 + Status Codes: + 200: line 1 + + bla bla + """ + + +def test_lines_iterator(): + lines_iterator = LinesIterator("AAAA\nBBBB") + with pytest.raises(StopIteration): + lines_iterator.rewind() + + assert lines_iterator.next_line() == "AAAA" + assert lines_iterator.rewind() + assert lines_iterator.next_line() == "AAAA" + assert lines_iterator.next_line() == "BBBB" + with pytest.raises(StopIteration): + lines_iterator.next_line() + + +def test_status_code(): + + expected = { + 200: "line 1\n\nline 2:\n - line 3\n - line 4\n\nline 5", + 300: "line A 1", + 301: "line B 1\nline B 2", + 400: "line C 1\n\nline C 2\n\n line C 3", + } + + assert status_code(getdoc(web_handler)) == expected + + +def test_tags(): + expected = ["tag1", "tag2", "tag3", "t a g 4"] + assert tags(getdoc(web_handler)) == expected + + +def test_operation(): + expected = "bla bla bla\n\n\nbla bla" + assert operation(getdoc(web_handler)) == expected + assert operation(getdoc(web_handler_2)) == expected + + +def test_i_extract_block(): + + blocks = dedent( + """ + aaaa: + + bbbb + + cccc + dddd + """ + ) + + lines = LinesIterator(blocks) + text = "\n".join(_i_extract_block(lines)) + assert text == """aaaa:\n\n bbbb\n\n cccc""" + + blocks = dedent( + """ + aaaa: + + bbbb + + cccc + + dddd + """ + ) + + lines = LinesIterator(blocks) + text = "\n".join(_i_extract_block(lines)) + assert text == """aaaa:\n\n bbbb\n\n cccc\n""" + + blocks = dedent( + """ + aaaa: + + bbbb + + cccc + """ + ) + + lines = LinesIterator(blocks) + text = "\n".join(_i_extract_block(lines)) + assert text == """aaaa:\n\n bbbb\n\n cccc""" + + lines = LinesIterator("") + text = "\n".join(_i_extract_block(lines)) + assert text == "" + + lines = LinesIterator("\n") + text = "\n".join(_i_extract_block(lines)) + assert text == "" + + lines = LinesIterator("aaaa:") + text = "\n".join(_i_extract_block(lines)) + assert text == "aaaa:" diff --git a/tests/test_oas/test_struct/test_info.py b/tests/test_oas/test_struct/test_info.py index bcd1bf6..ce3a3c5 100644 --- a/tests/test_oas/test_struct/test_info.py +++ b/tests/test_oas/test_struct/test_info.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest from aiohttp_pydantic.oas.struct import OpenApiSpec3 @@ -5,10 +7,16 @@ from aiohttp_pydantic.oas.struct import OpenApiSpec3 def test_info_title(): oas = OpenApiSpec3() - assert oas.info.title is None + assert oas.info.title == "Aiohttp pydantic application" oas.info.title = "Info Title" assert oas.info.title == "Info Title" - assert oas.spec == {"info": {"title": "Info Title"}, "openapi": "3.0.0"} + assert oas.spec == { + "info": { + "title": "Info Title", + "version": "1.0.0", + }, + "openapi": "3.0.0", + } def test_info_description(): @@ -16,15 +24,25 @@ 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"}, "openapi": "3.0.0"} + assert oas.spec == { + "info": { + "description": "info description", + "title": "Aiohttp pydantic application", + "version": "1.0.0", + }, + "openapi": "3.0.0", + } def test_info_version(): oas = OpenApiSpec3() - assert oas.info.version is None + assert oas.info.version == "1.0.0" oas.info.version = "3.14" assert oas.info.version == "3.14" - assert oas.spec == {"info": {"version": "3.14"}, "openapi": "3.0.0"} + assert oas.spec == { + "info": {"version": "3.14", "title": "Aiohttp pydantic application"}, + "openapi": "3.0.0", + } def test_info_terms_of_service(): @@ -33,7 +51,11 @@ 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": {"termsOfService": "http://example.com/terms/"}, + "info": { + "title": "Aiohttp pydantic application", + "version": "1.0.0", + "termsOfService": "http://example.com/terms/", + }, "openapi": "3.0.0", } diff --git a/tests/test_oas/test_struct/test_paths.py b/tests/test_oas/test_struct/test_paths.py index 7db09f3..ece2a70 100644 --- a/tests/test_oas/test_struct/test_paths.py +++ b/tests/test_oas/test_struct/test_paths.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from aiohttp_pydantic.oas.struct import OpenApiSpec3 @@ -6,6 +8,7 @@ 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 ..."}}, } @@ -13,7 +16,11 @@ def test_paths_description(): def test_paths_get(): oas = OpenApiSpec3() oas.paths["/users/{id}"].get - assert oas.spec == {"openapi": "3.0.0", "paths": {"/users/{id}": {"get": {}}}} + assert oas.spec == { + "openapi": "3.0.0", + "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, + "paths": {"/users/{id}": {"get": {}}}, + } def test_paths_operation_description(): @@ -22,6 +29,7 @@ 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 ..."}}}, } @@ -32,6 +40,7 @@ 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"} @@ -51,6 +60,7 @@ 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": { @@ -86,6 +96,7 @@ 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": { @@ -110,6 +121,18 @@ 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] diff --git a/tests/test_oas/test_struct/test_servers.py b/tests/test_oas/test_struct/test_servers.py index b8e0406..cccb9cb 100644 --- a/tests/test_oas/test_struct/test_servers.py +++ b/tests/test_oas/test_struct/test_servers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest from aiohttp_pydantic.oas.struct import OpenApiSpec3 @@ -9,6 +11,7 @@ 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"}, @@ -22,6 +25,7 @@ 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", diff --git a/tests/test_oas/test_view.py b/tests/test_oas/test_view.py index 283acd0..83363ce 100644 --- a/tests/test_oas/test_view.py +++ b/tests/test_oas/test_view.py @@ -1,5 +1,7 @@ +from __future__ import annotations + from enum import Enum -from typing import List, Optional, Union +from typing import List, Optional, Union, Literal from uuid import UUID import pytest @@ -7,7 +9,9 @@ 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): @@ -33,6 +37,10 @@ class PetCollectionView(PydanticView): ) -> r200[List[Pet]]: """ Get a list of pets + + Tags: pet + Status Codes: + 200: Successful operation """ return web.json_response() @@ -42,28 +50,62 @@ class PetCollectionView(PydanticView): class PetItemView(PydanticView): - async def get(self, id: int, /) -> Union[r200[Pet], r404]: + async def get( + self, + id: int, + /, + size: Union[int, Literal["x", "l", "s"]], + day: Union[int, Literal["now"]] = "now", + ) -> Union[r200[Pet], r404]: return web.json_response() 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) - 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() + return await ensure_content_durability(await aiohttp_client(app)) async def test_generated_oas_should_have_components_schemas(generated_oas): @@ -93,6 +135,7 @@ 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", @@ -115,29 +158,11 @@ async def test_pets_route_should_have_get_method(generated_oas): ], "responses": { "200": { + "description": "Successful operation", "content": { "application/json": { "schema": { "items": { - "definitions": { - "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", - }, - }, "properties": { "id": {"title": "Id", "type": "integer"}, "name": {"title": "Name", "type": "string"}, @@ -154,7 +179,7 @@ async def test_pets_route_should_have_get_method(generated_oas): "type": "array", } } - } + }, } }, } @@ -167,23 +192,6 @@ async def test_pets_route_should_have_post_method(generated_oas): "content": { "application/json": { "schema": { - "definitions": { - "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", - }, - }, "properties": { "id": {"title": "Id", "type": "integer"}, "name": {"title": "Name", "type": "string"}, @@ -202,26 +210,10 @@ async def test_pets_route_should_have_post_method(generated_oas): }, "responses": { "201": { + "description": "", "content": { "application/json": { "schema": { - "definitions": { - "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", - }, - }, "properties": { "id": {"title": "Id", "type": "integer"}, "name": {"title": "Name", "type": "string"}, @@ -236,7 +228,7 @@ async def test_pets_route_should_have_post_method(generated_oas): "type": "object", } } - } + }, } }, } @@ -248,15 +240,16 @@ async def test_generated_oas_should_have_pets_id_paths(generated_oas): async def test_pets_id_route_should_have_delete_method(generated_oas): assert generated_oas["paths"]["/pets/{id}"]["delete"] == { + "description": "", "parameters": [ { - "required": True, "in": "path", "name": "id", + "required": True, "schema": {"title": "id", "type": "integer"}, } ], - "responses": {"204": {"content": {}}}, + "responses": {"204": {"content": {}, "description": "Empty but OK"}}, } @@ -268,30 +261,36 @@ async def test_pets_id_route_should_have_get_method(generated_oas): "name": "id", "required": True, "schema": {"title": "id", "type": "integer"}, - } + }, + { + "in": "query", + "name": "size", + "required": True, + "schema": { + "anyOf": [ + {"type": "integer"}, + {"enum": ["x", "l", "s"], "type": "string"}, + ], + "title": "size", + }, + }, + { + "in": "query", + "name": "day", + "required": False, + "schema": { + "anyOf": [{"type": "integer"}, {"enum": ["now"], "type": "string"}], + "default": "now", + "title": "day", + }, + }, ], "responses": { "200": { + "description": "", "content": { "application/json": { "schema": { - "definitions": { - "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", - }, - }, "properties": { "id": {"title": "Id", "type": "integer"}, "name": {"title": "Name", "type": "string"}, @@ -306,9 +305,9 @@ async def test_pets_id_route_should_have_get_method(generated_oas): "type": "object", } } - } + }, }, - "404": {"content": {}}, + "404": {"description": "", "content": {}}, }, } @@ -327,23 +326,6 @@ async def test_pets_id_route_should_have_put_method(generated_oas): "content": { "application/json": { "schema": { - "definitions": { - "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", - }, - }, "properties": { "id": {"title": "Id", "type": "integer"}, "name": {"title": "Name", "type": "string"}, @@ -361,3 +343,72 @@ async def test_pets_id_route_should_have_put_method(generated_oas): } }, } + + +async def test_simple_type_route_should_have_get_method(generated_oas): + assert generated_oas["paths"]["/simple-type"]["get"] == { + "description": "", + "responses": { + "200": { + "content": {"application/json": {"schema": {}}}, + "description": "The new number", + } + }, + } + + +async def test_generated_view_info_default(): + apps = (web.Application(),) + spec = generate_oas(apps) + + assert spec == { + "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, + "openapi": "3.0.0", + } + + +async def test_generated_view_info_as_version(): + apps = (web.Application(),) + spec = generate_oas(apps, version_spec="test version") + + assert spec == { + "info": {"title": "Aiohttp pydantic application", "version": "test version"}, + "openapi": "3.0.0", + } + + +async def test_generated_view_info_as_title(): + apps = (web.Application(),) + spec = generate_oas(apps, title_spec="test title") + + assert spec == { + "info": {"title": "test title", "version": "1.0.0"}, + "openapi": "3.0.0", + } + + +class Pagination(Group): + page: int = 1 + page_size: int = 20 + + +async def test_use_parameters_group_should_not_impact_the_oas(aiohttp_client): + class PetCollectionView1(PydanticView): + async def get(self, page: int = 1, page_size: int = 20) -> r200[List[Pet]]: + return web.json_response() + + class PetCollectionView2(PydanticView): + async def get(self, pagination: Pagination) -> r200[List[Pet]]: + return web.json_response() + + app1 = web.Application() + app1.router.add_view("/pets", PetCollectionView1) + oas.setup(app1) + + app2 = web.Application() + app2.router.add_view("/pets", PetCollectionView2) + oas.setup(app2) + + assert await ensure_content_durability( + await aiohttp_client(app1) + ) == await ensure_content_durability(await aiohttp_client(app2)) diff --git a/tests/test_parse_func_signature.py b/tests/test_parse_func_signature.py index 6e588e7..256e128 100644 --- a/tests/test_parse_func_signature.py +++ b/tests/test_parse_func_signature.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from uuid import UUID from pydantic import BaseModel diff --git a/tests/test_validation_body.py b/tests/test_validation_body.py index 424f136..6a03316 100644 --- a/tests/test_validation_body.py +++ b/tests/test_validation_body.py @@ -1,4 +1,6 @@ -from typing import Optional +from __future__ import annotations + +from typing import Iterator, List, Optional from aiohttp import web from pydantic import BaseModel @@ -11,10 +13,20 @@ 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 @@ -56,6 +68,58 @@ async def test_post_an_article_with_wrong_type_field_should_return_an_error_mess ] +async def test_post_an_array_json_is_supported(aiohttp_client, loop): + app = web.Application() + app.router.add_view("/article", ArticleView) + + client = await aiohttp_client(app) + body = [{"name": "foo", "nb_page": 3}] * 2 + resp = await client.put("/article", json=body) + assert resp.status == 200 + assert resp.content_type == "application/json" + assert await resp.json() == body + + +async def test_post_an_array_json_to_an_object_model_should_return_an_error( + aiohttp_client, loop +): + app = web.Application() + app.router.add_view("/article", ArticleView) + + client = await aiohttp_client(app) + resp = await client.post("/article", json=[{"name": "foo", "nb_page": 3}]) + assert resp.status == 400 + assert resp.content_type == "application/json" + assert await resp.json() == [ + { + "in": "body", + "loc": ["__root__"], + "msg": "value is not a valid dict", + "type": "type_error.dict", + } + ] + + +async def test_post_an_object_json_to_a_list_model_should_return_an_error( + aiohttp_client, loop +): + app = web.Application() + app.router.add_view("/article", ArticleView) + + client = await aiohttp_client(app) + resp = await client.put("/article", json={"name": "foo", "nb_page": 3}) + assert resp.status == 400 + assert resp.content_type == "application/json" + assert await resp.json() == [ + { + "in": "body", + "loc": ["__root__"], + "msg": "value is not a valid list", + "type": "type_error.list", + } + ] + + async def test_post_a_valid_article_should_return_the_parsed_type(aiohttp_client, loop): app = web.Application() app.router.add_view("/article", ArticleView) diff --git a/tests/test_validation_header.py b/tests/test_validation_header.py index e18a863..95d317a 100644 --- a/tests/test_validation_header.py +++ b/tests/test_validation_header.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json from datetime import datetime from enum import Enum @@ -5,6 +7,7 @@ from enum import Enum from aiohttp import web from aiohttp_pydantic import PydanticView +from aiohttp_pydantic.injectors import Group class JSONEncoder(json.JSONEncoder): @@ -32,6 +35,31 @@ class ViewWithEnumType(PydanticView): 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 ): @@ -134,3 +162,21 @@ async def test_correct_value_to_header_defined_with_str_enum(aiohttp_client, loo 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" diff --git a/tests/test_validation_path.py b/tests/test_validation_path.py index bfa2016..ae8a453 100644 --- a/tests/test_validation_path.py +++ b/tests/test_validation_path.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from aiohttp import web from aiohttp_pydantic import PydanticView diff --git a/tests/test_validation_query_string.py b/tests/test_validation_query_string.py index 363c461..d6041f1 100644 --- a/tests/test_validation_query_string.py +++ b/tests/test_validation_query_string.py @@ -1,16 +1,56 @@ -from typing import Optional +from __future__ import annotations +from typing import Optional, List +from pydantic import Field from aiohttp import web from aiohttp_pydantic import PydanticView +from aiohttp_pydantic.injectors import Group class ArticleView(PydanticView): async def get( - self, with_comments: bool, age: Optional[int] = None, nb_items: int = 7 + 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} + { + "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, + } ) @@ -65,7 +105,12 @@ 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} + assert await resp.json() == { + "with_comments": True, + "age": 3, + "nb_items": 7, + "tags": [], + } async def test_get_article_with_valid_qs_and_omitted_optional_should_return_default_value( @@ -77,6 +122,136 @@ 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} + 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"