Add tempest request/responses to the examples

This allows some back-filling of missinge request/response examples with
examples taken from a tempest run.
This commit is contained in:
Russell Sim 2015-09-15 19:00:02 +10:00
parent bbe662d728
commit e79ef40108
13 changed files with 465 additions and 37 deletions

55
doc/source/tempest.rst Normal file
View File

@ -0,0 +1,55 @@
Tempest Examples
================
To back fill the missing examples from the WADL, tempest result logs
can be used.
If you run tempest with a `logging.conf` like. The one below then the
resulting log `requests.log` will be suitable for processing.
::
[loggers]
keys=root,tempest_http
[handlers]
keys=file,devel
[formatters]
keys=simple,dumb
[logger_root]
level=DEBUG
handlers=devel
[logger_tempest_http]
level=DEBUG
handlers=file
qualname=tempest_lib.common.rest_client
[handler_file]
class=FileHandler
level=DEBUG
args=('requests.log', 'w+')
formatter=dumb
[handler_devel]
class=StreamHandler
level=INFO
args=(sys.stdout,)
formatter=simple
[formatter_simple]
format=%(asctime)s.%(msecs)03d %(process)d %(levelname)s: %(message)s
[formatter_dumb]
format=%(message)s
Once the tempest run has completed, you can then process the log into
a format for the WADL conversion process. This is done using the `fairy-slipper-tempest-log` tool::
fairy-slipper-tempest-log -o conversion_files/ requests.log
Where `conversion_files/` is the directory you are using to store the
intermediate files during the migration from docbook.

View File

@ -42,10 +42,16 @@ TMPL_API = """
{% if request['examples']['application/json'] %}
:requestexample: {{version}}/examples/{{request['id']}}_req.json
{%- endif -%}
{% if request['examples']['text/plain'] %}
:requestexample: {{version}}/examples/{{request['id']}}_req.txt
{%- endif -%}
{% for status_code, response in request.responses.items() -%}
{%- if response['examples']['application/json'] %}
:responseexample {{status_code}}: {{version}}/examples/{{request['id']}}_resp_{{status_code}}.json
{%- endif -%}
{%- if response['examples']['text/plain'] %}
:responseexample {{status_code}}: {{version}}/examples/{{request['id']}}_resp_{{status_code}}.txt
{%- endif -%}
{% endfor -%}
{% for mime in request.consumes %}
:accepts: {{mime}}

View File

@ -0,0 +1,142 @@
# Copyright (c) 2015 Russell Sim <russell.sim@gmail.com>
#
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import print_function
from __future__ import unicode_literals
from collections import defaultdict
import json
import logging
from os import path
import re
import urlparse
log = logging.getLogger(__name__)
DEFAULT_PORTS = {
'5000': 'identity',
'35357': 'identity-admin',
'8774': 'compute',
'8776': 'volume',
'8773': 'compute-ec2',
'9292': 'image',
}
REQUEST_RE = re.compile("Request (?P<test>\([^()]+\)):"
" (?P<status_code>\d+)"
" (?P<method>[A-Z]+) (?P<url>\S+)")
def parse_logfile(log_file):
"""Yet another shonky stream parser."""
calls = []
current_request = {}
current_response = {}
for line in log_file:
request = REQUEST_RE.match(line)
if request:
if current_request and current_response:
calls.append((current_request, current_response))
request_dict = request.groupdict()
url = urlparse.urlsplit(request_dict['url'])
port = url.netloc.split(':')[-1]
service = DEFAULT_PORTS[port]
current_request = {
'service': service,
'url': urlparse.urlunsplit(('', '') + url[2:]),
'method': request_dict['method']}
current_response = {
'status_code': request_dict['status_code']}
else:
try:
key, value = line.split(':', 1)
except ValueError:
# For some wacky reason, when you request JSON,
# sometimes you get text. Handle this rad behaviour.
if current_response.get('headers') is not None:
current_response['body'] += line
else:
current_request['body'] += line
continue
key = key.strip()
value = value.strip()
if key == 'Request - Headers':
current_request['headers'] = eval(value)
if key == 'Response - Headers':
current_response['headers'] = eval(value)
if key == 'Body':
if value == 'None':
body = None
elif value == '':
body = None
else:
try:
body = json.loads(value)
body = json.dumps(body, indent=2)
except ValueError:
body = value
log.info("Failed to parse %r", value)
if current_response.get('headers') is not None:
current_response['body'] = body
else:
current_request['body'] = body
else:
calls.append((current_request, current_response))
return calls
def main1(log_file, output_dir):
log.info('Reading %s' % log_file)
calls = parse_logfile(open(log_file))
services = defaultdict(list)
for call in calls:
services[call[0]['service']].append(call)
for service, calls in services.items():
pathname = path.join(output_dir, '%s-examples.json' % (service))
with open(pathname, 'w') as out_file:
json.dump(calls, out_file, indent=2)
def main():
import argparse
parser = argparse.ArgumentParser(
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument(
'-v', '--verbose', action='count', default=0,
help="Increase verbosity (specify multiple times for more)")
parser.add_argument(
'-o', '--output-dir', action='store',
help="The directory to output the JSON files too.")
parser.add_argument(
'filename',
help="File to convert")
args = parser.parse_args()
log_level = logging.WARNING
if args.verbose == 1:
log_level = logging.INFO
elif args.verbose >= 2:
log_level = logging.DEBUG
logging.basicConfig(
level=log_level,
format='%(asctime)s %(name)s %(levelname)s %(message)s')
filename = path.abspath(args.filename)
main1(filename, output_dir=args.output_dir)

View File

@ -29,6 +29,7 @@ import textwrap
import xml.sax
import prettytable
from jinja2 import Environment
log = logging.getLogger(__name__)
@ -105,6 +106,23 @@ MIME_MAP = {
VERSION_RE = re.compile('v[0-9\.]+')
WHITESPACE_RE = re.compile('[\s]+', re.MULTILINE)
URL_TEMPLATE_RE = re.compile('{[^{}]+}')
environment = Environment()
HTTP_REQUEST = """{{ method }} {{ url }} HTTP/1.1
{% for key, value in headers.items() -%}
{{ key }}: {{ value }}
{% endfor %}
"""
HTTP_REQUEST_TMPL = environment.from_string(HTTP_REQUEST)
HTTP_RESPONSE = """HTTP/1.1 {{ status_code }}
{% for key, value in headers.items() -%}
{{ key }}: {{ value }}
{% endfor %}
{{ body }}
"""
HTTP_RESPONSE_TMPL = environment.from_string(HTTP_RESPONSE)
def create_parameter(name, _in, description='',
@ -708,6 +726,15 @@ def main1(source_file, output_dir):
for filepath in api_ref['file_tags'].keys():
files.add(filepath.split('#', 1)[0])
# Load supplementary examples file
examples_file = path.join(path.dirname(source_file),
api_ref['service'] + '-examples.json')
if path.exists(examples_file):
log.info('Reading examples from %s' % examples_file)
examples = json.load(open(examples_file))
else:
examples = []
output = {
u'info': {
'version': api_ref['version'],
@ -736,6 +763,43 @@ def main1(source_file, output_dir):
for urlpath, apis in ch.apis.items():
output['paths'][urlpath].extend(apis)
output['definitions'].update(ch.schemas)
for ex_request, ex_response in examples:
for urlpath in output['paths']:
url_matcher = "^" + URL_TEMPLATE_RE.sub('[^/]+', urlpath) + "$"
if re.match(url_matcher, ex_request['url']):
if len(output['paths'][urlpath]) > 1:
# Skip any of the multi-payload endpoints. They
# are madness.
break
# Override any requests
try:
operation = output['paths'][urlpath][0]
except:
log.warning("Couldn't find any operations for %s", urlpath)
break
request = HTTP_REQUEST_TMPL.render(
headers=ex_request['headers'],
method=ex_request['method'],
url=ex_request['url'])
operation['examples'] = {'text/plain': request}
# Override any responses
status_code = ex_response['status_code']
response = HTTP_RESPONSE_TMPL.render(
status_code=status_code,
headers=ex_response['headers'],
body=ex_response['body'] or '')
if status_code in operation['responses']:
operation['responses'][status_code]['examples'] = \
{'text/plain': response}
else:
operation['responses'][status_code] = \
{'examples': {'text/plain': response}}
else:
log.warning("Couldn't find matching URL for %s" % urlpath)
os.chdir(output_dir)
pathname = '%s-%s-swagger.json' % (api_ref['service'],
api_ref['version'])

View File

@ -41,9 +41,12 @@ class JSONFileController(object):
self.filepath = filepath
@expose('json')
@expose(content_type='text/plain')
def _default(self):
if path.exists(self.filepath + '.json'):
self.filepath = self.filepath + '.json'
if path.exists(self.filepath + '.txt'):
self.filepath = self.filepath + '.txt'
if not path.exists(self.filepath):
response.status = 404
return response
@ -84,7 +87,7 @@ class DocController(object):
'paths': json['paths'],
'tags': json['tags']}
@expose('json')
@expose()
def _lookup(self, *components):
if len(components) != 2 and len(components) != 3:
return
@ -92,9 +95,10 @@ class DocController(object):
if components[0] == 'examples':
example = components[1]
filepath = path.join(self.examples_dir, example)
return JSONFileController(filepath), ['']
return JSONFileController(filepath), []
else:
filename = components[0]
print(filename)
filepath = path.join(self.schema_dir, filename)
return JSONFileController(filepath), []

View File

@ -0,0 +1,104 @@
# Copyright (c) 2015 Russell Sim <russell.sim@gmail.com>
#
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import unicode_literals
try:
from StringIO import StringIO
except ImportError:
from io import StringIO
import unittest
from fairy_slipper.cmd import tempest_log
SIMPLE_LOG = """Request (FlavorsV2TestJSON:setUpClass): 200 POST http://192.168.122.201:5000/v2.0/tokens
Request - Headers: {}
Body: None
Response - Headers: {'status': '200', 'content-length': '2987', 'vary': 'X-Auth-Token', 'server': 'Apache/2.4.7 (Ubuntu)', 'connection': 'close', 'date': 'Sun, 13 Sep 2015 07:43:01 GMT', 'content-type': 'application/json', 'x-openstack-request-id': 'req-1'}
Body: None
Request (FlavorsV2TestJSON:test_get_flavor): 200 POST http://192.168.122.201:5000/v2.0/tokens
Request - Headers: {}
Body: None
Response - Headers: {'status': '200', 'content-length': '2987', 'vary': 'X-Auth-Token', 'server': 'Apache/2.4.7 (Ubuntu)', 'connection': 'close', 'date': 'Sun, 13 Sep 2015 07:43:01 GMT', 'content-type': 'application/json', 'x-openstack-request-id': 'req-2'}
Body: None
""" # noqa
SIMPLE_LOG_BODY = """Request (FlavorsV2TestJSON:test_get_flavor): 200 GET http://192.168.122.201:8774/v2.1/6b45254f6f7c44a1b65ddb8218932226/flavors/1 0.117s
Request - Headers: {'Content-Type': 'application/json', 'Accept': 'application/json', 'X-Auth-Token': '<omitted>'}
Body: None
Response - Headers: {'status': '200', 'content-length': '430', 'content-location': 'http://192.168.122.201:8774/v2.1/6b45254f6f7c44a1b65ddb8218932226/flavors/1', 'x-compute-request-id': 'req-959a09e8-3628-419d-964a-1be4ca604232', 'vary': 'X-OpenStack-Nova-API-Version', 'connection': 'close', 'x-openstack-nova-api-version': '2.1', 'date': 'Sun, 13 Sep 2015 07:43:01 GMT', 'content-type': 'application/json'}
Body: {"flavor": {"name": "m1.tiny", "links": [{"href": "http://192.168.122.201:8774/v2.1/6b45254f6f7c44a1b65ddb8218932226/flavors/1", "rel": "self"}, {"href": "http://192.168.122.201:8774/6b45254f6f7c44a1b65ddb8218932226/flavors/1", "rel": "bookmark"}], "ram": 512, "OS-FLV-DISABLED:disabled": false, "vcpus": 1, "swap": "", "os-flavor-access:is_public": true, "rxtx_factor": 1.0, "OS-FLV-EXT-DATA:ephemeral": 0, "disk": 1, "id": "1"}}
""" # noqa
class TestLogParser(unittest.TestCase):
def test_simple_parse(self):
result = tempest_log.parse_logfile(StringIO(SIMPLE_LOG))
self.assertEqual(result, [
({'url': '/v2.0/tokens',
'service': 'identity',
'headers': {},
'body': None,
'method': 'POST'},
{'status_code': '200',
'body': None,
'headers': {'status': '200',
'content-length': '2987',
'date': 'Sun, 13 Sep 2015 07:43:01 GMT',
'content-type': 'application/json',
'x-openstack-request-id': 'req-1',
'vary': 'X-Auth-Token',
'connection': 'close',
'server': 'Apache/2.4.7 (Ubuntu)'}}),
({'url': '/v2.0/tokens',
'service': 'identity',
'headers': {},
'body': None,
'method': 'POST'},
{'status_code': '200',
'body': None,
'headers': {'status': '200',
'content-length': '2987',
'date': 'Sun, 13 Sep 2015 07:43:01 GMT',
'content-type': 'application/json',
'x-openstack-request-id': 'req-2',
'vary': 'X-Auth-Token',
'connection': 'close',
'server': 'Apache/2.4.7 (Ubuntu)'}})])
def test_body_parse(self):
result = tempest_log.parse_logfile(StringIO(SIMPLE_LOG_BODY))
self.assertEqual(result, [
({'url': '/v2.1/6b45254f6f7c44a1b65ddb8218932226/flavors/1',
'headers': {'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Auth-Token': '<omitted>'},
'body': None,
'method': 'GET',
'service': 'compute'},
{'body': '{\n "flavor": {\n "name": "m1.tiny", \n "links": [\n {\n "href": "http://192.168.122.201:8774/v2.1/6b45254f6f7c44a1b65ddb8218932226/flavors/1", \n "rel": "self"\n }, \n {\n "href": "http://192.168.122.201:8774/6b45254f6f7c44a1b65ddb8218932226/flavors/1", \n "rel": "bookmark"\n }\n ], \n "ram": 512, \n "OS-FLV-DISABLED:disabled": false, \n "vcpus": 1, \n "swap": "", \n "os-flavor-access:is_public": true, \n "rxtx_factor": 1.0, \n "OS-FLV-EXT-DATA:ephemeral": 0, \n "disk": 1, \n "id": "1"\n }\n}', # noqa
'status_code': '200',
'headers': {'status': '200', 'content-length': '430',
'content-location': 'http://192.168.122.201:8774/v2.1/6b45254f6f7c44a1b65ddb8218932226/flavors/1', # noqa
'x-openstack-nova-api-version': '2.1',
'date': 'Sun, 13 Sep 2015 07:43:01 GMT',
'vary': 'X-OpenStack-Nova-API-Version',
'x-compute-request-id': 'req-959a09e8-3628-419d-964a-1be4ca604232', # noqa
'content-type': 'application/json',
'connection': 'close'}})])

View File

@ -35,10 +35,6 @@ a.accordion-toggle:focus, a.accordion-toggle:hover {
text-decoration: none;
}
.operation-header {
margin-top: 10px;
}
.operation-header h3{
margin-top: 0;
}
@ -58,7 +54,7 @@ a.accordion-toggle:focus, a.accordion-toggle:hover {
.swagger-method {
vertical-align: top;
top: 10px;
top: 0.5em;
position: relative;
}

View File

@ -9,3 +9,27 @@ angular.module('fairySlipper', [
config(['$routeProvider', function($routeProvider) {
$routeProvider.otherwise({redirectTo: '/'});
}]);
// Speed up calls to hasOwnProperty
var hasOwnProperty = Object.prototype.hasOwnProperty;
function isEmpty(obj) {
// null and undefined are "empty"
if (obj == null) return true;
// Assume if it has a length property with a non-zero value
// that that property is correct.
if (obj.length > 0) return false;
if (obj.length === 0) return true;
// Otherwise, does it have any properties of its own?
// Note that this doesn't handle
// toString and valueOf enumeration bugs in IE < 9
for (var key in obj) {
if (hasOwnProperty.call(obj, key)) return false;
}
return true;
}

View File

@ -34,7 +34,18 @@ angular.module('fairySlipper.browser', [
.directive('swaggerExample', ['$http', function($http) {
function link(scope, element, attrs) {
scope.$watch('triggerLoad', function(newValue, oldValue) {
scope.language = 'json';
var mimes = [];
angular.forEach(scope.source, function(value, key) {
this.push(key);
}, mimes);
if (!scope.mimetype) {
scope.mimetype = mimes[0];
}
var load = function(newValue, oldValue) {
if (newValue && scope.source && ! scope.example) {
$http.get('/doc/' + scope.swagger.info.service + '/' +
scope.source[scope.mimetype].$ref +
@ -47,7 +58,12 @@ angular.module('fairySlipper.browser', [
scope.example = data;
}
});
}});
}};
scope.$watch('triggerLoad', load);
if (scope.triggerLoad) {
load();
}
}
return {
@ -142,6 +158,7 @@ angular.module('fairySlipper.browser', [
}])
.controller('ByPathCtrl', ['$scope', '$http', '$routeParams', 'Service', function($scope, $http, $routeParams, Service) {
$scope.isEmpty = isEmpty;
Service.get({
service: $routeParams.service,
version: $routeParams.version
@ -155,6 +172,7 @@ angular.module('fairySlipper.browser', [
}])
.controller('ByTagCtrl', ['$scope', '$http', '$routeParams', 'Service', function($scope, $http, $routeParams, Service) {
$scope.isEmpty = isEmpty;
Service.get({
service: $routeParams.service,
version: $routeParams.version

View File

@ -5,24 +5,23 @@
</div>
<div ng-repeat="operations in paths | orderBy: '$key'">
<h2>
<h3>
<swagger-path path="operations.$key"
parameters="operations[0].parameters">
</swagger-path>
</h2>
</h3>
<accordion>
<accordion-group ng-repeat="operation in operations">
<accordion-group ng-repeat="operation in operations"
is-open="operation_open">
<accordion-heading>
<div class="operation-header">
<div class="operation-method">
<swagger-method method="operation.method"></swagger-method>
</div>
<div class="operation-title">
<h3>
{{ operation.title }}
<br/>
<small>{{ operation.summary }}</small>
</h3>
{{ operation.title }}
<br/>
<small>{{ operation.summary }}</small>
</div>
</div>
</accordion-heading>
@ -36,7 +35,7 @@
</tab>
<tab heading="Details">
<div ng-if="parameters.header">
<h4>Headers</h4>
<h5>Headers</h5>
<dl>
<dt ng-repeat-start="parameter in parameters.header">{{parameter.name}}
</dt>
@ -44,7 +43,7 @@
</dl>
</div>
<div ng-if="parameters.query">
<h4>URL Parameters</h4>
<h5>URL Parameters</h5>
<dl>
<dt ng-repeat-start="parameter in parameters.query">{{parameter.name}}
</dt>
@ -52,27 +51,35 @@
</dl>
</div>
<div ng-if="parameters.body">
<h4>Request Schema</h4>
<h5>Request Schema</h5>
<swagger-schema swagger="swagger" parameters="parameters.body"></swagger-schema>
</div>
</tab>
</tabset>
</div>
<div class="col-md-6">
<h4>Request</h4>
<swagger-example ng-if="!isEmpty(operation.examples)"
swagger="swagger"
src="operation.examples"
trigger-load="operation_open">
</swagger-example>
<div ng-if="isEmpty(response.examples)">
No request recorded.
</div>
<h4>Responses</h4>
<accordion close-others="true">
<accordion-group ng-repeat="(status_code, response) in operation.responses"
is-disabled="!response.examples['application/json']"
is-disabled="isEmpty(response.examples)"
is-open="status.open">
<accordion-heading>
{{ status_code }}: {{ response.description }}
<i ng-if="response.examples['application/json']"
<i ng-if="!isEmpty(response.examples)"
class="pull-right glyphicon"
ng-class="{'glyphicon-chevron-down': status.open, 'glyphicon-chevron-right': !status.open}"></i>
</accordion-heading>
<swagger-example swagger="swagger"
src="response.examples"
mimetype="'application/json'"
trigger-load="status.open">
</swagger-example>
</accordion-group>

View File

@ -5,21 +5,20 @@
</div>
<div ng-repeat="tag in swagger.tags">
<h2>{{ tag.summary }}</h2>
<h3>{{ tag.summary }}</h3>
<div marked="tag.description"></div>
<accordion>
<accordion-group ng-repeat="operation in operations[tag.name]">
<accordion-group ng-repeat="operation in operations[tag.name]"
is-open="operation_open">
<accordion-heading>
<div class="operation-header">
<div class="operation-method">
<swagger-method method="operation.method"></swagger-method>
</div>
<div class="operation-title">
<h3>
{{ operation.title }}
<br/>
<small>{{ operation.summary }}</small>
</h3>
{{ operation.title }}
<br/>
<small>{{ operation.summary }}</small>
</div>
</div>
</accordion-heading>
@ -38,7 +37,7 @@
</tab>
<tab heading="Details">
<div ng-if="parameters.header">
<h4>Headers</h4>
<h5>Headers</h5>
<dl>
<dt ng-repeat-start="parameter in parameters.header">{{parameter.name}}
</dt>
@ -46,7 +45,7 @@
</dl>
</div>
<div ng-if="parameters.query">
<h4>URL Parameters</h4>
<h5>URL Parameters</h5>
<dl>
<dt ng-repeat-start="parameter in parameters.query">{{parameter.name}}
</dt>
@ -54,27 +53,35 @@
</dl>
</div>
<div ng-if="parameters.body">
<h4>Request Schema</h4>
<h5>Request Schema</h5>
<swagger-schema swagger="swagger" parameters="parameters.body"></swagger-schema>
</div>
</tab>
</tabset>
</div>
<div class="col-md-6">
<h4>Request</h4>
<swagger-example ng-if="!isEmpty(operation.examples)"
swagger="swagger"
src="operation.examples"
trigger-load="operation_open">
</swagger-example>
<div ng-if="isEmpty(response.examples)">
No request recorded.
</div>
<h4>Responses</h4>
<accordion close-others="true">
<accordion-group ng-repeat="(status_code, response) in operation.responses"
is-disabled="!response.examples['application/json']"
is-disabled="isEmpty(response.examples)"
is-open="status.open">
<accordion-heading>
{{ status_code }}: {{ response.description }}
<i ng-if="response.examples['application/json']"
<i ng-if="!isEmpty(response.examples)"
class="pull-right glyphicon"
ng-class="{'glyphicon-chevron-down': status.open, 'glyphicon-chevron-right': !status.open}"></i>
</accordion-heading>
<swagger-example swagger="swagger"
src="response.examples"
mimetype="'application/json'"
trigger-load="status.open">
</swagger-example>
</accordion-group>

View File

@ -1 +1 @@
<div hljs source="example"></div>
<div hljs source="example" language="{{ language }}"></div>

View File

@ -29,6 +29,7 @@ console_scripts =
fairy-slipper-docbkx-to-json = fairy_slipper.cmd.docbkx_to_json:main
fairy-slipper-swagger-to-rst = fairy_slipper.cmd.swagger_to_rst:main
fairy-slipper-wadl-to-swagger = fairy_slipper.cmd.wadl_to_swagger:main
fairy-slipper-tempest-log = fairy_slipper.cmd.tempest_log:main
[build_sphinx]
source-dir = doc/source