Skip to content

Commit

Permalink
Merge branch 'master' into feature/using_both_produces_and_response
Browse files Browse the repository at this point in the history
  • Loading branch information
denisovkiv authored Mar 16, 2021
2 parents dd8542d + 18c719b commit 5061b17
Show file tree
Hide file tree
Showing 12 changed files with 232 additions and 19 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Sanic OpenAPI

[![Build Status](https://travis-ci.org/huge-success/sanic-openapi.svg?branch=master)](https://travis-ci.org/huge-success/sanic-openapi)
[![Build Status](https://travis-ci.com/sanic-org/sanic-openapi.svg?branch=master)](https://travis-ci.com/sanic-org/sanic-openapi)
[![PyPI](https://img.shields.io/pypi/v/sanic-openapi.svg)](https://pypi.python.org/pypi/sanic-openapi/)
[![PyPI](https://img.shields.io/pypi/pyversions/sanic-openapi.svg)](https://pypi.python.org/pypi/sanic-openapi/)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/python/black)
[![codecov](https://codecov.io/gh/huge-success/sanic-openapi/branch/master/graph/badge.svg)](https://codecov.io/gh/huge-success/sanic-openapi)
[![codecov](https://codecov.io/gh/sanic-org/sanic-openapi/branch/master/graph/badge.svg)](https://codecov.io/gh/sanic-org/sanic-openapi)

Give your Sanic API a UI and OpenAPI documentation, all for the price of free!

Expand Down
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
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion examples/cars/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@ And, you can run this example by:
python main.py
```

Now, check <http://localhost:8000> and you should see the swagger like:
Now, check <http://localhost:8000/swagger> and you should see the swagger like:
![](./swagger.png)
2 changes: 1 addition & 1 deletion examples/cars/blueprints/car.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,5 @@ def car_put(request, car_id):
@blueprint.delete("/<car_id:int>", strict_slashes=True)
@doc.summary("Deletes a car")
@doc.produces(Status)
def car_put(request, car_id):
def car_delete(request, car_id):
return json(test_success)
2 changes: 1 addition & 1 deletion examples/class_based_view/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@ And, you can run this example by:
python main.py
```

Now, check <http://localhost:8000> and you should see the swagger like:
Now, check <http://localhost:8000/swagger> and you should see the swagger like:
![](./swagger.png)
93 changes: 93 additions & 0 deletions sanic_openapi/autodoc.py
Original file line number Diff line number Diff line change
@@ -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:
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()
8 changes: 6 additions & 2 deletions sanic_openapi/doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,11 @@ def definition(self):
"properties": {
key: serialize_schema(schema)
for key, schema in chain(
{key: getattr(self.cls, key) for key in dir(self.cls)}.items(),
{
key: getattr(self.cls, key)
for key in dir(self.cls)
if not key.startswith("_")
}.items(),
typing.get_type_hints(self.cls).items(),
)
if not key.startswith("_")
Expand Down Expand Up @@ -304,7 +308,7 @@ def inner(func):
for arg in args:
field = RouteField(arg, location, required)
route_specs[func].consumes.append(field)
route_specs[func].consumes_content_type = [content_type]
route_specs[func].consumes_content_type = [content_type]
return func

return inner
Expand Down
32 changes: 27 additions & 5 deletions sanic_openapi/swagger.py
Original file line number Diff line number Diff line change
@@ -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 @@ -173,6 +173,13 @@ def build_spec(app, loop):
route_param["schema"] = {"$ref": route_param["$ref"]}
del route_param["$ref"]

if route_param["in"] == "path":
route_param["required"] = True
for i, parameter in enumerate(route_parameters):
if parameter["name"] == route_param["name"]:
route_parameters.pop(i)
break

route_parameters.append(route_param)

responses = {}
Expand All @@ -191,6 +198,18 @@ def build_spec(app, loop):
elif not responses:
responses["200"] = {"schema": None, "description": None}

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 @@ -204,6 +223,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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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"]
7 changes: 4 additions & 3 deletions tests/test_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,10 +249,11 @@ def test(request):


def test_uuid_field(app):
field = doc.UUID()
assert field.serialize() == {"type": "string", "format": "uuid"}
field = doc.UUID(name="id")
assert field.serialize() == {"type": "string", "format": "uuid", "name": "id"}

@app.get("/<id:uuid>")
@doc.consumes(field, location="path")
@doc.response(204, {})
def test(request):
return HTTPResponse(status=204)
Expand Down Expand Up @@ -285,7 +286,7 @@ def test(request):
path = swagger_json["paths"]["/"]["get"]
assert path["parameters"][0] == {
"in": "formData",
"name": None,
"name": "id",
"type": "string",
"format": "uuid",
"required": True,
Expand Down

0 comments on commit 5061b17

Please sign in to comment.