Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: docstring parsing #208

Merged
merged 5 commits into from Mar 16, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 0 additions & 3 deletions docs/sanic_openapi/api_factory.md

This file was deleted.

49 changes: 49 additions & 0 deletions docs/sanic_openapi/docstring_parsing.md
@@ -0,0 +1,49 @@
# Docstring Parsing

sanic-openAPI will try to parse your function for documentation to add to the swagger interface, so for example:

```python
app = Sanic()
app.blueprint(swagger_blueprint)


@app.get("/test")
async def test(request):
'''
This route is a test route

In can do lots of cool things
'''
return json({"Hello": "World"})
```

Would add that docstring to the openAPI route 'summary' and 'description' fields.

For advanced users, you can also edit the yaml yourself, by adding the line "openapi:" followed by a valid yaml string.

Note: the line "openapi:" should contain no whitespace before or after it.

Note: any decorators you use on the function must utilise functools.wraps or similar in order to preserve the docstring if you would like to utilising the docstring parsing capability.

```python
app = Sanic()
app.blueprint(swagger_blueprint)


@app.get("/test")
async def test(request):
'''
This route is a test route

In can do lots of cool things

openapi:
---
responses:
'200':
description: OK
'''
return json({"Hello": "World"})
```

If the yaml fails to parse for any reason, a warning will be printed, and the yaml will be ignored.
93 changes: 93 additions & 0 deletions sanic_openapi/autodoc.py
@@ -0,0 +1,93 @@
import inspect
import warnings

import yaml


class OpenAPIDocstringParser:
def __init__(self, docstring: str):
"""
Args:
docstring (str): docstring of function to be parsed
"""
if docstring is None:
docstring = ""
self.docstring = inspect.cleandoc(docstring)

def to_openAPI_2(self) -> dict:
"""
Returns:
json style dict: dict to be read for the path by swagger 2.0 UI
"""
raise NotImplementedError()

def to_openAPI_3(self) -> dict:
"""
Returns:
json style dict: dict to be read for the path by swagger 3.0.0 UI
"""
raise NotImplementedError()


class YamlStyleParametersParser(OpenAPIDocstringParser):
def _parse_no_yaml(self, doc: str) -> dict:
"""
Args:
artcg marked this conversation as resolved.
Show resolved Hide resolved
doc (str): section of doc before yaml, or full section of doc
Returns:
json style dict: dict to be read for the path by swagger UI
"""
# clean again in case further indentation can be removed,
# usually this do nothing...
doc = inspect.cleandoc(doc)

if len(doc) == 0:
return {}

lines = doc.split("\n")

if len(lines) == 1:
return {"summary": lines[0]}
else:
summary = lines.pop(0)

# remove empty lines at the beginning of the description
while len(lines) and lines[0].strip() == "":
lines.pop(0)

if len(lines) == 0:
return {"summary": summary}
else:
# use html tag to preserve linebreaks
return {"summary": summary, "description": "<br>".join(lines)}

def _parse_yaml(self, doc: str) -> dict:
"""
Args:
doc (str): section of doc detected as openapi yaml
Returns:
json style dict: dict to be read for the path by swagger UI
Warns:
UserWarning if the yaml couldn't be parsed
"""
try:
return yaml.safe_load(doc)
except Exception as e:
warnings.warn("error parsing openAPI yaml, ignoring it. ({})".format(e))
return {}

def _parse_all(self) -> dict:
if "openapi:\n" not in self.docstring:
return self._parse_no_yaml(self.docstring)

predoc, yamldoc = self.docstring.split("openapi:\n", 1)

conf = self._parse_no_yaml(predoc)
conf.update(self._parse_yaml(yamldoc))
return conf

def to_openAPI_2(self) -> dict:
return self._parse_all()

def to_openAPI_3(self) -> dict:
return self._parse_all()
25 changes: 20 additions & 5 deletions sanic_openapi/swagger.py
@@ -1,11 +1,13 @@
import os
import inspect
import re
from itertools import repeat

from sanic.blueprints import Blueprint
from sanic.response import json, redirect
from sanic.views import CompositionView

from .autodoc import YamlStyleParametersParser
from .doc import RouteSpec, definitions
from .doc import route as doc_route
from .doc import route_specs, serialize_schema
Expand Down Expand Up @@ -66,7 +68,6 @@ def remove_nulls(dictionary, deep=True):
@swagger_blueprint.listener("after_server_start")
def build_spec(app, loop):
_spec = Spec(app=app)

# --------------------------------------------------------------- #
# Blueprint Tags
# --------------------------------------------------------------- #
Expand Down Expand Up @@ -123,10 +124,9 @@ def build_spec(app, loop):
methods = {}
for _method, _handler in method_handlers:
if hasattr(_handler, "view_class"):
view_handler = getattr(_handler.view_class, _method.lower())
route_spec = route_specs.get(view_handler) or RouteSpec()
else:
route_spec = route_specs.get(_handler) or RouteSpec()
_handler = getattr(_handler.view_class, _method.lower())

route_spec = route_specs.get(_handler) or RouteSpec()

if _method == "OPTIONS" or route_spec.exclude:
continue
Expand Down Expand Up @@ -196,6 +196,18 @@ def build_spec(app, loop):
"description": routefield.description,
}

y = YamlStyleParametersParser(inspect.getdoc(_handler))
autodoc_endpoint = y.to_openAPI_2()

# if the user has manualy added a description or summary via
# the decorator, then use theirs

if route_spec.summary:
autodoc_endpoint["summary"] = route_spec.summary

if route_spec.description:
autodoc_endpoint["description"] = route_spec.description

endpoint = remove_nulls(
{
"operationId": route_spec.operation or route.name,
Expand All @@ -209,6 +221,9 @@ def build_spec(app, loop):
}
)

# otherwise, update with anything parsed from the docstrings yaml
endpoint.update(autodoc_endpoint)

methods[_method.lower()] = endpoint

uri_parsed = uri
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Expand Up @@ -7,7 +7,7 @@

from setuptools import setup

install_requires = ["sanic>=18.12.0"]
install_requires = ["sanic>=18.12.0", "pyyaml>=5.4.1"]

dev_requires = ["black==19.3b0", "flake8==3.7.7", "isort==4.3.19"]

Expand Down
47 changes: 47 additions & 0 deletions tests/test_autodoc.py
@@ -0,0 +1,47 @@
from sanic_openapi import autodoc


tests = []

_ = ''

tests.append({'doc': _, 'expects': {}})

_ = 'one line docstring'

tests.append({'doc': _, 'expects': {"summary": "one line docstring"}})

_ = '''
first line

more lines
'''

tests.append({'doc': _, 'expects': {
"summary": "first line",
"description": "more lines"}})


_ = '''
first line

more lines

openapi:
---
responses:
'200':
description: OK
'''

tests.append({'doc': _, 'expects': {
"summary": "first line",
"description": "more lines",
"responses": {"200": {"description": "OK"}}}})


def test_autodoc():
for t in tests:
parser = autodoc.YamlStyleParametersParser(t["doc"])
assert parser.to_openAPI_2() == t["expects"]
assert parser.to_openAPI_3() == t["expects"]