From 0cf6b232ac9cf128ee9183a27c08f4f74ab2e2e6 Mon Sep 17 00:00:00 2001 From: grakiss Date: Thu, 28 Sep 2017 03:47:54 -0400 Subject: add api&web services for cvp JIRA: DOVETAIL-512 add api&web services for cvp Change-Id: I9ef9525e980fe61dc3108035ef9a3ff8783b2697 Signed-off-by: grakiss --- cvp/opnfv_testapi/tornado_swagger/README.md | 273 ++++++++++++++++++++++++ cvp/opnfv_testapi/tornado_swagger/__init__.py | 8 + cvp/opnfv_testapi/tornado_swagger/handlers.py | 38 ++++ cvp/opnfv_testapi/tornado_swagger/settings.py | 25 +++ cvp/opnfv_testapi/tornado_swagger/swagger.py | 291 ++++++++++++++++++++++++++ cvp/opnfv_testapi/tornado_swagger/views.py | 134 ++++++++++++ 6 files changed, 769 insertions(+) create mode 100644 cvp/opnfv_testapi/tornado_swagger/README.md create mode 100644 cvp/opnfv_testapi/tornado_swagger/__init__.py create mode 100644 cvp/opnfv_testapi/tornado_swagger/handlers.py create mode 100644 cvp/opnfv_testapi/tornado_swagger/settings.py create mode 100644 cvp/opnfv_testapi/tornado_swagger/swagger.py create mode 100644 cvp/opnfv_testapi/tornado_swagger/views.py (limited to 'cvp/opnfv_testapi/tornado_swagger') diff --git a/cvp/opnfv_testapi/tornado_swagger/README.md b/cvp/opnfv_testapi/tornado_swagger/README.md new file mode 100644 index 00000000..d815f216 --- /dev/null +++ b/cvp/opnfv_testapi/tornado_swagger/README.md @@ -0,0 +1,273 @@ +# tornado-swagger + +## What is tornado-swagger? +tornado is a wrapper for tornado which enables swagger-ui support. + +In essense, you just need to wrap the Api instance and add a few python decorators to +get full swagger support.http://swagger.io/ + + +## How to use: + + +```python +from tornado.web import RequestHandler, HTTPError +from tornado_swagger import swagger + +swagger.docs() + +# You may decorate your operation with @swagger.operation and use docs to inform information +class ItemNoParamHandler(GenericApiHandler): + @swagger.operation(nickname='create') + def post(self): + """ + @param body: create test results for a item. + @type body: L{Item} + @return 200: item is created. + @raise 400: invalid input + """ + +# Operations not decorated with @swagger.operation do not get added to the swagger docs + +class ItemNoParamHandler(GenericApiHandler): + def options(self): + """ + I'm not visible in the swagger docs + """ + pass + + +# Then you use swagger.Application instead of tornado.web.Application +# and do other operations as usual + +def make_app(): + return swagger.Application([ + (r"/items", ItemNoParamHandler), + (r"/items/([^/]+)", ItemHandler), + (r"/items/([^/]+)/cases/([^/]+)", ItemOptionParamHandler), + ]) + +# You define models like this: +@swagger.model +class Item: + """ + @descriptin: + This is an example of a model class that has parameters in its constructor + and the fields in the swagger spec are derived from the parameters to __init__. + @notes: + In this case we would have property1, property2 as required parameters + and property3 as optional parameter. + @property property3: Item decription + @ptype property3: L{PropertySubclass} + """ + def __init__(self, property1, property2=None): + self.property1 = property1 + self.property2 = property2 + +# Swagger json: + "models": { + "Item": { + "description": "A description...", + "id": "Item", + "required": [ + "property1", + ], + "properties": [ + "property1": { + "type": "string" + }, + "property2": { + "type": "string" + "default": null + } + ] + } + } + +# If you declare an __init__ method with meaningful arguments +# then those args could be used to deduce the swagger model fields. +# just as shown above + +# if you declare an @property in docs, this property property2 will also be used +# to deduce the swagger model fields +class Item: + """ + @property property3: Item description + """ + def __init__(self, property1, property2): + self.property1 = property1 + self.property2 = property2 + +# Swagger json: + "models": { + "Item": { + "description": "A description...", + "id": "Item", + "required": [ + "property1", + ], + "properties": [ + "property1": { + "type": "string" + }, + "property2": { + "type": "string" + } + "property3": { + "type": "string" + } + ] + } + } + +# if you declare an argument with @ptype, the type of this argument will be specified +# rather than the default 'string' +class Item: + """ + @ptype property3: L{PropertySubclass} + """ + def __init__(self, property1, property2, property3=None): + self.property1 = property1 + self.property2 = property2 + self.property3 = property3 + +# Swagger json: + "models": { + "Item": { + "description": "A description...", + "id": "Item", + "required": [ + "property1", + ], + "properties": [ + "property1": { + "type": "string" + }, + "property2": { + "type": "string" + }, + "property3": { + "type": "PropertySubclass" + "default": null + } + ] + } + } + +# if you want to declare an list property, you can do it like this: +class Item: + """ + @ptype property3: L{PropertySubclass} + @ptype property4: C{list} of L{PropertySubclass} + """ + def __init__(self, property1, property2, property3, property4=None): + self.property1 = property1 + self.property2 = property2 + self.property3 = property3 + self.property4 = property4 + +# Swagger json: + "models": { + "Item": { + "description": "A description...", + "id": "Item", + "required": [ + "property1", + ], + "properties": [ + "property1": { + "type": "string" + }, + "property2": { + "type": "string" + }, + "property3": { + "type": "PropertySubclass" + "default": null + }, + "property4": { + "default": null, + "items": { + "type": "PropertySubclass"}, + "type": "array" + } + } + ] + } + } + +# if it is a query: +class ItemQueryHandler(GenericApiHandler): + @swagger.operation(nickname='query') + def get(self): + """ + @param property1: + @type property1: L{string} + @in property1: query + @required property1: False + + @param property2: + @type property2: L{string} + @in property2: query + @required property2: True + @rtype: L{Item} + + @notes: GET /item?property1=1&property2=1 + """ + +# Swagger json: + "apis": [ + { + "operations": [ + { + "parameters": [ + { + "name": "property1", + "dataType": "string", + "paramType": "query", + "description": "" + }, + { + "name": "property2", + "dataType": "string", + "paramType": "query", + "required": true, + "description": "" + } + ], + "responseClass": "Item", + "notes": null, + "responseMessages": [], + "summary": null, + "httpMethod": "GET", + "nickname": "query" + } + ], + "path": "/item", + "description": null + }, + .... + ] +``` + +# Running and testing + +Now run your tornado app + +``` +python main.py +``` + +And visit: + +``` +curl http://ip:port/swagger/spec +``` + +access to web +``` +http://ip:port/swagger/spec.html +``` + +# Passing more metadata to swagger +customized arguments used in creating the 'swagger.docs' object will be supported later diff --git a/cvp/opnfv_testapi/tornado_swagger/__init__.py b/cvp/opnfv_testapi/tornado_swagger/__init__.py new file mode 100644 index 00000000..363bc388 --- /dev/null +++ b/cvp/opnfv_testapi/tornado_swagger/__init__.py @@ -0,0 +1,8 @@ +############################################################################## +# Copyright (c) 2016 ZTE Corporation +# feng.xiaowei@zte.com.cn +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## diff --git a/cvp/opnfv_testapi/tornado_swagger/handlers.py b/cvp/opnfv_testapi/tornado_swagger/handlers.py new file mode 100644 index 00000000..e39a9f63 --- /dev/null +++ b/cvp/opnfv_testapi/tornado_swagger/handlers.py @@ -0,0 +1,38 @@ +############################################################################## +# Copyright (c) 2016 ZTE Corporation +# feng.xiaowei@zte.com.cn +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## +import tornado.web + +from opnfv_testapi.tornado_swagger import settings +from opnfv_testapi.tornado_swagger import views + + +def swagger_handlers(): + prefix = settings.docs_settings.get('swagger_prefix', '/swagger') + if prefix[-1] != '/': + prefix += '/' + + def _path(suffix): + return prefix + suffix + return [ + tornado.web.URLSpec( + _path(r'spec.html$'), + views.SwaggerUIHandler, + settings.docs_settings, + name=settings.API_DOCS_NAME), + tornado.web.URLSpec( + _path(r'resources.json$'), + views.SwaggerResourcesHandler, + settings.docs_settings, + name=settings.RESOURCE_LISTING_NAME), + tornado.web.URLSpec( + _path(r'APIs$'), + views.SwaggerApiHandler, + settings.docs_settings, + name=settings.API_DECLARATION_NAME), + ] diff --git a/cvp/opnfv_testapi/tornado_swagger/settings.py b/cvp/opnfv_testapi/tornado_swagger/settings.py new file mode 100644 index 00000000..28422611 --- /dev/null +++ b/cvp/opnfv_testapi/tornado_swagger/settings.py @@ -0,0 +1,25 @@ +############################################################################## +# Copyright (c) 2016 ZTE Corporation +# feng.xiaowei@zte.com.cn +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + +API_DOCS_NAME = 'swagger-api-docs' +RESOURCE_LISTING_NAME = 'swagger-resource-listing' +API_DECLARATION_NAME = 'swagger-api-declaration' + +docs_settings = { + 'base_url': '', + 'static_path': '', + 'swagger_prefix': '/swagger', + 'api_version': 'v1.0', + 'swagger_version': '1.2', + 'api_key': '', + 'enabled_methods': ['get', 'post', 'put', 'patch', 'delete'], + 'exclude_namespaces': [], +} + +models = [] diff --git a/cvp/opnfv_testapi/tornado_swagger/swagger.py b/cvp/opnfv_testapi/tornado_swagger/swagger.py new file mode 100644 index 00000000..83f389a6 --- /dev/null +++ b/cvp/opnfv_testapi/tornado_swagger/swagger.py @@ -0,0 +1,291 @@ +############################################################################## +# Copyright (c) 2016 ZTE Corporation +# feng.xiaowei@zte.com.cn +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## +from HTMLParser import HTMLParser +from functools import wraps +import inspect + +import epydoc.markup +import tornado.web + +from opnfv_testapi.tornado_swagger import handlers +from opnfv_testapi.tornado_swagger import settings + + +class EpytextParser(HTMLParser): + a_text = False + + def __init__(self, tag): + HTMLParser.__init__(self) + self.tag = tag + self.data = None + + def handle_starttag(self, tag, attr): + if tag == self.tag: + self.a_text = True + + def handle_endtag(self, tag): + if tag == self.tag: + self.a_text = False + + def handle_data(self, data): + if self.a_text: + self.data = data + + def get_data(self): + return self.data + + +class DocParser(object): + def __init__(self): + self.notes = None + self.summary = None + self.responseClass = None + self.responseMessages = [] + self.params = {} + self.properties = {} + + def parse_docstring(self, text): + if text is None: + return + + errors = [] + doc = epydoc.markup.parse(text, markup='epytext', errors=errors) + _, fields = doc.split_fields(errors) + + for field in fields: + tag = field.tag() + arg = field.arg() + body = field.body() + self._get_parser(tag)(arg=arg, body=body) + return doc + + def _get_parser(self, tag): + parser = { + 'param': self._parse_param, + 'type': self._parse_type, + 'in': self._parse_in, + 'required': self._parse_required, + 'rtype': self._parse_rtype, + 'property': self._parse_property, + 'ptype': self._parse_ptype, + 'return': self._parse_return, + 'raise': self._parse_return, + 'notes': self._parse_notes, + 'description': self._parse_description, + } + return parser.get(tag, self._not_supported) + + def _parse_param(self, **kwargs): + arg = kwargs.get('arg', None) + body = self._get_body(**kwargs) + self.params.setdefault(arg, {}).update({ + 'name': arg, + 'description': body, + }) + + if 'paramType' not in self.params[arg]: + self.params[arg]['paramType'] = 'query' + + def _parse_type(self, **kwargs): + arg = kwargs.get('arg', None) + body = self._get_body(**kwargs) + self.params.setdefault(arg, {}).update({ + 'name': arg, + 'dataType': body + }) + + def _parse_in(self, **kwargs): + arg = kwargs.get('arg', None) + body = self._get_body(**kwargs) + self.params.setdefault(arg, {}).update({ + 'name': arg, + 'paramType': body + }) + + def _parse_required(self, **kwargs): + arg = kwargs.get('arg', None) + body = self._get_body(**kwargs) + self.params.setdefault(arg, {}).update({ + 'name': arg, + 'required': False if body in ['False', 'false'] else True + }) + + def _parse_rtype(self, **kwargs): + body = self._get_body(**kwargs) + self.responseClass = body + + def _parse_property(self, **kwargs): + arg = kwargs.get('arg', None) + self.properties.setdefault(arg, {}).update({ + 'type': 'string' + }) + + def _parse_ptype(self, **kwargs): + arg = kwargs.get('arg', None) + code = self._parse_epytext_para('code', **kwargs) + link = self._parse_epytext_para('link', **kwargs) + if code is None: + self.properties.setdefault(arg, {}).update({ + 'type': link + }) + elif code == 'list': + self.properties.setdefault(arg, {}).update({ + 'type': 'array', + 'items': {'type': link} + }) + + def _parse_return(self, **kwargs): + arg = kwargs.get('arg', None) + body = self._get_body(**kwargs) + self.responseMessages.append({ + 'code': arg, + 'message': body + }) + + def _parse_notes(self, **kwargs): + body = self._get_body(**kwargs) + self.notes = self._sanitize_doc(body) + + def _parse_description(self, **kwargs): + body = self._get_body(**kwargs) + self.summary = self._sanitize_doc(body) + + def _not_supported(self, **kwargs): + pass + + @staticmethod + def _sanitize_doc(comment): + return comment.replace('\n', '
') if comment else comment + + @staticmethod + def _get_body(**kwargs): + body = kwargs.get('body', None) + return body.to_plaintext(None).strip() if body else body + + @staticmethod + def _parse_epytext_para(tag, **kwargs): + def _parse_epytext(tag, body): + epytextParser = EpytextParser(tag) + epytextParser.feed(str(body)) + data = epytextParser.get_data() + epytextParser.close() + return data + + body = kwargs.get('body', None) + return _parse_epytext(tag, body) if body else body + + +class model(DocParser): + def __init__(self, *args, **kwargs): + super(model, self).__init__() + self.args = args + self.kwargs = kwargs + self.required = [] + self.cls = None + + def __call__(self, *args): + if self.cls: + return self.cls + + cls = args[0] + self._parse_model(cls) + + return cls + + def _parse_model(self, cls): + self.id = cls.__name__ + self.cls = cls + if '__init__' in dir(cls): + self._parse_args(cls.__init__) + self.parse_docstring(inspect.getdoc(cls)) + settings.models.append(self) + + def _parse_args(self, func): + argspec = inspect.getargspec(func) + argspec.args.remove("self") + defaults = {} + if argspec.defaults: + defaults = list(zip(argspec.args[-len(argspec.defaults):], + argspec.defaults)) + required_args_count = len(argspec.args) - len(defaults) + for arg in argspec.args[:required_args_count]: + self.required.append(arg) + self.properties.setdefault(arg, {'type': 'string'}) + for arg, default in defaults: + self.properties.setdefault(arg, { + 'type': 'string', + "default": default + }) + + +class operation(DocParser): + def __init__(self, nickname='apis', **kwds): + super(operation, self).__init__() + self.nickname = nickname + self.func = None + self.func_args = [] + self.kwds = kwds + + def __call__(self, *args, **kwds): + if self.func: + return self.func(*args, **kwds) + + func = args[0] + self._parse_operation(func) + + @wraps(func) + def __wrapper__(*in_args, **in_kwds): + return self.func(*in_args, **in_kwds) + + __wrapper__.rest_api = self + return __wrapper__ + + def _parse_operation(self, func): + self.func = func + + self.__name__ = func.__name__ + self._parse_args(func) + self.parse_docstring(inspect.getdoc(self.func)) + + def _parse_args(self, func): + argspec = inspect.getargspec(func) + argspec.args.remove("self") + + defaults = [] + if argspec.defaults: + defaults = argspec.args[-len(argspec.defaults):] + + for arg in argspec.args: + if arg in defaults: + required = False + else: + required = True + self.params.setdefault(arg, { + 'name': arg, + 'required': required, + 'paramType': 'path', + 'dataType': 'string' + }) + self.func_args = argspec.args + + +def docs(**opts): + settings.docs_settings.update(opts) + + +class Application(tornado.web.Application): + def __init__(self, app_handlers=None, + default_host="", + transforms=None, + **settings): + super(Application, self).__init__( + handlers.swagger_handlers() + app_handlers, + default_host, + transforms, + **settings) diff --git a/cvp/opnfv_testapi/tornado_swagger/views.py b/cvp/opnfv_testapi/tornado_swagger/views.py new file mode 100644 index 00000000..79399970 --- /dev/null +++ b/cvp/opnfv_testapi/tornado_swagger/views.py @@ -0,0 +1,134 @@ +############################################################################## +# Copyright (c) 2016 ZTE Corporation +# feng.xiaowei@zte.com.cn +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## +import inspect +import json + +import tornado.template +import tornado.web + +from opnfv_testapi.tornado_swagger import settings + + +def json_dumps(obj, pretty=False): + return json.dumps(obj, + sort_keys=True, + indent=4, + separators=(',', ': ')) if pretty else json.dumps(obj) + + +class SwaggerUIHandler(tornado.web.RequestHandler): + def initialize(self, **kwargs): + self.static_path = kwargs.get('static_path') + self.base_url = kwargs.get('base_url') + + def get_template_path(self): + return self.static_path + + def get(self): + resource_url = self.reverse_url(settings.RESOURCE_LISTING_NAME) + discovery_url = self.base_url + resource_url + self.render('swagger/index.html', discovery_url=discovery_url) + + +class SwaggerResourcesHandler(tornado.web.RequestHandler): + def initialize(self, **kwargs): + self.api_version = kwargs.get('api_version') + self.swagger_version = kwargs.get('swagger_version') + self.base_url = kwargs.get('base_url') + self.exclude_namespaces = kwargs.get('exclude_namespaces') + + def get(self): + self.set_header('content-type', 'application/json') + resources = { + 'apiVersion': self.api_version, + 'swaggerVersion': self.swagger_version, + 'basePath': self.base_url, + 'apis': [{ + 'path': self.reverse_url(settings.API_DECLARATION_NAME), + 'description': 'Restful APIs Specification' + }] + } + + self.finish(json_dumps(resources, self.get_arguments('pretty'))) + + +class SwaggerApiHandler(tornado.web.RequestHandler): + def initialize(self, **kwargs): + self.api_version = kwargs.get('api_version') + self.swagger_version = kwargs.get('swagger_version') + self.base_url = kwargs.get('base_url') + + def get(self): + self.set_header('content-type', 'application/json') + apis = self.find_api(self.application.handlers) + if apis is None: + raise tornado.web.HTTPError(404) + + specs = { + 'apiVersion': self.api_version, + 'swaggerVersion': self.swagger_version, + 'basePath': self.base_url, + 'resourcePath': '/', + 'produces': ["application/json"], + 'apis': [self.__get_api_spec__(path, spec, operations) + for path, spec, operations in apis], + 'models': self.__get_models_spec(settings.models) + } + self.finish(json_dumps(specs, self.get_arguments('pretty'))) + + def __get_models_spec(self, models): + models_spec = {} + for model in models: + models_spec.setdefault(model.id, self.__get_model_spec(model)) + return models_spec + + @staticmethod + def __get_model_spec(model): + return { + 'description': model.summary, + 'id': model.id, + 'notes': model.notes, + 'properties': model.properties, + 'required': model.required + } + + @staticmethod + def __get_api_spec__(path, spec, operations): + return { + 'path': path, + 'description': spec.handler_class.__doc__, + 'operations': [{ + 'httpMethod': api.func.__name__.upper(), + 'nickname': api.nickname, + 'parameters': api.params.values(), + 'summary': api.summary, + 'notes': api.notes, + 'responseClass': api.responseClass, + 'responseMessages': api.responseMessages, + } for api in operations] + } + + @staticmethod + def find_api(host_handlers): + def get_path(url, args): + return url % tuple(['{%s}' % arg for arg in args]) + + def get_operations(cls): + return [member.rest_api + for (_, member) in inspect.getmembers(cls) + if hasattr(member, 'rest_api')] + + for host, handlers in host_handlers: + for spec in handlers: + for (_, mbr) in inspect.getmembers(spec.handler_class): + if inspect.ismethod(mbr) and hasattr(mbr, 'rest_api'): + path = get_path(spec._path, mbr.rest_api.func_args) + operations = get_operations(spec.handler_class) + yield path, spec, operations + break -- cgit 1.2.3-korg