summaryrefslogtreecommitdiffstats
path: root/utils/test/testapi/opnfv_testapi/tornado_swagger
diff options
context:
space:
mode:
authorSerenaFeng <feng.xiaowei@zte.com.cn>2016-10-18 17:30:31 +0800
committerSerenaFeng <feng.xiaowei@zte.com.cn>2016-10-18 17:30:31 +0800
commitd30f7afdd2d39302174a788e9e008feb61b25596 (patch)
treeeed10dd17021d0f4ee87fb90089dab6eed5b5071 /utils/test/testapi/opnfv_testapi/tornado_swagger
parent307a8f07e8c17800ed1214394a3aacf02045a03a (diff)
rename result_collection_api to testapi
Change-Id: Iec4e3db23cd44f30831e17c127eda74e9d9b5d14 Signed-off-by: SerenaFeng <feng.xiaowei@zte.com.cn>
Diffstat (limited to 'utils/test/testapi/opnfv_testapi/tornado_swagger')
-rw-r--r--utils/test/testapi/opnfv_testapi/tornado_swagger/README.md273
-rw-r--r--utils/test/testapi/opnfv_testapi/tornado_swagger/__init__.py8
-rw-r--r--utils/test/testapi/opnfv_testapi/tornado_swagger/handlers.py43
-rw-r--r--utils/test/testapi/opnfv_testapi/tornado_swagger/settings.py32
-rw-r--r--utils/test/testapi/opnfv_testapi/tornado_swagger/swagger.py290
-rw-r--r--utils/test/testapi/opnfv_testapi/tornado_swagger/views.py128
6 files changed, 774 insertions, 0 deletions
diff --git a/utils/test/testapi/opnfv_testapi/tornado_swagger/README.md b/utils/test/testapi/opnfv_testapi/tornado_swagger/README.md
new file mode 100644
index 000000000..d815f2161
--- /dev/null
+++ b/utils/test/testapi/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/utils/test/testapi/opnfv_testapi/tornado_swagger/__init__.py b/utils/test/testapi/opnfv_testapi/tornado_swagger/__init__.py
new file mode 100644
index 000000000..363bc388e
--- /dev/null
+++ b/utils/test/testapi/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/utils/test/testapi/opnfv_testapi/tornado_swagger/handlers.py b/utils/test/testapi/opnfv_testapi/tornado_swagger/handlers.py
new file mode 100644
index 000000000..2154b4697
--- /dev/null
+++ b/utils/test/testapi/opnfv_testapi/tornado_swagger/handlers.py
@@ -0,0 +1,43 @@
+##############################################################################
+# 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 tornado.web import URLSpec, StaticFileHandler
+
+from settings import default_settings, \
+ SWAGGER_API_DOCS, SWAGGER_API_LIST, SWAGGER_API_SPEC
+from views import SwaggerUIHandler, SwaggerResourcesHandler, SwaggerApiHandler
+
+
+def swagger_handlers():
+ prefix = default_settings.get('swagger_prefix', '/swagger')
+ if prefix[-1] != '/':
+ prefix += '/'
+
+ def _path(suffix):
+ return prefix + suffix
+ return [
+ URLSpec(
+ _path(r'spec.html$'),
+ SwaggerUIHandler,
+ default_settings,
+ name=SWAGGER_API_DOCS),
+ URLSpec(
+ _path(r'spec.json$'),
+ SwaggerResourcesHandler,
+ default_settings,
+ name=SWAGGER_API_LIST),
+ URLSpec(
+ _path(r'spec$'),
+ SwaggerApiHandler,
+ default_settings,
+ name=SWAGGER_API_SPEC),
+ (
+ _path(r'(.*\.(css|png|gif|js))'),
+ StaticFileHandler,
+ {'path': default_settings.get('static_path')}),
+ ]
diff --git a/utils/test/testapi/opnfv_testapi/tornado_swagger/settings.py b/utils/test/testapi/opnfv_testapi/tornado_swagger/settings.py
new file mode 100644
index 000000000..88d0d0f88
--- /dev/null
+++ b/utils/test/testapi/opnfv_testapi/tornado_swagger/settings.py
@@ -0,0 +1,32 @@
+##############################################################################
+# 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 os.path
+
+SWAGGER_VERSION = '1.2'
+SWAGGER_API_DOCS = 'swagger-api-docs'
+SWAGGER_API_LIST = 'swagger-api-list'
+SWAGGER_API_SPEC = 'swagger-api-spec'
+STATIC_PATH = os.path.join(os.path.dirname(os.path.normpath(__file__)),
+ 'static')
+
+default_settings = {
+ 'base_url': '',
+ 'static_path': STATIC_PATH,
+ 'swagger_prefix': '/swagger',
+ 'api_version': 'v1.0',
+ 'api_key': '',
+ 'enabled_methods': ['get', 'post', 'put', 'patch', 'delete'],
+ 'exclude_namespaces': [],
+}
+
+models = []
+
+
+def basePath():
+ return default_settings.get('base_url')
diff --git a/utils/test/testapi/opnfv_testapi/tornado_swagger/swagger.py b/utils/test/testapi/opnfv_testapi/tornado_swagger/swagger.py
new file mode 100644
index 000000000..3d21edefb
--- /dev/null
+++ b/utils/test/testapi/opnfv_testapi/tornado_swagger/swagger.py
@@ -0,0 +1,290 @@
+##############################################################################
+# 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
+from functools import wraps
+from HTMLParser import HTMLParser
+
+import epydoc.markup
+import tornado.web
+
+from settings import default_settings, models
+from handlers import swagger_handlers
+
+
+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', '<br/>') 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))
+ 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):
+ default_settings.update(opts)
+
+
+class Application(tornado.web.Application):
+ def __init__(self, handlers=None,
+ default_host="",
+ transforms=None,
+ **settings):
+ super(Application, self).__init__(swagger_handlers() + handlers,
+ default_host,
+ transforms,
+ **settings)
diff --git a/utils/test/testapi/opnfv_testapi/tornado_swagger/views.py b/utils/test/testapi/opnfv_testapi/tornado_swagger/views.py
new file mode 100644
index 000000000..25083195b
--- /dev/null
+++ b/utils/test/testapi/opnfv_testapi/tornado_swagger/views.py
@@ -0,0 +1,128 @@
+##############################################################################
+# 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 settings import SWAGGER_VERSION, SWAGGER_API_LIST, SWAGGER_API_SPEC
+from settings import models, basePath
+
+
+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, static_path, **kwds):
+ self.static_path = static_path
+
+ def get_template_path(self):
+ return self.static_path
+
+ def get(self):
+ discovery_url = basePath() + self.reverse_url(SWAGGER_API_LIST)
+ self.render('index.html', discovery_url=discovery_url)
+
+
+class SwaggerResourcesHandler(tornado.web.RequestHandler):
+ def initialize(self, api_version, exclude_namespaces, **kwds):
+ self.api_version = api_version
+ self.exclude_namespaces = exclude_namespaces
+
+ def get(self):
+ self.set_header('content-type', 'application/json')
+ resources = {
+ 'apiVersion': self.api_version,
+ 'swaggerVersion': SWAGGER_VERSION,
+ 'basePath': basePath(),
+ 'produces': ["application/json"],
+ 'description': 'Test Api Spec',
+ 'apis': [{
+ 'path': self.reverse_url(SWAGGER_API_SPEC),
+ 'description': 'Test Api Spec'
+ }]
+ }
+
+ self.finish(json_dumps(resources, self.get_arguments('pretty')))
+
+
+class SwaggerApiHandler(tornado.web.RequestHandler):
+ def initialize(self, api_version, base_url, **kwds):
+ self.api_version = api_version
+ self.base_url = 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': SWAGGER_VERSION,
+ 'basePath': basePath(),
+ 'apis': [self.__get_api_spec__(path, spec, operations)
+ for path, spec, operations in apis],
+ 'models': self.__get_models_spec(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