Add links section to the stacks index response
Add a `links` section to the stacks index response that will contain pagination links for the collection - this commit includes adding a `next` link to the section. blueprint pagination Change-Id: I76451a101e7c417a485da78dd7fb19229d91861d
This commit is contained in:
parent
0ab4a74e97
commit
89955891c0
|
@ -17,10 +17,10 @@
|
|||
Stack endpoint for Heat v1 ReST API.
|
||||
"""
|
||||
|
||||
import itertools
|
||||
from webob import exc
|
||||
|
||||
from heat.api.openstack.v1 import util
|
||||
from heat.api.openstack.v1.views import stacks_view
|
||||
from heat.common import identifier
|
||||
from heat.common import wsgi
|
||||
from heat.common import template_format
|
||||
|
@ -137,34 +137,6 @@ class InstantiationData(object):
|
|||
return dict((k, v) for k, v in params if k not in self.PARAMS)
|
||||
|
||||
|
||||
def format_stack(req, stack, keys=[]):
|
||||
include_key = lambda k: k in keys if keys else True
|
||||
|
||||
def transform(key, value):
|
||||
if not include_key(key):
|
||||
return
|
||||
|
||||
if key == engine_api.STACK_ID:
|
||||
yield ('id', value['stack_id'])
|
||||
yield ('links', [util.make_link(req, value)])
|
||||
elif key == engine_api.STACK_ACTION:
|
||||
return
|
||||
elif (key == engine_api.STACK_STATUS and
|
||||
engine_api.STACK_ACTION in stack):
|
||||
# To avoid breaking API compatibility, we join RES_ACTION
|
||||
# and RES_STATUS, so the API format doesn't expose the
|
||||
# internal split of state into action/status
|
||||
yield (key, '_'.join((stack[engine_api.STACK_ACTION], value)))
|
||||
else:
|
||||
# TODO(zaneb): ensure parameters can be formatted for XML
|
||||
#elif key == engine_api.STACK_PARAMETERS:
|
||||
# return key, json.dumps(value)
|
||||
yield (key, value)
|
||||
|
||||
return dict(itertools.chain.from_iterable(
|
||||
transform(k, v) for k, v in stack.items()))
|
||||
|
||||
|
||||
class StackController(object):
|
||||
"""
|
||||
WSGI controller for stacks resource in Heat v1 API
|
||||
|
@ -192,16 +164,7 @@ class StackController(object):
|
|||
params = util.get_allowed_params(req.params, whitelist)
|
||||
stacks = self.engine.list_stacks(req.context, **params)
|
||||
|
||||
summary_keys = (engine_api.STACK_ID,
|
||||
engine_api.STACK_NAME,
|
||||
engine_api.STACK_DESCRIPTION,
|
||||
engine_api.STACK_STATUS,
|
||||
engine_api.STACK_STATUS_DATA,
|
||||
engine_api.STACK_CREATION_TIME,
|
||||
engine_api.STACK_DELETION_TIME,
|
||||
engine_api.STACK_UPDATED_TIME)
|
||||
|
||||
return {'stacks': [format_stack(req, s, summary_keys) for s in stacks]}
|
||||
return stacks_view.collection(req, stacks)
|
||||
|
||||
@util.tenant_local
|
||||
def detail(self, req):
|
||||
|
@ -210,7 +173,7 @@ class StackController(object):
|
|||
"""
|
||||
stacks = self.engine.list_stacks(req.context)
|
||||
|
||||
return {'stacks': [format_stack(req, s) for s in stacks]}
|
||||
return {'stacks': [stacks_view.format_stack(req, s) for s in stacks]}
|
||||
|
||||
@util.tenant_local
|
||||
def create(self, req, body):
|
||||
|
@ -227,7 +190,11 @@ class StackController(object):
|
|||
data.files(),
|
||||
data.args())
|
||||
|
||||
return {'stack': format_stack(req, {engine_api.STACK_ID: result})}
|
||||
formatted_stack = stacks_view.format_stack(
|
||||
req,
|
||||
{engine_api.STACK_ID: result}
|
||||
)
|
||||
return {'stack': formatted_stack}
|
||||
|
||||
@util.tenant_local
|
||||
def lookup(self, req, stack_name, path='', body=None):
|
||||
|
@ -260,7 +227,7 @@ class StackController(object):
|
|||
|
||||
stack = stack_list[0]
|
||||
|
||||
return {'stack': format_stack(req, stack)}
|
||||
return {'stack': stacks_view.format_stack(req, stack)}
|
||||
|
||||
@util.identified_stack
|
||||
def template(self, req, identity):
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
|
||||
#
|
||||
# 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.
|
||||
|
||||
import itertools
|
||||
|
||||
from heat.api.openstack.v1 import util
|
||||
from heat.api.openstack.v1.views import views_common
|
||||
from heat.rpc import api as engine_api
|
||||
|
||||
_collection_name = 'stacks'
|
||||
|
||||
basic_keys = (engine_api.STACK_ID,
|
||||
engine_api.STACK_NAME,
|
||||
engine_api.STACK_DESCRIPTION,
|
||||
engine_api.STACK_STATUS,
|
||||
engine_api.STACK_STATUS_DATA,
|
||||
engine_api.STACK_CREATION_TIME,
|
||||
engine_api.STACK_DELETION_TIME,
|
||||
engine_api.STACK_UPDATED_TIME)
|
||||
|
||||
|
||||
def format_stack(req, stack, keys=None):
|
||||
def transform(key, value):
|
||||
if keys and key not in keys:
|
||||
return
|
||||
|
||||
if key == engine_api.STACK_ID:
|
||||
yield ('id', value['stack_id'])
|
||||
yield ('links', [util.make_link(req, value)])
|
||||
elif key == engine_api.STACK_ACTION:
|
||||
return
|
||||
elif (key == engine_api.STACK_STATUS and
|
||||
engine_api.STACK_ACTION in stack):
|
||||
# To avoid breaking API compatibility, we join RES_ACTION
|
||||
# and RES_STATUS, so the API format doesn't expose the
|
||||
# internal split of state into action/status
|
||||
yield (key, '_'.join((stack[engine_api.STACK_ACTION], value)))
|
||||
else:
|
||||
# TODO(zaneb): ensure parameters can be formatted for XML
|
||||
#elif key == engine_api.STACK_PARAMETERS:
|
||||
# return key, json.dumps(value)
|
||||
yield (key, value)
|
||||
|
||||
return dict(itertools.chain.from_iterable(
|
||||
transform(k, v) for k, v in stack.items()))
|
||||
|
||||
|
||||
def collection(req, stacks):
|
||||
formatted_stacks = [format_stack(req, s, basic_keys) for s in stacks]
|
||||
|
||||
result = {'stacks': formatted_stacks}
|
||||
links = views_common.get_collection_links(req, formatted_stacks)
|
||||
if links:
|
||||
result['links'] = links
|
||||
|
||||
return result
|
|
@ -0,0 +1,42 @@
|
|||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
|
||||
#
|
||||
# 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.
|
||||
|
||||
import urllib
|
||||
|
||||
|
||||
def get_collection_links(request, items):
|
||||
"""Retrieve 'next' link, if applicable."""
|
||||
links = []
|
||||
try:
|
||||
limit = int(request.params.get("limit") or 0)
|
||||
except ValueError:
|
||||
limit = 0
|
||||
|
||||
if limit > 0 and limit == len(items):
|
||||
last_item = items[-1]
|
||||
last_item_id = last_item["id"]
|
||||
links.append({
|
||||
"rel": "next",
|
||||
"href": _get_next_link(request, last_item_id)
|
||||
})
|
||||
return links
|
||||
|
||||
|
||||
def _get_next_link(request, marker):
|
||||
"""Return href string with proper limit and marker params."""
|
||||
params = request.params.copy()
|
||||
params['marker'] = marker
|
||||
|
||||
return "%s?%s" % (request.path_url, urllib.urlencode(params))
|
|
@ -0,0 +1,136 @@
|
|||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
|
||||
# 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.
|
||||
|
||||
import mock
|
||||
|
||||
from heat.tests.common import HeatTestCase
|
||||
from heat.api.openstack.v1.views import stacks_view
|
||||
from heat.engine import parser
|
||||
from heat.common import identifier
|
||||
|
||||
|
||||
class TestFormatStack(HeatTestCase):
|
||||
def setUp(self):
|
||||
super(TestFormatStack, self).setUp()
|
||||
self.request = mock.Mock()
|
||||
|
||||
def test_doesnt_include_stack_action(self):
|
||||
stack = {'stack_action': 'CREATE'}
|
||||
|
||||
result = stacks_view.format_stack(self.request, stack)
|
||||
self.assertEqual({}, result)
|
||||
|
||||
def test_merges_stack_action_and_status(self):
|
||||
stack = {'stack_action': 'CREATE',
|
||||
'stack_status': 'COMPLETE'}
|
||||
|
||||
result = stacks_view.format_stack(self.request, stack)
|
||||
self.assertIn('stack_status', result)
|
||||
self.assertEquals('CREATE_COMPLETE', result['stack_status'])
|
||||
|
||||
def test_include_stack_status_with_no_action(self):
|
||||
stack = {'stack_status': 'COMPLETE'}
|
||||
|
||||
result = stacks_view.format_stack(self.request, stack)
|
||||
self.assertIn('stack_status', result)
|
||||
self.assertEquals('COMPLETE', result['stack_status'])
|
||||
|
||||
@mock.patch.object(stacks_view, 'util')
|
||||
def test_replace_stack_identity_with_id_and_links(self, mock_util):
|
||||
mock_util.make_link.return_value = 'blah'
|
||||
stack = {'stack_identity': {'stack_id': 'foo'}}
|
||||
|
||||
result = stacks_view.format_stack(self.request, stack)
|
||||
self.assertIn('id', result)
|
||||
self.assertNotIn('stack_identity', result)
|
||||
self.assertEquals('foo', result['id'])
|
||||
|
||||
self.assertIn('links', result)
|
||||
self.assertEquals(['blah'], result['links'])
|
||||
|
||||
def test_includes_all_other_keys(self):
|
||||
stack = {'foo': 'bar'}
|
||||
|
||||
result = stacks_view.format_stack(self.request, stack)
|
||||
self.assertIn('foo', result)
|
||||
self.assertEqual('bar', result['foo'])
|
||||
|
||||
def test_filter_out_all_but_given_keys(self):
|
||||
stack = {
|
||||
'foo1': 'bar1',
|
||||
'foo2': 'bar2',
|
||||
'foo3': 'bar3',
|
||||
}
|
||||
|
||||
result = stacks_view.format_stack(self.request, stack, ['foo2'])
|
||||
self.assertIn('foo2', result)
|
||||
self.assertNotIn('foo1', result)
|
||||
self.assertNotIn('foo3', result)
|
||||
|
||||
|
||||
class TestStacksViewBuilder(HeatTestCase):
|
||||
def setUp(self):
|
||||
super(TestStacksViewBuilder, self).setUp()
|
||||
self.request = mock.Mock()
|
||||
self.request.params = {}
|
||||
template = parser.Template({})
|
||||
identity = identifier.HeatIdentifier('123456', 'wordpress', '1')
|
||||
self.stack1 = {
|
||||
u'stack_identity': dict(identity),
|
||||
u'updated_time': u'2012-07-09T09:13:11Z',
|
||||
u'template_description': u'blah',
|
||||
u'description': u'blah',
|
||||
u'stack_status_reason': u'Stack successfully created',
|
||||
u'creation_time': u'2012-07-09T09:12:45Z',
|
||||
u'stack_name': identity.stack_name,
|
||||
u'stack_action': u'CREATE',
|
||||
u'stack_status': u'COMPLETE',
|
||||
u'parameters': {'foo': 'bar'},
|
||||
u'outputs': ['key', 'value'],
|
||||
u'notification_topics': [],
|
||||
u'capabilities': [],
|
||||
u'disable_rollback': True,
|
||||
u'timeout_mins': 60,
|
||||
}
|
||||
|
||||
def test_stack_index(self):
|
||||
stacks = [self.stack1]
|
||||
stack_view = stacks_view.collection(self.request, stacks)
|
||||
self.assertIn('stacks', stack_view)
|
||||
self.assertEqual(1, len(stack_view['stacks']))
|
||||
|
||||
@mock.patch.object(stacks_view, 'format_stack')
|
||||
def test_stack_basic_details(self, mock_format_stack):
|
||||
stacks = [self.stack1]
|
||||
expected_keys = stacks_view.basic_keys
|
||||
|
||||
stack_view = stacks_view.collection(self.request, stacks)
|
||||
mock_format_stack.assert_called_once_with(self.request,
|
||||
self.stack1,
|
||||
expected_keys)
|
||||
|
||||
@mock.patch.object(stacks_view.views_common, 'get_collection_links')
|
||||
def test_append_collection_links(self, mock_get_collection_links):
|
||||
# If the page is full, assume a next page exists
|
||||
stacks = [self.stack1]
|
||||
mock_get_collection_links.return_value = 'fake links'
|
||||
stack_view = stacks_view.collection(self.request, stacks)
|
||||
self.assertIn('links', stack_view)
|
||||
|
||||
@mock.patch.object(stacks_view.views_common, 'get_collection_links')
|
||||
def test_doesnt_append_collection_links(self, mock_get_collection_links):
|
||||
stacks = [self.stack1]
|
||||
mock_get_collection_links.return_value = None
|
||||
stack_view = stacks_view.collection(self.request, stacks)
|
||||
self.assertNotIn('links', stack_view)
|
|
@ -0,0 +1,90 @@
|
|||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
|
||||
# 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.
|
||||
|
||||
import mock
|
||||
import urlparse
|
||||
|
||||
from heat.tests.common import HeatTestCase
|
||||
from heat.api.openstack.v1.views import views_common
|
||||
|
||||
|
||||
class TestViewsCommon(HeatTestCase):
|
||||
def setUp(self):
|
||||
super(TestViewsCommon, self).setUp()
|
||||
self.request = mock.Mock()
|
||||
self.stack1 = {
|
||||
'id': 'id1',
|
||||
}
|
||||
self.stack2 = {
|
||||
'id': 'id2',
|
||||
}
|
||||
|
||||
def setUpGetCollectionLinks(self):
|
||||
self.items = [self.stack1, self.stack2]
|
||||
self.request.params = {'limit': '2'}
|
||||
self.request.path_url = "http://example.com/fake/path"
|
||||
|
||||
def test_get_collection_links_creates_next(self):
|
||||
self.setUpGetCollectionLinks()
|
||||
links = views_common.get_collection_links(self.request, self.items)
|
||||
|
||||
expected = 'http://example.com/fake/path?marker=id2&limit=2'
|
||||
next_link = filter(lambda link: link['rel'] == 'next', links).pop()
|
||||
self.assertEqual('next', next_link['rel'])
|
||||
self.assertEqual(expected, next_link['href'])
|
||||
|
||||
def test_get_collection_links_doesnt_create_next_if_no_limit(self):
|
||||
self.setUpGetCollectionLinks()
|
||||
del self.request.params['limit']
|
||||
links = views_common.get_collection_links(self.request, self.items)
|
||||
|
||||
self.assertEqual([], links)
|
||||
|
||||
def test_get_collection_links_doesnt_create_next_if_page_not_full(self):
|
||||
self.setUpGetCollectionLinks()
|
||||
self.request.params['limit'] = '10'
|
||||
links = views_common.get_collection_links(self.request, self.items)
|
||||
|
||||
self.assertEqual([], links)
|
||||
|
||||
def test_get_collection_links_overwrites_url_marker(self):
|
||||
self.setUpGetCollectionLinks()
|
||||
self.request.params = {'limit': '2', 'marker': 'some_marker'}
|
||||
links = views_common.get_collection_links(self.request, self.items)
|
||||
|
||||
expected = 'http://example.com/fake/path?marker=id2&limit=2'
|
||||
next_link = filter(lambda link: link['rel'] == 'next', links).pop()
|
||||
self.assertEqual(expected, next_link['href'])
|
||||
|
||||
def test_get_collection_links_does_not_overwrite_other_params(self):
|
||||
self.setUpGetCollectionLinks()
|
||||
self.request.params = {'limit': '2', 'foo': 'bar'}
|
||||
links = views_common.get_collection_links(self.request, self.items)
|
||||
|
||||
next_link = filter(lambda link: link['rel'] == 'next', links).pop()
|
||||
url = next_link['href']
|
||||
query_string = urlparse.urlparse(url).query
|
||||
params = urlparse.parse_qs(query_string)
|
||||
self.assertEqual('2', params['limit'][0])
|
||||
self.assertEqual('bar', params['foo'][0])
|
||||
|
||||
def test_get_collection_links_handles_invalid_limits(self):
|
||||
self.setUpGetCollectionLinks()
|
||||
self.request.params = {'limit': 'foo'}
|
||||
links = views_common.get_collection_links(self.request, self.items)
|
||||
self.assertEqual([], links)
|
||||
|
||||
self.request.params = {'limit': None}
|
||||
links = views_common.get_collection_links(self.request, self.items)
|
||||
self.assertEqual([], links)
|
Loading…
Reference in New Issue