From 89955891c06f5ee64ad49f78e0bfe23c7b0b2f72 Mon Sep 17 00:00:00 2001 From: Richard Lee Date: Tue, 12 Nov 2013 14:25:34 -0600 Subject: [PATCH] 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 --- heat/api/openstack/v1/stacks.py | 51 ++----- heat/api/openstack/v1/views/__init__.py | 0 heat/api/openstack/v1/views/stacks_view.py | 68 +++++++++ heat/api/openstack/v1/views/views_common.py | 42 ++++++ ..._openstack_v1_views_stacks_view_builder.py | 136 ++++++++++++++++++ ...est_api_openstack_v1_views_views_common.py | 90 ++++++++++++ 6 files changed, 345 insertions(+), 42 deletions(-) create mode 100644 heat/api/openstack/v1/views/__init__.py create mode 100644 heat/api/openstack/v1/views/stacks_view.py create mode 100644 heat/api/openstack/v1/views/views_common.py create mode 100644 heat/tests/test_api_openstack_v1_views_stacks_view_builder.py create mode 100644 heat/tests/test_api_openstack_v1_views_views_common.py diff --git a/heat/api/openstack/v1/stacks.py b/heat/api/openstack/v1/stacks.py index eceb000d0a..79c6666ae6 100644 --- a/heat/api/openstack/v1/stacks.py +++ b/heat/api/openstack/v1/stacks.py @@ -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): diff --git a/heat/api/openstack/v1/views/__init__.py b/heat/api/openstack/v1/views/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/heat/api/openstack/v1/views/stacks_view.py b/heat/api/openstack/v1/views/stacks_view.py new file mode 100644 index 0000000000..32ca983ac6 --- /dev/null +++ b/heat/api/openstack/v1/views/stacks_view.py @@ -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 diff --git a/heat/api/openstack/v1/views/views_common.py b/heat/api/openstack/v1/views/views_common.py new file mode 100644 index 0000000000..9bcdda14c5 --- /dev/null +++ b/heat/api/openstack/v1/views/views_common.py @@ -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)) diff --git a/heat/tests/test_api_openstack_v1_views_stacks_view_builder.py b/heat/tests/test_api_openstack_v1_views_stacks_view_builder.py new file mode 100644 index 0000000000..1f57773f74 --- /dev/null +++ b/heat/tests/test_api_openstack_v1_views_stacks_view_builder.py @@ -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) diff --git a/heat/tests/test_api_openstack_v1_views_views_common.py b/heat/tests/test_api_openstack_v1_views_views_common.py new file mode 100644 index 0000000000..f09f1f35fc --- /dev/null +++ b/heat/tests/test_api_openstack_v1_views_views_common.py @@ -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)