Skip to content
This repository has been archived by the owner on Apr 22, 2024. It is now read-only.

Commit

Permalink
Merge pull request #121 from cemsbr/openapi
Browse files Browse the repository at this point in the history
Create OpenAPI skeleton
  • Loading branch information
beraldoleal authored Sep 19, 2017
2 parents ba0efb8 + 8c45082 commit 434676b
Show file tree
Hide file tree
Showing 7 changed files with 287 additions and 15 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@ Security
Added
=====
- Version tags - now NApps fully support the <username>/<nappname>:<version> format.
- Create an OpenAPI skeleton based on NApp's rest decorators.

Changed
=======
- NApps will now install other NApps listed as dependencies.
- Do not require a running kytosd for some commands.
- Yala substitutes Pylama as the main linter checker.
- Requirements files updated and restructured.

Expand Down
46 changes: 46 additions & 0 deletions etc/skel/kytos/napp-structure/username/napp/openapi.yml.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
openapi: 3.0.0
info:
title: {{napp.username}}/{{napp.name}}
version: {{napp.version}}
description: {{napp.description}}
paths:
{% for path, methods in paths.items() %}
{{path}}:
{% for method, method_info in methods.items() %}
{{method}}:
summary: {{method_info.summary}}
description: {{method_info.description}}
parameters: # If you have parameters in the URL
- name: Parameter's name as in path.
required: true
description: Describe parameter here
in: path
{% if method == "post" %}
requestBody:
content:
application/json:
schema:
properties: # What the user should post
dpid: # "dpid" is just an example. Replace it.
type: string
description: Switch datapath ID.
example: 00:...:01
{% endif %}
responses:
200: # You can add more responses
description: Describe a successful call.
content:
application/json: # You can also use text/plain, for example
schema:
type: object # Adapt to your response
properties:
prop_one:
type: string
description: Meaning of prop_one
example: an example of prop_one
second_prop:
type: integer
description: Meaning of second_prop
example: 42
{% endfor %}
{% endfor %}
6 changes: 6 additions & 0 deletions kytos/cli/commands/napps/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,9 @@ def delete(args):
else:
msg = json.loads(exception.response.content)
LOG.error(' Server error: %s - ', msg['error'])

@classmethod
def prepare(cls, args):
"""Create OpenAPI v3.0 spec skeleton."""
mgr = NAppsManager()
mgr.prepare()
2 changes: 2 additions & 0 deletions kytos/cli/commands/napps/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
Usage:
kytos napps create
kytos napps prepare
kytos napps upload
kytos napps delete <napp>...
kytos napps list
Expand All @@ -21,6 +22,7 @@
Common napps subcommands:
create Create a bootstrap NApp structure for development.
prepare Prepare NApp to be uploaded (called by "upload").
upload Upload current NApp to Kytos repository.
delete Delete NApps from NApps Server.
list List all NApps installed into your system.
Expand Down
92 changes: 78 additions & 14 deletions kytos/utils/napps.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@
from random import randint

from jinja2 import Environment, FileSystemLoader
from ruamel.yaml import YAML

from kytos.utils.client import NAppsClient
from kytos.utils.config import KytosConfig
from kytos.utils.openapi import OpenAPI

LOG = logging.getLogger(__name__)

Expand All @@ -39,23 +41,42 @@ def __init__(self, controller=None):
self._controller = controller
self._config = KytosConfig().config
self._kytos_api = self._config.get('kytos', 'api')
self._load_kytos_configuration()

self.user = None
self.napp = None
self.version = None

def _load_kytos_configuration(self):
"""Request current configurations loaded by Kytos instance."""
uri = self._kytos_api + 'api/kytos/core/config/'
try:
options = json.loads(urllib.request.urlopen(uri).read())
except urllib.error.URLError:
print('Kytos is not running.')
sys.exit()
# Automatically get from kytosd API when needed
self.__enabled = None
self.__installed = None

self._installed = Path(options.get('installed_napps'))
self._enabled = Path(options.get('napps'))
@property
def _enabled(self):
if self.__enabled is None:
self.__require_kytos_config()
return self.__enabled

@property
def _installed(self):
if self.__installed is None:
self.__require_kytos_config()
return self.__installed

def __require_kytos_config(self):
"""Set path locations from kytosd API.
It should not be called directly, but from properties that require a
running kytosd instance.
"""
if self.__enabled is None:
uri = self._kytos_api + 'api/kytos/core/config/'
try:
options = json.loads(urllib.request.urlopen(uri).read())
except urllib.error.URLError:
print('Kytos is not running.')
sys.exit()
self.__enabled = Path(options.get('napps'))
self.__installed = Path(options.get('installed_napps'))

def set_napp(self, user, napp, version=None):
"""Set info about NApp.
Expand Down Expand Up @@ -214,9 +235,11 @@ def valid_name(username):
@staticmethod
def render_template(templates_path, template_filename, context):
"""Render Jinja2 template for a NApp structure."""
template_env = Environment(autoescape=False, trim_blocks=False,
loader=FileSystemLoader(templates_path))
return template_env.get_template(template_filename).render(context)
template_env = Environment(
autoescape=False, trim_blocks=False,
loader=FileSystemLoader(str(templates_path)))
return template_env.get_template(str(template_filename)) \
.render(context)

@staticmethod
def search(pattern):
Expand Down Expand Up @@ -457,6 +480,14 @@ def create_metadata(*args, **kwargs): # pylint: disable=unused-argument
except FileNotFoundError:
metadata['readme'] = ''

try:
yaml = YAML(typ='safe')
openapi_dict = yaml.load(Path('openapi.yml').open())
openapi = json.dumps(openapi_dict)
except FileNotFoundError:
openapi = ''
metadata['OpenAPI_Spec'] = openapi

return metadata

def upload(self, *args, **kwargs):
Expand All @@ -465,6 +496,7 @@ def upload(self, *args, **kwargs):
Raises:
FileNotFoundError: If kytos.json is not found.
"""
self.prepare()
metadata = self.create_metadata(*args, **kwargs)
package = self.build_napp_package(metadata.get('name'))

Expand All @@ -478,4 +510,36 @@ def delete(self):
"""
client = NAppsClient(self._config)
client.delete(self.user, self.napp)

@classmethod
def prepare(cls):
"""Prepare NApp to be uploaded by creating openAPI skeleton."""
if cls._ask_openapi():
napp_path = Path()
prefix = Path(sys.prefix)
tpl_path = prefix / 'etc/skel/kytos/napp-structure/username/napp'
OpenAPI(napp_path, tpl_path).render_template()
print('Please, update your openapi.yml file.')
sys.exit()

@staticmethod
def _ask_openapi():
"""Return whether we should create a (new) skeleton."""
if Path('openapi.yml').exists():
question = 'Override local openapi.yml with a new skeleton? (y/N) '
default = False
else:
question = 'Do you have REST endpoints and wish to create an API' \
' skeleton in openapi.yml? (Y/n) '
default = True

while True:
answer = input(question)
if answer == '':
return default
if answer.lower() in ['y', 'yes']:
return True
if answer.lower() in ['n', 'no']:
return False

# pylint: enable=too-many-instance-attributes,too-many-public-methods
151 changes: 151 additions & 0 deletions kytos/utils/openapi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""Deal with OpenAPI v3."""
import json
import re

from jinja2 import Environment, FileSystemLoader
from kytos.core.api_server import APIServer
from kytos.core.napps.base import NApp


class OpenAPI: # pylint: disable=too-few-public-methods
"""Create OpenAPI skeleton."""

def __init__(self, napp_path, tpl_path):
self._napp_path = napp_path
self._template = tpl_path / 'openapi.yml.template'
self._api_file = napp_path / 'openapi.yml'

metadata = napp_path / 'kytos.json'
self._napp = NApp.create_from_json(metadata)

# Data for a path
self._summary = None
self._description = None

# Part of template context
self._paths = {}

def render_template(self):
"""Render and save API doc in openapi.yml."""
self._parse_paths()
context = dict(napp=self._napp.__dict__, paths=self._paths)
self._save(context)

def _parse_paths(self):
main_file = self._napp_path / 'main.py'
code = main_file.open().read()
return self._parse_decorated_functions(code)

def _parse_decorated_functions(self, code):
"""Return URL rule, HTTP methods and docstring."""
matches = re.finditer(r"""
# @rest decorators
(?P<decorators>
(?:@rest\(.+?\)\n)+ # one or more @rest decorators inside
)
# docstring delimited by 3 double quotes
.+?"{3}(?P<docstring>.+?)"{3}
""", code, re.VERBOSE | re.DOTALL)

for function_match in matches:
m_dict = function_match.groupdict()
self._parse_docstring(m_dict['docstring'])
self._add_function_paths(m_dict['decorators'])

def _add_function_paths(self, decorators_str):
for rule, parsed_methods in self._parse_decorators(decorators_str):
absolute_rule = APIServer.get_absolute_rule(rule, self._napp)
path_url = self._rule2path(absolute_rule)
path_methods = self._paths.setdefault(path_url, {})
self._add_methods(parsed_methods, path_methods)

def _parse_docstring(self, docstring):
"""Parse the method docstring."""
match = re.match(r"""
# Following PEP 257
\s* (?P<summary>[^\n]+?) \s* # First line
( # Description and YAML are optional
(\n \s*){2} # Blank line
# Description (optional)
(
(?!-{3,}) # Don't use YAML as description
\s* (?P<description>.+?) \s* # Third line and maybe others
(?=-{3,})? # Stop if "---" is found
)?
# YAML spec (optional) **currently not used**
(
-{3,}\n # "---" begins yaml spec
(?P<open_api>.+)
)?
)?
$""", docstring, re.VERBOSE | re.DOTALL)

summary = 'TODO write the summary.'
description = 'TODO write/remove the description'
if match:
m_dict = match.groupdict()
summary = m_dict['summary']
if m_dict['description']:
description = re.sub(r'(\s|\n){2,}', ' ',
m_dict['description'])
self._summary = summary
self._description = description

def _parse_decorators(self, decorators_str):
matches = re.finditer(r"""
@rest\(
## Endpoint rule
(?P<quote>['"]) # inside single or double quotes
(?P<rule>.+?)
(?P=quote)
## HTTP methods (optional)
(\s*,\s*
methods=(?P<methods>\[.+?\])
)?
.*?\)\s*$
""", decorators_str, re.VERBOSE)

for match in matches:
rule = match.group('rule')
methods = self._parse_methods(match.group('methods'))
yield rule, methods

@classmethod
def _parse_methods(cls, list_string):
"""Return HTTP method list. Use json for security reasons."""
if list_string is None:
return APIServer.DEFAULT_METHODS
# json requires double quotes
json_list = list_string.replace("'", '"')
return json.loads(json_list)

def _add_methods(self, methods, path_methods):
for method in methods:
path_method = dict(summary=self._summary,
description=self._description)
path_methods[method.lower()] = path_method

@classmethod
def _rule2path(cls, rule):
"""Convert relative Flask rule to absolute OpenAPI path."""
typeless = re.sub(r'<\w+?:', '<', rule) # remove Flask types
return typeless.replace('<', '{').replace('>', '}') # <> -> {}

def _read_napp_info(self):
filename = self._napp_path / 'kytos.json'
return json.load(filename.open())

def _save(self, context):
tpl_env = Environment(
loader=FileSystemLoader(str(self._template.parent)),
trim_blocks=True)
content = tpl_env.get_template(
'openapi.yml.template').render(context)
with self._api_file.open('w') as openapi:
openapi.write(content)
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
(os.path.join(BASE_ENV, NAPP_PATH),
[os.path.join(NAPP_PATH, '__init__.py'),
os.path.join(NAPP_PATH, 'kytos.json.template'),
os.path.join(NAPP_PATH, 'openapi.yml.template'),
os.path.join(NAPP_PATH, 'main.py.template'),
os.path.join(NAPP_PATH, 'README.rst.template'),
os.path.join(NAPP_PATH, 'settings.py.template')])]
Expand Down Expand Up @@ -150,7 +151,7 @@ def _create_data_files_directory():
test_suite='tests',
include_package_data=True,
scripts=['bin/kytos'],
install_requires=['docopt', 'requests', 'jinja2>=2.9.5'],
install_requires=['docopt', 'requests', 'jinja2>=2.9.5', 'ruamel.yaml'],
extras_require={
'dev': [
'tox',
Expand Down

0 comments on commit 434676b

Please sign in to comment.