Merge pull request #6 from Maillol/increase_oas
Increase OAS description
This commit is contained in:
commit
cd8422bde3
49
README.rst
49
README.rst
@ -217,6 +217,9 @@ For example *r200[List[Pet]]* means the server responses with
|
|||||||
the status code 200 and the response content is a List of Pet where Pet will be
|
the status code 200 and the response content is a List of Pet where Pet will be
|
||||||
defined using a pydantic.BaseModel
|
defined using a pydantic.BaseModel
|
||||||
|
|
||||||
|
The docstring of methods will be parsed to fill the descriptions in the
|
||||||
|
Open Api Specification.
|
||||||
|
|
||||||
|
|
||||||
.. code-block:: python3
|
.. code-block:: python3
|
||||||
|
|
||||||
@ -235,20 +238,42 @@ defined using a pydantic.BaseModel
|
|||||||
|
|
||||||
class PetCollectionView(PydanticView):
|
class PetCollectionView(PydanticView):
|
||||||
async def get(self) -> r200[List[Pet]]:
|
async def get(self) -> r200[List[Pet]]:
|
||||||
|
"""
|
||||||
|
Find all pets
|
||||||
|
"""
|
||||||
pets = self.request.app["model"].list_pets()
|
pets = self.request.app["model"].list_pets()
|
||||||
return web.json_response([pet.dict() for pet in pets])
|
return web.json_response([pet.dict() for pet in pets])
|
||||||
|
|
||||||
async def post(self, pet: Pet) -> r201[Pet]:
|
async def post(self, pet: Pet) -> r201[Pet]:
|
||||||
|
"""
|
||||||
|
Add a new pet to the store
|
||||||
|
|
||||||
|
Status Codes:
|
||||||
|
201: The pet is created
|
||||||
|
"""
|
||||||
self.request.app["model"].add_pet(pet)
|
self.request.app["model"].add_pet(pet)
|
||||||
return web.json_response(pet.dict())
|
return web.json_response(pet.dict())
|
||||||
|
|
||||||
|
|
||||||
class PetItemView(PydanticView):
|
class PetItemView(PydanticView):
|
||||||
async def get(self, id: int, /) -> Union[r200[Pet], r404[Error]]:
|
async def get(self, id: int, /) -> Union[r200[Pet], r404[Error]]:
|
||||||
|
"""
|
||||||
|
Find a pet by ID
|
||||||
|
|
||||||
|
Status Codes:
|
||||||
|
200: Successful operation
|
||||||
|
404: Pet not found
|
||||||
|
"""
|
||||||
pet = self.request.app["model"].find_pet(id)
|
pet = self.request.app["model"].find_pet(id)
|
||||||
return web.json_response(pet.dict())
|
return web.json_response(pet.dict())
|
||||||
|
|
||||||
async def put(self, id: int, /, pet: Pet) -> r200[Pet]:
|
async def put(self, id: int, /, pet: Pet) -> r200[Pet]:
|
||||||
|
"""
|
||||||
|
Update an existing pet
|
||||||
|
|
||||||
|
Status Codes:
|
||||||
|
200: successful operation
|
||||||
|
"""
|
||||||
self.request.app["model"].update_pet(id, pet)
|
self.request.app["model"].update_pet(id, pet)
|
||||||
return web.json_response(pet.dict())
|
return web.json_response(pet.dict())
|
||||||
|
|
||||||
@ -270,12 +295,34 @@ Have a look at `demo`_ for a complete example
|
|||||||
|
|
||||||
Go to http://127.0.0.1:8080/oas
|
Go to http://127.0.0.1:8080/oas
|
||||||
|
|
||||||
You can generate the OAS in a json file using the command:
|
You can generate the OAS in a json or yaml file using the aiohttp_pydantic.oas command:
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
python -m aiohttp_pydantic.oas demo.main
|
python -m aiohttp_pydantic.oas demo.main
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
$ python3 -m aiohttp_pydantic.oas --help
|
||||||
|
usage: __main__.py [-h] [-b FILE] [-o FILE] [-f FORMAT] [APP [APP ...]]
|
||||||
|
|
||||||
|
Generate Open API Specification
|
||||||
|
|
||||||
|
positional arguments:
|
||||||
|
APP The name of the module containing the asyncio.web.Application. By default the variable named
|
||||||
|
'app' is loaded but you can define an other variable name ending the name of module with :
|
||||||
|
characters and the name of variable. Example: my_package.my_module:my_app If your
|
||||||
|
asyncio.web.Application is returned by a function, you can use the syntax:
|
||||||
|
my_package.my_module:my_app()
|
||||||
|
|
||||||
|
optional arguments:
|
||||||
|
-h, --help show this help message and exit
|
||||||
|
-b FILE, --base-oas-file FILE
|
||||||
|
A file that will be used as base to generate OAS
|
||||||
|
-o FILE, --output FILE
|
||||||
|
File to write the output
|
||||||
|
-f FORMAT, --format FORMAT
|
||||||
|
The output format, can be 'json' or 'yaml' (default is json)
|
||||||
|
|
||||||
|
|
||||||
.. _demo: https://github.com/Maillol/aiohttp-pydantic/tree/main/demo
|
.. _demo: https://github.com/Maillol/aiohttp-pydantic/tree/main/demo
|
||||||
.. _aiohttp view: https://docs.aiohttp.org/en/stable/web_quickstart.html#class-based-views
|
.. _aiohttp view: https://docs.aiohttp.org/en/stable/web_quickstart.html#class-based-views
|
||||||
|
@ -1,10 +1,28 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import importlib
|
import importlib
|
||||||
import json
|
import json
|
||||||
|
from typing import Dict, Protocol, Optional, Callable
|
||||||
|
import sys
|
||||||
from .view import generate_oas
|
from .view import generate_oas
|
||||||
|
|
||||||
|
|
||||||
|
class YamlModule(Protocol):
|
||||||
|
"""
|
||||||
|
Yaml Module type hint
|
||||||
|
"""
|
||||||
|
|
||||||
|
def dump(self, data) -> str:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
yaml: Optional[YamlModule]
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError:
|
||||||
|
yaml = None
|
||||||
|
|
||||||
|
|
||||||
def application_type(value):
|
def application_type(value):
|
||||||
"""
|
"""
|
||||||
Return aiohttp application defined in the value.
|
Return aiohttp application defined in the value.
|
||||||
@ -26,6 +44,35 @@ def application_type(value):
|
|||||||
raise argparse.ArgumentTypeError(error) from error
|
raise argparse.ArgumentTypeError(error) from error
|
||||||
|
|
||||||
|
|
||||||
|
def base_oas_file_type(value) -> Dict:
|
||||||
|
"""
|
||||||
|
Load base oas file
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(value) as oas_file:
|
||||||
|
data = oas_file.read()
|
||||||
|
except OSError as error:
|
||||||
|
raise argparse.ArgumentTypeError(error) from error
|
||||||
|
|
||||||
|
return json.loads(data)
|
||||||
|
|
||||||
|
|
||||||
|
def format_type(value) -> Callable:
|
||||||
|
"""
|
||||||
|
Date Dumper one of (json, yaml)
|
||||||
|
"""
|
||||||
|
dumpers = {"json": lambda data: json.dumps(data, sort_keys=True, indent=4)}
|
||||||
|
if yaml is not None:
|
||||||
|
dumpers["yaml"] = yaml.dump
|
||||||
|
|
||||||
|
try:
|
||||||
|
return dumpers[value]
|
||||||
|
except KeyError:
|
||||||
|
raise argparse.ArgumentTypeError(
|
||||||
|
f"Wrong format value. (allowed values: {tuple(dumpers.keys())})"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
|
||||||
def setup(parser: argparse.ArgumentParser):
|
def setup(parser: argparse.ArgumentParser):
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"apps",
|
"apps",
|
||||||
@ -35,11 +82,52 @@ def setup(parser: argparse.ArgumentParser):
|
|||||||
help="The name of the module containing the asyncio.web.Application."
|
help="The name of the module containing the asyncio.web.Application."
|
||||||
" By default the variable named 'app' is loaded but you can define"
|
" By default the variable named 'app' is loaded but you can define"
|
||||||
" an other variable name ending the name of module with : characters"
|
" an other variable name ending the name of module with : characters"
|
||||||
" and the name of variable. Example: my_package.my_module:my_app",
|
" and the name of variable. Example: my_package.my_module:my_app"
|
||||||
|
" If your asyncio.web.Application is returned by a function, you can"
|
||||||
|
" use the syntax: my_package.my_module:my_app()",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-b",
|
||||||
|
"--base-oas-file",
|
||||||
|
metavar="FILE",
|
||||||
|
dest="base",
|
||||||
|
type=base_oas_file_type,
|
||||||
|
help="A file that will be used as base to generate OAS",
|
||||||
|
default={},
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-o",
|
||||||
|
"--output",
|
||||||
|
metavar="FILE",
|
||||||
|
type=argparse.FileType("w"),
|
||||||
|
help="File to write the output",
|
||||||
|
default=sys.stdout,
|
||||||
|
)
|
||||||
|
|
||||||
|
if yaml:
|
||||||
|
help_output_format = (
|
||||||
|
"The output format, can be 'json' or 'yaml' (default is json)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
help_output_format = "The output format, only 'json' is available install pyyaml to have yaml output format"
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"-f",
|
||||||
|
"--format",
|
||||||
|
metavar="FORMAT",
|
||||||
|
dest="formatter",
|
||||||
|
type=format_type,
|
||||||
|
help=help_output_format,
|
||||||
|
default=format_type("json"),
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.set_defaults(func=show_oas)
|
parser.set_defaults(func=show_oas)
|
||||||
|
|
||||||
|
|
||||||
def show_oas(args: argparse.Namespace):
|
def show_oas(args: argparse.Namespace):
|
||||||
print(json.dumps(generate_oas(args.apps), sort_keys=True, indent=4))
|
"""
|
||||||
|
Display Open API Specification on the stdout.
|
||||||
|
"""
|
||||||
|
spec = args.base
|
||||||
|
spec.update(generate_oas(args.apps))
|
||||||
|
print(args.formatter(spec), file=args.output)
|
||||||
|
120
aiohttp_pydantic/oas/docstring_parser.py
Normal file
120
aiohttp_pydantic/oas/docstring_parser.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
"""
|
||||||
|
Utility to extract extra OAS description from docstring.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import textwrap
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Get the size of the indentation.
|
||||||
|
if (match := re.search("^ +", line)) is None:
|
||||||
|
return # No block to extract.
|
||||||
|
indent = match.group()
|
||||||
|
yield line[len(indent) :]
|
||||||
|
|
||||||
|
# Yield lines until the indentation is the same or is greater than
|
||||||
|
# the first block line.
|
||||||
|
try:
|
||||||
|
line = next(lines)
|
||||||
|
except StopIteration:
|
||||||
|
return
|
||||||
|
while (is_empty := line.strip() == "") or line.startswith(indent):
|
||||||
|
yield "" if is_empty else line[len(indent) :]
|
||||||
|
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):
|
||||||
|
blocks = []
|
||||||
|
lines = []
|
||||||
|
for line_of_block in _i_extract_block(iterator):
|
||||||
|
if re.search("^\\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 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*:", line, re.IGNORECASE):
|
||||||
|
for _ in _i_extract_block(lines):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
ret.append(line)
|
||||||
|
return ("\n".join(ret)).strip()
|
@ -133,6 +133,7 @@ class Parameters:
|
|||||||
class Response:
|
class Response:
|
||||||
def __init__(self, spec: dict):
|
def __init__(self, spec: dict):
|
||||||
self._spec = spec
|
self._spec = spec
|
||||||
|
self._spec.setdefault("description", "")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
|
@ -8,6 +8,7 @@ from aiohttp.web_app import Application
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem
|
from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem
|
||||||
|
from . import docstring_parser
|
||||||
|
|
||||||
from ..injectors import _parse_func_signature
|
from ..injectors import _parse_func_signature
|
||||||
from ..utils import is_pydantic_base_model
|
from ..utils import is_pydantic_base_model
|
||||||
@ -18,7 +19,7 @@ from .typing import is_status_code_type
|
|||||||
def _handle_optional(type_):
|
def _handle_optional(type_):
|
||||||
"""
|
"""
|
||||||
Returns the type wrapped in Optional or None.
|
Returns the type wrapped in Optional or None.
|
||||||
|
>>> from typing import Optional
|
||||||
>>> _handle_optional(int)
|
>>> _handle_optional(int)
|
||||||
>>> _handle_optional(Optional[str])
|
>>> _handle_optional(Optional[str])
|
||||||
<class 'str'>
|
<class 'str'>
|
||||||
@ -36,14 +37,15 @@ class _OASResponseBuilder:
|
|||||||
generate the OAS operation response.
|
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_operation = oas_operation
|
||||||
self._oas = oas
|
self._oas = oas
|
||||||
|
self._status_code_descriptions = status_code_descriptions
|
||||||
|
|
||||||
def _handle_pydantic_base_model(self, obj):
|
def _handle_pydantic_base_model(self, obj):
|
||||||
if is_pydantic_base_model(obj):
|
if is_pydantic_base_model(obj):
|
||||||
response_schema = obj.schema(ref_template="#/components/schemas/{model}")
|
response_schema = obj.schema(ref_template="#/components/schemas/{model}")
|
||||||
if def_sub_schemas := response_schema.get("definitions", None):
|
if def_sub_schemas := response_schema.pop("definitions", None):
|
||||||
self._oas.components.schemas.update(def_sub_schemas)
|
self._oas.components.schemas.update(def_sub_schemas)
|
||||||
return response_schema
|
return response_schema
|
||||||
return {}
|
return {}
|
||||||
@ -64,10 +66,16 @@ class _OASResponseBuilder:
|
|||||||
"schema": self._handle_list(typing.get_args(obj)[0])
|
"schema": self._handle_list(typing.get_args(obj)[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
desc = self._status_code_descriptions.get(int(status_code))
|
||||||
|
if desc:
|
||||||
|
self._oas_operation.responses[status_code].description = desc
|
||||||
|
|
||||||
elif is_status_code_type(obj):
|
elif is_status_code_type(obj):
|
||||||
status_code = obj.__name__[1:]
|
status_code = obj.__name__[1:]
|
||||||
self._oas_operation.responses[status_code].content = {}
|
self._oas_operation.responses[status_code].content = {}
|
||||||
|
desc = self._status_code_descriptions.get(int(status_code))
|
||||||
|
if desc:
|
||||||
|
self._oas_operation.responses[status_code].description = desc
|
||||||
|
|
||||||
def _handle_union(self, obj):
|
def _handle_union(self, obj):
|
||||||
if typing.get_origin(obj) is typing.Union:
|
if typing.get_origin(obj) is typing.Union:
|
||||||
@ -90,13 +98,16 @@ def _add_http_method_to_oas(
|
|||||||
)
|
)
|
||||||
description = getdoc(handler)
|
description = getdoc(handler)
|
||||||
if description:
|
if description:
|
||||||
oas_operation.description = description
|
oas_operation.description = docstring_parser.operation(description)
|
||||||
|
status_code_descriptions = docstring_parser.status_code(description)
|
||||||
|
else:
|
||||||
|
status_code_descriptions = {}
|
||||||
|
|
||||||
if body_args:
|
if body_args:
|
||||||
body_schema = next(iter(body_args.values())).schema(
|
body_schema = next(iter(body_args.values())).schema(
|
||||||
ref_template="#/components/schemas/{model}"
|
ref_template="#/components/schemas/{model}"
|
||||||
)
|
)
|
||||||
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.components.schemas.update(def_sub_schemas)
|
||||||
|
|
||||||
oas_operation.request_body.content = {
|
oas_operation.request_body.content = {
|
||||||
@ -127,7 +138,9 @@ def _add_http_method_to_oas(
|
|||||||
|
|
||||||
return_type = handler.__annotations__.get("return")
|
return_type = handler.__annotations__.get("return")
|
||||||
if return_type is not None:
|
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]) -> dict:
|
||||||
|
@ -9,8 +9,14 @@ from aiohttp.web_exceptions import HTTPMethodNotAllowed
|
|||||||
from aiohttp.web_response import StreamResponse
|
from aiohttp.web_response import StreamResponse
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from .injectors import (AbstractInjector, BodyGetter, HeadersGetter,
|
from .injectors import (
|
||||||
MatchInfoGetter, QueryGetter, _parse_func_signature)
|
AbstractInjector,
|
||||||
|
BodyGetter,
|
||||||
|
HeadersGetter,
|
||||||
|
MatchInfoGetter,
|
||||||
|
QueryGetter,
|
||||||
|
_parse_func_signature,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PydanticView(AbstractView):
|
class PydanticView(AbstractView):
|
||||||
|
29
demo/view.py
29
demo/view.py
@ -10,25 +10,54 @@ from .model import Error, Pet
|
|||||||
|
|
||||||
class PetCollectionView(PydanticView):
|
class PetCollectionView(PydanticView):
|
||||||
async def get(self, age: Optional[int] = None) -> r200[List[Pet]]:
|
async def get(self, age: Optional[int] = None) -> r200[List[Pet]]:
|
||||||
|
"""
|
||||||
|
List all pets
|
||||||
|
|
||||||
|
Status Codes:
|
||||||
|
200: Successful operation
|
||||||
|
"""
|
||||||
pets = self.request.app["model"].list_pets()
|
pets = self.request.app["model"].list_pets()
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
[pet.dict() for pet in pets if age is None or age == pet.age]
|
[pet.dict() for pet in pets if age is None or age == pet.age]
|
||||||
)
|
)
|
||||||
|
|
||||||
async def post(self, pet: Pet) -> r201[Pet]:
|
async def post(self, pet: Pet) -> r201[Pet]:
|
||||||
|
"""
|
||||||
|
Add a new pet to the store
|
||||||
|
|
||||||
|
Status Codes:
|
||||||
|
201: Successful operation
|
||||||
|
"""
|
||||||
self.request.app["model"].add_pet(pet)
|
self.request.app["model"].add_pet(pet)
|
||||||
return web.json_response(pet.dict())
|
return web.json_response(pet.dict())
|
||||||
|
|
||||||
|
|
||||||
class PetItemView(PydanticView):
|
class PetItemView(PydanticView):
|
||||||
async def get(self, id: int, /) -> Union[r200[Pet], r404[Error]]:
|
async def get(self, id: int, /) -> Union[r200[Pet], r404[Error]]:
|
||||||
|
"""
|
||||||
|
Find a pet by ID
|
||||||
|
|
||||||
|
Status Codes:
|
||||||
|
200: Successful operation
|
||||||
|
404: Pet not found
|
||||||
|
"""
|
||||||
pet = self.request.app["model"].find_pet(id)
|
pet = self.request.app["model"].find_pet(id)
|
||||||
return web.json_response(pet.dict())
|
return web.json_response(pet.dict())
|
||||||
|
|
||||||
async def put(self, id: int, /, pet: Pet) -> r200[Pet]:
|
async def put(self, id: int, /, pet: Pet) -> r200[Pet]:
|
||||||
|
"""
|
||||||
|
Update an existing object
|
||||||
|
|
||||||
|
Status Codes:
|
||||||
|
200: Successful operation
|
||||||
|
404: Pet not found
|
||||||
|
"""
|
||||||
self.request.app["model"].update_pet(id, pet)
|
self.request.app["model"].update_pet(id, pet)
|
||||||
return web.json_response(pet.dict())
|
return web.json_response(pet.dict())
|
||||||
|
|
||||||
async def delete(self, id: int, /) -> r204:
|
async def delete(self, id: int, /) -> r204:
|
||||||
|
"""
|
||||||
|
Deletes a pet
|
||||||
|
"""
|
||||||
self.request.app["model"].remove_pet(id)
|
self.request.app["model"].remove_pet(id)
|
||||||
return web.Response(status=204)
|
return web.Response(status=204)
|
||||||
|
@ -8,6 +8,7 @@ pyparsing==2.4.7
|
|||||||
pytest==6.1.2
|
pytest==6.1.2
|
||||||
pytest-aiohttp==0.3.0
|
pytest-aiohttp==0.3.0
|
||||||
pytest-cov==2.10.1
|
pytest-cov==2.10.1
|
||||||
|
pyyaml==5.3.1
|
||||||
six==1.15.0
|
six==1.15.0
|
||||||
toml==0.10.2
|
toml==0.10.2
|
||||||
typing-extensions==3.7.4.3
|
typing-extensions==3.7.4.3
|
||||||
|
1
tests/test_oas/test_cmd/oas_base.json
Normal file
1
tests/test_oas/test_cmd/oas_base.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"info": {"title": "MyApp", "version": "1.0.0"}}
|
@ -1,10 +1,13 @@
|
|||||||
import argparse
|
import argparse
|
||||||
from textwrap import dedent
|
from textwrap import dedent
|
||||||
|
from io import StringIO
|
||||||
|
from pathlib import Path
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from aiohttp_pydantic.oas import cmd
|
from aiohttp_pydantic.oas import cmd
|
||||||
|
|
||||||
|
PATH_TO_BASE_JSON_FILE = str(Path(__file__).parent / "oas_base.json")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def cmd_line():
|
def cmd_line():
|
||||||
@ -13,10 +16,11 @@ def cmd_line():
|
|||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def test_show_oad_of_app(cmd_line, capfd):
|
def test_show_oas_of_app(cmd_line):
|
||||||
args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample"])
|
args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample"])
|
||||||
|
args.output = StringIO()
|
||||||
args.func(args)
|
args.func(args)
|
||||||
captured = capfd.readouterr()
|
|
||||||
expected = dedent(
|
expected = dedent(
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
@ -57,13 +61,13 @@ def test_show_oad_of_app(cmd_line, capfd):
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
assert captured.out.strip() == expected.strip()
|
assert args.output.getvalue().strip() == expected.strip()
|
||||||
|
|
||||||
|
|
||||||
def test_show_oad_of_sub_app(cmd_line, capfd):
|
def test_show_oas_of_sub_app(cmd_line):
|
||||||
args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample:sub_app"])
|
args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample:sub_app"])
|
||||||
|
args.output = StringIO()
|
||||||
args.func(args)
|
args.func(args)
|
||||||
captured = capfd.readouterr()
|
|
||||||
expected = dedent(
|
expected = dedent(
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
@ -89,16 +93,26 @@ def test_show_oad_of_sub_app(cmd_line, capfd):
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
assert captured.out.strip() == expected.strip()
|
assert args.output.getvalue().strip() == expected.strip()
|
||||||
|
|
||||||
|
|
||||||
def test_show_oad_of_a_callable(cmd_line, capfd):
|
def test_show_oas_of_a_callable(cmd_line):
|
||||||
args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample:make_app()"])
|
args = cmd_line.parse_args(
|
||||||
|
[
|
||||||
|
"tests.test_oas.test_cmd.sample:make_app()",
|
||||||
|
"--base-oas-file",
|
||||||
|
PATH_TO_BASE_JSON_FILE,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
args.output = StringIO()
|
||||||
args.func(args)
|
args.func(args)
|
||||||
captured = capfd.readouterr()
|
|
||||||
expected = dedent(
|
expected = dedent(
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
|
"info": {
|
||||||
|
"title": "MyApp",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
"openapi": "3.0.0",
|
"openapi": "3.0.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
"/route-3/{a}": {
|
"/route-3/{a}": {
|
||||||
@ -121,4 +135,4 @@ def test_show_oad_of_a_callable(cmd_line, capfd):
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
assert captured.out.strip() == expected.strip()
|
assert args.output.getvalue().strip() == expected.strip()
|
||||||
|
88
tests/test_oas/test_docstring_parser.py
Normal file
88
tests/test_oas/test_docstring_parser.py
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
from aiohttp_pydantic.oas.docstring_parser import (
|
||||||
|
status_code,
|
||||||
|
operation,
|
||||||
|
_i_extract_block,
|
||||||
|
LinesIterator,
|
||||||
|
)
|
||||||
|
from inspect import getdoc
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def web_handler():
|
||||||
|
"""
|
||||||
|
bla bla bla
|
||||||
|
|
||||||
|
|
||||||
|
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 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_operation():
|
||||||
|
expected = "bla bla bla\n\n\nbla bla"
|
||||||
|
assert operation(getdoc(web_handler)) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_extract_block():
|
||||||
|
lines = LinesIterator(" aaaa\n\n bbbb\n\n cccc\n dddd")
|
||||||
|
text = "\n".join(_i_extract_block(lines))
|
||||||
|
assert text == """aaaa\n\n bbbb\n\ncccc"""
|
||||||
|
|
||||||
|
lines = LinesIterator("")
|
||||||
|
text = "\n".join(_i_extract_block(lines))
|
||||||
|
assert text == ""
|
||||||
|
|
||||||
|
lines = LinesIterator("aaaa\n bbbb") # the indented block is cut by a new block
|
||||||
|
text = "\n".join(_i_extract_block(lines))
|
||||||
|
assert text == ""
|
||||||
|
|
||||||
|
lines = LinesIterator(" \n ")
|
||||||
|
text = "\n".join(_i_extract_block(lines))
|
||||||
|
assert text == ""
|
||||||
|
|
||||||
|
lines = LinesIterator(" aaaa\n bbbb")
|
||||||
|
text = "\n".join(_i_extract_block(lines))
|
||||||
|
assert text == "aaaa\nbbbb"
|
@ -33,6 +33,9 @@ class PetCollectionView(PydanticView):
|
|||||||
) -> r200[List[Pet]]:
|
) -> r200[List[Pet]]:
|
||||||
"""
|
"""
|
||||||
Get a list of pets
|
Get a list of pets
|
||||||
|
|
||||||
|
Status Codes:
|
||||||
|
200: Successful operation
|
||||||
"""
|
"""
|
||||||
return web.json_response()
|
return web.json_response()
|
||||||
|
|
||||||
@ -49,6 +52,19 @@ class PetItemView(PydanticView):
|
|||||||
return web.json_response()
|
return web.json_response()
|
||||||
|
|
||||||
async def delete(self, id: int, /) -> r204:
|
async def delete(self, id: int, /) -> r204:
|
||||||
|
"""
|
||||||
|
Status Code:
|
||||||
|
204: Empty but OK
|
||||||
|
"""
|
||||||
|
return web.json_response()
|
||||||
|
|
||||||
|
|
||||||
|
class TestResponseReturnASimpleType(PydanticView):
|
||||||
|
async def get(self) -> r200[int]:
|
||||||
|
"""
|
||||||
|
Status Codes:
|
||||||
|
200: The new number
|
||||||
|
"""
|
||||||
return web.json_response()
|
return web.json_response()
|
||||||
|
|
||||||
|
|
||||||
@ -57,6 +73,7 @@ async def generated_oas(aiohttp_client, loop) -> web.Application:
|
|||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/pets", PetCollectionView)
|
app.router.add_view("/pets", PetCollectionView)
|
||||||
app.router.add_view("/pets/{id}", PetItemView)
|
app.router.add_view("/pets/{id}", PetItemView)
|
||||||
|
app.router.add_view("/simple-type", TestResponseReturnASimpleType)
|
||||||
oas.setup(app)
|
oas.setup(app)
|
||||||
|
|
||||||
client = await aiohttp_client(app)
|
client = await aiohttp_client(app)
|
||||||
@ -115,29 +132,11 @@ async def test_pets_route_should_have_get_method(generated_oas):
|
|||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
|
"description": "Successful operation",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"items": {
|
"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": {
|
"properties": {
|
||||||
"id": {"title": "Id", "type": "integer"},
|
"id": {"title": "Id", "type": "integer"},
|
||||||
"name": {"title": "Name", "type": "string"},
|
"name": {"title": "Name", "type": "string"},
|
||||||
@ -154,7 +153,7 @@ async def test_pets_route_should_have_get_method(generated_oas):
|
|||||||
"type": "array",
|
"type": "array",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -167,23 +166,6 @@ async def test_pets_route_should_have_post_method(generated_oas):
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"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": {
|
"properties": {
|
||||||
"id": {"title": "Id", "type": "integer"},
|
"id": {"title": "Id", "type": "integer"},
|
||||||
"name": {"title": "Name", "type": "string"},
|
"name": {"title": "Name", "type": "string"},
|
||||||
@ -202,26 +184,10 @@ async def test_pets_route_should_have_post_method(generated_oas):
|
|||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"201": {
|
"201": {
|
||||||
|
"description": "",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"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": {
|
"properties": {
|
||||||
"id": {"title": "Id", "type": "integer"},
|
"id": {"title": "Id", "type": "integer"},
|
||||||
"name": {"title": "Name", "type": "string"},
|
"name": {"title": "Name", "type": "string"},
|
||||||
@ -236,7 +202,7 @@ async def test_pets_route_should_have_post_method(generated_oas):
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -248,15 +214,16 @@ async def test_generated_oas_should_have_pets_id_paths(generated_oas):
|
|||||||
|
|
||||||
async def test_pets_id_route_should_have_delete_method(generated_oas):
|
async def test_pets_id_route_should_have_delete_method(generated_oas):
|
||||||
assert generated_oas["paths"]["/pets/{id}"]["delete"] == {
|
assert generated_oas["paths"]["/pets/{id}"]["delete"] == {
|
||||||
|
"description": "",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"required": True,
|
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"name": "id",
|
"name": "id",
|
||||||
|
"required": True,
|
||||||
"schema": {"title": "id", "type": "integer"},
|
"schema": {"title": "id", "type": "integer"},
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {"204": {"content": {}}},
|
"responses": {"204": {"content": {}, "description": "Empty but OK"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -272,26 +239,10 @@ async def test_pets_id_route_should_have_get_method(generated_oas):
|
|||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
|
"description": "",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"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": {
|
"properties": {
|
||||||
"id": {"title": "Id", "type": "integer"},
|
"id": {"title": "Id", "type": "integer"},
|
||||||
"name": {"title": "Name", "type": "string"},
|
"name": {"title": "Name", "type": "string"},
|
||||||
@ -306,9 +257,9 @@ async def test_pets_id_route_should_have_get_method(generated_oas):
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"404": {"content": {}},
|
},
|
||||||
|
"404": {"description": "", "content": {}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -327,23 +278,6 @@ async def test_pets_id_route_should_have_put_method(generated_oas):
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"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": {
|
"properties": {
|
||||||
"id": {"title": "Id", "type": "integer"},
|
"id": {"title": "Id", "type": "integer"},
|
||||||
"name": {"title": "Name", "type": "string"},
|
"name": {"title": "Name", "type": "string"},
|
||||||
@ -361,3 +295,15 @@ 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",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user