Merge pull request #25 from Maillol/user_can_add_tags_to_generated_oas

We can add custom tags to generated OPS
This commit is contained in:
MAILLOL Vincent 2021-08-22 15:03:04 +02:00 committed by GitHub
commit 1b10ebbcfa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 173 additions and 40 deletions

View File

@ -269,6 +269,8 @@ Open Api Specification.
async def get(self) -> r200[List[Pet]]: async def get(self) -> r200[List[Pet]]:
""" """
Find all pets Find all pets
Tags: pet
""" """
pets = self.request.app["model"].list_pets() pets = self.request.app["model"].list_pets()
return web.json_response([pet.dict() for pet in pets]) return web.json_response([pet.dict() for pet in pets])
@ -277,6 +279,7 @@ Open Api Specification.
""" """
Add a new pet to the store Add a new pet to the store
Tags: pet
Status Codes: Status Codes:
201: The pet is created 201: The pet is created
""" """
@ -289,6 +292,7 @@ Open Api Specification.
""" """
Find a pet by ID Find a pet by ID
Tags: pet
Status Codes: Status Codes:
200: Successful operation 200: Successful operation
404: Pet not found 404: Pet not found
@ -300,6 +304,7 @@ Open Api Specification.
""" """
Update an existing pet Update an existing pet
Tags: pet
Status Codes: Status Codes:
200: successful operation 200: successful operation
""" """

View File

@ -4,7 +4,7 @@ Utility to extract extra OAS description from docstring.
import re import re
import textwrap import textwrap
from typing import Dict from typing import Dict, List
class LinesIterator: class LinesIterator:
@ -47,11 +47,10 @@ def _i_extract_block(lines: LinesIterator):
except StopIteration: except StopIteration:
return return
# Get the size of the indentation. indent = re.fullmatch("( *).*", line).groups()[0]
if (match := re.search("^ +", line)) is None: indentation = len(indent)
return # No block to extract. start_of_other_block = re.compile(f" {{0,{indentation}}}[^ ].*")
indent = match.group() yield line[indentation:]
yield line[len(indent) :]
# Yield lines until the indentation is the same or is greater than # Yield lines until the indentation is the same or is greater than
# the first block line. # the first block line.
@ -59,8 +58,8 @@ def _i_extract_block(lines: LinesIterator):
line = next(lines) line = next(lines)
except StopIteration: except StopIteration:
return return
while (is_empty := line.strip() == "") or line.startswith(indent): while not start_of_other_block.fullmatch(line):
yield "" if is_empty else line[len(indent) :] yield line[indentation:]
try: try:
line = next(lines) line = next(lines)
except StopIteration: except StopIteration:
@ -87,10 +86,13 @@ def status_code(docstring: str) -> Dict[int, str]:
iterator = LinesIterator(docstring) iterator = LinesIterator(docstring)
for line in iterator: for line in iterator:
if re.fullmatch("status\\s+codes?\\s*:", line, re.IGNORECASE): if re.fullmatch("status\\s+codes?\\s*:", line, re.IGNORECASE):
iterator.rewind()
blocks = [] blocks = []
lines = [] lines = []
for line_of_block in _i_extract_block(iterator): i_block = _i_extract_block(iterator)
if re.search("^\\d{3}\\s*:", line_of_block): next(i_block)
for line_of_block in i_block:
if re.search("^\\s*\\d{3}\\s*:", line_of_block):
if lines: if lines:
blocks.append("\n".join(lines)) blocks.append("\n".join(lines))
lines = [] lines = []
@ -105,6 +107,19 @@ def status_code(docstring: str) -> Dict[int, str]:
return {} 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: def operation(docstring: str) -> str:
""" """
Extract all docstring except the "Status Code:" block. Extract all docstring except the "Status Code:" block.
@ -112,7 +127,8 @@ def operation(docstring: str) -> str:
lines = LinesIterator(docstring) lines = LinesIterator(docstring)
ret = [] ret = []
for line in lines: for line in lines:
if re.fullmatch("status\\s+codes?\\s*:", line, re.IGNORECASE): if re.fullmatch("status\\s+codes?\\s*:|tags\\s*:.*", line, re.IGNORECASE):
lines.rewind()
for _ in _i_extract_block(lines): for _ in _i_extract_block(lines):
pass pass
else: else:

View File

@ -2,7 +2,7 @@
Utility to write Open Api Specifications using the Python language. Utility to write Open Api Specifications using the Python language.
""" """
from typing import Union from typing import Union, List
class Info: class Info:
@ -157,7 +157,7 @@ class Responses:
self._spec = spec.setdefault("responses", {}) self._spec = spec.setdefault("responses", {})
def __getitem__(self, status_code: Union[int, str]) -> Response: def __getitem__(self, status_code: Union[int, str]) -> Response:
if not (100 <= int(status_code) < 600): if not 100 <= int(status_code) < 600:
raise ValueError("status_code must be between 100 and 599") raise ValueError("status_code must be between 100 and 599")
spec = self._spec.setdefault(str(status_code), {}) spec = self._spec.setdefault(str(status_code), {})
@ -196,6 +196,17 @@ class OperationObject:
def responses(self) -> Responses: def responses(self) -> Responses:
return Responses(self._spec) return Responses(self._spec)
@property
def tags(self) -> List[str]:
return self._spec.get("tags", [])[:]
@tags.setter
def tags(self, tags: List[str]):
if tags:
self._spec["tags"] = tags[:]
else:
self._spec.pop("tags", None)
class PathItem: class PathItem:
def __init__(self, spec: dict): def __init__(self, spec: dict):

View File

@ -1,7 +1,7 @@
import typing import typing
from inspect import getdoc from inspect import getdoc
from itertools import count from itertools import count
from typing import List, Type, Optional, Dict from typing import List, Type, Optional
from aiohttp.web import Response, json_response from aiohttp.web import Response, json_response
from aiohttp.web_app import Application from aiohttp.web_app import Application
@ -86,6 +86,7 @@ def _add_http_method_to_oas(
description = getdoc(handler) description = getdoc(handler)
if description: if description:
oas_operation.description = docstring_parser.operation(description) oas_operation.description = docstring_parser.operation(description)
oas_operation.tags = docstring_parser.tags(description)
status_code_descriptions = docstring_parser.status_code(description) status_code_descriptions = docstring_parser.status_code(description)
else: else:
status_code_descriptions = {} status_code_descriptions = {}

View File

@ -1,6 +1,6 @@
from functools import update_wrapper from functools import update_wrapper
from inspect import iscoroutinefunction from inspect import iscoroutinefunction
from typing import Any, Callable, Generator, Iterable, Set, ClassVar, Literal from typing import Any, Callable, Generator, Iterable, Set, ClassVar
import warnings import warnings
from aiohttp.abc import AbstractView from aiohttp.abc import AbstractView

View File

@ -20,7 +20,6 @@ class ArticleModels(BaseModel):
class ArticleView(PydanticView): class ArticleView(PydanticView):
async def post(self, article: ArticleModel): async def post(self, article: ArticleModel):
return web.json_response(article.dict()) return web.json_response(article.dict())

View File

@ -1,5 +1,8 @@
from textwrap import dedent
from aiohttp_pydantic.oas.docstring_parser import ( from aiohttp_pydantic.oas.docstring_parser import (
status_code, status_code,
tags,
operation, operation,
_i_extract_block, _i_extract_block,
LinesIterator, LinesIterator,
@ -13,6 +16,13 @@ def web_handler():
bla bla bla bla bla bla
Tags: tag1, tag2
, tag3,
t a
g
4
Status Codes: Status Codes:
200: line 1 200: line 1
@ -36,6 +46,19 @@ def web_handler():
""" """
def web_handler_2():
"""
bla bla bla
Tags: tag1
Status Codes:
200: line 1
bla bla
"""
def test_lines_iterator(): def test_lines_iterator():
lines_iterator = LinesIterator("AAAA\nBBBB") lines_iterator = LinesIterator("AAAA\nBBBB")
with pytest.raises(StopIteration): with pytest.raises(StopIteration):
@ -61,28 +84,72 @@ def test_status_code():
assert status_code(getdoc(web_handler)) == expected 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(): def test_operation():
expected = "bla bla bla\n\n\nbla bla" expected = "bla bla bla\n\n\nbla bla"
assert operation(getdoc(web_handler)) == expected assert operation(getdoc(web_handler)) == expected
assert operation(getdoc(web_handler_2)) == expected
def test_i_extract_block(): def test_i_extract_block():
lines = LinesIterator(" aaaa\n\n bbbb\n\n cccc\n dddd")
blocks = dedent(
"""
aaaa:
bbbb
cccc
dddd
"""
)
lines = LinesIterator(blocks)
text = "\n".join(_i_extract_block(lines)) text = "\n".join(_i_extract_block(lines))
assert text == """aaaa\n\n bbbb\n\ncccc""" 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("") lines = LinesIterator("")
text = "\n".join(_i_extract_block(lines)) text = "\n".join(_i_extract_block(lines))
assert text == "" assert text == ""
lines = LinesIterator("aaaa\n bbbb") # the indented block is cut by a new block lines = LinesIterator("\n")
text = "\n".join(_i_extract_block(lines)) text = "\n".join(_i_extract_block(lines))
assert text == "" assert text == ""
lines = LinesIterator(" \n ") lines = LinesIterator("aaaa:")
text = "\n".join(_i_extract_block(lines)) text = "\n".join(_i_extract_block(lines))
assert text == "" assert text == "aaaa:"
lines = LinesIterator(" aaaa\n bbbb")
text = "\n".join(_i_extract_block(lines))
assert text == "aaaa\nbbbb"

View File

@ -119,6 +119,24 @@ 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(): def test_paths_operation_responses():
oas = OpenApiSpec3() oas = OpenApiSpec3()
response = oas.paths["/users/{petId}"].get.responses[200] response = oas.paths["/users/{petId}"].get.responses[200]

View File

@ -35,6 +35,7 @@ class PetCollectionView(PydanticView):
""" """
Get a list of pets Get a list of pets
Tags: pet
Status Codes: Status Codes:
200: Successful operation 200: Successful operation
""" """
@ -46,7 +47,13 @@ class PetCollectionView(PydanticView):
class PetItemView(PydanticView): class PetItemView(PydanticView):
async def get(self, id: int, /, size: Union[int, Literal['x', 'l', 's']], day: Union[int, Literal["now"]] = "now") -> Union[r200[Pet], r404]: async def get(
self,
id: int,
/,
size: Union[int, Literal["x", "l", "s"]],
day: Union[int, Literal["now"]] = "now",
) -> Union[r200[Pet], r404]:
return web.json_response() return web.json_response()
async def put(self, id: int, /, pet: Pet): async def put(self, id: int, /, pet: Pet):
@ -120,6 +127,7 @@ async def test_generated_oas_should_have_pets_paths(generated_oas):
async def test_pets_route_should_have_get_method(generated_oas): async def test_pets_route_should_have_get_method(generated_oas):
assert generated_oas["paths"]["/pets"]["get"] == { assert generated_oas["paths"]["/pets"]["get"] == {
"description": "Get a list of pets", "description": "Get a list of pets",
"tags": ["pet"],
"parameters": [ "parameters": [
{ {
"in": "query", "in": "query",
@ -246,20 +254,28 @@ async def test_pets_id_route_should_have_get_method(generated_oas):
"required": True, "required": True,
"schema": {"title": "id", "type": "integer"}, "schema": {"title": "id", "type": "integer"},
}, },
{'in': 'query', {
'name': 'size', "in": "query",
'required': True, "name": "size",
'schema': {'anyOf': [{'type': 'integer'}, "required": True,
{'enum': ['x', 'l', 's'], "schema": {
'type': 'string'}], "anyOf": [
'title': 'size'}}, {"type": "integer"},
{'in': 'query', {"enum": ["x", "l", "s"], "type": "string"},
'name': 'day', ],
'required': False, "title": "size",
'schema': {'anyOf': [{'type': 'integer'}, },
{'enum': ['now'], 'type': 'string'}], },
'default': 'now', {
'title': 'day'}} "in": "query",
"name": "day",
"required": False,
"schema": {
"anyOf": [{"type": "integer"}, {"enum": ["now"], "type": "string"}],
"default": "now",
"title": "day",
},
},
], ],
"responses": { "responses": {
"200": { "200": {