diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9178606..db4615a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -29,10 +29,12 @@ Security Added ===== - Version tags - now NApps fully support the /: 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. diff --git a/etc/skel/kytos/napp-structure/username/napp/openapi.yml.template b/etc/skel/kytos/napp-structure/username/napp/openapi.yml.template new file mode 100644 index 0000000..9a7464f --- /dev/null +++ b/etc/skel/kytos/napp-structure/username/napp/openapi.yml.template @@ -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 %} diff --git a/kytos/cli/commands/napps/api.py b/kytos/cli/commands/napps/api.py index 798cd57..8a062c4 100644 --- a/kytos/cli/commands/napps/api.py +++ b/kytos/cli/commands/napps/api.py @@ -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() diff --git a/kytos/cli/commands/napps/parser.py b/kytos/cli/commands/napps/parser.py index a735558..98a8c86 100644 --- a/kytos/cli/commands/napps/parser.py +++ b/kytos/cli/commands/napps/parser.py @@ -4,6 +4,7 @@ Usage: kytos napps create + kytos napps prepare kytos napps upload kytos napps delete ... kytos napps list @@ -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. diff --git a/kytos/utils/napps.py b/kytos/utils/napps.py index 98efa6b..2aedad5 100644 --- a/kytos/utils/napps.py +++ b/kytos/utils/napps.py @@ -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__) @@ -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. @@ -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): @@ -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): @@ -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')) @@ -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 diff --git a/kytos/utils/openapi.py b/kytos/utils/openapi.py new file mode 100644 index 0000000..1a65441 --- /dev/null +++ b/kytos/utils/openapi.py @@ -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 + (?:@rest\(.+?\)\n)+ # one or more @rest decorators inside + ) + # docstring delimited by 3 double quotes + .+?"{3}(?P.+?)"{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[^\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.+?) \s* # Third line and maybe others + (?=-{3,})? # Stop if "---" is found + )? + + # YAML spec (optional) **currently not used** + ( + -{3,}\n # "---" begins yaml spec + (?P.+) + )? + )? + $""", 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['"]) # inside single or double quotes + (?P.+?) + (?P=quote) + + ## HTTP methods (optional) + (\s*,\s* + methods=(?P\[.+?\]) + )? + + .*?\)\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) diff --git a/setup.py b/setup.py index 8c96f94..a2b9727 100644 --- a/setup.py +++ b/setup.py @@ -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')])] @@ -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',